diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 9350f9d..639c2e1 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -3,7 +3,7 @@ from decimal import Decimal from rest_framework import serializers from apps.accounts.models import Team, TeamMember, User -from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord +from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.assets.models import Asset from apps.billing.models import CreditLedger, QuotaPolicy from apps.projects.models import Project @@ -25,6 +25,30 @@ class QualityWordSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at"] +# 每条提示词可用的 {占位符}(给 admin 提示;运行时由对应 builder 注入,改错会原样保留不致命) +PROMPT_PLACEHOLDERS = { + "person_portrait": ["描述"], + "person_triview": [], + "product_triview": ["商品", "补充"], + "scene": ["场景描述"], + "storyboard_frame": ["设定", "场景上下文", "时长", "脚本", "补充"], + "video_segment": ["设定", "分镜", "脚本", "时长"], +} + + +class PromptTemplateSerializer(serializers.ModelSerializer): + label = serializers.CharField(source="get_key_display", read_only=True) + placeholders = serializers.SerializerMethodField() + + class Meta: + model = PromptTemplate + fields = ["id", "key", "label", "template", "ratio", "enabled", "placeholders", "updated_at"] + read_only_fields = ["id", "key", "label", "placeholders", "updated_at"] # key 固定,只改正文/比例/启用 + + def get_placeholders(self, obj) -> list: + return PROMPT_PLACEHOLDERS.get(obj.key, []) + + class AdminReviewAssetSerializer(serializers.ModelSerializer): """跨团队人像审核队列行:复用 AssetFileSerializer 的首图 preview_url。""" diff --git a/core/backend/apps/adminpanel/tests.py b/core/backend/apps/adminpanel/tests.py index c8be02b..1e676cf 100644 --- a/core/backend/apps/adminpanel/tests.py +++ b/core/backend/apps/adminpanel/tests.py @@ -210,21 +210,24 @@ class AdminQualityWordTests(TestCase): r = self.ac.post("/api/admin/quality-words/", {"stage": "bogus", "text": "x"}, format="json") self.assertEqual(r.status_code, 400) - def test_generation_reads_config_with_fallback(self): - from apps.ai.models import QualityWord + def test_person_prompt_reads_template_with_fallback(self): + """人物立绘提示词改由「提示词模板」(person_portrait)驱动,取代旧质量词: + 种子模板 = 原默认正文(描述填进 {描述});改模板 → 生成立即用新正文。""" + from apps.ai.models import PromptTemplate from apps.ai.services import build_person_frontal_prompt - # 无配置 → 回落写死值 - self.assertIn("纯色背景", build_person_frontal_prompt("小姐姐")) - - # 配置后 → 生成用新词,描述仍保留 - QualityWord.objects.create(stage="person", text="赛博朋克风", sort=0) - QualityWord.objects.create(stage="person", text="霓虹光", sort=1) + # 种子模板 → 含默认尾串,{描述} 被填入 out = build_person_frontal_prompt("小姐姐") - self.assertIn("赛博朋克风", out) - self.assertIn("霓虹光", out) + self.assertIn("纯色背景", out) self.assertIn("小姐姐", out) - self.assertNotIn("纯色背景", out) + + # 后台改模板 → 生成用新正文,{描述} 仍替换 + PromptTemplate.objects.filter(key="person_portrait").update(template="赛博朋克风{描述}霓虹光") + out2 = build_person_frontal_prompt("小姐姐") + self.assertIn("赛博朋克风", out2) + self.assertIn("霓虹光", out2) + self.assertIn("小姐姐", out2) + self.assertNotIn("纯色背景", out2) def test_disabled_word_not_used(self): from apps.ai.models import QualityWord @@ -236,6 +239,32 @@ class AdminQualityWordTests(TestCase): self.assertIn("启用词", words) self.assertNotIn("停用词", words) + def test_prompt_templates_list_update_permission(self): + """提示词模板:列表(6 条,带 label/placeholders)+ 改正文比例 + 权限 + 审计 + 生成侧立即生效。""" + from apps.ai.services import build_product_triview_prompt_refs + + self.assertEqual(self.nc.get("/api/admin/prompt-templates/").status_code, 403) + lst = self.ac.get("/api/admin/prompt-templates/") + self.assertEqual(lst.status_code, 200) + self.assertEqual(len(lst.data), 6) # 视频线 6 条 + row = next(r for r in lst.data if r["key"] == "product_triview") + self.assertEqual(row["label"], "商品三视图") + self.assertIn("商品", row["placeholders"]) + + up = self.ac.patch( + f"/api/admin/prompt-templates/{row['id']}/", + {"template": "参考图1是「{商品}」是商品。生成三视图。", "ratio": "portrait"}, + format="json", + ) + self.assertEqual(up.status_code, 200) + self.assertTrue(AdminAuditLog.objects.filter(action="prompt_template.update").exists()) + + class P: + title = "杂鱼煲" + + out = build_product_triview_prompt_refs(P(), "") + self.assertEqual(out, "参考图1是「杂鱼煲」是商品。生成三视图。") # 生成侧立即用新模板,且 {商品} 已替换 + class AdminAssetReviewTests(TestCase): """Phase 5:跨团队人像审核队列(列/筛/批量送审/轮询)+ 权限。火山调用全 mock。""" diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index d97d8fc..515ed1b 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -19,6 +19,8 @@ from .views import ( admin_task_detail, admin_task_retry, admin_tasks, + admin_prompt_template_detail, + admin_prompt_templates, admin_quality_word_detail, admin_quality_words, admin_revoke_invitation, @@ -42,6 +44,8 @@ urlpatterns = [ path("users//reset-password/", admin_user_reset_password, name="admin-user-reset-password"), path("quality-words/", admin_quality_words, name="admin-quality-words"), path("quality-words//", admin_quality_word_detail, name="admin-quality-word-detail"), + path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"), + path("prompt-templates//", admin_prompt_template_detail, name="admin-prompt-template-detail"), path("asset-reviews/", admin_asset_reviews, name="admin-asset-reviews"), path("asset-reviews/submit/", admin_asset_reviews_submit, name="admin-asset-reviews-submit"), path("asset-reviews/poll/", admin_asset_reviews_poll, name="admin-asset-reviews-poll"), diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 88053b2..788fedb 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -12,7 +12,7 @@ from apps.accounts.audit import log_admin_action from apps.accounts.models import Invitation, Team, User from apps.accounts.permissions import IsPlatformAdmin from apps.accounts.serializers import InvitationSerializer -from apps.ai.models import AITask, ModelConfig, ModelProvider, QualityWord +from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.assets.models import Asset from apps.assets.review import poll_asset_review, submit_asset_for_review from apps.billing.models import CreditLedger, QuotaPolicy @@ -34,6 +34,7 @@ from .serializers import ( AdminTeamMemberSerializer, AdminTeamSerializer, AdminUserSerializer, + PromptTemplateSerializer, QualityWordSerializer, ) @@ -281,6 +282,40 @@ def admin_quality_word_detail(request, word_id): return Response(QualityWordSerializer(obj).data) +# ─────────────────────────── 生图/视频 提示词模板 ─────────────────────────── + + +@api_view(["GET"]) +@permission_classes([IsPlatformAdmin]) +def admin_prompt_templates(request): + """列视频线 6 条提示词模板(固定 key,数量小不分页,按 key 排序)。""" + qs = PromptTemplate.objects.all().order_by("key") + return Response(PromptTemplateSerializer(qs, many=True).data) + + +@api_view(["PATCH"]) +@permission_classes([IsPlatformAdmin]) +def admin_prompt_template_detail(request, template_id): + """改一条提示词模板的正文 / 比例 / 启用(key 固定不可改,不增不删)。""" + obj = PromptTemplate.objects.filter(id=template_id).first() + if obj is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + before = {"template": obj.template, "ratio": obj.ratio, "enabled": obj.enabled} + serializer = PromptTemplateSerializer(obj, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + log_admin_action( + request, + "prompt_template.update", + target_type="prompt_template", + target_id=obj.id, + target_name=obj.key, + before=before, + after={"template": obj.template, "ratio": obj.ratio, "enabled": obj.enabled}, + ) + return Response(PromptTemplateSerializer(obj).data) + + # ─────────────────────────── 火山人像审核队列 ─────────────────────────── _REVIEW_STATUSES = {"", "processing", "active", "failed"} diff --git a/core/backend/apps/ai/migrations/0012_prompttemplate.py b/core/backend/apps/ai/migrations/0012_prompttemplate.py new file mode 100644 index 0000000..3cf008c --- /dev/null +++ b/core/backend/apps/ai/migrations/0012_prompttemplate.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.15 on 2026-06-22 18:53 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0011_aitask_entity_extraction_type'), + ] + + operations = [ + migrations.CreateModel( + name='PromptTemplate', + 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)), + ('key', models.CharField(choices=[('person_portrait', '人物立绘'), ('person_triview', '人物三视图'), ('product_triview', '商品三视图'), ('scene', '场景图'), ('storyboard_frame', '分镜图'), ('video_segment', '视频')], max_length=32, unique=True)), + ('template', models.TextField(blank=True, default='')), + ('ratio', models.CharField(blank=True, default='', max_length=12)), + ('enabled', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['key'], + }, + ), + ] diff --git a/core/backend/apps/ai/migrations/0013_seed_prompt_templates.py b/core/backend/apps/ai/migrations/0013_seed_prompt_templates.py new file mode 100644 index 0000000..cfc6740 --- /dev/null +++ b/core/backend/apps/ai/migrations/0013_seed_prompt_templates.py @@ -0,0 +1,60 @@ +# 种入视频线 6 条提示词模板的默认值(= 各 builder 现在的写死文本,零回归)。 +# 后台超管在 admin「提示词」页改正文/比例;留空或停用 → builder 回落写死默认。 +from django.db import migrations + +# key, 默认正文(含 {占位符}), 默认比例("portrait" 竖 / "landscape" 横) +SEED = [ + ( + "person_portrait", + "电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景", + "portrait", + ), + ( + "person_triview", + "参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景", + "landscape", + ), + ( + "product_triview", + "参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质," + "生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。" + "三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}", + "landscape", + ), + ( + "scene", + "{场景描述}", + "landscape", + ), + ( + "storyboard_frame", + "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" + "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" + "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}", + "portrait", + ), + ( + "video_segment", + "{设定}{分镜}【脚本】{脚本}\n{时长}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。", + "portrait", + ), +] + + +def seed(apps, schema_editor): + PromptTemplate = apps.get_model("ai", "PromptTemplate") + for key, template, ratio in SEED: + PromptTemplate.objects.update_or_create( + key=key, defaults={"template": template, "ratio": ratio, "enabled": True} + ) + + +def unseed(apps, schema_editor): + PromptTemplate = apps.get_model("ai", "PromptTemplate") + PromptTemplate.objects.filter(key__in=[k for k, _, _ in SEED]).delete() + + +class Migration(migrations.Migration): + dependencies = [("ai", "0012_prompttemplate")] + operations = [migrations.RunPython(seed, unseed)] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 4d6a0ee..35529b3 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -135,3 +135,29 @@ class QualityWord(TimeStampedModel): def __str__(self) -> str: return f"{self.stage}/{self.slot}/{self.text}" + +class PromptTemplate(TimeStampedModel): + """平台可编辑的「生图/视频提示词模板」(视频项目生产线 6 条)。后台超管在 admin 改正文, + 生成侧据 key 读取本表;留空/停用 → 回落各 builder 的写死默认(零回归)。 + 正文里 {占位符}(如 {商品}/{描述}/{脚本})运行时替换;未知占位符原样保留,改错不致命。 + ratio:成品比例(空=按写死默认),gpt-image 仅 1:1 / portrait(竖) / landscape(横)三种。""" + + class Key(models.TextChoices): + PERSON_PORTRAIT = "person_portrait", "人物立绘" + PERSON_TRIVIEW = "person_triview", "人物三视图" + PRODUCT_TRIVIEW = "product_triview", "商品三视图" + SCENE = "scene", "场景图" + STORYBOARD_FRAME = "storyboard_frame", "分镜图" + VIDEO_SEGMENT = "video_segment", "视频" + + key = models.CharField(max_length=32, choices=Key.choices, unique=True) + template = models.TextField(blank=True, default="") + ratio = models.CharField(max_length=12, blank=True, default="") # "" / "1:1" / "portrait" / "landscape" + enabled = models.BooleanField(default=True) + + class Meta: + ordering = ["key"] + + def __str__(self) -> str: + return f"prompt:{self.key}" + diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 28e7624..d72b708 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -762,18 +762,46 @@ def quality_suffix(stage: str, fallback: str, slot: str = "quality", sep: str = return sep.join(words) if words else fallback +_PROMPT_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}", re.UNICODE) +_RATIO_TO_SIZE = {"1:1": "1024x1024", "portrait": "1024x1536", "landscape": "1536x1024"} + + +def _prompt_template_row(key: str): + """读 admin 可编辑的提示词模板行(启用的);缺表/缺行/异常一律返回 None(回落写死默认,零回归)。""" + try: + from apps.ai.models import PromptTemplate + + return PromptTemplate.objects.filter(key=key, enabled=True).first() + except Exception: # noqa: BLE001 — 表还没迁移/查询异常都不该挡生成 + return None + + +def render_prompt(key: str, default_text: str, **fields) -> str: + """按 admin 可编辑模板渲染提示词。无模板/停用/正文空 → 回落 default_text(各 builder 的写死值)。 + 安全替换 {占位符}:已知字段换值,未知占位符**原样保留**(用户改错也不会让生成崩)。占位符名支持中文。""" + row = _prompt_template_row(key) + tpl = row.template if (row and (row.template or "").strip()) else default_text + return _PROMPT_PLACEHOLDER_RE.sub(lambda m: str(fields.get(m.group(1), m.group(0))), tpl) + + +def prompt_ratio_size(key: str, default_size: str) -> str: + """模板配的比例 → gpt-image 尺寸(1024x1024 / 1024x1536 / 1536x1024);未配则用 default_size。""" + row = _prompt_template_row(key) + if row and row.ratio: + return _RATIO_TO_SIZE.get(row.ratio, default_size) + return default_size + + def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str: - """商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图,锁包装一致性。""" + """商品三视图 image_edit 提示词(refs 版):参考图1=商品真实主图。正文可在 admin「提示词」页改; + {商品}=主图商品名(写死字段,不可删),{补充}=调用方附加文本。""" name = (getattr(product, "title", "") or "商品").strip() - lines = [ - f"参考图1是「{name}」的真实商品主图。", - "请严格参照该图的包装外形、品牌文字、配色、Logo 与材质,生成同一件商品的三视图:", - "从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。", - "三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。", - ] - if base_prompt and base_prompt.strip(): - lines.append(base_prompt.strip()) - return " ".join(lines) + default = ( + "参考图1是「{商品}」的真实商品主图。请严格参照该图的包装外形、品牌文字、配色、Logo 与材质," + "生成同一件商品的三视图:从左到右依次为正面、侧面、背面,统一光照,纯白背景,16:9 构图。" + "三个视图必须是同一件商品,品牌字样/配色/外形高度一致,不要改动或重新设计包装。{补充}" + ) + return render_prompt("product_triview", default, 商品=name, 补充=(base_prompt or "").strip()) def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str: @@ -824,9 +852,9 @@ def build_person_frontal_prompt(description: str = "") -> str: """人物正面氛围图提示词:把脚本提取(或用户输入)的人物描述包成统一模板。 用户钦定格式:电商真人模特,氛围正面全身照,<描述>,自然妆容,柔和影棚光,真实质感,单人,纯色背景。""" desc = (description or "").strip() - inner = f"{desc}," if desc else "" - tail = quality_suffix("person", "自然妆容,柔和影棚光,真实质感,单人,纯色背景") - return f"电商真人模特,氛围正面全身照,{inner}{tail}" + # 正文可在 admin「提示词」页改;{描述}=脚本提取/用户输入的人物描述(写死字段)。默认含原质量尾串。 + default = "电商真人模特,氛围正面全身照,{描述},自然妆容,柔和影棚光,真实质感,单人,纯色背景" + return render_prompt("person_portrait", default, 描述=desc) def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask: @@ -850,7 +878,8 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = " elif kind == BaseAssetGroup.Kind.PERSON: gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板 else: - gen_prompt = prompt + # 场景图:默认模板只是 {场景描述} 本身(零回归);可在 admin「提示词」页加全局场景风格 + gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip()) payload = { "model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt, "kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "", @@ -891,10 +920,15 @@ def run_base_asset_task(*, task_id: str) -> None: reservation = task.credit_reservation try: if use_edit and ref_url: - response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024") + # 商品三视图:比例可在 admin 改(默认横) + size = prompt_ratio_size("product_triview", "1536x1024") + response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=size) else: - # 场景 = 16:9 横构图(环境/背景空镜);人物立绘 = 9:16 竖全身。默认竖。 - gen_size = "1536x1024" if kind == BaseAssetGroup.Kind.SCENE else "1024x1536" + # 场景默认横、人物立绘默认竖;两者比例都可在 admin 改 + if kind == BaseAssetGroup.Kind.SCENE: + gen_size = prompt_ratio_size("scene", "1536x1024") + else: + gen_size = prompt_ratio_size("person_portrait", "1024x1536") response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size) media = provider.extract_first_media_url(response) with transaction.atomic(): @@ -959,7 +993,9 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask: if not hasattr(provider, "image_edit"): raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)") ref_url = _asset_preview_url(portrait_asset) - payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key, "reference_image": ref_url} + # 人物三视图提示词:正文可在 admin「提示词」页改(无占位符) + tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT) + payload = {"model": model_config.name, "prompt": tri_prompt, "kind": "person", "triview_of": asset_key, "reference_image": ref_url} task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload) generate_triview_task.delay(str(task.id)) return task @@ -983,7 +1019,8 @@ def run_triview_task(*, task_id: str) -> None: provider = get_image_provider(model_config) reservation = task.credit_reservation try: - response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024") + tri_size = prompt_ratio_size("person_triview", "1536x1024") + response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size=tri_size) media = provider.extract_first_media_url(response) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED @@ -1067,18 +1104,23 @@ def _segment_script_text(segment) -> str: def build_storyboard_frame_prompt(project, version, segment) -> str: - """单镜导演故事板提示词(一镜 = 一张导演故事板,用户钦定结构)。无参考图时的文本版。""" + """单镜导演故事板提示词(无参考图时的文本版)。与 refs 版共用 admin「提示词·分镜图」模板(设定为空)。""" dur = segment.duration_seconds or 15 - lines = [ - "根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。", - _scene_context(project), - f"【分镜脚本】(本段时长约 {dur} 秒)", - _segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜", - ] - if version.prompt: - lines.append(version.prompt.strip()) - lines.append(quality_suffix("storyboard", "电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。")) - return "\n".join(line for line in lines if line) + default = ( + "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" + "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" + "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}" + ) + rendered = render_prompt( + "storyboard_frame", default, + 设定="", + 场景上下文=_scene_context(project), + 时长=dur, + 脚本=(_segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜"), + 补充=(("\n" + version.prompt.strip()) if version.prompt else ""), + ) + return "\n".join(line for line in rendered.split("\n") if line.strip()) def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str: @@ -1094,21 +1136,20 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: storyboard_idx = n else: setup_parts.append(f"@图{n}是{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})") - lines = [] - if setup_parts: - lines.append("【设定】" + ",".join(setup_parts) + "。") - if storyboard_idx is not None: - lines.append(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。") script_text = _segment_script_text(scene) if scene is not None else "" extra = (user_prompt or "").strip() if extra: script_text = (script_text + "\n" + extra).strip() if script_text else extra - lines.append("【脚本】" + (script_text or f"第 {video_segment.sort_order + 1} 段")) - lines.append( - f"{video_segment.target_duration_seconds}s · " - + quality_suffix("video", "9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。") + # 正文可在 admin「提示词·视频」页改。占位符:{设定}=@图N点名、{分镜}=分镜图引用、{脚本}=本段脚本、{时长}。 + default = "{设定}{分镜}【脚本】{脚本}\n{时长}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。" + rendered = render_prompt( + "video_segment", default, + 设定=("【设定】" + ",".join(setup_parts) + "。\n" if setup_parts else ""), + 分镜=(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。\n" if storyboard_idx is not None else ""), + 脚本=(script_text or f"第 {video_segment.sort_order + 1} 段"), + 时长=video_segment.target_duration_seconds, ) - return "\n".join(line for line in lines if line) + return "\n".join(line for line in rendered.split("\n") if line.strip()) def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion: @@ -1223,17 +1264,22 @@ def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dic f"@图{i + 1}是{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs) ) dur = segment.duration_seconds or 15 - lines = [ - f"【设定】{setup}。", - "根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。", - _scene_context(project), - f"【分镜脚本】(本段时长约 {dur} 秒)", - _segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜", - "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。", - ] - if version.prompt: - lines.append(version.prompt.strip()) - return "\n".join(line for line in lines if line) + # 正文可在 admin「提示词」页改。占位符:{设定}=@图N点名、{场景上下文}、{时长}、{脚本}=本镜脚本、{补充}=本版附加。 + default = ( + "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" + "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" + "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}" + ) + rendered = render_prompt( + "storyboard_frame", default, + 设定=(f"【设定】{setup}。\n" if setup else ""), + 场景上下文=_scene_context(project), + 时长=dur, + 脚本=(_segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜"), + 补充=(("\n" + version.prompt.strip()) if version.prompt else ""), + ) + return "\n".join(line for line in rendered.split("\n") if line.strip()) def _is_transient_error(exc: Exception) -> bool: diff --git a/core/frontend/src/admin-page.css b/core/frontend/src/admin-page.css index 4cc7263..adf119d 100644 --- a/core/frontend/src/admin-page.css +++ b/core/frontend/src/admin-page.css @@ -293,3 +293,23 @@ .gov-card.warn .gov-count { color: var(--heat); } .gov-label { font-size: 12px; color: var(--black-alpha-56); } .gov-total { margin-left: auto; font-size: 12px; color: var(--black-alpha-48); } + +/* 提示词模板页(仅 token,8px 圆角,单橙 accent) */ +.pt-list { display: flex; flex-direction: column; gap: 14px; } +.pt-card { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; } +.pt-card.pt-off { opacity: .6; } +.pt-head { display: flex; align-items: center; gap: 10px; } +.pt-title { font-size: 14px; font-weight: 600; color: var(--accent-black); } +.pt-key { font-size: 11.5px; color: var(--black-alpha-40); } +.pt-head .spacer { flex: 1; } +.pt-enabled { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--black-alpha-56); cursor: pointer; } +.pt-vars { font-size: 11.5px; color: var(--black-alpha-48); display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.pt-vars code { background: var(--heat-12); color: var(--heat); border-radius: var(--r-sm); padding: 1px 6px; } +.pt-vars-hint { color: var(--black-alpha-40); } +.pt-textarea { width: 100%; box-sizing: border-box; background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 13px; line-height: 1.6; color: var(--accent-black); font-family: var(--font-mono); resize: vertical; outline: none; transition: border-color var(--t-base), box-shadow var(--t-base); } +.pt-textarea:focus { border-color: var(--heat); box-shadow: 0 0 0 3px var(--heat-12); } +.pt-foot { display: flex; align-items: center; gap: 8px; } +.pt-ratio-label { font-size: 11.5px; color: var(--black-alpha-48); } +.pt-ratios { display: inline-flex; gap: 6px; } +.pt-ratio-na { font-size: 12px; color: var(--black-alpha-40); } +.pt-foot .spacer { flex: 1; } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index c7d86a4..ad98678 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -651,6 +651,13 @@ export const adminApi = { deleteQualityWord(id: string) { return request(`/api/admin/quality-words/${id}/`, { method: "DELETE" }); }, + // 生图/视频提示词模板(视频线 6 条):列表 + 改正文/比例/启用 + promptTemplates() { + return request("/api/admin/prompt-templates/"); + }, + updatePromptTemplate(id: string, payload: { template?: string; ratio?: string; enabled?: boolean }) { + return request(`/api/admin/prompt-templates/${id}/`, { method: "PATCH", body: JSON.stringify(payload) }); + }, assetReviews(params?: { review_status?: string; search?: string; page?: number; page_size?: number }) { const qs = new URLSearchParams(); if (params?.review_status) qs.set("review_status", params.review_status); diff --git a/core/frontend/src/routes/admin/admin-app.tsx b/core/frontend/src/routes/admin/admin-app.tsx index 74532c3..b07607c 100644 --- a/core/frontend/src/routes/admin/admin-app.tsx +++ b/core/frontend/src/routes/admin/admin-app.tsx @@ -7,6 +7,7 @@ import { AdminLedgersPage, AdminQuotaPage } from "./admin-billing"; import { AdminGovernancePage } from "./admin-governance"; import { AdminInvitesPage } from "./admin-invites"; import { AdminModelsPage } from "./admin-models"; +import { AdminPromptsPage } from "./admin-prompts"; import { AdminQualityPage } from "./admin-quality"; import { AdminReviewsPage } from "./admin-reviews"; import { AdminTasksPage } from "./admin-tasks"; @@ -30,6 +31,7 @@ export const ADMIN_SECTIONS: AdminSection[] = [ { slug: "invites", label: "邀请码", icon: "ticket", group: "团队 · 用户", phase: 2 }, { slug: "teams", label: "团队", icon: "building", group: "团队 · 用户", phase: 3 }, { slug: "users", label: "用户", icon: "users", group: "团队 · 用户", phase: 3 }, + { slug: "prompts", label: "提示词", icon: "sparkles", group: "内容", phase: 4 }, { slug: "quality", label: "质量词", icon: "type", group: "内容", phase: 4 }, { slug: "reviews", label: "资产审核", icon: "shield", group: "内容", phase: 5 }, { slug: "tasks", label: "任务监控", icon: "activity", group: "生成", phase: 6 }, @@ -180,6 +182,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe if (section.slug === "users") { return ; } + if (section.slug === "prompts") { + return ; + } if (section.slug === "quality") { return ; } diff --git a/core/frontend/src/routes/admin/admin-prompts.tsx b/core/frontend/src/routes/admin/admin-prompts.tsx new file mode 100644 index 0000000..2835cdd --- /dev/null +++ b/core/frontend/src/routes/admin/admin-prompts.tsx @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useState } from "react"; +import { adminApi } from "../../api"; +import { IconKitSvg } from "../../components/IconKitSvg"; +import type { AdminPromptTemplate } from "../../types"; + +type Notify = (type: "success" | "error" | "info", text: string) => void; + +// gpt-image 仅支持这 3 种成品尺寸;"" = 用各 builder 的写死默认 +const RATIOS = [ + { key: "", label: "默认" }, + { key: "1:1", label: "1:1 方" }, + { key: "portrait", label: "竖 2:3" }, + { key: "landscape", label: "横 3:2" } +]; + +export function AdminPromptsPage({ notify }: { notify: Notify }) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [draft, setDraft] = useState>({}); + const [saving, setSaving] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const list = await adminApi.promptTemplates(); + setRows(list); + setDraft(Object.fromEntries(list.map((r) => [r.id, { template: r.template, ratio: r.ratio }]))); + } catch { + notify("error", "加载提示词模板失败"); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { void load(); }, [load]); + + async function save(r: AdminPromptTemplate) { + const d = draft[r.id]; + if (!d) return; + setSaving(r.id); + try { + const u = await adminApi.updatePromptTemplate(r.id, { template: d.template, ratio: d.ratio }); + setRows((l) => l.map((x) => (x.id === r.id ? u : x))); + notify("success", `已保存「${r.label}」`); + } catch { + notify("error", "保存失败"); + } finally { + setSaving(null); + } + } + + async function toggle(r: AdminPromptTemplate, enabled: boolean) { + try { + const u = await adminApi.updatePromptTemplate(r.id, { enabled }); + setRows((l) => l.map((x) => (x.id === r.id ? u : x))); + } catch { + notify("error", "切换失败"); + } + } + + return ( + <> +
+
+

提示词

+
// 视频线 6 条 · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认
+
+
+ {loading ? ( +

加载中…

// fetching prompt templates

+ ) : ( +
+ {rows.map((r) => { + const d = draft[r.id] || { template: r.template, ratio: r.ratio }; + const dirty = d.template !== r.template || d.ratio !== r.ratio; + const isVideo = r.key === "video_segment"; + return ( +
+
+ {r.label} + // {r.key} + + +
+ {r.placeholders.length > 0 && ( +
+ 占位符 {r.placeholders.map((p) => {`{${p}}`})} + · 运行时自动替换,保留这些占位符即可 +
+ )} +