diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index b051d68..49e63b7 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -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 = { diff --git a/core/backend/airshelf/settings/test.py b/core/backend/airshelf/settings/test.py index 247a4d8..bbf8552 100644 --- a/core/backend/airshelf/settings/test.py +++ b/core/backend/airshelf/settings/test.py @@ -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 = { diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index be96cc2..4662441 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -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 diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index df41d60..9c120f1 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -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): diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index bac56a1..24f366d 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -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) diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 9ee1f40..54ad5a4 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -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)) diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index eb35fa5..7fe2752 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -20,6 +20,7 @@ from __future__ import annotations import json import logging import re +import time from collections.abc import Iterator from dataclasses import dataclass @@ -57,9 +58,143 @@ logger = logging.getLogger(__name__) # 单条用户消息的循环上限。8 轮足够「查素材 → 反问 → 写方案 → 出图」, # 再多基本是模型在原地打转。 MAX_TOOL_ROUNDS = 8 +# 单次模型调用的上限。正常一轮(含思考 + 写方案)在 1 分钟上下,3 分钟已属异常。 +CREATION_AGENT_MODEL_TIMEOUT_SECONDS = 180 +# 一条用户消息内所有轮次的总预算。单轮卡死不该连带吃光整轮额度,整轮也不能无限拖。 +CREATION_AGENT_TURN_TIMEOUT_SECONDS = 420 +# 输出被 max_tokens 截断后最多让模型重写几次;再失败就明确告诉用户,不再闷着重试到超时。 +MAX_TRUNCATION_RETRIES = 2 +LONG_VIDEO_DURATION_SLACK_SECONDS = 2 # 单条用户消息最多触发一次计费生成(契约 §4) MAX_BILLED_GENERATIONS = 1 + +def creation_agent_max_output_tokens() -> int: + """豆包 Seed 系列最大输出 16k、默认 4k。不抬高会把长方案的 tool 参数截成坏 JSON。""" + from django.conf import settings + + return max(1024, int(getattr(settings, "CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000) or 16000)) + + +def creation_model_extra_body(model_config: ModelConfig, tools: list[dict]) -> dict: + """统一拼模型请求体的可选参数。 + + max_tokens 所有网关都认;thinking 只对火山官方直连下发,避免中转站因未知参数报 400。 + """ + from django.conf import settings + + from .services import OFFICIAL_DIRECT_PROVIDERS + + body: dict = {"tools": tools, "max_tokens": creation_agent_max_output_tokens()} + mode = (getattr(settings, "CREATION_AGENT_THINKING_MODE", "") or "").strip() + provider_name = str(getattr(getattr(model_config, "provider", None), "name", "") or "") + if mode in {"enabled", "disabled", "auto"} and provider_name in OFFICIAL_DIRECT_PROVIDERS: + body["thinking"] = {"type": mode} + return body + + +def is_unsupported_thinking_error(exc: Exception) -> bool: + """模型不认 thinking 档位(Seed 2.1 Pro 收到 auto 就 400)。这种 400 应当去掉参数重试, + 而不是让整条会话吐「生成过程出错了」。""" + blob = str(exc).lower() + return "thinking" in blob and ("unsupported" in blob or "invalidparameter" in blob) + + +def creation_agent_timeout_notice(seconds: int, *, has_open_gate: bool = False) -> str: + """超时文案必须按现场说话:没有待确认的步骤卡时,页面上根本没有「按这个继续」可点。""" + minutes = max(1, round(int(seconds or 0) / 60)) + tail = ( + "原来的确认内容还在,直接重新点「按这个继续」即可。" + if has_open_gate + else "可以把刚才那条要求再发一次。" + ) + return f"这次整理等了约 {minutes} 分钟还没写完,已经停止。{tail}" + + +TRUNCATION_RETRY_INSTRUCTION = ( + "上一次输出撞到长度上限被截断,工具参数不是完整 JSON,本次作废。" + "请立刻重新调用同一个工具:先写 usp / points / timeline,再写 video_prompt;" + "video_prompt 压到更紧凑的篇幅(长视频只写章节结构、每个 30 秒段 3–5 个关键镜头和段尾交接状态)," + "不要复述已写过的规则,不要输出工具调用以外的解释文字。" +) + +TRUNCATION_GIVE_UP_NOTICE = ( + "这一版方案太长,连续几次都写到一半被截断,已经停止。" + "可以把时长改短一点,或直接点「按这个继续」让我按更紧凑的结构重写一次。" +) + + +def long_video_script_covers_requested_duration( + raw_duration: int | None, + requested_duration: int, +) -> bool: + """长视频允许时间轴和目标时长差 1–2 秒,避免整篇重写把超时额度耗光。""" + if raw_duration is None: + return False + return abs(int(raw_duration) - int(requested_duration)) <= LONG_VIDEO_DURATION_SLACK_SECONDS + + +def is_long_form_video(params: dict | None = None) -> bool: + return video_duration(params or {}) > LONG_VIDEO_CHAPTER_DURATION + + +def _public_reasoning_detail_key(reasoning_text: str) -> str: + """把最近一小段内部 reasoning 映射为白名单进度,不回传原文。""" + text = str(reasoning_text or "")[-360:] + if re.search(r"镜头|分镜|脚本|画面|出片|prompt", text, re.IGNORECASE): + return "shots" + if re.search(r"时长|分段|章节|节奏|\d{2,3}\s*秒", text, re.IGNORECASE): + return "structure" + if re.search(r"一致|参考|锁定|衔接|连续", text): + return "consistency" + if re.search(r"角色|人物|模特|出镜|主讲", text): + return "cast" + if re.search(r"商品|卖点|功效|受众|质地", text): + return "product" + return "brief" + + +def set_public_agent_progress( + conversation: CreationConversation, + phase: str, + *, + detail_key: str = "", +) -> None: + """记录给创作页轮询使用的受控进度阶段。 + + reasoning 原文是模型内部工作草稿,不适合作为用户内容。这里只存固定 phase, + 由 serializer 映射成简短、稳定的可读文案,既能让用户看见正在推进,也不会泄露或堆积原始思考流。 + 同时保留本轮最近的受控阶段,供创作页以可滚动的「创作过程」呈现。 + """ + memory = dict(conversation.memory or {}) + if ( + memory.get("agent_progress_phase") == phase + and memory.get("agent_progress_detail_key", "") == detail_key + ): + return + history = list(memory.get("agent_progress_history") or []) + history.append({"phase": phase, "detail_key": detail_key}) + memory["agent_progress_history"] = history[-12:] + memory["agent_progress_phase"] = phase + memory["agent_progress_detail_key"] = detail_key + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + + +def clear_public_agent_progress(conversation: CreationConversation) -> None: + """本轮完成、取消或失败后立即撤掉临时进度,不能和错误/确认卡同时出现。""" + memory = dict(conversation.memory or {}) + if not any( + key in memory + for key in ("agent_progress_phase", "agent_progress_detail_key", "agent_progress_history") + ): + return + memory.pop("agent_progress_phase", None) + memory.pop("agent_progress_detail_key", None) + memory.pop("agent_progress_history", None) + conversation.memory = memory + conversation.save(update_fields=["memory", "updated_at"]) + # 视频闸门阶段(落在 conversation.memory.stage;resume 靠它) # clarify → strategy → plan → prompt → confirm → done VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done") @@ -1140,6 +1275,12 @@ def emit_final_confirm_gate( prompt = str((last.payload or {}).get("body") or "").strip() if last else "" if not prompt: return None + latest_plan = ( + conversation.messages.filter(kind=CreationMessage.Kind.PLAN) + .order_by("-seq") + .first() + ) + timeline = list((latest_plan.payload or {}).get("timeline") or []) if latest_plan else [] prompt = apply_product_voice_visual_guard(conversation, prompt) prompt = apply_product_reality_guard(prompt) prompt = apply_clothing_video_guard(conversation, prompt) @@ -1173,6 +1314,7 @@ def emit_final_confirm_gate( "label": "开始生成", "estimated_credits": credits, "video_prompt": prompt, + "timeline": timeline, "submitted": False, "params": snapshot_session_params(conversation), "param_options": confirm_param_options(True), @@ -1209,13 +1351,17 @@ IMAGE_MODEL_BY_LABEL = { # 「智能时长」没有可读到的脚本时才用的保守兜底。实际方案生成后必须从时间轴推导, # 不能把每条片都悄悄压成 15 秒。 SMART_DURATION = 15 +# 临时关闭 >60 秒长视频生成;恢复时改回 180。 +MAX_VIDEO_DURATION = 60 +MAX_VIDEO_SEGMENT_DURATION = 30 +LONG_VIDEO_CHAPTER_DURATION = 60 _SMART_DURATION_RE = re.compile( # 结尾必须明确带「秒/s」。原来的「秒?」会把“18–22岁”误当成 22 秒。 - r"(? bool: + lower = str(blob or "").lower() + return any(hint.lower() in lower for hint in _CLOTHING_CATEGORY_HINTS) + + +def _product_context_blob(conversation: CreationConversation | None) -> str: + """只汇总「卖的是什么」:商品名、品类和记忆里的商品字段,不含 prompt。""" + if conversation is None: + return "" + parts: list[str] = [] + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + for key in ("product_name", "product_brand", "product_brand_and_name"): + parts.append(str(memory.get(key) or "")) + for ref in locked_product_references(conversation): + if not isinstance(ref, dict): + continue + parts.append(str(ref.get("name") or "")) + parts.append(str(ref.get("category") or "")) + parts.append(str(ref.get("title") or "")) + return "\n".join(part for part in parts if part.strip()) + + def is_clothing_video_context(conversation: CreationConversation | None, prompt: str = "") -> bool: - """服装/服饰类视频:含鱼眼换装预设,或商品名/品类命中服装关键词。""" + """服装/服饰类视频:含鱼眼换装预设,或商品本身命中服装关键词。 + + 已知卖什么时**以商品为准**:任何带人的脚本都会写人物穿的卫衣、针织、阔腿裤, + 拿整段 prompt 去判会把护肤品、家电一律误判成服装视频,白套一身穿衣禁令。 + """ if conversation is not None and str(conversation.preset or "").strip() == "鱼眼换装": return True - blob = _clothing_context_blob(conversation, prompt) - lower = blob.lower() - return any(hint.lower() in lower for hint in _CLOTHING_CATEGORY_HINTS) + product_blob = _product_context_blob(conversation) + if product_blob.strip(): + return _clothing_hint_hit(product_blob) + # 还不知道卖什么(未锁商品/无会话)时才退回看文案。 + return _clothing_hint_hit(_clothing_context_blob(conversation, prompt)) def apply_clothing_video_guard( @@ -1445,11 +1632,14 @@ def apply_clothing_video_guard( base = str(prompt or "").strip() if not base or not is_clothing_video_context(conversation, base): return base + # 先摘掉已有的约束段再改写:改写规则会把约束段里的「穿衣服」也一起替换掉, + # 于是下一次调用按原文比对不上、又补一段,重复调用能叠出四五份。 + base = "\n".join( + line for line in base.splitlines() if CLOTHING_GUARD_MARKER not in line + ).strip() for pattern, replacement in _CLOTHING_DRESSING_REWRITES: base = re.sub(pattern, replacement, base) - if CLOTHING_VIDEO_NO_DRESSING_GUARD not in base: - base = f"{base}\n{CLOTHING_VIDEO_NO_DRESSING_GUARD}".strip() - return base + return f"{base}\n{CLOTHING_VIDEO_NO_DRESSING_GUARD}".strip() def apply_product_appearance_guard( @@ -1477,6 +1667,11 @@ def apply_pet_dialogue_guard( return base if not is_pet_preset(conversation.preset): return base + # 先摘掉已有的约束段再改写:改写规则会把约束段里当反例的「今天给大家介绍」也一起换掉, + # 于是下一次调用比对不上、又补一段,出片确认 / 重试几轮能叠出好几份。 + base = "\n".join( + line for line in base.splitlines() if PET_DIALOGUE_GUARD_MARKER not in line + ).strip() # 轻量改写常见旁白开场,避免最终模型照念 rewrites = ( (r"今天给大家介绍", "我今天发现"), @@ -1487,9 +1682,7 @@ def apply_pet_dialogue_guard( ) for pattern, replacement in rewrites: base = re.sub(pattern, replacement, base) - if PET_DIALOGUE_GUARD not in base: - base = f"{base}\n{PET_DIALOGUE_GUARD}".strip() - return base + return f"{base}\n{PET_DIALOGUE_GUARD}".strip() def apply_video_platform_safety_guard(prompt: str) -> str: @@ -2004,8 +2197,28 @@ VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2 IMAGE_MODELS = ["Seedream5.0", "YQ image2"] RATIOS = ["16:9", "9:16", "4:3", "3:4", "1:1"] RESOLUTIONS = ["480p", "720p", "1080p"] -VIDEO_DURATIONS = ["智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", "30 秒", "60 秒"] +VIDEO_DURATIONS = [ + "智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", + "30 秒", "45 秒", "60 秒", +] IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"] + + +def filter_visible_duration_options(options: list) -> list: + """临时隐藏 >60 秒长视频选项(含「90秒」「120 秒」等写法)。""" + out = [] + for item in options or []: + if isinstance(item, dict): + label = str(item.get("label") or item.get("value") or "") + else: + label = str(item or "") + digits = "".join(ch for ch in label if ch.isdigit()) + # 仅当像「N秒」时长选项时过滤;普通文案不动 + if "秒" in label and digits and int(digits) > MAX_VIDEO_DURATION: + continue + out.append(item) + return out + SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count") _PARAM_TO_STORED = {"video_model": "model"} @@ -2036,7 +2249,10 @@ def session_param_fields(keys: list[str], is_video: bool) -> list[dict]: for key in keys[:1]: if key not in _PARAM_PICK_LABEL: continue - options = [{"value": item, "label": item} for item in _param_options(key, is_video)] + raw_options = _param_options(key, is_video) + if key == "duration": + raw_options = filter_visible_duration_options(raw_options) + options = [{"value": item, "label": item} for item in raw_options] fields.append({ "key": key, "label": _PARAM_PICK_LABEL[key], @@ -2073,6 +2289,39 @@ def wanted_param_keys(user_text: str, *, is_video: bool) -> list[str]: return out +_EXPLICIT_VIDEO_DURATION_RE = re.compile( + r"(?:做|制作|生成|来|要|成片|视频|时长).{0,12}?(? bool: + """把自然 brief 里的明确成片时长写回会话参数。 + + 例如「用这三个角色做一条 120 秒视频」不能继续沿用顶部的「智能时长」。 + 正则要求时长前有创作语义,避免把「0-30 秒镜头」或年龄误判为整片时长。 + """ + if conversation.mode != CreationConversation.Mode.VIDEO: + return False + match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or "")) + if match is None: + return False + seconds = int(match.group(1)) + if seconds < 4 or seconds > MAX_VIDEO_DURATION: + return False + params = dict(conversation.params or {}) + duration = f"{seconds} 秒" + if params.get("duration") == duration: + return False + params["duration"] = duration + conversation.params = params + conversation.save(update_fields=["params", "updated_at"]) + return True + + def apply_session_params(conversation, fields, answers: dict) -> bool: """追问卡里选出的时长/模型等写回会话参数。返回是否有改动。""" current = dict(conversation.params or {}) @@ -2088,6 +2337,10 @@ def apply_session_params(conversation, fields, answers: dict) -> bool: value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip() if not value or current.get(stored) == value: continue + if stored == "duration" and value != "智能时长": + digits = "".join(ch for ch in value if ch.isdigit()) + if digits and int(digits) > MAX_VIDEO_DURATION: + value = f"{MAX_VIDEO_DURATION} 秒" current[stored] = value changed = True if changed: @@ -2304,10 +2557,17 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "写「视频最终方案」卡(USP/卖点/时间轴)并请用户确认。" "**仅当用户已确认策略、或明确要改方案时调用**;打招呼或闲聊不要调。" "调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。" - "usp / points / timeline 必须写满具体文案;同时把完整 video_prompt 写好存档," + "usp / points / timeline 必须写满具体文案;同时把 video_prompt 写好存档," "用户确认方案后由平台展示 Prompt。" - "video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、" - "声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。" + + ( + "当前是超过 60 秒的长视频:video_prompt 只写章节结构、每个 30 秒段 3–5 个关键镜头和段尾交接," + "全文约 800–1400 字。不要套 15 秒短片的制作级长文模板,不要为凑字数重复灯光/声音/风格。" + if is_long_form_video(context.conversation.params) + else + "video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、" + "声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。" + ) + + "第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。" "先有已确认的 write_strategy,再调它。" ), @@ -2338,9 +2598,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "video_prompt": { "type": "string", "description": ( + "超过 60 秒时写紧凑可执行稿:章节结构 + 每个 30 秒段 3–5 个关键镜头 + 段尾交接状态," + "约 800–1400 字。不要写成短片制作级长文。" + if is_long_form_video(context.conversation.params) + else "交给出片模型的制作级完整指令,不是大纲。按系统规定的标题顺序写:" "时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。" - "每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。" + "60 秒以内每一镜包含拍法/画面内容/主体或产品露出/声音;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。" "已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。" "人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。" ), @@ -2358,7 +2622,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "写出片 Prompt 文件卡并请用户确认。" "仅当用户已确认方案、或明确要求改 Prompt 时调用;" "不要在 write_plan 同轮调用。调完停下等人确认,不要同轮出积分确认卡或直接出片。" - "video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。" + + ( + "长视频优先沿用方案里已存的 video_prompt,只补一段简短全片规则(风格/光线/参考图/一致性)," + "不要把逐镜再扩写成短片密度。" + if is_long_form_video(context.conversation.params) + else + "video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。" + ) ), "parameters": { "type": "object", @@ -2366,6 +2636,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "video_prompt": { "type": "string", "description": ( + "优先沿用方案草稿,只补一段简短全片规则。不要把每个 30 秒段再扩写成短片制作长文。" + if is_long_form_video(context.conversation.params) + else "交给出片模型的完整制作文件。必须含总体视觉/美术/色彩/打光/声音/场景/参考图锁定规则," "以及按秒分段、每镜均含拍法/画面内容/主体或产品露出/声音的完整脚本与一致性收束。" ), @@ -2564,11 +2837,11 @@ def infer_script_duration(*, timeline: list[dict] | None = None, prompt: str = " if not ends: return None # 方案的最后一个时间点就是成片总长;向上取整避免 19.2 秒被截成 19 秒。 - return max(4, min(60, int(max(ends) + 0.999))) + return max(4, min(MAX_VIDEO_DURATION, int(max(ends) + 0.999))) def _raw_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None: - """返回方案真实的最大时间点,不在这里截断,供 60 秒硬上限校验使用。""" + """返回方案真实的最大时间点,不在这里截断,供 180 秒硬上限校验使用。""" ends: list[float] = [] for item in timeline or []: if not isinstance(item, dict): @@ -2593,13 +2866,13 @@ def _raw_script_duration(*, timeline: list[dict] | None = None, prompt: str = "" def plan_video_segments(duration: int, timeline: list[dict] | None = None) -> list[dict]: - """按方案节奏把 31–60 秒视频拆成可由 Seedance 2.5 单独完成的片段。 + """按方案节奏把 31–180 秒视频拆成 Seedance 2.5 可完成的片段。 每段不超过 30 秒;优先落在时间轴的自然转场处,找不到合适转场再均分。 - 当前模型上限下 31–60 秒稳定拆为两段,避免无意义地把同一支片拆得过碎。 + 分点逐个校验,保证剩余片段仍能落在 4–30 秒,不会出现最后一段过短或超长。 """ - total = max(4, min(int(duration or 0), 60)) - if total <= 30: + total = max(4, min(int(duration or 0), MAX_VIDEO_DURATION)) + if total <= MAX_VIDEO_SEGMENT_DURATION: return [{"index": 1, "start": 0, "end": total, "duration": total}] candidates: list[int] = [] @@ -2612,15 +2885,21 @@ def plan_video_segments(duration: int, timeline: list[dict] | None = None) -> li continue if 4 <= end <= total - 4: candidates.append(end) - # 两段都必须 <= 30 秒,因此 60 秒时唯一合法分点就是 30 秒。 - lower, upper = max(4, total - 30), min(30, total - 4) - target = total / 2 - legal = [point for point in candidates if lower <= point <= upper] - split = min(legal, key=lambda point: abs(point - target)) if legal else int(round(target)) - split = max(lower, min(upper, split)) + segment_count = (total + MAX_VIDEO_SEGMENT_DURATION - 1) // MAX_VIDEO_SEGMENT_DURATION + points = [0] + for boundary_index in range(1, segment_count): + previous = points[-1] + remaining_segments = segment_count - boundary_index + lower = max(previous + 4, total - MAX_VIDEO_SEGMENT_DURATION * remaining_segments) + upper = min(previous + MAX_VIDEO_SEGMENT_DURATION, total - 4 * remaining_segments) + target = total * boundary_index / segment_count + legal = [point for point in candidates if lower <= point <= upper] + split = min(legal, key=lambda point: abs(point - target)) if legal else int(round(target)) + points.append(max(lower, min(upper, split))) + points.append(total) return [ - {"index": 1, "start": 0, "end": split, "duration": split}, - {"index": 2, "start": split, "end": total, "duration": total - split}, + {"index": index + 1, "start": start, "end": end, "duration": end - start} + for index, (start, end) in enumerate(zip(points, points[1:])) ] @@ -2629,13 +2908,32 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str index = int(segment.get("index") or 1) start = int(segment.get("start") or 0) end = int(segment.get("end") or 0) + segment_count = max(1, (int(total_duration) + MAX_VIDEO_SEGMENT_DURATION - 1) // MAX_VIDEO_SEGMENT_DURATION) + chapter_index = min( + max(1, (start // LONG_VIDEO_CHAPTER_DURATION) + 1), + max(1, (int(total_duration) + LONG_VIDEO_CHAPTER_DURATION - 1) // LONG_VIDEO_CHAPTER_DURATION), + ) + opening_rule = ( + "这是全片唯一开场,按完整脚本建立人物、商品与场景。" + if index == 1 else + "这不是新视频的开场:直接承接上一段结束时尚未完成的动作、对白、商品位置、机位与光线,不重复介绍人物、商品和卖点。" + ) + ending_rule = ( + "这是全片最后一段,完成因果回收与唯一一次最终 CTA。" + if index == segment_count else + "本段结尾保持动作、视线或运镜未完全结束,为下一段留下明确可续接状态;不要提前做总结或 CTA。" + ) return ( f"{prompt.strip()}\n\n" - f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。" - "仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的原有人物阵容与身份关系," - "不得在本段重新设计、随机替换、遗漏人物或改变各自的五官、发型、年龄、身形和服装。" - "承接上一段的动作、商品、场景和光线," - "为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。" + f"【分段出片约束·第 {index}/{segment_count} 段·第 {chapter_index} 章】" + f"整支视频共 {total_duration} 秒,本任务只生成全片 {start}–{end} 秒,对应时长 {end - start} 秒。" + "只呈现完整脚本中落在这个时间窗的镜头、对白与声音,不得把整支故事压缩重演一遍。" + f"{opening_rule}{ending_rule}" + "所有分段必须复用完全相同的参考图编号、人物阵容、商品/SKU 映射与确定性 seed。" + "人物五官、发型、肤色、年龄感、身形、手部和基础服装保持不变,不得换人、漏人或合并角色。" + "商品颜色、材质、包装、标签、结构、配件和使用状态严格按对应参考图保持;不同商品或 SKU 不得混合、互换或变成近似款。" + "场景空间关系、道具位置、主光方向、色温、人物音色、语速、环境底噪与 BGM 节拍保持连续。" + "画面不添加字幕、文字、标题贴片、弹幕、角标、水印或购物浮层。" ) @@ -2670,6 +2968,45 @@ def apply_person_identity_guard(prompt: str, references: list[dict]) -> str: ) +def apply_product_reference_guard(prompt: str, references: list[dict]) -> str: + """把每张商品参考图的真实编号与名称写进所有分段,避免长视频里串 SKU。""" + products = [ + (index, str(item.get("label") or f"商品{index}")) + for index, item in enumerate(references or [], start=1) + if isinstance(item, dict) and item.get("type") == "product" + ] + if not products: + return prompt + mapping = ";".join(f"参考图{index}={label}" for index, label in products) + return ( + f"{prompt.strip()}\n\n【商品参考映射·全片与所有分段最高优先级】{mapping}。" + "同一名称或仅带‘三视图/细节图’后缀的商品图片共同定义同一 SKU;其余不同名称代表相互独立的商品或 SKU。" + "每次露出都必须调用对应参考图,保持颜色、材质、比例、包装、标签、结构、按键、接口、配件和正常使用状态一致。" + "不得把多张商品图融合成新商品,不得跨 SKU 互换颜色、部件、标签或功能,也不得用近似款替代。" + ) + + +def build_segment_video_submit(submit: dict, segment: dict, total_duration: int) -> dict: + """从同一份最终 Prompt/参考图/seed 派生单段请求,供首批与后续批次共用。""" + segment_prompt = ( + segment_video_prompt(str(submit.get("prompt") or ""), segment, total_duration) + if total_duration > MAX_VIDEO_SEGMENT_DURATION else str(submit.get("prompt") or "") + ) + return { + **submit, + "duration": int(segment["duration"]), + "prompt": enforce_no_embedded_captions(segment_prompt), + "extra_payload": { + "omni_segment": { + "index": int(segment["index"]), + "start": int(segment["start"]), + "end": int(segment["end"]), + "total_duration": int(total_duration), + } + }, + } + + def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int: """显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。""" raw = str(params.get("duration") or "") @@ -2679,7 +3016,7 @@ def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | Non if inferred is not None: return inferred return SMART_DURATION - return max(4, min(int(digits), 60)) + return max(4, min(int(digits), MAX_VIDEO_DURATION)) def _model_supports_duration(model_name: str, duration: int) -> bool: @@ -2715,8 +3052,8 @@ def resolve_smart_video_duration( params["duration"] = f"{duration} 秒" selected_model = video_model_name(params) switched = False - # Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–60 秒分段的模型。 - # 即使总时长 45 秒不在单段能力表里,后续也会拆成两条 <=30 秒的 2.5 任务。 + # Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–180 秒分段的模型。 + # 总时长不在单段能力表里时,后续会拆成多条 <=30 秒的 2.5 任务。 if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL: params["model"] = "Seedance 2.5" switched = True @@ -2823,6 +3160,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list if selected: prompt = apply_plot_twist_direction_contract(prompt, **selected) prompt = apply_person_identity_guard(prompt, resolved.references) + prompt = apply_product_reference_guard(prompt, resolved.references) duration = resolve_smart_video_duration(context.conversation, prompt=prompt) # resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。 params = context.conversation.params or {} @@ -2837,9 +3175,12 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list "generate_audio": True, "references": resolved.references, } - if any(item.get("type") in {"model", "character"} for item in resolved.references): - # 长视频的多个任务共用同一个确定性 seed,减少两段各自随机采样导致的脸部/服装漂移。 - submit["seed"] = int(str(context.conversation.id).replace("-", "")[:8], 16) + if any(item.get("type") in {"model", "character", "product"} for item in resolved.references): + # 长视频的多个任务共用同一个确定性 seed,减少跨段的人脸、服装和商品外观漂移。 + # 必须过 normalize_video_seed:8 位十六进制最大约 42.9 亿,直接送会超火山的 int32 上限被拒。 + from .free_video import normalize_video_seed + + submit["seed"] = normalize_video_seed(int(str(context.conversation.id).replace("-", "")[:8], 16)) return submit, resolved.references @@ -2910,48 +3251,38 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ context = AgentContext(conversation=conversation, user=user, model_config=None) submit, _references = _video_submit_params(context, prompt) total_duration = int(submit["duration"]) - segments = plan_video_segments(total_duration) - # 先整体检查并发余量,再提交任何一段;否则第一段已扣费、第二段才因额度满失败会留下孤儿片段。 + segments = plan_video_segments(total_duration, timeline=payload.get("timeline") or []) + # 长视频最多 6 段,不能为了它抬高全站并发上限。先占用当前可用槽位;后续由会话轮询在 + # 已完成片段释放槽位后自动补交下一批,既遵守团队并发限制,也不会让 180 秒一开始就被拒。 in_flight = AITask.objects.filter( team=conversation.team, task_type=AITask.Type.FREE_VIDEO, status__in=IN_FLIGHT_STATUSES, ).count() max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3)) - if in_flight + len(segments) > max_concurrent: - return None, f"当前视频任务余量不足,需要同时生成 {len(segments)} 段;请等待现有任务完成后再试。" + available_slots = max(0, max_concurrent - in_flight) + if available_slots <= 0: + return None, f"当前有 {in_flight} 个视频任务进行中(上限 {max_concurrent}),请等待完成后再提交。" tasks = [] try: - for segment in segments: - segment_prompt = ( - segment_video_prompt(submit["prompt"], segment, total_duration) - if len(segments) > 1 - else submit["prompt"] - ) - segment_submit = { - **submit, - "duration": int(segment["duration"]), - # 长视频也必须从完整的最终 Prompt 分段。以前这里误用原始 prompt, - # 会丢掉预设约束、商品安全约束和人物锁定,是 60s 前后换人的直接原因。 - # 分段之后再执行一次统一清洗:移除模型/用户写进正文的任何屏显指令, - # 并让每个独立视频任务的首尾都带最高优先级画面洁净约束。 - "prompt": enforce_no_embedded_captions(segment_prompt), - "extra_payload": { - "omni_segment": { - "index": segment["index"], - "start": segment["start"], - "end": segment["end"], - "total_duration": total_duration, - } - }, - } + for segment in segments[:available_slots]: + # 长视频也必须从完整的最终 Prompt 分段;这个唯一构造函数同时用于后续批次,确保 + # 预设、人物、商品、安全、无字幕、参考图与 seed 不会在换批次时丢失。 + segment_submit = build_segment_video_submit(submit, segment, total_duration) tasks.append(submit_free_video(team=conversation.team, user=user, params=segment_submit)) except ValueError as exc: # 校验类错误(时长/比例/额度),给用户看原文 - return None, str(exc) + if not tasks: + return None, str(exc) + # 并发额度可能在逐段提交期间被另一请求抢占。已成功提交的片段不能变成孤儿; + # 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。 - if len(tasks) == 1: + if len(segments) == 1: message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt} else: + submitted_by_index = { + int(segment["index"]): str(task.id) + for segment, task in zip(segments, tasks, strict=False) + } message_payload = { "task_id": str(tasks[0].id), "task_ids": [str(task.id) for task in tasks], @@ -2959,14 +3290,33 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_ "prompt": prompt, "total_duration": total_duration, "segments": [ - {**segment, "task_id": str(task.id)} - for segment, task in zip(segments, tasks, strict=True) + {**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})} + for segment in segments ], + # 仅保存后续分批提交所需的非敏感生成参数。所有批次从同一份 prompt、refs 与 seed + # 派生,避免第二批退化成无参考图的“续写”。 + "generation_spec": submit, } message = append_message( conversation, role="assistant", kind=CreationMessage.Kind.GENERATING, payload=message_payload, task=tasks[0], ) + if len(segments) > 1: + # 每个片段都记住聚合消息。这样用户离开页面后,任一 worker 完成也能找到整组任务、 + # 回填进度并补交下一批,不再只依赖首段 FK 或浏览器轮询。 + for segment, task in zip(segments, tasks, strict=False): + request_payload = dict(task.request_payload or {}) + marker = dict(request_payload.get("omni_segment") or {}) + marker.update({ + "index": int(segment["index"]), + "start": int(segment["start"]), + "end": int(segment["end"]), + "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"]) _remember_artifact(conversation, prompt, "video") set_video_gate_stage(conversation, "done", clear_pending_prompt=True) return message, "" @@ -3156,7 +3506,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 用户已经添加多位角色并分别描述造型时,这些角色默认都属于本次创作,不得擅自删掉或要求用户从中三选一。" " 如果人物关系没说清,只确认‘共同出镜’还是‘一位主讲、其余辅助’;只有用户明确说只用一位时才缩减角色。", "- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 启动素材确认,不要强行出大卡片。", - "- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。", + "- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。改时长时 options 只能给 ≤60 秒的常用档(如 15/30/45/60 秒),禁止出现 90/120/180 秒或更长。", "- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。", ] if context.is_video: @@ -3176,9 +3526,15 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c dur = video_duration(params) except Exception: # noqa: BLE001 dur = SMART_DURATION - lo = max(1, int(dur * 5.0)) - hi = max(lo, min(85, int(dur * 5.7))) + # 长视频不应沿用 15 秒短片的 85 字上限;按自然中文口播速度给整片留出呼吸空间。 + lo = max(1, int(dur * 3.4)) + hi = max(lo, int(dur * 4.0)) lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。") + if dur > LONG_VIDEO_CHAPTER_DURATION: + lines.append( + f"- 当前是 {dur} 秒长视频。write_plan 只交方案卡和紧凑章节稿(每章任务、每个 30 秒段 3–5 个关键镜头、段尾交接)," + "全文约 800–1400 字;禁止把 15 秒短片的制作级长文整篇套用。用户确认方案后,write_prompt 只补短规则,不再扩写逐镜。" + ) lines.append( "- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → " "③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。" @@ -3186,7 +3542,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c lines.append( "- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;" "闲聊与打招呼绝对不要。用户对某一步提出修改时,只重写那一步,不要跳到后面。" - "write_plan 里的 video_prompt 要按上面的秒级分镜规范写满,供平台后台出片使用;不要只给大纲,也禁止只回「好的,有需要再说」。" + + ( + "write_plan 的 video_prompt 按长视频紧凑章节稿来写,不要为了完整去凑字数。" + if dur > LONG_VIDEO_CHAPTER_DURATION else + "write_plan 里的 video_prompt 要按上面的秒级分镜规范写满,供平台后台出片使用;不要只给大纲,也禁止只回「好的,有需要再说」。" + ) ) stage = get_video_gate_stage(conversation) memory = conversation.memory if isinstance(conversation.memory, dict) else {} @@ -3709,7 +4069,7 @@ def apply_click_swap_plan_card( sequence = click_swap_sequence(conversation) if not sequence: return card - total = max(4, min(int(duration or 0), 60)) + total = max(4, min(int(duration or 0), MAX_VIDEO_DURATION)) first_end = max(1, round(total * 0.2)) second_end = max(first_end + 1, round(total * 0.45)) third_end = max(second_end + 1, round(total * 0.75)) @@ -3836,6 +4196,11 @@ def iter_creation_agent_events( user_message = append_message(conversation, role="user", text=text, refs=refs) if user_message is not None: yield {"type": "message", "message": _message_payload(user_message)} + set_public_agent_progress(conversation, "starting") + # brief 里直接写「做一条 120 秒视频」就是明确参数,不能仍沿用顶部的智能时长。 + # 要在任何创作闸门之前落库,后面的策略、脚本和出片分段才会统一使用该时长。 + if not fast_greeting: + apply_explicit_video_duration_from_text(conversation, text) # 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。 # 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。 if fast_greeting: @@ -4013,34 +4378,148 @@ def iter_creation_agent_events( turn_has_gate = False last_text_bubble = None + turn_started = time.monotonic() + turn_deadline = turn_started + CREATION_AGENT_TURN_TIMEOUT_SECONDS + extra_body = creation_model_extra_body(model_config, tools) + truncation_retries = 0 for _round in range(MAX_TOOL_ROUNDS): if is_agent_cancel_requested(conversation.id): # 用户终止:干净收束,不落 ERROR,已落库消息保留 yield {"type": "cancelled"} return text_buffer: list[str] = [] + reasoning_buffer: list[str] = [] tool_buffer: dict = {} - for chunk in provider.chat_completion_stream( - model=model_config.name, - messages=messages, - endpoint=model_config.endpoint or "chat/completions", - extra_body={"tools": tools}, - ): - kind = chunk.get("type") - if kind == "reasoning": - yield {"type": "reasoning", "text": chunk.get("text", "")} - elif kind == "delta": - piece = chunk.get("text", "") - text_buffer.append(piece) - yield {"type": "delta", "text": piece} - elif kind == "tool_call": - _merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls")) + finish_reason = "" + set_public_agent_progress(conversation, "reasoning") + try: + # 单轮上限与整轮预算取小者:一轮卡住不至于吃光整轮,整轮也不会被多轮拖成无限等待。 + round_deadline = min(turn_deadline, time.monotonic() + CREATION_AGENT_MODEL_TIMEOUT_SECONDS) + remaining = max(1.0, round_deadline - time.monotonic()) + for chunk in provider.chat_completion_stream( + model=model_config.name, + messages=messages, + endpoint=model_config.endpoint or "chat/completions", + extra_body=extra_body, + timeout=remaining, + ): + if time.monotonic() >= round_deadline: + raise TimeoutError("creation agent model deadline exceeded") + kind = chunk.get("type") + if kind == "finish": + finish_reason = str(chunk.get("reason") or "") + continue + if kind == "reasoning": + # 只从 reasoning 中识别当前阶段;原文不落库、不向用户展示。 + reasoning_buffer.append(str(chunk.get("text", ""))) + set_public_agent_progress( + conversation, + "reasoning", + detail_key=_public_reasoning_detail_key("".join(reasoning_buffer)), + ) + yield {"type": "reasoning", "text": chunk.get("text", "")} + elif kind == "delta": + set_public_agent_progress(conversation, "responding") + piece = chunk.get("text", "") + text_buffer.append(piece) + yield {"type": "delta", "text": piece} + elif kind == "tool_call": + _merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls")) + except Exception as exc: # noqa: BLE001 — provider 超时类型会因 SDK/requests 而不同 + if is_unsupported_thinking_error(exc) and extra_body.pop("thinking", None): + logger.warning("model rejected thinking param, retrying without it: %s", exc) + continue + is_timeout = isinstance(exc, TimeoutError) or "timeout" in type(exc).__name__.lower() + if not is_timeout: + raise + # 错误卡一落库就撤掉进度,禁止出现「已停止」和「正在分析」并存。 + clear_public_agent_progress(conversation) + restored = restore_gated_step_after_cancel(conversation) + has_open_gate = False + if restored: + open_gate = next(( + item + for item in conversation.messages.filter( + kind=CreationMessage.Kind.ELICIT, + ).order_by("-seq")[:30] + if (item.payload or {}).get("interaction") == "step_confirm" + and not (item.payload or {}).get("submitted") + ), None) + if open_gate is not None: + turn_has_gate = True + has_open_gate = True + yield {"type": "message", "message": _message_payload(open_gate)} + failure = append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ERROR, + text=creation_agent_timeout_notice( + int(time.monotonic() - turn_started), has_open_gate=has_open_gate + ), + ) + yield {"type": "message", "message": _message_payload(failure)} + yield {"type": "done"} + return said = strip_numeric_reply_instruction("".join(text_buffer)) calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")] + + # 撞到 max_tokens 的那一轮,tool 参数往往断在 JSON 中间。此时 _parse_arguments 只会 + # 返回 {},工具随后抱怨「缺正文」,模型被误导去重写而不是压缩 —— 必须显式识别并要求它写短。 + broken_calls = [ + call for call in calls + if str(call.get("arguments") or "").strip() and not _parse_arguments(call["arguments"]) + ] + output_truncated = finish_reason == "length" + if broken_calls or (output_truncated and not calls and not said): + truncation_retries += 1 + logger.warning( + "creation agent output truncated (finish_reason=%s, broken_calls=%s, retry=%s)", + finish_reason or "-", + [call.get("name") for call in broken_calls], + truncation_retries, + ) + if truncation_retries > MAX_TRUNCATION_RETRIES: + clear_public_agent_progress(conversation) + restore_gated_step_after_cancel(conversation) + failure = append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ERROR, + text=TRUNCATION_GIVE_UP_NOTICE, + ) + yield {"type": "message", "message": _message_payload(failure)} + yield {"type": "done"} + return + # 坏 JSON 不进历史:既省上下文,也避免模型照着残稿续写。 + messages.append({"role": "user", "content": TRUNCATION_RETRY_INSTRUCTION}) + continue fallback_fields = None allow_pick = False - if not calls: + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + # 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条转成单选按钮。 + # 这一条要排在「想挑素材」兜底**前面**:用户常说「用这个商品做一条痛点解决演示」, + # 那句话会先命中挑素材兜底,而商品早就钉住了、那张卡随后又被去重抑制, + # 最后三条方向只剩一段纯文字,用户点不了也不该再手抄一遍。 + pain_options = ( + pain_point_direction_options_from_text(said) + if ( + not calls + and allow_plan + and is_pain_point_conversation(conversation) + and not memory.get("selling_point_ready") + ) + else [] + ) + if pain_options: + fallback_fields = [{ + "key": PAIN_POINT_DIRECTION_KEY, + "label": "选择这条视频要重点解决的痛点", + "type": "single", + "required": True, + "options": pain_options, + }] + elif not calls: requested_card = requested_asset_card_from_context(conversation, text) wanted_pick = wanted_asset_pick(text, refs) pick = requested_card or wanted_pick @@ -4072,29 +4551,8 @@ def iter_creation_agent_events( "asset_types": ["product"], }] - # 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条 - # 转成单选按钮;不允许用户再手抄一遍,也不进入重复的核心卖点闸门。 - memory = conversation.memory if isinstance(conversation.memory, dict) else {} - if ( - not calls - and fallback_fields is None - and allow_plan - and is_pain_point_conversation(conversation) - and not memory.get("selling_point_ready") - ): - pain_options = pain_point_direction_options_from_text(said) - if pain_options: - fallback_fields = [{ - "key": PAIN_POINT_DIRECTION_KEY, - "label": "选择这条视频要重点解决的痛点", - "type": "single", - "required": True, - "options": pain_options, - }] - # 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具, # 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。 - memory = conversation.memory if isinstance(conversation.memory, dict) else {} needs_plot_twist_directions = ( not calls and allow_plan @@ -4115,6 +4573,8 @@ def iter_creation_agent_events( break # 有些模型会把完整策略按「核心卖点/目标受众/内容逻辑/视觉调性」写成散文, + # (痛点三方向已经成卡时不走这条:那段列表会被误认成散文策略,而 write_strategy + # 在痛点卖点没定前只回错误、不落卡,整轮就会一张卡都不给。) # 却漏掉 write_strategy 工具调用。高置信识别后直接走同一条结构化策略闸门, # 避免前端退化成一整块普通聊天气泡,也避免缺失「按这个继续」。 current_stage = get_video_gate_stage(conversation) @@ -4124,7 +4584,7 @@ def iter_creation_agent_events( ) prose_strategy = ( strategy_args_from_text(said) - if not calls and context.is_video and may_write_strategy + if not calls and context.is_video and may_write_strategy and not pain_options else {} ) if prose_strategy: @@ -4181,6 +4641,7 @@ def iter_creation_agent_events( return name = call["name"] args = _parse_arguments(call["arguments"]) + set_public_agent_progress(conversation, name) yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"} try: result, stop_after = _dispatch_tool(context, name, args) @@ -4340,6 +4801,10 @@ def run_creation_agent_turn( logger.exception("creation agent turn: failed to append error message") awaiting_user = False finally: + try: + clear_public_agent_progress(conversation) + except Exception: # noqa: BLE001 — 清临时进度失败不该影响规划收束 + logger.exception("creation agent turn: failed to clear public progress") try: conversation.refresh_from_db(fields=["agent_status", "team_id"]) except Exception: # noqa: BLE001 @@ -4470,15 +4935,19 @@ def _asset_ask_already_resolved(conversation: CreationConversation, fields: list if not fields: return None field = fields[0] if isinstance(fields[0], dict) else {} - types = [t for t in (field.get("asset_types") or infer_field_types(field) or []) if t in TYPE_LABELS] key = str(field.get("key") or "").strip().lower() - if not types: - if key in {"character", "person", "model", "product", "scene", "asset"}: - types = [key if key != "person" else "character"] - elif field.get("type") != "asset": - return None - else: - types = ["product"] + declared = [t for t in (field.get("asset_types") or []) if t in TYPE_LABELS] + if declared: + types = declared + elif key in {"character", "person", "model", "product", "scene", "asset"}: + types = [key if key != "person" else "character"] + elif str(field.get("type") or "").strip() == "asset": + types = [t for t in (infer_field_types(field) or []) if t in TYPE_LABELS] or ["product"] + else: + # 非素材卡(痛点方向、时长、卖点等单选)不参与素材去重。 + # infer_field_types 认不出字段时会回落成「全部素材类型」,照着去重会在 + # 锁人或锁商品之后把这些卡整张吃掉,那一轮就一个可点的东西都不剩。 + return None memory = conversation.memory if isinstance(conversation.memory, dict) else {} if any(t in {"character", "model", "person"} for t in types): if person_identity_ready(conversation): @@ -4535,7 +5004,19 @@ def _dispatch_tool( fields = _coerce_fields(args.get("fields")) if not fields: return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False - skip_reason = _asset_ask_already_resolved(context.conversation, fields) + # 临时隐藏 >60 秒长视频:模型自拟的时长选项也裁掉 + for field in fields: + if not isinstance(field, dict): + continue + key = str(field.get("key") or "") + opts = field.get("options") + if not isinstance(opts, list) or not opts: + continue + if key == "duration" or any("秒" in str((o.get("label") if isinstance(o, dict) else o) or "") for o in opts): + field["options"] = filter_visible_duration_options(opts) + # allow_pick = 用户明确要「把列表发出来」。这种点名要卡的请求不能被去重吃掉, + # 否则上一轮回过「你发一下卡片我选择」之后,再要卡永远只得到一句话。 + skip_reason = None if allow_pick else _asset_ask_already_resolved(context.conversation, fields) if skip_reason: return {"payload": {"skipped": True, "reason": "already_resolved", "note": skip_reason}}, False payload = _elicit_payload_for_fields(fields, allow_pick=allow_pick, is_video=context.is_video) @@ -4729,10 +5210,39 @@ def _dispatch_tool( "整条视频必须围绕这个卖点展开,并用真实使用动作或素材可见细节证明;不得替换、夸大或新增未经确认的功效。" ) raw_duration = _raw_script_duration(timeline=card["timeline"], prompt=video_prompt) - if raw_duration is not None and raw_duration > 60: + if raw_duration is not None and raw_duration > MAX_VIDEO_DURATION: return { "payload": {"error": "脚本时长不能超过 60 秒。请把方案收束到 60 秒以内,再重新给出完整时间轴和出片指令。"} }, False + requested_duration = video_duration( + context.conversation.params or {}, + prompt=video_prompt, + timeline=card["timeline"], + ) + if requested_duration > LONG_VIDEO_CHAPTER_DURATION and not long_video_script_covers_requested_duration( + raw_duration, requested_duration + ): + covered = f"目前时间轴到 {raw_duration} 秒" if raw_duration else "目前没有完整时间轴" + return { + "payload": { + "error": ( + f"长视频脚本必须完整覆盖 {requested_duration} 秒,{covered}。" + "请按每章不超过 60 秒重写章节结构,并补齐每个 30 秒边界的交接状态和逐镜出片指令。" + ) + } + }, False + if requested_duration > LONG_VIDEO_CHAPTER_DURATION and len(card["timeline"]) < ( + requested_duration + LONG_VIDEO_CHAPTER_DURATION - 1 + ) // LONG_VIDEO_CHAPTER_DURATION: + return { + "payload": { + "error": ( + f"{requested_duration} 秒方案需要至少 " + f"{(requested_duration + LONG_VIDEO_CHAPTER_DURATION - 1) // LONG_VIDEO_CHAPTER_DURATION} 个连续章节。" + "请补齐章节目标、承接关系和商品证据,再重新提交完整方案。" + ) + } + }, False duration = resolve_smart_video_duration( context.conversation, prompt=video_prompt, diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index ab28309..89d1c01 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -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秒完整短剧带货或智能推荐;暂不开放更长时长),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。", "商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。", "达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index ee98f1c..fed7be2 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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,否则真人素材直接被火山拒。 diff --git a/core/backend/apps/ai/mentions.py b/core/backend/apps/ai/mentions.py index a478713..a0b9bfd 100644 --- a/core/backend/apps/ai/mentions.py +++ b/core/backend/apps/ai/mentions.py @@ -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) diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py index f5847cd..0d72800 100644 --- a/core/backend/apps/ai/providers/volcano.py +++ b/core/backend/apps/ai/providers/volcano.py @@ -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( diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index 693279f..040f779 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -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) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 507ec07..3833dd3 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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: diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 079365b..1f8555c 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -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("800–1400", 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("1800–3200", 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 diff --git a/core/backend/apps/ai/test_creation_conversation.py b/core/backend/apps/ai/test_creation_conversation.py index 3d084be..e1b377c 100644 --- a/core/backend/apps/ai/test_creation_conversation.py +++ b/core/backend/apps/ai/test_creation_conversation.py @@ -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") diff --git a/core/backend/apps/ai/test_creation_mentions.py b/core/backend/apps/ai/test_creation_mentions.py index 52181e5..61acbcc 100644 --- a/core/backend/apps/ai/test_creation_mentions.py +++ b/core/backend/apps/ai/test_creation_mentions.py @@ -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="裸商品") diff --git a/core/backend/apps/ai/test_entity_extraction_routing.py b/core/backend/apps/ai/test_entity_extraction_routing.py index d91c56c..d10e6cd 100644 --- a/core/backend/apps/ai/test_entity_extraction_routing.py +++ b/core/backend/apps/ai/test_entity_extraction_routing.py @@ -1,35 +1,33 @@ +"""实体提取已经改成**本地**拆脚本:不调模型、不扣积分、不换模型重试。 + +这份文件原来锁的是「调模型 + 失败换模型 + 按 token 结算」那条链路(AIModelAttempt、 +CreditLedger、model_routing_v1)。那条链路已经随 submit_extract_entities 改成本地提取一起下线, +`run_extract_entities_task` 现在没有任何地方会入队。所以这里改为锁住真正在跑的契约: +提取必须零成本、不留模型调用痕迹,脚本缺 entities 时本地补齐。 + +落库内容本身(cast/scenes/entity_refs 回填)由 apps/projects/tests.py 的本地提取用例覆盖。 +""" + from decimal import Decimal -from unittest.mock import Mock, patch +from unittest.mock import patch -import requests from django.test import TestCase from apps.accounts.models import Team, TeamMember, User from apps.ai.models import AITask, ModelConfig, ModelProvider -from apps.ai.services import run_extract_entities_task, submit_extract_entities +from apps.ai.services import submit_extract_entities from apps.billing.models import CreditAccount, CreditLedger from apps.products.models import Product from apps.projects.models import Project, ScriptSegment, ScriptVersion -def _metadata(*, outbound=True, base_cost="0.50"): - return { - "routing": {"fallback_on_failure": outbound, "fallback_candidate": True}, - "capabilities": { - "operations": ["chat"], - "features": ["streaming", "structured_output"], - }, - "pricing": {"base_cost_yuan": base_cost}, - } - - -class EntityExtractionRoutingTests(TestCase): +class LocalEntityExtractionTests(TestCase): def setUp(self): ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update( status=ModelConfig.Status.DISABLED ) - self.user = User.objects.create_user(username="entity-routing", password="x") - self.team = Team.objects.create(name="Entity Routing", owner=self.user) + self.user = User.objects.create_user(username="entity-local", password="x") + self.team = Team.objects.create(name="Entity Local", owner=self.user) TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) CreditAccount.objects.create(team=self.team, balance=Decimal("1000")) product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品") @@ -37,222 +35,77 @@ class EntityExtractionRoutingTests(TestCase): team=self.team, created_by=self.user, product=product, - name="实体提取路由项目", + name="实体提取项目", metadata={"cast": ["旧角色"], "entities_extracted": False}, ) + provider = ModelProvider.objects.create( + name="entity-local-provider", + display_name="entity-local-provider", + status=ModelProvider.Status.ACTIVE, + ) + ModelConfig.objects.create( + provider=provider, + name="entity-local-model", + display_name="entity-local-model", + capability=ModelConfig.Capability.TEXT, + endpoint="chat/completions", + unit_price=Decimal("10"), + status=ModelConfig.Status.ACTIVE, + ) self.script = ScriptVersion.objects.create( project=self.project, title="脚本", content="结构化脚本", is_adopted=True, + metadata={"entities": [ + {"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女主"}, + {"id": "s1", "type": "scene", "name": "客厅", "visual_prompt": "现代客厅"}, + ]}, ) - self.segment = ScriptSegment.objects.create( + ScriptSegment.objects.create( script_version=self.script, sort_order=0, narration="女主在客厅展示商品", visual_prompt="女主站在客厅", entity_refs=["old"], ) - self.provider_mocks = {} - patch("apps.ai.services.get_text_provider", side_effect=self._provider_for).start() - patch("apps.ai.tasks.extract_entities_task.delay").start() - self.addCleanup(patch.stopall) - def provider(self, name, priority): - return ModelProvider.objects.create( - name=name, - display_name=name, - status=ModelProvider.Status.ACTIVE, - metadata={"routing": {"fallback_priority": priority}}, - ) + def test_extraction_is_free_and_never_calls_a_model(self): + with patch("apps.ai.services.get_text_provider") as get_provider: + task = submit_extract_entities(project=self.project, user=self.user) - def model(self, provider, name, *, outbound=True, base_cost="0.50", is_default=False): - return ModelConfig.objects.create( - provider=provider, - name=name, - display_name=name, - capability=ModelConfig.Capability.TEXT, - endpoint="chat/completions", - unit_price=Decimal("10"), - status=ModelConfig.Status.ACTIVE, - is_default=is_default, - metadata=_metadata(outbound=outbound, base_cost=base_cost), - ) + get_provider.assert_not_called() + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + self.assertEqual(task.request_payload.get("mode"), "local") + self.assertEqual(task.estimated_cost, Decimal("0")) + self.assertEqual(task.actual_cost, Decimal("0")) + self.assertEqual(task.base_cost, Decimal("0")) + # 不走模型就不该留下换模型痕迹,也不该有任何积分流水。 + self.assertFalse(task.model_attempts.exists()) + self.assertFalse(CreditLedger.objects.filter(task=task).exists()) - @staticmethod - def valid_events(): - return [ - { - "type": "delta", - "text": ( - '{"entities":[' - '{"id":"c1","type":"character","name":"女主","visual_prompt":"都市女主"},' - '{"id":"s1","type":"scene","name":"客厅","visual_prompt":"现代客厅"}' - '],"segments":[{"index":0,"entity_refs":["c1","s1"]}]}' - ), - }, - {"type": "done"}, - ] + def test_entities_are_persisted_and_old_metadata_is_replaced(self): + submit_extract_entities(project=self.project, user=self.user) - @staticmethod - def invalid_events(): - return [{"type": "delta", "text": "这是一段没有 JSON 的解释"}, {"type": "done"}] - - @classmethod - def _new_provider_mock(cls): - provider = Mock() - provider.chat_completion_stream.return_value = cls.valid_events() - return provider - - def _provider_for(self, model): - return self.provider_mocks.setdefault(model.id, self._new_provider_mock()) - - def submit(self): - return submit_extract_entities(project=self.project, user=self.user) - - @staticmethod - def ledger_count(task, ledger_type): - return CreditLedger.objects.filter(task=task, ledger_type=ledger_type).count() - - def test_first_success_records_streaming_structured_attempt_and_persists_entities(self): - primary = self.model(self.provider("entity-primary", 20), "entity-primary", is_default=True) - task = self.submit() - - run_extract_entities_task(task_id=str(task.id)) - - task.refresh_from_db() self.project.refresh_from_db() - self.segment.refresh_from_db() - self.assertEqual(task.status, AITask.Status.SUCCEEDED) - self.assertTrue(task.request_payload["model_routing_v1"]) - attempt = task.model_attempts.get() - self.assertEqual(attempt.model_config_id, primary.id) - self.assertEqual(attempt.operation, "chat") - self.assertTrue(attempt.request_summary["streaming"]) - self.assertTrue(attempt.request_summary["structured_output"]) - self.assertEqual(attempt.request_summary["business_operation"], "entity_extract") - self.assertEqual(attempt.public_model_name, "AirShelf Script") - self.assertTrue(self.project.metadata["entities_extracted"]) - self.assertEqual(self.project.metadata["cast"], ["女主"]) - self.assertEqual(self.project.metadata["scenes"], ["客厅"]) - self.assertEqual(self.segment.entity_refs, ["c1", "s1"]) - call = self.provider_mocks[primary.id].chat_completion_stream.call_args - self.assertGreater(call.kwargs["timeout"], 0) - self.assertEqual(call.kwargs["temperature"], 0.3) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1) + metadata = self.project.metadata or {} + self.assertTrue(metadata.get("entities_extracted")) + self.assertIn("女主", metadata.get("cast") or []) + self.assertNotIn("旧角色", metadata.get("cast") or []) - def test_invalid_structure_retries_then_falls_back_and_records_failed_call_costs(self): - primary = self.model( - self.provider("entity-fallback-primary", 100), - "entity-primary", - base_cost="0.25", + def test_script_without_segments_is_rejected_before_any_task(self): + empty_script = ScriptVersion.objects.create( + project=self.project, title="空脚本", content="{}", is_adopted=True, ) - candidate = self.model( - self.provider("entity-fallback-candidate", 10), - "entity-candidate", - outbound=False, - base_cost="0.75", + self.script.is_adopted = False + self.script.save(update_fields=["is_adopted"]) + + with self.assertRaises(ValueError): + submit_extract_entities(project=self.project, user=self.user) + + self.assertFalse( + AITask.objects.filter( + project=self.project, task_type=AITask.Type.ENTITY_EXTRACTION + ).exists() ) - primary_mock = self._new_provider_mock() - primary_mock.chat_completion_stream.side_effect = [ - self.invalid_events(), - self.invalid_events(), - self.invalid_events(), - ] - self.provider_mocks[primary.id] = primary_mock - task = self.submit() - - run_extract_entities_task(task_id=str(task.id)) - - task.refresh_from_db() - attempts = list(task.model_attempts.all()) - self.assertEqual(task.status, AITask.Status.SUCCEEDED) - self.assertEqual( - [a.model_config_id for a in attempts], - [primary.id, primary.id, primary.id, candidate.id], - ) - self.assertEqual( - [a.status for a in attempts], - ["failed", "failed", "failed", "succeeded"], - ) - self.assertTrue(attempts[-1].is_fallback) - self.assertTrue(attempts[0].response_summary["validation_failed"]) - self.assertEqual(task.base_cost, Decimal("1.5000")) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0) - - def test_all_candidates_fail_releases_and_preserves_old_project_metadata(self): - primary = self.model(self.provider("entity-all-primary", 100), "entity-primary") - fallback_1 = self.model( - self.provider("entity-all-fallback-1", 10), "entity-fallback-1", outbound=False - ) - fallback_2 = self.model( - self.provider("entity-all-fallback-2", 20), "entity-fallback-2", outbound=False - ) - for model in (primary, fallback_1, fallback_2): - provider = self._new_provider_mock() - provider.chat_completion_stream.side_effect = requests.ConnectionError("offline") - self.provider_mocks[model.id] = provider - task = self.submit() - - run_extract_entities_task(task_id=str(task.id)) - - task.refresh_from_db() - self.project.refresh_from_db() - self.segment.refresh_from_db() - self.assertEqual(task.status, AITask.Status.FAILED) - self.assertEqual(task.model_attempts.count(), 4) - self.assertEqual(self.project.metadata["cast"], ["旧角色"]) - self.assertFalse(self.project.metadata["entities_extracted"]) - self.assertEqual(self.segment.entity_refs, ["old"]) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1) - self.assertEqual(CreditAccount.objects.get(team=self.team).reserved_balance, Decimal("0")) - - def test_direct_doubao_primary_retries_but_does_not_switch_when_outbound_disabled(self): - primary = self.model( - self.provider("doubao", 10), "doubao-seed-2-0-pro-260215", outbound=False - ) - candidate = self.model( - self.provider("entity-unused-candidate", 20), "entity-unused", outbound=False - ) - provider = self._new_provider_mock() - provider.chat_completion_stream.side_effect = requests.ConnectionError("offline") - self.provider_mocks[primary.id] = provider - task = self.submit() - - run_extract_entities_task(task_id=str(task.id)) - - task.refresh_from_db() - attempts = list(task.model_attempts.all()) - self.assertEqual(task.status, AITask.Status.FAILED) - self.assertEqual( - [attempt.model_config_id for attempt in attempts], - [primary.id, primary.id, primary.id], - ) - self.assertFalse(any(attempt.model_config_id == candidate.id for attempt in attempts)) - self.assertTrue(all(attempt.public_model_name == primary.display_name for attempt in attempts)) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1) - self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 1) - - def test_uses_database_default_text_model_not_older_doubao_pin(self): - older = self.model(self.provider("volcengine-old", 20), "doubao-seed-2-0-pro-260215") - default = self.model( - self.provider("volcengine-default", 10), - "doubao-seed-2-1-pro-260628", - is_default=True, - ) - task = self.submit() - - run_extract_entities_task(task_id=str(task.id)) - - task.refresh_from_db() - attempt = task.model_attempts.get() - self.assertEqual(task.status, AITask.Status.SUCCEEDED) - self.assertEqual(attempt.model_config_id, default.id) - self.assertNotEqual(attempt.model_config_id, older.id) - self.assertEqual(task.request_payload["model"], "doubao-seed-2-1-pro-260628") - + self.assertFalse(empty_script.segments.exists()) diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index b23469c..2dee0e2 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -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, "余额不足"): diff --git a/core/backend/apps/ai/test_free_video_asset_ref.py b/core/backend/apps/ai/test_free_video_asset_ref.py index 1932658..f5ae492 100644 --- a/core/backend/apps/ai/test_free_video_asset_ref.py +++ b/core/backend/apps/ai/test_free_video_asset_ref.py @@ -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 diff --git a/core/backend/apps/ai/test_script_entry_source.py b/core/backend/apps/ai/test_script_entry_source.py index ffca899..b681814 100644 --- a/core/backend/apps/ai/test_script_entry_source.py +++ b/core/backend/apps/ai/test_script_entry_source.py @@ -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": [], } diff --git a/core/backend/apps/ai/test_script_stream_routing.py b/core/backend/apps/ai/test_script_stream_routing.py index ccca47b..fb4aa62 100644 --- a/core/backend/apps/ai/test_script_stream_routing.py +++ b/core/backend/apps/ai/test_script_stream_routing.py @@ -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"], diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 6852301..b099148 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -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(删除联动)端到端: diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index 2624ef7..c9567ab 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -33,6 +33,7 @@ from .free_video import ( IN_FLIGHT_STATUSES, RATIOS, RESOLUTIONS, + normalize_video_seed, _reap_stale_free_video_tasks, model_duration_range, serialize_free_video_task, @@ -654,6 +655,21 @@ def video_replace_q() -> Q: return Q(request_payload__feature=FEATURE) | Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX) +def not_video_replace_q() -> Q: + """「不是视频复刻」。不能写成 exclude(video_replace_q())。 + + JSON 键不存在时取值是 SQL NULL,NOT(NULL = 'x') 仍是 NULL,整行会被 exclude 一起筛掉 —— + 于是 request_payload 里没有 feature 的历史任务在列表和回收站里凭空消失。必须把「键缺失」 + 显式算作不匹配。 + """ + return ( + Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature=FEATURE) + ) & ( + Q(request_payload__prompt__isnull=True) + | ~Q(request_payload__prompt__startswith=LEGACY_PROMPT_PREFIX) + ) + + def _fresh_digest_source(payload: dict) -> dict | None: """历史卡「原视频」用的原片快照。快照 URL 会过期,统一走 rehydrate_ref_urls 重新取长期直链。""" from .free_video import rehydrate_ref_urls @@ -1028,10 +1044,7 @@ def _legacy_start_pending_replace_shots(task): generate_audio = bool(payload.get("generate_audio", True)) search_mode = str(payload.get("search_mode") or "off") feature = str(payload.get("feature") or FEATURE) - try: - seed = int(payload.get("seed") if payload.get("seed") is not None else -1) - except (TypeError, ValueError): - seed = -1 + seed = normalize_video_seed(payload.get("seed")) billed_duration = sum(int(item.get("seconds") or 0) for item in plan) or int(payload.get("duration") or 5) references = _seedance_references(payload) try: @@ -1238,10 +1251,7 @@ def _dispatch_next_replace_shot(task, shot: dict): locked.request_payload = next_payload locked.save(update_fields=["request_payload", "updated_at"]) - try: - seed = int(payload.get("seed") if payload.get("seed") is not None else -1) - except (TypeError, ValueError): - seed = -1 + seed = normalize_video_seed(payload.get("seed")) dispatched = _dispatch_free_video_provider( task=locked, built=built, @@ -1403,7 +1413,9 @@ def _concat_shot_media(urls: list[str]) -> bytes: "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-threads", "2", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", str(output), ] - proc = subprocess.run(cmd, capture_output=True, timeout=180) + # 180 秒成片最多 6 段,1080p 重编码明显可能超过旧的 180 秒固定超时。 + # 按片段数放宽,但仍保留上限式超时,避免异常 ffmpeg 永久占住 worker。 + proc = subprocess.run(cmd, capture_output=True, timeout=max(180, len(paths) * 120)) if proc.returncode != 0 or not output.exists() or not output.stat().st_size: err = (proc.stderr or b"").decode("utf-8", errors="replace")[-500:] raise RuntimeError(f"镜头拼接失败:{err or 'ffmpeg 未产出文件'}") @@ -1605,10 +1617,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F if available < reserve_amount: raise ValueError("团队余额不足,请充值后重试") - try: - seed = int(params.get("seed") if params.get("seed") is not None else -1) - except (TypeError, ValueError): - seed = -1 + seed = normalize_video_seed(params.get("seed")) request_payload = { "feature": FEATURE, "mode": "universal", diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 926ecf2..a98e229 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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 键缺失时比较结果是 NULL,exclude 会把没写 feature 的历史任务一起筛没。""" + return Q(request_payload__feature__isnull=True) | ~Q(request_payload__feature="omni_create") + + def _free_video_list_queryset(team, *, include_replace=False): """正常任务流隐藏已从资产库删除的成品,但保留生成中/失败及无落库资产的历史任务。""" - from .video_replace import video_replace_q + from .video_replace import not_video_replace_q, video_replace_q video_assets = Asset.objects.filter(origin_task_id=OuterRef("pk"), asset_type=Asset.Type.VIDEO) active_video_assets = video_assets.filter(is_deleted=False, purged_at__isnull=True) @@ -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 diff --git a/core/backend/apps/assets/character_tags.py b/core/backend/apps/assets/character_tags.py new file mode 100644 index 0000000..907b1c6 --- /dev/null +++ b/core/backend/apps/assets/character_tags.py @@ -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), '/')}" diff --git a/core/backend/apps/assets/management/commands/import_character_pack.py b/core/backend/apps/assets/management/commands/import_character_pack.py new file mode 100644 index 0000000..3af1210 --- /dev/null +++ b/core/backend/apps/assets/management/commands/import_character_pack.py @@ -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, + }, + ) diff --git a/core/backend/apps/assets/migrations/0011_model_library_tag.py b/core/backend/apps/assets/migrations/0011_model_library_tag.py new file mode 100644 index 0000000..884de22 --- /dev/null +++ b/core/backend/apps/assets/migrations/0011_model_library_tag.py @@ -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'], + }, + ), + ] diff --git a/core/backend/apps/assets/model_library_tags.py b/core/backend/apps/assets/model_library_tags.py new file mode 100644 index 0000000..eea5aab --- /dev/null +++ b/core/backend/apps/assets/model_library_tags.py @@ -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 diff --git a/core/backend/apps/assets/models.py b/core/backend/apps/assets/models.py index d876482..6ecd321 100644 --- a/core/backend/apps/assets/models.py +++ b/core/backend/apps/assets/models.py @@ -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 万)。""" diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index 41d303b..b39a1c7 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -888,22 +888,52 @@ class ReviewScopeTests(TestCase): ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"), ) - def test_poll_team_reviews_only_scans_review_categories(self): + def test_poll_only_covers_assets_that_really_reached_volcano(self): + """轮询的判据是「拿到了火山远端 Id」,不是类别。 + + force 送审的上传视频、临时图也会是 processing,worker 必须盯到绿/红; + 反过来,没有远端 Id 的 processing 是没真正送出去的,轮它只会白打请求。 + 送审范围仍然只挑 REVIEW_CATEGORIES(见下一条)。 + """ from apps.assets import review _, team = _mk_team("uc", "TeamC") - keep = {} + polled = {} for cat in ("person", "model_portrait", "tri_view", "storyboard", "scene", "product_image"): a = Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated", - category=cat, review_status="processing") - keep[cat] = str(a.id) - # 图片趴:不应进送审队列 - for cat in ("model_tryon", "platform_kit", "free_create"): - Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated", - category=cat, review_status="processing") + category=cat, review_status="processing", + review_remote_id=f"remote-{cat}") + polled[cat] = str(a.id) + # 图片趴不在送审范围里,但已经 force 送出去的同样要盯到终态。 + forced = Asset.objects.create( + team=team, name="force", asset_type="image", source="ai_generated", + category="free_create", review_status="processing", review_remote_id="remote-force", + ) + # 没有远端 Id = 没真送出去,不该被轮询。 + Asset.objects.create(team=team, name="没送出去", asset_type="image", source="ai_generated", + category="person", review_status="processing") + with patch("apps.assets.review.assets_client.is_enabled", return_value=False): out = review.poll_team_reviews(team) - self.assertEqual(set(out.keys()), set(keep.values())) + + self.assertEqual(set(out.keys()), set(polled.values()) | {str(forced.id)}) + + def test_submission_still_only_covers_review_categories(self): + from apps.assets import review + + _, team = _mk_team("uc-sub", "TeamCSub") + for cat in ("person", "product_image"): + Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated", + category=cat, review_status="") + for cat in ("model_tryon", "platform_kit", "free_create"): + Asset.objects.create(team=team, name=cat, asset_type="image", source="ai_generated", + category=cat, review_status="") + + with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub: + review.submit_unsubmitted_reviews(team=team) + + submitted = {asset.category for (asset,), _ in (call for call in sub.call_args_list)} + self.assertEqual(submitted, {"person", "product_image"}) def test_poll_team_reviews_auto_submits_unsubmitted(self): from apps.assets import review @@ -931,6 +961,7 @@ class ReviewScopeTests(TestCase): proc = Asset.objects.create( team=team, name="审中", asset_type="image", source="ai_generated", category=Asset.Category.PERSON, review_status="processing", + review_remote_id="remote-proc", # 只有真的送到火山、拿到 Id 的才会被轮询 ) with patch("apps.assets.review.submit_asset_for_review", return_value=True) as sub, patch( "apps.assets.review.poll_asset_review", return_value="active" diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 69fc4b3..30ace91 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -43,6 +43,15 @@ _KNOWN_CATS = [ ] +def _not_omni_create_q() -> Q: + """「不是全能创作产出」。不能只写 ~Q(metadata__feature="omni_create")。 + + metadata 里没有 feature 键时取值是 SQL NULL,NOT(NULL = 'x') 还是 NULL,整行会被筛掉 —— + 历史资产(没写过 feature)会从「视频自由创作」「自由创作」「素材」这些分桶里整批消失。 + """ + return Q(metadata__feature__isnull=True) | ~Q(metadata__feature="omni_create") + + def _tab_q(tab: str) -> Q: if tab == "people": return Q(category="person") @@ -57,13 +66,13 @@ def _tab_q(tab: str) -> Q: if tab == "image_creations": # 图片自由创作 return Q(category="free_create", asset_type="image") if tab == "video_creations": # 视频自由创作 - return Q(category="free_create", asset_type="video") & ~Q(metadata__feature="omni_create") + return Q(category="free_create", asset_type="video") & _not_omni_create_q() if tab == "creations": # 自由创作(兼容旧入口:图片+视频) - return Q(category="free_create") & ~Q(metadata__feature="omni_create") + return Q(category="free_create") & _not_omni_create_q() if tab == "uploads": return Q(category="upload") if tab == "materials": # 素材(期3):视频素材 = 所有视频,排除「最终成片」(final_video 隐藏不列) - return ~Q(category="final_video") & Q(asset_type="video") & ~Q(metadata__feature="omni_create") + return ~Q(category="final_video") & Q(asset_type="video") & _not_omni_create_q() if tab == "others": # 其他(资产库成品化):我的上传 + 未归类非视频(兜底) return Q(category="upload") | (~Q(category__in=_KNOWN_CATS) & ~Q(asset_type="video")) if tab == "unclassified": # 未归类且非视频(也不含最终成片) @@ -482,6 +491,14 @@ class ModelLibraryViewSet(ModelViewSet): if self.request.query_params.get("q"): q = self.request.query_params["q"] qs = qs.filter(Q(name__icontains=q) | Q(description__icontains=q)) + # 标签筛选: ?tag=男人 或 ?tag=男人&tag=警察(多标签 AND) + raw_tags = self.request.query_params.getlist("tag") or [] + if not raw_tags: + joined = (self.request.query_params.get("tags") or "").strip() + if joined: + raw_tags = [t.strip() for t in joined.split(",") if t.strip()] + for tag in raw_tags: + qs = qs.filter(metadata__tags__contains=[tag]) # 官方模板靠前,再按新→旧 return qs.order_by("-is_official", "-created_at") @@ -605,6 +622,18 @@ class ModelLibraryViewSet(ModelViewSet): model.save(update_fields=["purged_at", "updated_at"]) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=False, methods=["get"], url_path="tags") + def tags(self, request): + """当前可见模特里出现过的标签(供前端筛选条)· 只返回后台标记为显示的。""" + from .model_library_tags import public_tag_rows + + tab = (request.query_params.get("tab") or "").strip() or None + if tab not in (None, "official", "mine"): + tab = None + team = self.get_team() + items = public_tag_rows(tab=tab, team_id=getattr(team, "id", None)) + return Response({"results": items}) + @action(detail=False, methods=["post"], url_path="upload", parser_classes=[MultiPartParser, FormParser]) def upload(self, request): """真人上传:一张人像图 → 建 model_portrait 资产 + Model(source=upload)。三视图/声线后续补。""" @@ -640,13 +669,27 @@ class ModelLibraryViewSet(ModelViewSet): size_bytes=stored.size_bytes, is_primary=True, ) + raw_tags = request.data.get("tags") + tags: list[str] = [] + if isinstance(raw_tags, str) and raw_tags.strip(): + import json + try: + parsed = json.loads(raw_tags) + if isinstance(parsed, list): + tags = [str(t).strip() for t in parsed if str(t).strip()] + else: + tags = [t.strip() for t in raw_tags.split(",") if t.strip()] + except Exception: + tags = [t.strip() for t in raw_tags.split(",") if t.strip()] + elif isinstance(raw_tags, list): + tags = [str(t).strip() for t in raw_tags if str(t).strip()] model = Model.objects.create( team=team, created_by=request.user, name=name, source=Model.Source.UPLOAD, portrait_asset=portrait, - metadata={"source": "upload"}, + metadata={"source": "upload", "tags": tags}, ) return Response(ModelLibrarySerializer(model).data, status=status.HTTP_201_CREATED) diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 1dd61d0..a0d7f0d 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -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): + + + + diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index dea07af..951b3ed 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -934,7 +934,6 @@ export function App() { } detail="为保护账号安全,当前设备的登录已失效。点击确认后返回登录页面。" confirmText="确认并返回登录" diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index a8fd148..1cb0ce5 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -323,9 +323,16 @@ export const api = { revokeInvitation(id: string) { return request(`/api/auth/team/invitations/${id}/revoke/`, { method: "POST" }); }, - products(pageSize?: number) { - const query = pageSize ? `?page_size=${pageSize}` : ""; - return request>(`/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>(`/api/products/${query ? `?${query}` : ""}`); }, product(id: string) { return request(`/api/products/${id}/`); @@ -875,14 +882,22 @@ export const api = { return request(`/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>(`/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("/api/models/upload/", { method: "POST", body: formData }); @@ -1216,7 +1231,7 @@ export const adminApi = { const q = qs.toString(); return request>(`/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("/api/admin/users/", { method: "POST", body: JSON.stringify(payload) }); }, adjustUserCredit(id: string, payload: { amount: string; reason?: string }) { diff --git a/core/frontend/src/components/asset-select-modal.css b/core/frontend/src/components/asset-select-modal.css index 39e5eae..a4e858f 100644 --- a/core/frontend/src/components/asset-select-modal.css +++ b/core/frontend/src/components/asset-select-modal.css @@ -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; +} diff --git a/core/frontend/src/components/asset-select-modal.tsx b/core/frontend/src/components/asset-select-modal.tsx index edb64bf..abb02d5 100644 --- a/core/frontend/src/components/asset-select-modal.tsx +++ b/core/frontend/src/components/asset-select-modal.tsx @@ -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(); - -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([]); - const [loadedKey, setLoadedKey] = useState(""); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [selectedRef, setSelectedRef] = useState(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({ {isAddingNew ? `新增${titleText}` : title} - {isAddingNew ? "[ UPLOAD · CREATE ]" : subtitle} {isAddingNew ? ( @@ -328,8 +342,8 @@ export function AssetSelectModal({ 按名称 - - {!hasCurrentData ? ( + + {loading && items.length === 0 ? ( {Array.from({ length: 10 }, (_, index) => ( @@ -353,38 +367,42 @@ export function AssetSelectModal({ ) : null} ) : ( - - {visibleItems.map((item) => { - const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id; - const displayName = item.name.split(" · ")[0]; - return ( - setSelectedRef(item)} - onDoubleClick={() => { - onSelect(item); - onClose(); - }} - > - - {item.cover ? ( - - ) : ( - - {type === "product" ? : } - - )} - {isSelected ? : null} - - {displayName} - // {item.type === "product" ? "商品" : item.type === "model" ? "角色库" : "角色素材"} - - ); - })} - + <> + + {visibleItems.map((item) => { + const isSelected = selectedRef?.type === item.type && selectedRef.id === item.id; + const displayName = item.name.split(" · ")[0]; + return ( + setSelectedRef(item)} + onDoubleClick={() => { + onSelect(item); + onClose(); + }} + > + + {item.cover ? ( + + ) : ( + + {type === "product" ? : } + + )} + {isSelected ? : null} + + {displayName} + + ); + })} + + + + + > )} {loading ? ( @@ -386,15 +398,16 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { - 用户名积分余额所属团队状态注册时间操作 + 用户名登录账号积分余额所属团队状态注册时间操作 {users.map((u) => ( - {u.username} + {u.first_name || u.username} {u.is_platform_admin && 超管} + {u.username} {u.wallet_team ? `${pts(u.balance)} 积分` : —} @@ -436,10 +449,25 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { - 直接开户,不用发邀请码。建好即可用该用户名密码登录,并拥有一个独立的积分钱包。 + 直接开户,不用发邀请码。用户名可中文展示;登录账号仅英文+数字且必须 6 位,建好后用登录账号和密码登录。 - 用户名 * - setNewName(e.target.value)} /> + 用户名 * + setNewName(e.target.value)} /> + + + 登录账号 * (6 位英文或数字) + setNewLogin(e.target.value.replace(/[^A-Za-z0-9]/g, "").slice(0, 6).toLowerCase())} + /> 初始密码 * (至少 8 位) diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 75fceb5..898dab0 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -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", diff --git a/core/frontend/src/routes/auth-screen.tsx b/core/frontend/src/routes/auth-screen.tsx index 82bdb9a..de125ee 100644 --- a/core/frontend/src/routes/auth-screen.tsx +++ b/core/frontend/src/routes/auth-screen.tsx @@ -266,7 +266,6 @@ export function AuthScreen({ YINGQING AIGC STUDIO - // AIGC COMMERCE CONTENT ENGINE 让每一次商品表达, diff --git a/core/frontend/src/routes/models.tsx b/core/frontend/src/routes/models.tsx index 48c9559..d9c10d8 100644 --- a/core/frontend/src/routes/models.tsx +++ b/core/frontend/src/routes/models.tsx @@ -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 && 官方模板} {model.source === "upload" ? "真人上传" : "AI 生成"} {pendingPortrait && 待保存} + {(Array.isArray(model.metadata?.tags) ? model.metadata.tags : []).filter((t): t is string => typeof t === "string" && Boolean(t.trim())).map((tag) => ( + {tag} + ))} @@ -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("all"); + const [tagFilter, setTagFilter] = useState([]); + const [tagOptions, setTagOptions] = useState<{ name: string; label?: string; count: number }[]>([]); const [items, setItems] = useState([]); + 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 }: { - - - {TABS.map((t) => ( - setTab(t.k)}> - {t.label} - - ))} - - 官方模特均为平台导入的授权素材 + + {TABS.map((t) => ( + setTab(t.k)} + aria-pressed={tab === t.k} + > + {t.label} + + ))} + + setTagFilter([])} + > + 不限标签 + + {tagOptions.map((tag) => ( + toggleTag(tag.name)} + title={`${tag.count} 个`} + > + {tag.label || tag.name} + {tag.count} + + ))} @@ -404,6 +475,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: { 点右上「添加模特」上传一张形象图,或在图片/视频流程里生成 ) : ( + <> {items.map((m) => { const selectable = !m.is_official; @@ -458,12 +530,19 @@ export function ModelsPage({ onNotify, onBillingChanged }: { > )} {m.source === "upload" ? "真人上传" : "AI 生成"} + {modelTags(m).slice(0, 4).map((tag) => ( + {tag} + ))} ); })} + + + + > )} diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index d592cec..6957056 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -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 ( - {options.map((option, index) => ( + {visibleOptions.map((option, index) => ( { @@ -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(""); + 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 ( - + { + if (!cover) return; + setHoverKey(chipKey); + setHoverDir(mentionHoverPlacement(event.currentTarget)); + }} + data-chip-key={chipKey} + > {cover ? ( <> 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)); + }} > @@ -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 ( ; @@ -1873,8 +1930,10 @@ function ConfirmCard({ catalogModels?: ModelConfig[]; disabled: boolean; onConfirm: (params: Record) => 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 ( - + - {submitted ? "已确认" : `即将用 ${summary} 生成`} - {submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"} + {title} + {subtitle} {submitted ? null : ( @@ -1935,16 +2019,18 @@ function ConfirmCard({ /> )} - {durationChanged ? ( + {submitted ? null : durationChanged ? ( 改时长会重新生成脚本。确认后先重写方案,不会直接出片。 ) : willGenerateInSegments ? ( - {durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。 + + {durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。 + ) : null} - {submitted ? "已确认,正在出片" : "点确认后才会提交生成"} + {foot} onConfirm(draft)}> - {durationChanged ? "确认并重写脚本" : String(message.payload.label || "开始生成")} - {!durationChanged && credits > 0 ? 约 {credits} 积分 : null} + {buttonLabel} + {!submitted && !durationChanged && credits > 0 ? 约 {credits} 积分 : null} @@ -1953,30 +2039,35 @@ function ConfirmCard({ function ResultCard({ payload, - onMerge, - merging, }: { payload: Record; - onMerge?: () => void; - merging?: boolean; }) { const assets = (payload.assets as Array> | 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 ( - - 1 ? " is-grid" : ""}`}> - {assets.map((asset, index) => { + + 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 ( { - isGeneratedVideo - ? payload.needs_merge ? `已生成 ${assets.length} 段视频` : "视频已生成" - : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成" + partialFailure + ? `已保留 ${assets.length} 段成功视频` + : isGeneratedVideo + ? "视频已生成" + : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成" } - {payload.needs_merge ? "请先预览片段,确认后再合并成片" : meta} + {partialFailure && payload.error ? {String(payload.error)} : null} - {payload.needs_merge ? ( - - {payload.merge_state === "completed" ? "已合并" : merging || payload.merge_state === "queued" || payload.merge_state === "processing" ? "正在合并" : "合并成片"} - - ) : null} 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, b: Record) { 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; - 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> | 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 ( - - - {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 ( - - url && onPreview?.(url, video ? "video" : "image", label)} - > - - {video ? ( - - - - ) : null} - - - ); - })} - {Array.from({ length: waitingSegments }).map((_, index) => ( - - - - {isVideo ? "视频生成中" : "图片生成中"} - - - ))} + + + + + + {isVideo ? "视频生成中" : "图片生成中"} + + { isMerge - ? "正在合并成片" - : isSegmentedVideo && completedSegments - ? `第 ${completedSegments} 段已生成,正在生成第 ${completedSegments + 1} 段` - : isSegmentedVideo - ? `正在生成 ${segmentCount || 2} 段视频` - : isVideo ? "正在生成视频" : "正在出图" + ? "成片收尾中" + : isSegmentedVideo + ? "正在生成长视频" + : isVideo ? "正在生成视频" : "正在出图" } { isMerge - ? "正在拼接已确认的片段" - : isSegmentedVideo && completedSegments - ? "已生成的片段可以先点击预览" - : isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候" + ? "马上就好,请稍候" + : isSegmentedVideo + ? "时长较长,生成会多花一点时间,请耐心等待" + : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候" } @@ -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(firstUploads || []); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); @@ -2474,6 +2571,7 @@ export function OmniSessionPage({ /** 商品闸门点「上传商品图」后,上传完成要回填 elicit_answer,而不是只塞进输入框。 */ const productGateMessageIdRef = useRef(null); const composerRef = useRef(null); + const thinkingLogRef = useRef(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("product"); const [confirming, setConfirming] = useState(false); - const [mergingMessageIds, setMergingMessageIds] = useState([]); 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[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 ( setAssetPreview({ src, kind, name })} /> ); case "result": @@ -3373,8 +3506,6 @@ export function OmniSessionPage({ 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 ? ( ) : liveText ? ( @@ -3532,9 +3667,26 @@ export function OmniSessionPage({ - {conversation?.agent_status === "planning" || streaming ? (isVideo ? "正在整理方案" : "正在整理画面") : activeTool ? `${activeTool}…` : liveReasoning ? "思考中" : isVideo ? "正在整理方案" : "正在整理画面"} + {activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")} - {liveReasoning ? {liveReasoning} : null} + {liveReasoning ? ( + + {(visibleProgressHistory.length + ? visibleProgressHistory + : [{ + label: activeTool || "正在思考", + detail: liveReasoning, + }] + ).map((progress, index, history) => ( + + {progress.detail} + + ))} + + ) : null} ) @@ -3542,13 +3694,18 @@ export function OmniSessionPage({
直接开户,不用发邀请码。建好即可用该用户名密码登录,并拥有一个独立的积分钱包。
直接开户,不用发邀请码。用户名可中文展示;登录账号仅英文+数字且必须 6 位,建好后用登录账号和密码登录。
// AIGC COMMERCE CONTENT ENGINE
改时长会重新生成脚本。确认后先重写方案,不会直接出片。
{durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。
+ {durationSeconds} 秒成片一次生成,时长较长会多花一点时间,请耐心等待。 +
{liveReasoning}
{progress.detail}