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
+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('/')}",