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:
co-authored by
Claude Opus 4.8
parent
a0ffb6fc8e
commit
6464001f84
+16
-1
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
@@ -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岁元气学生女生,马尾,清透妆,浅蓝色衬衫",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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('/')}",
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user