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
+16 -1
View File
@@ -10,7 +10,7 @@ DB_USER=airshelf_app
DB_PASSWORD=d5020f4d41e0e4c52a371ecb913be3d1f1ab2b85
DB_HOST=14.103.27.192
DB_PORT=3306
DB_BIND_ADDRESS=192.168.124.137
# DB_BIND_ADDRESS=192.168.124.137 # local-dev blanked: upstream dev-host LAN addr, not present on this machine -> WinError 10049
REDIS_CACHE_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/0
CELERY_BROKER_URL=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/1
CELERY_RESULT_BACKEND=redis://zyc:Zyc188208@redis-shzlsczo52dft8mia.redis.volces.com:6379/2
@@ -25,6 +25,21 @@ DEFAULT_TRIAL_CREDITS=1000.0000
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
# tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
TOKENSSR_BASE_URL=https://king.tokenssr.com/v1
# 飞书机器人「小毛球」(交接文档推送)· 凭证也在 AirDrama utils/alert_service.py
FEISHU_APP_ID=cli_a90478156bf85bd7
FEISHU_APP_SECRET=87N2nnx6Yv56TPjl2GraLdKOjFiGOSGp
# 火山人像素材库(审核绿/红标)· ⚠️ 暂借 AirDrama 已邀测开通的 AK/SK,AirShelf 自有账号开通后换这两把
# 张业昌待办:换成 AirShelf 自己火山账号的 AK/SK(独立于上面 TOS_*,只改这两行)
ASSETS_API_ACCESS_KEY=AKLTNGJiNzg2Y2I0NzlhNGRkM2FmYzAwYTliYmZkNzUxYzU
ASSETS_API_SECRET_KEY=WXpZeE5UQTRPV0prTXpJeU5HVTNORGxpTURjeE9ETXlOakl6TldKbU0yVQ==
ASSETS_API_ENABLED=true
ASSETS_API_PROJECT_NAME=int_dev_Airlabs
# 豆包语音合成(旁白配音 TTS)· 火山控制台-语音技术-语音合成
VOLC_TTS_APPID=8945759494
VOLC_TTS_ACCESS_TOKEN=w7Ye8FdTHADU05PV5cVNjud8FseOnYzR
+19
View File
@@ -186,4 +186,23 @@ YUNQI = {
"base_url": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
}
# tokenssr 中转站:一把 key 通吃 ~90 模型(gpt-image-2 参考图 / gpt-5.x / 全套 Gemini 文本&图像)。
TOKENSSR = {
"api_key": env("TOKENSSR_API_KEY", ""),
"base_url": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
# 中转站凭证回退表(provider.name → .env)。可插拔解析顺序见 services.resolve_provider_credentials:
# DB 的 ModelProvider.base_url/api_key 优先,留空才回退到这里。密钥只在 .env,不写死、不强制进库。
PROVIDER_BASE_URLS = {
"volcengine": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
"yunqi": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
}
PROVIDER_KEYS = {
"volcengine": env("VOLCANO_ARK_API_KEY", ""),
"yunqi": env("YUNQI_API_KEY", ""),
"tokenssr": env("TOKENSSR_API_KEY", ""),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
@@ -0,0 +1,43 @@
"""预生成电商模特库(gpt-image-2:9:16 氛围正面图 → 16:9 白底三视图)。
用法:
python manage.py seed_demo_models --count 2 [--team <team_id>] [--brief "自定义画像"]
默认给「第一个有成员的团队」生成 N 个默认人设。慢(每个模特 2 次 gpt-image-2,约 1-2 分钟)。
"""
from django.core.management.base import BaseCommand, CommandError
from apps.ai.model_library import DEFAULT_MODEL_BRIEFS, generate_model
class Command(BaseCommand):
help = "用 gpt-image-2 预生成电商模特(正面氛围图 + 白底三视图),存为 person 资产"
def add_arguments(self, parser):
parser.add_argument("--count", type=int, default=2, help="生成几个模特(默认 2)")
parser.add_argument("--team", type=str, default="", help="指定团队 id(默认第一个团队)")
parser.add_argument("--brief", type=str, default="", help="自定义单个模特画像(给了就只生成这一个)")
def handle(self, *args, **opts):
from apps.accounts.models import Team, User
team = Team.objects.filter(id=opts["team"]).first() if opts["team"] else Team.objects.order_by("created_at").first()
if team is None:
raise CommandError("找不到团队,请先建团队或用 --team 指定")
user = (
User.objects.filter(team_memberships__team=team).order_by("date_joined").first()
or getattr(team, "owner", None)
or User.objects.order_by("date_joined").first()
)
briefs = [opts["brief"]] if opts["brief"] else DEFAULT_MODEL_BRIEFS[: max(1, opts["count"])]
self.stdout.write(f"团队={team.id} 用户={getattr(user, 'username', None)} · 生成 {len(briefs)} 个模特…")
for i, brief in enumerate(briefs, 1):
self.stdout.write(f" [{i}/{len(briefs)}] {brief} … 生成中(gpt-image-2,稍候)")
try:
res = generate_model(team=team, user=user, brief=brief)
self.stdout.write(self.style.SUCCESS(
f" ✓ 正面={res['frontal'].id} 三视图={res['three_view'].id}"
))
except Exception as exc: # noqa: BLE001
self.stderr.write(self.style.ERROR(f" ✗ 失败:{exc}"))
self.stdout.write(self.style.SUCCESS("done"))
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-06-16 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0004_seed_tts_model'),
]
operations = [
migrations.AddField(
model_name='modelprovider',
name='api_key',
field=models.CharField(blank=True, max_length=255),
),
]
@@ -0,0 +1,82 @@
"""Seed tokenssr 中转站 provider + 多模型(文本 GPT-5.5 / Gemini-3-Pro,图像 gpt-image-2 支持参考图)。
可插拔:tokenssr provider 的 base_url 存 DB(换站改这里),api_key 留空 → 运行时回退 settings.PROVIDER_KEYS(.env)。
图像主力切到 tokenssr:gpt-image-2(images/edits 多图参考),停用只能纯文生图的 yunqi:gpt-image-2。
幂等:全部 get_or_create / update_or_create,可重复 apply。
"""
from django.db import migrations
TOKENSSR_BASE_URL = "https://king.tokenssr.com/v1"
TEXT_MODELS = [
# name, display_name, metadata
("gpt-5.5", "GPT-5.5", {"family": "gpt", "recommended": True}),
("gemini-3-pro-preview", "Gemini 3 Pro", {"family": "gemini"}),
]
IMAGE_MODELS = [
("gpt-image-2", "GPT-Image-2(参考图)", {"family": "gpt", "supports_reference": True, "recommended": True}),
("gemini-2.5-flash-image", "Gemini 2.5 Flash Image", {"family": "gemini", "supports_reference": True}),
]
def seed(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
tokenssr, _ = ModelProvider.objects.get_or_create(
name="tokenssr",
defaults={
"display_name": "tokenssr 中转站",
"status": "active",
"base_url": TOKENSSR_BASE_URL,
"api_key": "", # 留空:运行时从 .env(PROVIDER_KEYS)解析,密钥不进库
"metadata": {"kind": "relay", "note": "一把 key 通吃 ~90 模型"},
},
)
if not tokenssr.base_url:
tokenssr.base_url = TOKENSSR_BASE_URL
tokenssr.save(update_fields=["base_url"])
for name, display, meta in TEXT_MODELS:
ModelConfig.objects.update_or_create(
provider=tokenssr,
name=name,
capability="text",
defaults={
"display_name": display,
"endpoint": "chat/completions",
"unit_price": "1.0000",
"status": "active",
"metadata": meta,
},
)
for name, display, meta in IMAGE_MODELS:
ModelConfig.objects.update_or_create(
provider=tokenssr,
name=name,
capability="image",
defaults={
"display_name": display,
"endpoint": "images/generations",
"unit_price": "2.0000",
"status": "active" if name == "gpt-image-2" else "disabled",
"metadata": meta,
},
)
# 图像主力切到 tokenssr:gpt-image-2;停用只能纯文生图的 yunqi:gpt-image-2(参考图分镜要靠 tokenssr)
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="disabled")
def unseed(apps, schema_editor):
# 反向:停用 tokenssr 模型并复活 yunqi 图像(不删数据,保守)
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
ModelConfig.objects.filter(provider__name="tokenssr").update(status="disabled")
ModelConfig.objects.filter(provider__name="yunqi", capability="image").update(status="active")
ModelProvider.objects.filter(name="tokenssr").update(status="disabled")
class Migration(migrations.Migration):
dependencies = [("ai", "0005_modelprovider_api_key")]
operations = [migrations.RunPython(seed, unseed)]
+92
View File
@@ -0,0 +1,92 @@
"""模特库生成:用 gpt-image-2 预生成电商真人模特。
流程(与用户定的 SOP 一致):
1) 先出 9:16 竖屏氛围正面图(image_generation);
2) 以正面图为参考,出 16:9 白底三视图(image_edit,提示词锁角色一致性)。
两张都落 TOS,存为 person 类 Asset(metadata.kind="model"),进模特库/资产库。
可被管理命令(seed_demo_models)或后端业务复用。模型走默认 image 模型(当前 = tokenssr:gpt-image-2)。
"""
from __future__ import annotations
import uuid
from io import BytesIO
from apps.ai.models import ModelConfig
from apps.ai.services import _asset_preview_url, build_provider, get_default_model
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
THREE_VIEW_PROMPT = (
"参考图1角色,生成角色三视图,从左往右依次为:胸像特写,全身正面,全身侧面,全身背面,白色背景"
)
def _store_model_asset(*, team, user, media: str, name: str, brief: str, view: str) -> Asset:
"""把生成的图片(url/base64)落 TOS,存为 person 类模特资产(非项目维度)。"""
from apps.ai.providers import VolcanoArkProvider # media_to_bytes 复用
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
if "jpeg" in (content_type or ""):
suffix = ".jpg"
elif "webp" in (content_type or ""):
suffix = ".webp"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/models/{asset_id}{suffix}"
raw = fileobj.getvalue()
stored = TosStorage().upload_fileobj(fileobj=BytesIO(raw), object_key=object_key, content_type=content_type or "image/png")
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=user,
name=name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.PERSON,
metadata={"kind": "model", "brief": brief, "view": view},
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return asset
def generate_model(*, team, user, brief: str, name: str | None = None) -> dict:
"""生成一个电商模特(9:16 氛围正面图 + 16:9 白底三视图)。返回 {"frontal":Asset,"three_view":Asset}。"""
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("没有可用的图像模型(image capability)")
provider = build_provider(model_config)
if not hasattr(provider, "image_edit"):
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
label = name or brief[:16]
# 1) 9:16 氛围正面图
frontal_prompt = f"{brief},电商真人模特,9:16竖屏氛围正面半身,自然妆容,柔和影棚光,真实质感,单人,简洁背景"
resp = provider.image_generation(model=model_config.name, prompt=frontal_prompt, size="1024x1536")
frontal = _store_model_asset(
team=team, user=user, media=provider.extract_first_media_url(resp),
name=f"{label}·正面氛围", brief=brief, view="frontal",
)
# 2) 16:9 白底三视图(以正面图为参考,锁角色一致性)
frontal_url = _asset_preview_url(frontal)
resp2 = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[frontal_url], size="1536x1024")
three_view = _store_model_asset(
team=team, user=user, media=provider.extract_first_media_url(resp2),
name=f"{label}·三视图", brief=brief, view="three_view",
)
return {"frontal": frontal, "three_view": three_view}
# 演示用默认模特画像(电商常用人设)
DEFAULT_MODEL_BRIEFS = [
"26岁都市白领女性,知性温柔,黑色及肩直发,米色针织衫",
"30岁阳光运动男性,短发,健康肤色,浅灰色休闲卫衣",
"22岁元气学生女生,马尾,清透妆,浅蓝色衬衫",
]
+3
View File
@@ -12,6 +12,9 @@ class ModelProvider(TimeStampedModel):
display_name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
base_url = models.URLField(blank=True)
# 站级 API Key(中转站)。可插拔:换站 = 改这一行的 base_url + api_key,零改代码。
# 留空则 services 层按 provider.name 回退 settings.PROVIDER_KEYS(.env),避免密钥写死/进库。
api_key = models.CharField(max_length=255, blank=True)
metadata = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
+10 -1
View File
@@ -1,6 +1,15 @@
from .base import AIProvider, AIProviderResult
from .openai_compatible import OpenAICompatibleProvider
from .volcano import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider
from .yunqi import YunqiProvider
__all__ = ["AIProvider", "AIProviderResult", "TtsNotConfigured", "VolcanoArkProvider", "VolcanoTtsProvider", "YunqiProvider"]
__all__ = [
"AIProvider",
"AIProviderResult",
"OpenAICompatibleProvider",
"TtsNotConfigured",
"VolcanoArkProvider",
"VolcanoTtsProvider",
"YunqiProvider",
]
@@ -0,0 +1,89 @@
from typing import Any
import requests
from .volcano import VolcanoArkProvider
class OpenAICompatibleProvider(VolcanoArkProvider):
"""通用 OpenAI 兼容中转站适配器(tokenssr / yunqi / 任意 New-API 网关)。
设计目标:**可插拔**。凭证(base_url + api_key)由调用方显式注入——来自 DB
ModelProvider 或 .env,**不绑定任何具体站点**。换中转站 = 改 base_url + api_key,
零改代码。复用父类 VolcanoArkProvider 的 chat_completion / chat_completion_stream /
extract_text / extract_first_media_url / media_to_bytes。
与火山 ARK 的差异:生图走标准 OpenAI 形态(images/generations / images/edits),
不发 watermark / sequential_image_generation / response_format 等火山私有参数。
"""
def __post_init__(self) -> None:
# 关键:不回退到 settings.VOLCANO。凭证必须由 services 层显式注入,
# 否则就退化成「写死火山」破坏可插拔性。base_url 必填;api_key 允许构造期为空,
# 到真正调用时再报错(便于 seed / 探活阶段构造对象)。
if not self.base_url:
raise ValueError("OpenAICompatibleProvider requires base_url (中转站地址)")
def image_generation(
self,
*,
model: str,
prompt: str,
endpoint: str = "images/generations",
image: str | list[str] | None = None,
size: str = "1024x1536",
) -> dict[str, Any]:
"""文生图(可选单图参考 base64)。多图参考请用 image_edit。返回体含 url 或 b64_json。"""
if not self.api_key:
raise ValueError("中转站 api_key 未配置")
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1}
if image:
body["image"] = image
# 实测中转站生图延迟可达 75s+,超时给到 300s
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=300,
)
response.raise_for_status()
return response.json()
def image_edit(
self,
*,
model: str,
prompt: str,
images: list[str],
endpoint: str = "images/edits",
size: str = "1024x1536",
) -> dict[str, Any]:
"""参考图编辑/合成(gpt-image-2 核心能力):multipart `image[]` 上传一张或多张参考图。
images 元素可为 http(s) URL / data:base64 / 裸 base64(用父类 media_to_bytes 归一化为字节)。
多图参考即「@图1 @图2 @图3」——故事板按角色/场景/商品多锚点合成。返回体含 b64_json。
"""
if not self.api_key:
raise ValueError("中转站 api_key 未配置")
files: list[tuple[str, tuple[str, bytes, str]]] = []
for idx, ref in enumerate(images or []):
fileobj, content_type = self.media_to_bytes(ref)
content_type = content_type or "image/png"
ext = "png"
if "jpeg" in content_type or "jpg" in content_type:
ext = "jpg"
elif "webp" in content_type:
ext = "webp"
files.append(("image[]", (f"ref{idx + 1}.{ext}", fileobj.getvalue(), content_type)))
if not files:
raise ValueError("image_edit 至少需要一张参考图")
data = {"model": model, "prompt": prompt, "size": size, "n": "1"}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}"}, # multipart 不要手设 Content-Type
files=files,
data=data,
timeout=300,
)
response.raise_for_status()
return response.json()
+56 -2
View File
@@ -1,8 +1,9 @@
from dataclasses import dataclass
import base64
import json
import uuid
from io import BytesIO
from typing import Any
from typing import Any, Iterator
import requests
from django.conf import settings
@@ -52,6 +53,57 @@ class VolcanoArkProvider:
response.raise_for_status()
return response.json()
def chat_completion_stream(
self,
*,
model: str,
messages: list[dict[str, Any]],
endpoint: str = "chat/completions",
temperature: float = 0.8,
extra_body: dict[str, Any] | None = None,
) -> Iterator[dict[str, Any]]:
"""流式对话:逐块 yield {type:'delta'|'tool_call'|'done', ...}。
OpenAI 兼容 SSE(火山 ARK / 各中转站同构),供脚本 agent 的 SSE 端实时转发。"""
if not self.api_key:
raise ValueError("api_key is not configured")
body: dict[str, Any] = {"model": model, "messages": messages, "stream": True, "temperature": temperature}
if extra_body:
body.update(extra_body)
with requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json=body,
stream=True,
timeout=300,
) as response:
response.raise_for_status()
# SSE 响应常不带 charset,requests 会按 latin-1 解码 → 中文乱码。强制 UTF-8。
response.encoding = "utf-8"
for raw in response.iter_lines(decode_unicode=True):
if not raw or not raw.startswith("data:"):
continue
data = raw[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except ValueError:
continue
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
piece = delta.get("content")
if piece:
yield {"type": "delta", "text": piece}
if delta.get("tool_calls"):
yield {"type": "tool_call", "tool_calls": delta["tool_calls"]}
yield {"type": "done"}
@staticmethod
def extract_text(data: dict[str, Any]) -> str:
choices = data.get("choices") or []
@@ -113,6 +165,7 @@ class VolcanoArkProvider:
duration: int = 15,
resolution: str = "720p",
reference_images: list[str] | None = None,
generate_audio: bool = True,
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
@@ -126,7 +179,8 @@ class VolcanoArkProvider:
"duration": duration,
"resolution": resolution,
"watermark": False,
"generate_audio": False,
# Seedance 直接出音效 + 人物声音(参考生视频);关掉则是哑片。默认开。
"generate_audio": generate_audio,
}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
+494
View File
@@ -0,0 +1,494 @@
"""对话式脚本生成 agent(出稿 + 改稿一体,多模型可选,流式 SSE)。
设计:
- 加载电商脚本 skill(SKILL.md + references)作为领域知识系统提示词;模型无关。
- 3 种输入模式(全自动 / 一句话 / 改稿)收敛到同一份结构化 ScriptDraft(铁律1契约)。
- 流式:边生成边吐「工具卡 + 思考」事件,给前端真 agent 体感;JSON 由后端可靠抽取,不靠模型排版。
- 计费走现有 AITask + 额度预扣(reserve→charge/release),与 generate_project_script 一致。
SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
tool {id,label?,status:running|done|error} —— 工具卡(加载skill/分析商品/生成分镜/提取实体/自检)
delta {text} —— 模型自然语言前言(JSON 部分不外露)
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
done {} —— 结束
error {detail} —— 失败(已回滚额度)
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.billing.services.ledger import charge_reserved_credit, release_credit
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
VALID_ENTITY_TYPES = ["character", "scene", "product"]
DURATION_TIERS = [15, 30, 60, 90]
# --------------------------------------------------------------------------- #
# skill 加载(缓存)
# --------------------------------------------------------------------------- #
def _skill_dir() -> Path:
override = getattr(settings, "ECOMMERCE_SKILL_DIR", None)
if override:
return Path(override)
return Path(settings.BASE_DIR).parent.parent / "skills" / "ecommerce-video-script"
@lru_cache(maxsize=1)
def load_ecommerce_skill() -> str:
"""读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。"""
skill_dir = _skill_dir()
parts: list[str] = []
main = skill_dir / "SKILL.md"
if main.exists():
parts.append(main.read_text(encoding="utf-8"))
ref_dir = skill_dir / "references"
if ref_dir.exists():
for ref in sorted(ref_dir.glob("*.md")):
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
if not parts:
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
return "".join(parts)
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
_OUTPUT_PROTOCOL = """
---
## 运行时输出协议(AirShelf 流式展示专用,优先级高于技能正文的「只输出 JSON」)
严格按以下顺序输出,不要有别的内容:
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展;
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
3. json 代码块之后**不要再写任何文字**。
"""
# --------------------------------------------------------------------------- #
# 提示词构建(3 模式)
# --------------------------------------------------------------------------- #
def _product_context(project, selling_point_ids: list[str] | None) -> str:
product = project.product
selling_points = product.selling_points.all()
if selling_point_ids:
selling_points = selling_points.filter(id__in=selling_point_ids)
selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points)
return (
f"商品标题:{product.title}\n"
f"品牌:{product.brand or '未填写'}\n"
f"类目:{product.category or '未填写'}\n"
f"目标人群:{product.target_audience or '未填写'}\n"
f"商品描述:{product.description or '未填写'}\n"
f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}"
)
def build_agent_messages(
*,
project,
mode: str,
user_prompt: str,
selling_point_ids: list[str] | None,
base_draft: dict | None,
aspect_ratio: str,
total_duration: int,
) -> list[dict[str, str]]:
system = load_ecommerce_skill() + _OUTPUT_PROTOCOL
head = (
f"【画幅】{aspect_ratio}\n"
f"【总时长】{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15} 镜)\n"
f"【商品信息】\n{_product_context(project, selling_point_ids)}"
)
if mode == "revise" and base_draft:
user = (
"【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n"
f"{head}\n\n"
f"【现有脚本 JSON】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
f"【用户修改意见】{user_prompt.strip() or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n"
"请输出修订后的**完整** ScriptDraft。"
)
elif mode == "theme" or (user_prompt and user_prompt.strip()):
user = (
"【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,其余自动补全。\n"
f"{head}\n\n"
f"【用户主题】{user_prompt.strip()}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
else:
user = (
"【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n"
f"{head}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
# --------------------------------------------------------------------------- #
# JSON 抽取 + 契约规范化(模型无关,后端兜底)
# --------------------------------------------------------------------------- #
def _extract_json(text: str) -> str | None:
fenced = re.search(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
candidate = fenced.group(1) if fenced else text
start, end = candidate.find("{"), candidate.rfind("}")
if start != -1 and end != -1 and end > start:
return candidate[start : end + 1]
return None
def _nearest_duration(value) -> int:
try:
value = int(value)
except (TypeError, ValueError):
return 60
if value in DURATION_TIERS:
return value
return min(DURATION_TIERS, key=lambda t: abs(t - value))
def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text)
if not blob:
raise ValueError("模型没有输出结构化 JSON")
draft = json.loads(blob)
if not isinstance(draft, dict):
raise ValueError("脚本 JSON 顶层不是对象")
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
dur = _nearest_duration(draft.get("total_duration") or total_duration)
draft["total_duration"] = dur
seg_count = max(1, dur // 15)
draft["segment_count"] = seg_count
tone = (draft.get("tone") or "").strip()
draft["tone"] = tone if tone in VALID_TONES else "种草"
draft["hook"] = (draft.get("hook") or "").strip()
# entities 规范化:补 id / ref_index,过滤非法 type
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
norm_entities: list[dict] = []
seen_ids: set[str] = set()
for i, ent in enumerate(entities):
if not isinstance(ent, dict):
continue
eid = str(ent.get("id") or f"e{i + 1}").strip() or f"e{i + 1}"
while eid in seen_ids:
eid = f"{eid}_{i}"
seen_ids.add(eid)
etype = (ent.get("type") or "").strip()
if etype not in VALID_ENTITY_TYPES:
etype = "character"
norm_entities.append(
{
"id": eid,
"type": etype,
"name": (ent.get("name") or eid).strip(),
"visual_prompt": (ent.get("visual_prompt") or "").strip(),
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
"voice_ref": ent.get("voice_ref") or None,
}
)
draft["entities"] = norm_entities
valid_ids = {e["id"] for e in norm_entities}
# segments 规范化:对齐镜数,role 枚举,引用合法
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
norm_segments: list[dict] = []
for i, seg in enumerate(segments[:seg_count]):
if not isinstance(seg, dict):
seg = {}
role = (seg.get("role") or "").strip()
if role not in VALID_ROLES:
role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)]
speaker = seg.get("speaker")
speaker = speaker if (speaker in valid_ids) else None
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
norm_segments.append(
{
"index": i,
"duration": 15,
"role": role,
"narration": (seg.get("narration") or "").strip(),
"speaker": speaker,
"visual": (seg.get("visual") or seg.get("visual_prompt") or "").strip(),
"product_exposure": (seg.get("product_exposure") or "").strip(),
"entity_refs": refs,
}
)
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
while len(norm_segments) < seg_count:
i = len(norm_segments)
norm_segments.append(
{
"index": i,
"duration": 15,
"role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)],
"narration": "",
"speaker": None,
"visual": "",
"product_exposure": "",
"entity_refs": [],
}
)
if not norm_segments:
raise ValueError("脚本没有任何分镜")
draft["segments"] = norm_segments
return draft
# --------------------------------------------------------------------------- #
# 落库
# --------------------------------------------------------------------------- #
def _map_entities_to_project_metadata(project, entities: list[dict]) -> None:
"""把结构化 entities 回填到 project.metadata,复用下游已有的 cast/scenes/*_prompts 接线
(脚本页标签 + 基础资产 seed + 故事板 @图N)。只在有内容时覆盖,空结果不清旧标签。"""
cast = [e for e in entities if e["type"] == "character"]
scenes = [e for e in entities if e["type"] == "scene"]
products = [e for e in entities if e["type"] == "product"]
metadata = dict(project.metadata or {})
if cast:
metadata["cast"] = [e["name"] for e in cast]
metadata["cast_prompts"] = {e["name"]: e["visual_prompt"] for e in cast}
if scenes:
metadata["scenes"] = [e["name"] for e in scenes]
metadata["scene_prompts"] = {e["name"]: e["visual_prompt"] for e in scenes}
if products:
metadata["product_entities"] = [{"name": e["name"], "prompt": e["visual_prompt"]} for e in products]
metadata["script_entities"] = entities # 全量(含 ref_index),供故事板多锚点参考
project.metadata = metadata
project.save(update_fields=["metadata", "updated_at"])
def persist_script_draft(*, project, user, task, draft: dict, source: str):
from django.db import transaction
from apps.projects.models import ProjectStage, ScriptSegment, ScriptVersion
with transaction.atomic():
script = ScriptVersion.objects.create(
project=project,
task=task,
title=(draft.get("hook") or "AI 脚本")[:128],
content=json.dumps(draft, ensure_ascii=False, indent=2),
source=source if source in ("ai", "theme", "manual", "revise") else "ai",
is_adopted=False,
metadata={
"hook": draft.get("hook", ""),
"tone": draft.get("tone", ""),
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
"total_duration": draft.get("total_duration", 60),
"segment_count": draft.get("segment_count", 4),
"entities": draft.get("entities", []),
},
)
for seg in draft["segments"]:
ScriptSegment.objects.create(
script_version=script,
sort_order=seg["index"],
duration_seconds=seg.get("duration", 15),
narration=seg.get("narration", ""),
visual_prompt=seg.get("visual", ""),
role=seg.get("role", ""),
speaker=seg.get("speaker") or "",
product_exposure=seg.get("product_exposure", ""),
entity_refs=seg.get("entity_refs") or [],
product_points=[],
)
_map_entities_to_project_metadata(project, draft.get("entities", []))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
return script
# --------------------------------------------------------------------------- #
# 流式编排
# --------------------------------------------------------------------------- #
def _sse(obj: dict) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
def _visible_cut(text: str) -> int:
"""前言可见区终点 = JSON 起点(``` 或第一个 {)。之后的内容不外露,只在后端解析。"""
cands = []
for marker in ("```", "{"):
i = text.find(marker)
if i != -1:
cands.append(i)
return min(cands) if cands else len(text)
def stream_script_agent(
*,
project,
user,
model_config: ModelConfig,
mode: str = "auto",
user_prompt: str = "",
selling_point_ids: list[str] | None = None,
base_version_id: str | None = None,
aspect_ratio: str = "9:16",
total_duration: int = 60,
):
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。"""
from apps.ai.services import build_provider, create_ai_task
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
skill_loaded = bool(load_ecommerce_skill())
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
base_draft = None
if mode == "revise" and base_version_id:
base_draft = _load_base_draft(project, base_version_id)
messages = build_agent_messages(
project=project,
mode=mode,
user_prompt=user_prompt,
selling_point_ids=selling_point_ids,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
try:
task = create_ai_task(
project=project,
user=user,
task_type=task_type,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"mode": mode,
"aspect_ratio": aspect_ratio,
"total_duration": total_duration,
},
)
except Exception as exc: # noqa: BLE001 — 多为额度不足
yield _sse({"type": "error", "detail": f"任务创建失败(可能额度不足):{exc}"})
return
reservation = task.credit_reservation
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
full: list[str] = []
shown = 0
forwarding = True
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = build_provider(model_config)
for ev in provider.chat_completion_stream(
model=model_config.name,
endpoint=model_config.endpoint,
messages=messages,
temperature=0.85,
):
if ev.get("type") == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
cut = _visible_cut(text)
if cut < len(text):
forwarding = False
visible = text[:cut]
if len(visible) > shown:
piece = visible[shown:]
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif ev.get("type") == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
except Exception as exc: # noqa: BLE001
_fail_task(task, reservation, str(exc))
yield _sse({"type": "tool", "id": "generate", "status": "error"})
yield _sse({"type": "error", "detail": f"脚本生成失败:{exc}"})
return
yield _sse({"type": "tool", "id": "generate", "status": "done"})
yield _sse(
{
"type": "tool",
"id": "extract",
"label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])}",
"status": "done",
}
)
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
yield _sse({"type": "draft", "draft": draft})
try:
from django.db import transaction
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"raw": raw[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
from apps.projects.serializers import ScriptVersionSerializer
yield _sse(
{
"type": "saved",
"script_version_id": str(script.id),
"version": ScriptVersionSerializer(script).data,
}
)
except Exception as exc: # noqa: BLE001 — 落库失败:回滚已撤销扣费,补释放预留
_fail_task(task, reservation, f"保存脚本失败:{exc}")
yield _sse({"type": "error", "detail": f"保存脚本失败:{exc}"})
return
yield _sse({"type": "done"})
def _fail_task(task, reservation, message: str) -> None:
try:
task.status = AITask.Status.FAILED
task.error_message = message[:2000]
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
finally:
try:
release_credit(reservation=reservation, reason=message[:200])
except Exception: # noqa: BLE001
pass
def _load_base_draft(project, base_version_id: str) -> dict | None:
from apps.projects.models import ScriptVersion
try:
version = ScriptVersion.objects.get(project=project, id=base_version_id)
except (ScriptVersion.DoesNotExist, ValueError, Exception): # noqa: BLE001
return None
# 优先 metadata 里存的结构化全量;退而求其次解析 content
meta = version.metadata or {}
if meta.get("entities") is not None or meta.get("hook"):
try:
return json.loads(version.content)
except (ValueError, TypeError):
pass
try:
return json.loads(version.content)
except (ValueError, TypeError):
return None
+123 -16
View File
@@ -7,12 +7,18 @@ from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, YunqiProvider
from apps.ai.providers import (
OpenAICompatibleProvider,
TtsNotConfigured,
VolcanoArkProvider,
VolcanoTtsProvider,
)
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
@@ -39,11 +45,39 @@ def get_default_model(capability: str) -> ModelConfig:
)
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark"}
def resolve_provider_credentials(provider) -> tuple[str | None, str | None]:
"""解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。
两者都不写死;换站只改 DB 这一行,或改 .env 对应项。"""
base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(provider.name)
api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(provider.name)
return (base_url or None), (api_key or None)
def build_provider(model_config: ModelConfig):
"""按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。"""
provider = model_config.provider
if provider.name in OFFICIAL_DIRECT_PROVIDERS:
return VolcanoArkProvider(base_url=provider.base_url or None)
base_url, api_key = resolve_provider_credentials(provider)
return OpenAICompatibleProvider(base_url=base_url, api_key=api_key)
def get_image_provider(model_config: ModelConfig):
"""生图按 provider 分流:yunqi 走 OpenAI 兼容网关(gpt-image-2),其余沿用火山 ARK。"""
if model_config.provider.name == "yunqi":
return YunqiProvider(base_url=model_config.provider.base_url or None)
return VolcanoArkProvider(base_url=model_config.provider.base_url or None)
return build_provider(model_config)
def get_text_provider(model_config: ModelConfig):
return build_provider(model_config)
def get_video_provider(model_config: ModelConfig):
return build_provider(model_config)
def estimate_cost(model_config: ModelConfig) -> Decimal:
@@ -179,7 +213,7 @@ def extract_cast_and_scenes(*, project, user, content: str) -> dict:
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
text = provider.extract_text(response)
@@ -289,7 +323,7 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
@@ -422,7 +456,7 @@ def regenerate_script_segment(*, project, user, segment, instruction: str = "")
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
narration, visual = parse_segment_fields(content)
@@ -653,6 +687,65 @@ def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
return version
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
def _storyboard_reference_images(project, segment) -> list[dict]:
"""按本镜 entity_refs 取参考图(角色/场景/商品的已采用基础资产),供 gpt-image-2 多图合成 @图N。
返回 [{url,label,type}],最多 4 张;无匹配时兜底商品组。依赖脚本 agent 落进 metadata 的 script_entities。"""
entities = {
e.get("id"): e
for e in (project.metadata or {}).get("script_entities", [])
if isinstance(e, dict)
}
kind_by_type = {
"character": BaseAssetGroup.Kind.PERSON,
"scene": BaseAssetGroup.Kind.SCENE,
"product": BaseAssetGroup.Kind.PRODUCT,
}
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
out: list[dict] = []
used: set = set()
for rid in (segment.entity_refs or []):
ent = entities.get(rid)
if not ent:
continue
kind = kind_by_type.get(ent.get("type"))
name = (ent.get("name") or "").strip()
match = next(
(g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used),
None,
) or next((g for g in groups if g.kind == kind and g.id not in used), None)
if match:
used.add(match.id)
url = _asset_preview_url(match.adopted_asset)
if url:
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")})
if len(out) >= 4:
break
if not out:
pg = next((g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT), None)
if pg:
url = _asset_preview_url(pg.adopted_asset)
if url:
out.append({"url": url, "label": "商品", "type": "product"})
return out
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
"""参考图合成版故事板提示词:在基础提示词上点名每张参考图,要求锁脸/锁商品外观。"""
base = build_storyboard_frame_prompt(project, version, segment)
if not refs:
return base
ref_lines = "".join(
f"参考图{i + 1}={r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
)
return (
f"{base}\n参考图对应:{ref_lines}"
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色,按本镜画面重新构图合成为一张电商竖屏分镜图。"
)
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
import threading # noqa: F401 — 仅标注此函数运行在独立线程
@@ -672,12 +765,26 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
task.save(update_fields=["status", "updated_at"])
try:
provider = get_image_provider(model_config)
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
refs = _storyboard_reference_images(project, segment)
ref_urls = [r["url"] for r in refs]
if ref_urls and hasattr(provider, "image_edit"):
# gpt-image-2 多图参考:把角色/场景/商品合成进本镜(@图1@图2@图3),锁脸锁外观保一致
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt_refs(
project, version, segment, refs
)
response = provider.image_edit(
model=model_config.name,
prompt=frame_prompt,
images=ref_urls,
size="1024x1536",
)
else:
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=frame_prompt,
)
media = provider.extract_first_media_url(response)
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
@@ -885,7 +992,7 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
},
)
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = build_provider(model_config)
try:
response = provider.create_video_task(
model=model_config.name,
@@ -956,7 +1063,7 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
return None
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
provider = build_provider(ai_task.model_config)
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
@@ -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)promptmodel_config_idselling_point_ids
base_version_id(改稿)aspect_ratiototal_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):
+1
View File
@@ -669,6 +669,7 @@ export function App() {
key={pipelineProject.id}
project={pipelineProject}
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
loading={loading}
navigate={navigate}
user={currentUser}
+49
View File
@@ -223,6 +223,55 @@ export const api = {
body: JSON.stringify(payload)
});
},
// 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。
// 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。
async agentScriptStream(
projectId: string,
payload: {
mode?: "auto" | "theme" | "revise";
prompt?: string;
model_config_id?: string;
selling_point_ids?: string[];
base_version_id?: string;
aspect_ratio?: string;
total_duration?: number;
},
onEvent: (evt: { type: string; [k: string]: unknown }) => void
): Promise<void> {
const token = getToken();
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
if (token) headers.set("Authorization", `Token ${token}`);
const response = await fetch(`${API_BASE}/api/projects/${projectId}/script-agent-stream/`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
throw new ApiError(response.status, text || "脚本流式生成失败");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep: number;
// SSE 帧以空行分隔(\n\n);每帧取 data: 行解析
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
if (!dataLine) continue;
try {
onEvent(JSON.parse(dataLine.slice(5).trim()));
} catch {
/* 跳过解析失败的帧 */
}
}
}
},
adoptScript(projectId: string, script_version_id: string) {
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
method: "POST",
+77 -31
View File
@@ -2,7 +2,7 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useR
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { Play } from "lucide-react";
import { api } from "../api";
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@@ -362,6 +362,7 @@ export function PipelinePage(props: {
avatarChar: string;
logout: () => void;
scriptModelName: string;
textModels?: ModelConfig[];
onGenerateScript: (prompt: string, source?: string) => Promise<unknown>;
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
@@ -389,7 +390,7 @@ export function PipelinePage(props: {
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
@@ -573,7 +574,7 @@ export function PipelinePage(props: {
const chatBodyRef = useRef<HTMLDivElement | null>(null);
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; done?: boolean; auto?: boolean };
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; stream?: string; done?: boolean; auto?: boolean };
const nowHm = () => new Date().toTimeString().slice(0, 5);
const msgIdRef = useRef(1);
const nextMsgId = () => msgIdRef.current++;
@@ -603,6 +604,9 @@ export function PipelinePage(props: {
} catch { /* localStorage 不可用则忽略 */ }
}, [chatKey, chatMsgs]);
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
// 脚本模型下拉:用户可选 豆包/GPT-5.5/Gemini(空 = 用后端默认文本模型)
const [scriptModelId, setScriptModelId] = useState<string>("");
const activeScriptModelId = scriptModelId || textModels?.[0]?.id || "";
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
const [armedDelete, setArmedDelete] = useState<string | null>(null);
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
@@ -637,33 +641,59 @@ export function PipelinePage(props: {
const el = chatBodyRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [chatMsgs]);
// 行33 · 进度提示流:前端模拟 AI 分步思考(逐条滚动),不需要真后端分步。
// 生成期间逐条往同一条 progress 消息追加 step;onGenerateScript 返回后置 done 折叠收起
const PROGRESS_STEPS = [
"收到脚本,正在解析商品卖点与创作方向…",
"提取关键卖点 · 锁定目标人群画像…",
"匹配创作风格与镜头节奏…",
"编排分镜 · 旁白与画面逐镜成稿…",
"校对时长与转化点,整理输出…"
];
// 统一的脚本生成对话回合:用户消息 → 进度流 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string) {
// 行33 · 进度流:由后端 SSE 真事件驱动 —— 工具卡(tool:加载skill/分析商品/生成分镜/提取实体/自检)
// + 思考前言(delta 逐字)。真 agent 体感,不再前端假模拟。流式不可用时兜底退回旧同步端点
function mapSourceToMode(src?: string): "auto" | "theme" | "revise" {
if (src === "theme") return "theme";
if (src === "revise") return "revise";
return "auto"; // ai / manual / 默认
}
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise") {
pushMsg("user", userLabel || prompt);
const progressId = nextMsgId();
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [PROGRESS_STEPS[0]], done: false, time: nowHm() }]);
// 逐条滚出后续步骤(纯前端节奏,生成真完成时收口)
let stepIdx = 1;
const timer = window.setInterval(() => {
if (stepIdx >= PROGRESS_STEPS.length) { window.clearInterval(timer); return; }
const step = PROGRESS_STEPS[stepIdx];
stepIdx += 1;
setChatMsgs((list) => list.map((m) => (m.id === progressId && !m.done ? { ...m, steps: [...(m.steps ?? []), step] } : m)));
}, 900);
const res = await onGenerateScript(prompt, source ?? chatMode);
window.clearInterval(timer);
// 收口:把 progress 折叠成一行结果,并补一条结果文本
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", done: false, time: nowHm() }]);
const agentMode = mode ?? mapSourceToMode(source ?? chatMode);
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
let ok = false;
try {
await api.agentScriptStream(
project.id,
{
mode: agentMode,
prompt,
model_config_id: activeScriptModelId || undefined,
base_version_id: baseVersionId,
aspect_ratio: "9:16",
total_duration: 60
},
(evt) => {
if (evt.type === "tool") {
// 工具卡:running 时把 label 滚进进度流(done/error 暂只用于结束态)
if (evt.status === "running" && typeof evt.label === "string") {
const label = evt.label;
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, steps: [...(m.steps ?? []), label] } : m)));
}
} else if (evt.type === "delta" && typeof evt.text === "string") {
const piece = evt.text;
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, stream: (m.stream ?? "") + piece } : m)));
} else if (evt.type === "saved") {
ok = true;
} else if (evt.type === "error") {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
}
}
);
} catch {
// 流式不可用(网关不支持 SSE 等)→ 退回旧同步端点,保证可用性
const res = await onGenerateScript(prompt, source ?? chatMode).catch(() => null);
ok = !!res;
}
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", res ? "镜头脚本已生成,左侧已刷新。可继续输入修改意见整体重写,或点底部「确认脚本」进入下一步。" : "生成没有成功,请查看提示后重试。");
if (ok) {
await onRefreshProject();
pushMsg("ai", "镜头脚本已生成,左侧已刷新。可继续输入修改意见(会基于当前脚本改稿),或点「确认脚本」进入下一步。");
}
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
async function runScriptWithSetup() {
@@ -708,7 +738,8 @@ export function PipelinePage(props: {
setChatText("");
setChatAttachments([]);
setPendingTagEdits([]);
void runScriptGeneration(prompt, label || undefined);
// 已有脚本 → 追问走「改稿」模式(基于当前脚本增强,保留原意);否则全自动出稿
void runScriptGeneration(prompt, label || undefined, undefined, currentScript ? "revise" : "auto");
}
function clearChat() {
setPendingTagEdits([]);
@@ -1826,7 +1857,19 @@ export function PipelinePage(props: {
<div className="pane-h">
<div className="ai-avatar">AI</div>
<strong></strong>
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
{textModels && textModels.length > 0 ? (
<select
className="setup-select"
style={{ height: 24, fontSize: 12, padding: "0 6px", maxWidth: 150, marginLeft: 6 }}
value={activeScriptModelId}
onChange={(event) => setScriptModelId(event.target.value)}
title="选择脚本生成模型(豆包 / GPT / Gemini)"
>
{textModels.map((m) => <option key={m.id} value={m.id}>{m.display_name || m.name}</option>)}
</select>
) : (
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
)}
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}></button>
</div>
@@ -1837,7 +1880,10 @@ export function PipelinePage(props: {
<div className={`msg ${msg.role}`} key={msg.id}>
<div className="bubble">
{msg.kind === "progress"
? <ProgressStream steps={msg.steps} done={msg.done} />
? <>
<ProgressStream steps={msg.steps} done={msg.done} />
{msg.stream ? <div style={{ marginTop: 6, opacity: 0.85, whiteSpace: "pre-wrap" }}>{msg.stream}</div> : null}
</>
: <CollapsibleText text={msg.text} maxLines={10} />}
</div>
<div className="time">{msg.time}</div>
@@ -1933,7 +1979,7 @@ export function PipelinePage(props: {
<div className="stage-foot">
<div className="info"><span className="mono">[ LLM ~2.4k tokens · ¥0.04 · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
+14 -1
View File
@@ -83,7 +83,20 @@ export type ScriptVersion = {
content: string;
source?: string;
is_adopted: boolean;
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string; visual_prompt?: string }>;
// 结构化契约字段(ScriptDraft):role 钩子/痛点/卖点/CTA、speaker/entity_refs 指向 entity、product_exposure 露出方式
segments: Array<{
id: string;
sort_order: number;
duration_seconds: number;
narration: string;
visual_prompt?: string;
role?: string;
speaker?: string;
product_exposure?: string;
entity_refs?: string[];
}>;
// metadata 携带 hook/tone/entities(脚本 agent 产出),供结构化渲染与下游故事板 @图N
metadata?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
};