feat(prompts): admin「提示词」页 —— 视频线 6 条生图/视频提示词正文+比例可在后台改,不再写死
诉求:生图/视频提示词全写死在代码里,产品方看不到也改不了(之前那版只做成「质量后缀」不是正文)。
现新增可编辑「提示词模板」体系,覆盖视频项目生产线 6 条:人物立绘 / 人物三视图 / 商品三视图 /
场景图 / 分镜图 / 视频。
后端:
- 新模型 PromptTemplate(key 固定 6 条 + template 正文 + ratio 比例 + enabled);迁移 0012/0013
种入默认正文(= 各 builder 现写死文本,零回归)。
- render_prompt(key, default, **字段):读 DB 模板,留空/停用回落默认;安全替换 {占位符}(中文名),
未知占位符原样保留(改错不致命)。prompt_ratio_size:比例→gpt-image 尺寸。
- 6 个 builder/调用点改走模板;5 条图比例可改(gpt-image 仅 1:1/竖/横),视频固定 Seedance 9:16。
场景图新增可编辑「场景风格」包装(默认仅 {场景描述},零回归)。人物/分镜/视频原质量后缀并入模板。
- adminpanel CRUD:GET /prompt-templates、PATCH /<id>(改正文/比例/启用,key 不可改、不增删,带审计)。
前端:admin 新增「提示词」侧栏页(/admin/prompts):6 卡,每卡=占位符提示+正文文本框+比例按钮+
启用开关+保存。复用 .btn/.card-hard 共享类,仅 token 配色,单橙锚点。
验证:Django 套件 102 全绿(含新模型/API/生成侧立即生效 + 人物提示词改读模板);tsc+vite build 绿;
无头(独立 sqlite 栈)真开 /admin/prompts:6 卡齐、文本框/比例/占位符齐、0 控制台错误。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
304a083184
commit
9c2a3bc365
@@ -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。"""
|
||||
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
@@ -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/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
|
||||
path("quality-words/", admin_quality_words, name="admin-quality-words"),
|
||||
path("quality-words/<uuid:word_id>/", admin_quality_word_detail, name="admin-quality-word-detail"),
|
||||
path("prompt-templates/", admin_prompt_templates, name="admin-prompt-templates"),
|
||||
path("prompt-templates/<uuid:template_id>/", 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"),
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)]
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -651,6 +651,13 @@ export const adminApi = {
|
||||
deleteQualityWord(id: string) {
|
||||
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" });
|
||||
},
|
||||
// 生图/视频提示词模板(视频线 6 条):列表 + 改正文/比例/启用
|
||||
promptTemplates() {
|
||||
return request<import("./types").AdminPromptTemplate[]>("/api/admin/prompt-templates/");
|
||||
},
|
||||
updatePromptTemplate(id: string, payload: { template?: string; ratio?: string; enabled?: boolean }) {
|
||||
return request<import("./types").AdminPromptTemplate>(`/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);
|
||||
|
||||
@@ -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 <AdminUsersPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "prompts") {
|
||||
return <AdminPromptsPage notify={notify} />;
|
||||
}
|
||||
if (section.slug === "quality") {
|
||||
return <AdminQualityPage notify={notify} />;
|
||||
}
|
||||
|
||||
@@ -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<AdminPromptTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [draft, setDraft] = useState<Record<string, { template: string; ratio: string }>>({});
|
||||
const [saving, setSaving] = useState<string | null>(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 (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>提示词</h1>
|
||||
<div className="sub"><span className="mono">// 视频线 6 条</span> · 生图/视频提示词正文 + 比例可改;留空或停用 → 回落写死默认</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching prompt templates</p></div>
|
||||
) : (
|
||||
<div className="pt-list">
|
||||
{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 (
|
||||
<div key={r.id} className={`card-hard pt-card${r.enabled ? "" : " pt-off"}`}>
|
||||
<div className="pt-head">
|
||||
<span className="pt-title">{r.label}</span>
|
||||
<span className="mono pt-key">// {r.key}</span>
|
||||
<span className="spacer" />
|
||||
<label className="pt-enabled">
|
||||
<input type="checkbox" checked={r.enabled} onChange={(e) => void toggle(r, e.target.checked)} />
|
||||
<span>{r.enabled ? "已启用" : "已停用 · 用默认"}</span>
|
||||
</label>
|
||||
</div>
|
||||
{r.placeholders.length > 0 && (
|
||||
<div className="pt-vars mono">
|
||||
占位符 {r.placeholders.map((p) => <code key={p}>{`{${p}}`}</code>)}
|
||||
<span className="pt-vars-hint">· 运行时自动替换,保留这些占位符即可</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="pt-textarea"
|
||||
value={d.template}
|
||||
rows={Math.min(10, Math.max(4, d.template.split("\n").length))}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setDraft((m) => ({ ...m, [r.id]: { ...d, template: e.target.value } }))}
|
||||
/>
|
||||
<div className="pt-foot">
|
||||
<span className="pt-ratio-label mono">比例</span>
|
||||
{isVideo ? (
|
||||
<span className="pt-ratio-na mono">视频固定竖屏 9:16(Seedance)</span>
|
||||
) : (
|
||||
<div className="pt-ratios">
|
||||
{RATIOS.map((rt) => (
|
||||
<button key={rt.key} type="button" className={`btn btn-sm${d.ratio === rt.key ? " btn-primary" : ""}`}
|
||||
onClick={() => setDraft((m) => ({ ...m, [r.id]: { ...d, ratio: rt.key } }))}>{rt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!dirty || saving === r.id} onClick={() => void save(r)}>
|
||||
{saving === r.id ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,17 @@ export type AdminQualityWord = {
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AdminPromptTemplate = {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
template: string;
|
||||
ratio: string; // "" 默认 | "1:1" | "portrait" 竖 | "landscape" 横
|
||||
enabled: boolean;
|
||||
placeholders: string[];
|
||||
updated_at: string;
|
||||
};
|
||||
export type AdminReviewAsset = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user