update
This commit is contained in:
@@ -11,7 +11,8 @@ RUN pip install --no-cache-dir \
|
||||
uvicorn[standard]==0.27.0 \
|
||||
pydantic==2.5.3 \
|
||||
aiohttp>=3.9.0 \
|
||||
python-multipart>=0.0.6
|
||||
python-multipart>=0.0.6 \
|
||||
azure-storage-blob>=12.19.0
|
||||
|
||||
COPY common/agent_callback_utils.py /app/common/
|
||||
RUN touch /app/common/__init__.py
|
||||
@@ -21,12 +22,9 @@ COPY agents/ad_creator_agent/ad_creator_agent.py /app/
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV SERVICE_HOST=0.0.0.0
|
||||
ENV SERVICE_PORT=8000
|
||||
ENV OUTPUT_DIR=/app/outputs
|
||||
|
||||
ENV AGENT_CALLBACK_URL=http://mcp-server:8002/api/v1/billing/agent-callback
|
||||
|
||||
RUN mkdir -p /app/outputs/images /app/outputs/videos
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Ad Creator Agent - 多模态广告创意生成 Agent
|
||||
通过素材(文字描述/参考图片)生成广告图片或视频
|
||||
支持模型:Gemini 3 Pro Image / GPT Image 1 / DALL-E 3 / Sora 2
|
||||
生成文件上传至 Azure Blob Storage,返回带 SAS token 的公开访问 URL
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -10,17 +11,20 @@ import uuid
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, AsyncGenerator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Header, Depends, UploadFile, File, Form, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
import uvicorn
|
||||
from azure.storage.blob import BlobServiceClient, ContentSettings
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
@@ -41,7 +45,6 @@ SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
|
||||
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
|
||||
POD_NAME = os.getenv("POD_NAME", "ad-creator-agent")
|
||||
USER_ID = os.getenv("USER_ID", "")
|
||||
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/app/outputs")
|
||||
|
||||
LLM_BASE_URL = os.getenv(
|
||||
"LLM_BASE_URL",
|
||||
@@ -53,9 +56,82 @@ DEFAULT_IMAGE_MODEL = os.getenv("DEFAULT_IMAGE_MODEL", "taiji/gemini-3-pro-image
|
||||
DEFAULT_TEXT_MODEL = os.getenv("DEFAULT_TEXT_MODEL", "taiji/gpt-4o-mini")
|
||||
DEFAULT_VIDEO_MODEL = os.getenv("DEFAULT_VIDEO_MODEL", "taiji/sora-2")
|
||||
|
||||
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(f"{OUTPUT_DIR}/images").mkdir(parents=True, exist_ok=True)
|
||||
Path(f"{OUTPUT_DIR}/videos").mkdir(parents=True, exist_ok=True)
|
||||
AZURE_STORAGE_CONNECTION_STRING = os.getenv(
|
||||
"AZURE_STORAGE_CONNECTION_STRING",
|
||||
"DefaultEndpointsProtocol=https;AccountName=agnettool;AccountKey=BCjWGrpArS35FThjW8wUBU8Bs/cqxsovRBsnuk/pE//R2p09EBcvuV8PuW8Klgh2bmTVjqeaDppB+AStWkcTOA==;EndpointSuffix=core.windows.net"
|
||||
)
|
||||
AZURE_BLOB_CONTAINER = os.getenv("AZURE_BLOB_CONTAINER", "multimodal")
|
||||
AZURE_BLOB_SAS_TOKEN = os.getenv(
|
||||
"AZURE_BLOB_SAS_TOKEN",
|
||||
"sp=r&st=2026-03-02T15:55:34Z&se=2028-03-02T00:10:34Z&sv=2024-11-04&sr=c&sig=hv3949MK%2FBajcgvWFUnGzx3jZ4gz3A%2FDALvQzv9mGPQ%3D"
|
||||
)
|
||||
|
||||
|
||||
# ==================== Azure Blob Storage ====================
|
||||
|
||||
class BlobStorage:
|
||||
"""Azure Blob Storage 管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._client: Optional[BlobServiceClient] = None
|
||||
if AZURE_STORAGE_CONNECTION_STRING:
|
||||
try:
|
||||
self._client = BlobServiceClient.from_connection_string(AZURE_STORAGE_CONNECTION_STRING)
|
||||
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
|
||||
if not container_client.exists():
|
||||
container_client.create_container()
|
||||
logger.info(f"Blob Storage 已连接: container={AZURE_BLOB_CONTAINER}")
|
||||
except Exception as e:
|
||||
logger.error(f"Blob Storage 连接失败: {e}")
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._client is not None
|
||||
|
||||
def _content_type(self, filename: str) -> str:
|
||||
ext = filename.rsplit(".", 1)[-1].lower()
|
||||
return {
|
||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"webp": "image/webp", "gif": "image/gif", "mp4": "video/mp4",
|
||||
}.get(ext, "application/octet-stream")
|
||||
|
||||
def upload(self, data: bytes, blob_name: str) -> str:
|
||||
"""上传二进制数据到 Blob,返回带 SAS 的公开 URL"""
|
||||
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
|
||||
content_settings = ContentSettings(content_type=self._content_type(blob_name))
|
||||
container_client.upload_blob(
|
||||
name=blob_name, data=data,
|
||||
overwrite=True, content_settings=content_settings,
|
||||
)
|
||||
account_name = self._client.account_name
|
||||
base_url = f"https://{account_name}.blob.core.windows.net/{AZURE_BLOB_CONTAINER}/{blob_name}"
|
||||
if AZURE_BLOB_SAS_TOKEN:
|
||||
return f"{base_url}?{AZURE_BLOB_SAS_TOKEN}"
|
||||
return base_url
|
||||
|
||||
def list_blobs(self, prefix: str = None) -> List[dict]:
|
||||
"""列出 Blob"""
|
||||
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
|
||||
blobs = []
|
||||
for blob in container_client.list_blobs(name_starts_with=prefix):
|
||||
account_name = self._client.account_name
|
||||
base_url = f"https://{account_name}.blob.core.windows.net/{AZURE_BLOB_CONTAINER}/{blob.name}"
|
||||
url = f"{base_url}?{AZURE_BLOB_SAS_TOKEN}" if AZURE_BLOB_SAS_TOKEN else base_url
|
||||
blobs.append({
|
||||
"filename": blob.name,
|
||||
"url": url,
|
||||
"size_bytes": blob.size,
|
||||
"created_at": blob.last_modified.isoformat() if blob.last_modified else "",
|
||||
})
|
||||
return blobs
|
||||
|
||||
def delete_blob(self, blob_name: str):
|
||||
container_client = self._client.get_container_client(AZURE_BLOB_CONTAINER)
|
||||
container_client.delete_blob(blob_name)
|
||||
|
||||
|
||||
blob_storage = BlobStorage()
|
||||
|
||||
|
||||
# ==================== 模型枚举 ====================
|
||||
@@ -172,7 +248,8 @@ async def startup_event():
|
||||
logger.info(f"回调处理器已初始化: {callback_handler.callback_url}")
|
||||
else:
|
||||
logger.warning("回调模块未加载")
|
||||
logger.info(f"Ad Creator Agent 启动: port={SERVICE_PORT}, output={OUTPUT_DIR}")
|
||||
storage_mode = f"azure_blob({AZURE_BLOB_CONTAINER})" if blob_storage.enabled else "local"
|
||||
logger.info(f"Ad Creator Agent 启动: port={SERVICE_PORT}, storage={storage_mode}")
|
||||
logger.info(f"默认模型: image={DEFAULT_IMAGE_MODEL}, text={DEFAULT_TEXT_MODEL}, video={DEFAULT_VIDEO_MODEL}")
|
||||
|
||||
|
||||
@@ -236,22 +313,21 @@ async def generate_image_gemini(
|
||||
img_format = match.group(1).replace("+xml", "")
|
||||
ext = "jpg" if img_format == "jpeg" else img_format
|
||||
b64_data = match.group(2).replace("\n", "").replace(" ", "")
|
||||
image_bytes = base64.b64decode(b64_data)
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"ad_gemini_{ts}.{ext}"
|
||||
file_path = os.path.join(OUTPUT_DIR, "images", filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(base64.b64decode(b64_data))
|
||||
|
||||
logger.info(f"Gemini 图片已生成: {file_path} ({os.path.getsize(file_path)} bytes)")
|
||||
return {
|
||||
"success": True,
|
||||
"file_path": file_path,
|
||||
"filename": filename,
|
||||
"url": f"/api/v1/files/{filename}",
|
||||
"model": DEFAULT_IMAGE_MODEL,
|
||||
}
|
||||
if blob_storage.enabled:
|
||||
blob_url = blob_storage.upload(image_bytes, filename)
|
||||
logger.info(f"Gemini 图片已上传 Blob: {filename} ({len(image_bytes)} bytes)")
|
||||
return {"success": True, "filename": filename, "url": blob_url, "model": DEFAULT_IMAGE_MODEL}
|
||||
else:
|
||||
file_path = os.path.join("/tmp", filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
logger.info(f"Gemini 图片已生成(本地): {file_path} ({len(image_bytes)} bytes)")
|
||||
return {"success": True, "filename": filename, "url": f"/api/v1/files/{filename}", "model": DEFAULT_IMAGE_MODEL}
|
||||
|
||||
|
||||
async def generate_image_openai(
|
||||
@@ -294,28 +370,27 @@ async def generate_image_openai(
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
model_tag = model.split("/")[-1].replace("-", "")
|
||||
filename = f"ad_{model_tag}_{ts}.png"
|
||||
file_path = os.path.join(OUTPUT_DIR, "images", filename)
|
||||
|
||||
if b64_data:
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(base64.b64decode(b64_data))
|
||||
image_bytes = base64.b64decode(b64_data)
|
||||
elif image_url:
|
||||
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as dl_resp:
|
||||
if dl_resp.status != 200:
|
||||
return {"success": False, "error": f"下载图片失败: HTTP {dl_resp.status}"}
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(await dl_resp.read())
|
||||
image_bytes = await dl_resp.read()
|
||||
else:
|
||||
return {"success": False, "error": "API 响应中无图片数据"}
|
||||
|
||||
logger.info(f"OpenAI 图片已生成: {file_path} ({os.path.getsize(file_path)} bytes)")
|
||||
return {
|
||||
"success": True,
|
||||
"file_path": file_path,
|
||||
"filename": filename,
|
||||
"url": f"/api/v1/files/{filename}",
|
||||
"model": model,
|
||||
}
|
||||
if blob_storage.enabled:
|
||||
blob_url = blob_storage.upload(image_bytes, filename)
|
||||
logger.info(f"OpenAI 图片已上传 Blob: {filename} ({len(image_bytes)} bytes)")
|
||||
return {"success": True, "filename": filename, "url": blob_url, "model": model}
|
||||
else:
|
||||
file_path = os.path.join("/tmp", filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
logger.info(f"OpenAI 图片已生成(本地): {file_path} ({len(image_bytes)} bytes)")
|
||||
return {"success": True, "filename": filename, "url": f"/api/v1/files/{filename}", "model": model}
|
||||
|
||||
|
||||
async def generate_image_dispatch(
|
||||
@@ -475,28 +550,27 @@ async def generate_video_sora(
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"ad_video_{ts}.mp4"
|
||||
file_path = os.path.join(OUTPUT_DIR, "videos", filename)
|
||||
|
||||
if b64_data:
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(base64.b64decode(b64_data))
|
||||
video_bytes = base64.b64decode(b64_data)
|
||||
elif video_url:
|
||||
async with session.get(video_url, timeout=aiohttp.ClientTimeout(total=120)) as dl_resp:
|
||||
if dl_resp.status != 200:
|
||||
return {"success": False, "error": f"下载视频失败: HTTP {dl_resp.status}"}
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(await dl_resp.read())
|
||||
video_bytes = await dl_resp.read()
|
||||
else:
|
||||
return {"success": False, "error": "Sora 响应中无视频数据"}
|
||||
|
||||
logger.info(f"视频已生成: {file_path} ({os.path.getsize(file_path)} bytes)")
|
||||
return {
|
||||
"success": True,
|
||||
"file_path": file_path,
|
||||
"filename": filename,
|
||||
"url": f"/api/v1/files/{filename}",
|
||||
"model": model,
|
||||
}
|
||||
if blob_storage.enabled:
|
||||
blob_url = blob_storage.upload(video_bytes, filename)
|
||||
logger.info(f"视频已上传 Blob: {filename} ({len(video_bytes)} bytes)")
|
||||
return {"success": True, "filename": filename, "url": blob_url, "model": model}
|
||||
else:
|
||||
file_path = os.path.join("/tmp", filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(video_bytes)
|
||||
logger.info(f"视频已生成(本地): {file_path} ({len(video_bytes)} bytes)")
|
||||
return {"success": True, "filename": filename, "url": f"/api/v1/files/{filename}", "model": model}
|
||||
|
||||
|
||||
# ==================== API 端点 ====================
|
||||
@@ -508,6 +582,8 @@ async def health_check():
|
||||
"status": "healthy",
|
||||
"service": "Ad Creator Agent",
|
||||
"pod_name": POD_NAME,
|
||||
"storage": "azure_blob" if blob_storage.enabled else "local",
|
||||
"blob_container": AZURE_BLOB_CONTAINER if blob_storage.enabled else None,
|
||||
"models": {
|
||||
"image": DEFAULT_IMAGE_MODEL,
|
||||
"text": DEFAULT_TEXT_MODEL,
|
||||
@@ -520,13 +596,19 @@ async def health_check():
|
||||
|
||||
@app.get("/status")
|
||||
async def status():
|
||||
images = list(Path(f"{OUTPUT_DIR}/images").glob("*"))
|
||||
videos = list(Path(f"{OUTPUT_DIR}/videos").glob("*"))
|
||||
if blob_storage.enabled:
|
||||
images = blob_storage.list_blobs(prefix="ad_")
|
||||
img_count = sum(1 for b in images if not b["filename"].startswith("ad_video_"))
|
||||
vid_count = sum(1 for b in images if b["filename"].startswith("ad_video_"))
|
||||
else:
|
||||
img_count = 0
|
||||
vid_count = 0
|
||||
return {
|
||||
"status": "running",
|
||||
"pod_name": POD_NAME,
|
||||
"generated_images": len(images),
|
||||
"generated_videos": len(videos),
|
||||
"storage": "azure_blob" if blob_storage.enabled else "local",
|
||||
"generated_images": img_count,
|
||||
"generated_videos": vid_count,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -674,60 +756,55 @@ async def api_generate_video(request: GenerateVideoRequest, api_key: str = Depen
|
||||
|
||||
@app.get("/api/v1/files/{filename}")
|
||||
async def download_file(filename: str):
|
||||
"""下载生成的文件"""
|
||||
for subdir in ["images", "videos"]:
|
||||
path = os.path.join(OUTPUT_DIR, subdir, filename)
|
||||
if os.path.exists(path):
|
||||
ext = filename.rsplit(".", 1)[-1].lower()
|
||||
media_types = {
|
||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"webp": "image/webp", "mp4": "video/mp4", "gif": "image/gif",
|
||||
}
|
||||
return FileResponse(path, media_type=media_types.get(ext, "application/octet-stream"), filename=filename)
|
||||
"""获取文件(Blob 模式下 302 跳转到 Blob URL)"""
|
||||
if blob_storage.enabled:
|
||||
account_name = blob_storage._client.account_name
|
||||
base_url = f"https://{account_name}.blob.core.windows.net/{AZURE_BLOB_CONTAINER}/{filename}"
|
||||
url = f"{base_url}?{AZURE_BLOB_SAS_TOKEN}" if AZURE_BLOB_SAS_TOKEN else base_url
|
||||
return RedirectResponse(url=url)
|
||||
path = os.path.join("/tmp", filename)
|
||||
if os.path.exists(path):
|
||||
ext = filename.rsplit(".", 1)[-1].lower()
|
||||
media_types = {
|
||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"webp": "image/webp", "mp4": "video/mp4", "gif": "image/gif",
|
||||
}
|
||||
return FileResponse(path, media_type=media_types.get(ext, "application/octet-stream"), filename=filename)
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
|
||||
|
||||
@app.get("/api/v1/list-files")
|
||||
async def list_files(file_type: str = "all"):
|
||||
"""列出已生成的文件"""
|
||||
"""列出已生成的文件(从 Blob Storage 列出)"""
|
||||
result = {"images": [], "videos": []}
|
||||
|
||||
if file_type in ("all", "image"):
|
||||
img_dir = Path(f"{OUTPUT_DIR}/images")
|
||||
for f in sorted(img_dir.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
result["images"].append({
|
||||
"filename": f.name,
|
||||
"url": f"/api/v1/files/{f.name}",
|
||||
"size_bytes": f.stat().st_size,
|
||||
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
|
||||
if file_type in ("all", "video"):
|
||||
vid_dir = Path(f"{OUTPUT_DIR}/videos")
|
||||
for f in sorted(vid_dir.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
result["videos"].append({
|
||||
"filename": f.name,
|
||||
"url": f"/api/v1/files/{f.name}",
|
||||
"size_bytes": f.stat().st_size,
|
||||
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
|
||||
if blob_storage.enabled:
|
||||
all_blobs = blob_storage.list_blobs(prefix="ad_")
|
||||
for b in all_blobs:
|
||||
if b["filename"].startswith("ad_video_"):
|
||||
if file_type in ("all", "video"):
|
||||
result["videos"].append(b)
|
||||
else:
|
||||
if file_type in ("all", "image"):
|
||||
result["images"].append(b)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/v1/cleanup")
|
||||
async def cleanup_files(max_age_hours: int = 24):
|
||||
"""清理超过指定时间的旧文件"""
|
||||
import time
|
||||
|
||||
cutoff = time.time() - max_age_hours * 3600
|
||||
from datetime import timezone, timedelta
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
|
||||
deleted = 0
|
||||
for subdir in ["images", "videos"]:
|
||||
d = Path(f"{OUTPUT_DIR}/{subdir}")
|
||||
for f in d.glob("*"):
|
||||
if f.stat().st_mtime < cutoff:
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
if blob_storage.enabled:
|
||||
all_blobs = blob_storage.list_blobs(prefix="ad_")
|
||||
for b in all_blobs:
|
||||
if b["created_at"] and datetime.fromisoformat(b["created_at"]) < cutoff:
|
||||
try:
|
||||
blob_storage.delete_blob(b["filename"])
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"删除 blob {b['filename']} 失败: {e}")
|
||||
return {"deleted": deleted, "max_age_hours": max_age_hours}
|
||||
|
||||
|
||||
@@ -781,6 +858,237 @@ async def chat(request: ChatRequest, api_key: str = Depends(get_api_key)):
|
||||
}
|
||||
|
||||
|
||||
# ==================== MCP 端点 ====================
|
||||
|
||||
SERVER_NAME = "Ad Creator Agent"
|
||||
|
||||
MCP_TOOL_MAP = {
|
||||
"generate_ad_image": None,
|
||||
"generate_ad_copy": None,
|
||||
"generate_full_ad": None,
|
||||
"list_generated_files": None,
|
||||
}
|
||||
|
||||
MCP_TOOL_LIST = [
|
||||
{
|
||||
"name": "generate_ad_image",
|
||||
"description": "生成广告图片。支持 Gemini / GPT Image / DALL-E 模型,可指定风格、宽高比和品牌名。返回图片的公开 URL。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "广告图片描述(英文效果更好)"},
|
||||
"model": {"type": "string", "description": "模型: taiji/gemini-3-pro-image-preview, taiji/gpt-image-1, taiji/dall-e-3"},
|
||||
"aspect_ratio": {"type": "string", "description": "宽高比: 1:1, 16:9, 9:16, 4:3, 3:4"},
|
||||
"style": {"type": "string", "description": "风格: modern, minimalist, luxury, playful, tech, vintage"},
|
||||
"brand_name": {"type": "string", "description": "品牌名称"},
|
||||
},
|
||||
"required": ["prompt"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "generate_ad_copy",
|
||||
"description": "生成广告文案方案,包含标题、正文、CTA、hashtags 以及配图 prompt。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product": {"type": "string", "description": "产品/服务描述"},
|
||||
"target_audience": {"type": "string", "description": "目标受众"},
|
||||
"tone": {"type": "string", "description": "语气: professional, casual, humorous, urgent, luxury"},
|
||||
"platform": {"type": "string", "description": "投放平台: instagram, facebook, tiktok, billboard, general"},
|
||||
"language": {"type": "string", "description": "语言: zh, en, ja"},
|
||||
},
|
||||
"required": ["product"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "generate_full_ad",
|
||||
"description": "一键生成完整广告:先生成文案,再根据文案自动生成配图。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product": {"type": "string", "description": "产品/服务描述"},
|
||||
"style": {"type": "string", "description": "广告风格"},
|
||||
"brand_name": {"type": "string", "description": "品牌名称"},
|
||||
"target_audience": {"type": "string", "description": "目标受众"},
|
||||
"tone": {"type": "string", "description": "语气"},
|
||||
"platform": {"type": "string", "description": "投放平台"},
|
||||
"language": {"type": "string", "description": "语言: zh, en, ja"},
|
||||
},
|
||||
"required": ["product"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_generated_files",
|
||||
"description": "列出已生成的广告素材文件(图片和视频)。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_type": {"type": "string", "description": "类型: all, image, video"},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _mcp_generate_ad_image(api_key: str, **kwargs) -> str:
|
||||
prompt = kwargs.get("prompt", "")
|
||||
if kwargs.get("style"):
|
||||
prompt = f"[{kwargs['style']} style] {prompt}"
|
||||
if kwargs.get("brand_name"):
|
||||
prompt = f"{prompt}. Brand: {kwargs['brand_name']}"
|
||||
result = await generate_image_dispatch(
|
||||
prompt=prompt, api_key=api_key,
|
||||
model=kwargs.get("model"), aspect_ratio=kwargs.get("aspect_ratio", "1:1"),
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def _mcp_generate_ad_copy(api_key: str, **kwargs) -> str:
|
||||
result = await generate_ad_copy(
|
||||
product=kwargs["product"], api_key=api_key,
|
||||
target_audience=kwargs.get("target_audience"),
|
||||
tone=kwargs.get("tone", "professional"),
|
||||
platform=kwargs.get("platform", "general"),
|
||||
language=kwargs.get("language", "zh"),
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def _mcp_generate_full_ad(api_key: str, **kwargs) -> str:
|
||||
copy_result = await generate_ad_copy(
|
||||
product=kwargs["product"], api_key=api_key,
|
||||
target_audience=kwargs.get("target_audience"),
|
||||
tone=kwargs.get("tone", "professional"),
|
||||
platform=kwargs.get("platform", "general"),
|
||||
language=kwargs.get("language", "zh"),
|
||||
)
|
||||
image_prompt = copy_result.get("image_prompt", "") or f"Advertisement for: {kwargs['product']}"
|
||||
if kwargs.get("style"):
|
||||
image_prompt = f"[{kwargs['style']} style] {image_prompt}"
|
||||
if kwargs.get("brand_name"):
|
||||
image_prompt = f"{image_prompt}. Brand: {kwargs['brand_name']}"
|
||||
image_result = await generate_image_dispatch(prompt=image_prompt, api_key=api_key)
|
||||
return json.dumps({"success": True, "copy": copy_result, "image": image_result}, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
async def _mcp_list_files(api_key: str, **kwargs) -> str:
|
||||
result = {"images": [], "videos": []}
|
||||
if blob_storage.enabled:
|
||||
all_blobs = blob_storage.list_blobs(prefix="ad_")
|
||||
ft = kwargs.get("file_type", "all")
|
||||
for b in all_blobs:
|
||||
if b["filename"].startswith("ad_video_"):
|
||||
if ft in ("all", "video"):
|
||||
result["videos"].append(b)
|
||||
else:
|
||||
if ft in ("all", "image"):
|
||||
result["images"].append(b)
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
_MCP_HANDLERS = {
|
||||
"generate_ad_image": _mcp_generate_ad_image,
|
||||
"generate_ad_copy": _mcp_generate_ad_copy,
|
||||
"generate_full_ad": _mcp_generate_full_ad,
|
||||
"list_generated_files": _mcp_list_files,
|
||||
}
|
||||
|
||||
sessions: Dict[str, Dict] = {}
|
||||
|
||||
|
||||
def _get_api_key_from_request(request: Request) -> Optional[str]:
|
||||
api_key = request.headers.get("api-key") or request.headers.get("api_key")
|
||||
if not api_key:
|
||||
auth = request.headers.get("Authorization")
|
||||
if auth:
|
||||
api_key = auth[7:] if auth.startswith("Bearer ") else auth
|
||||
return api_key or LLM_API_KEY or None
|
||||
|
||||
|
||||
async def _handle_mcp_request(data: Dict, session_id: str = None, api_key: str = None) -> Dict:
|
||||
method = data.get("method")
|
||||
params = data.get("params", {})
|
||||
req_id = data.get("id")
|
||||
|
||||
if method == "tools/call" and not api_key:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32001, "message": "缺少 API Key"}}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
session_id = session_id or str(uuid.uuid4())
|
||||
sessions[session_id] = {"initialized": True}
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": "1.0.0"},
|
||||
},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": MCP_TOOL_LIST}}
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
handler = _MCP_HANDLERS.get(tool_name)
|
||||
if not handler:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
result = await handler(api_key=api_key, **args)
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"content": [{"type": "text", "text": result}]}}
|
||||
elif method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": str(e)}}
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""MCP HTTP 端点"""
|
||||
try:
|
||||
body = await request.json()
|
||||
session_id = request.headers.get("x-mcp-session-id")
|
||||
api_key = _get_api_key_from_request(request)
|
||||
response = await _handle_mcp_request(body, session_id, api_key)
|
||||
return JSONResponse(content=response, headers={"x-mcp-session-id": session_id or ""})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=400, content={"jsonrpc": "2.0", "error": {"code": -32700, "message": str(e)}})
|
||||
|
||||
|
||||
@app.get("/mcp/sse")
|
||||
async def mcp_sse(request: Request):
|
||||
"""MCP SSE 端点"""
|
||||
session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4())
|
||||
|
||||
async def stream() -> AsyncGenerator[str, None]:
|
||||
yield f"data: {json.dumps({'type': 'connection', 'sessionId': session_id})}\n\n"
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "x-mcp-session-id": session_id})
|
||||
|
||||
|
||||
@app.post("/mcp/sse")
|
||||
async def mcp_sse_post(request: Request):
|
||||
"""MCP SSE POST 端点"""
|
||||
try:
|
||||
body = await request.json()
|
||||
session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4())
|
||||
api_key = _get_api_key_from_request(request)
|
||||
|
||||
async def stream() -> AsyncGenerator[str, None]:
|
||||
response = await _handle_mcp_request(body, session_id, api_key)
|
||||
yield f"data: {json.dumps(response)}\n\n"
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "x-mcp-session-id": session_id})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=400, content={"jsonrpc": "2.0", "error": {"code": -32700, "message": str(e)}})
|
||||
|
||||
|
||||
# ==================== 主入口 ====================
|
||||
|
||||
def main():
|
||||
|
||||
@@ -49,7 +49,7 @@ callback_handler: Optional[AgentCallbackHandler] = None
|
||||
|
||||
# 环境变量
|
||||
USER_ID = os.getenv("USER_ID", "")
|
||||
|
||||
|
||||
# FastAPI应用
|
||||
app = FastAPI(
|
||||
title="Intelligent Search AI Agent",
|
||||
|
||||
Reference in New Issue
Block a user