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
@@ -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('/')}",
|
||||
|
||||
Reference in New Issue
Block a user