feat(core): AI 生成 Agent 化 — 多模型流式脚本 agent + 可插拔 Provider + gpt-image-2 参考图 + 模特库

- 后端·可插拔 Provider 层:通用 OpenAICompatibleProvider(tokenssr 等中转站,base_url+api_key,零改代码换站)+ ModelProvider.api_key
- 后端·脚本 agent:结构化 ScriptDraft 契约 + 加载电商 skill + 出稿/改稿一体对话 agent(3模式/多模型)+ 流式 SSE 端点(DRF SSE renderer)
- 后端·图像:gpt-image-2 参考图出图 + 故事板 @图1@图2@图3 多锚点合成(锁脸锁商品);Seedance 打开 generate_audio
- 后端·模特库:gpt-image-2 生成器(9:16氛围图→16:9白底三视图)+ seed_demo_models 管理命令
- DB·迁移:tokenssr 中转站 + 多模型 seed(豆包/GPT-5.5/Gemini + gpt-image-2);ScriptSegment 结构化字段
- 前端·脚本趴:接真 SSE(工具卡 + 思考流)+ 模型下拉 + 3模式 + 改稿;agentScriptStream
- skills/ecommerce-video-script 电商脚本技能(运行时依赖)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-17 03:12:03 +08:00
co-authored by Claude Opus 4.8
parent a0ffb6fc8e
commit 6464001f84
32 changed files with 9294 additions and 54 deletions
@@ -0,0 +1,33 @@
# Generated by Django 5.1.15 on 2026-06-16 17:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='scriptsegment',
name='entity_refs',
field=models.JSONField(blank=True, default=list),
),
migrations.AddField(
model_name='scriptsegment',
name='product_exposure',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='scriptsegment',
name='role',
field=models.CharField(blank=True, max_length=16),
),
migrations.AddField(
model_name='scriptsegment',
name='speaker',
field=models.CharField(blank=True, max_length=32),
),
]
+5
View File
@@ -80,6 +80,11 @@ class ScriptSegment(TimeStampedModel):
narration = models.TextField(blank=True)
visual_prompt = models.TextField(blank=True)
product_points = models.JSONField(default=list, blank=True)
# ScriptDraft 结构化契约字段(对话式脚本 agent 产出):
role = models.CharField(max_length=16, blank=True) # 钩子|痛点|卖点|CTA
speaker = models.CharField(max_length=32, blank=True) # 指向某 entity id;画外旁白为空
product_exposure = models.CharField(max_length=64, blank=True) # 手持/特写/使用中…
entity_refs = models.JSONField(default=list, blank=True) # 本镜引用的 entity id 列表(→ 故事板 @图N)
class Meta:
ordering = ["sort_order", "created_at"]
+6 -2
View File
@@ -228,7 +228,10 @@ class ExportJobSerializer(serializers.ModelSerializer):
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
fields = ["id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points"]
fields = [
"id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points",
"role", "speaker", "product_exposure", "entity_refs",
]
read_only_fields = fields
@@ -237,7 +240,8 @@ class ScriptVersionSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptVersion
fields = ["id", "title", "content", "source", "is_adopted", "segments", "created_at", "updated_at"]
# metadata 携带 ScriptDraft 的 hook/tone/entities,供前端结构化渲染与下游故事板 @图N
fields = ["id", "title", "content", "source", "is_adopted", "segments", "metadata", "created_at", "updated_at"]
read_only_fields = fields
+64
View File
@@ -3,13 +3,17 @@ from pathlib import Path
import uuid
from django.db import transaction
from django.http import JsonResponse, StreamingHttpResponse
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.renderers import BaseRenderer
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.ai.models import ModelConfig
from apps.ai.providers import TtsNotConfigured
from apps.ai.script_agent import stream_script_agent
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
@@ -17,6 +21,7 @@ from apps.ai.services import (
generate_base_asset,
generate_project_script,
generate_storyboard_frame,
get_default_model,
poll_video_segment,
regenerate_script_segment,
submit_storyboard,
@@ -58,6 +63,18 @@ from .tasks import poll_video_segment_task
logger = logging.getLogger(__name__)
class ServerSentEventRenderer(BaseRenderer):
"""让 DRF 内容协商接受 Accept: text/event-stream(否则流式端点直接 406)。
实际响应由视图返回 StreamingHttpResponse 直接下发,这个 renderer 只用于通过协商。"""
media_type = "text/event-stream"
format = "event-stream"
charset = None
def render(self, data, accepted_media_type=None, renderer_context=None):
return data
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
@@ -133,6 +150,53 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
def script_agent_stream(self, request, pk=None):
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、
base_version_id(改稿)、aspect_ratio、total_duration。
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
project = self.get_object()
mode = str(request.data.get("mode") or "auto")
prompt = str(request.data.get("prompt") or "")
selling_point_ids = request.data.get("selling_point_ids") or []
base_version_id = request.data.get("base_version_id") or None
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
try:
total_duration = int(request.data.get("total_duration") or 60)
except (TypeError, ValueError):
total_duration = 60
model_config = None
requested = request.data.get("model_config_id")
if requested:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
if model_config is None:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
# 纯 Django 响应:绕开 DRF 渲染(此 action 只挂了 SSE renderer)
return JsonResponse({"detail": "没有可用的文本模型,请先在模型库配置"}, status=400)
stream = stream_script_agent(
project=project,
user=request.user,
model_config=model_config,
mode=mode,
user_prompt=prompt,
selling_point_ids=selling_point_ids,
base_version_id=base_version_id,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" # 关 nginx 缓冲,保证逐帧下发
return response
@action(detail=True, methods=["post"], url_path="adopt-script")
@transaction.atomic
def adopt_script(self, request, pk=None):