feat(chat): support AGNES image/video models in AI chat - backend _generate_media (images/generations + async videos polling) and frontend MarkdownContent media rendering + model capability badges; nginx proxy_read_timeout raised to 600s for long media generation
This commit is contained in:
1 parent
774f1648dc
commit
d62848ee98
3 files changed
+2370
-2128
No files matched your search
@@ -12,6 +12,7 @@ import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import re
|
||||
import json
|
||||
import asyncio
|
||||
import math
|
||||
import httpx
|
||||
import os
|
||||
@@ -40,6 +41,108 @@ def cst_now():
|
||||
return datetime.now(CST_TZ)
|
||||
|
||||
|
||||
def _html_escape(s: str) -> str:
|
||||
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """).replace("'", "'"))
|
||||
|
||||
|
||||
async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str:
|
||||
"""图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。
|
||||
返回可直接在 AI 聊天中渲染的富文本(含 <img>/<video> 标签)。"""
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
if not api_key:
|
||||
return "⚠️ 该媒体模型缺少 API Key,请在后台「大模型库」中配置。"
|
||||
|
||||
caps = [c.lower() for c in (ai_model.capabilities or [])]
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
timeout = ai_model.timeout or 120
|
||||
|
||||
# ============ 图像生成 ============
|
||||
if "image" in caps:
|
||||
api_url = ai_model.endpoint
|
||||
if not api_url:
|
||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/images/generations" if provider else ""
|
||||
if not api_url:
|
||||
return "⚠️ 该图像模型未配置 Endpoint。"
|
||||
payload = {
|
||||
"model": ai_model.name,
|
||||
"prompt": prompt,
|
||||
"size": "1024x768",
|
||||
"extra_body": {"response_format": "url"},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(api_url, headers=headers, json=payload)
|
||||
if resp.status_code != 200:
|
||||
return f"⚠️ 图像生成失败 (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||
data = resp.json()
|
||||
items = data.get("data") or []
|
||||
if not items:
|
||||
return f"⚠️ 图像生成失败:响应无图片数据 {str(data)[:200]}"
|
||||
item = items[0]
|
||||
url = item.get("url") or item.get("b64_json")
|
||||
if not url:
|
||||
return f"⚠️ 图像生成失败:未返回图片地址 {str(data)[:200]}"
|
||||
if not url.startswith("http"):
|
||||
url = f"data:image/png;base64,{url}"
|
||||
return (f'<img class="md-img" src="{url}" alt="生成图片"/>\n\n'
|
||||
f'**提示词:** {_html_escape(prompt)}')
|
||||
except Exception as e:
|
||||
return f"⚠️ 图像生成异常:{str(e)[:200]}"
|
||||
|
||||
# ============ 视频生成 (异步任务 + 轮询) ============
|
||||
if "video" in caps:
|
||||
api_url = ai_model.endpoint
|
||||
if not api_url:
|
||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/videos" if provider else ""
|
||||
if not api_url:
|
||||
return "⚠️ 该视频模型未配置 Endpoint。"
|
||||
base = api_url.split("/v1")[0] if "/v1" in api_url else api_url
|
||||
payload = {
|
||||
"model": ai_model.name,
|
||||
"prompt": prompt,
|
||||
"height": 768,
|
||||
"width": 1152,
|
||||
"num_frames": 121,
|
||||
"frame_rate": 24,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(api_url, headers=headers, json=payload)
|
||||
if resp.status_code != 200:
|
||||
return f"⚠️ 视频生成失败 (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||
data = resp.json()
|
||||
video_id = data.get("video_id") or data.get("task_id") or data.get("id")
|
||||
if not video_id:
|
||||
return f"⚠️ 视频生成失败:未返回任务ID {str(data)[:200]}"
|
||||
# 轮询结果(最多约 5 分钟)
|
||||
result_url = None
|
||||
err_msg = None
|
||||
for _ in range(60):
|
||||
await asyncio.sleep(5)
|
||||
r2 = await client.get(f"{base}/agnesapi?video_id={video_id}", headers=headers)
|
||||
if r2.status_code != 200:
|
||||
continue
|
||||
rd = r2.json()
|
||||
st = rd.get("status")
|
||||
if st == "completed":
|
||||
result_url = rd.get("url")
|
||||
break
|
||||
if st == "failed":
|
||||
err_msg = rd.get("error")
|
||||
break
|
||||
if result_url:
|
||||
return (f'<video class="md-video" controls src="{result_url}"></video>\n\n'
|
||||
f'**提示词:** {_html_escape(prompt)}')
|
||||
return f"⚠️ 视频生成失败:{err_msg or '生成超时,请稍后在对话中重试'}"
|
||||
except Exception as e:
|
||||
return f"⚠️ 视频生成异常:{str(e)[:200]}"
|
||||
|
||||
return "⚠️ 未知的媒体模型类型。"
|
||||
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Request Models
|
||||
# ============================================================
|
||||
@@ -881,7 +984,14 @@ async def send_message(
|
||||
if model_id:
|
||||
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
|
||||
|
||||
if ai_model and ai_model.endpoint:
|
||||
# ====== 图像/视频生成模型分支 ======
|
||||
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else []
|
||||
if ai_model and ("image" in caps or "video" in caps):
|
||||
try:
|
||||
ai_content = await _generate_media(db, ai_model, req.content, current_user.id)
|
||||
except Exception as e:
|
||||
ai_content = f"⚠️ 媒体生成失败:{str(e)[:200]}"
|
||||
elif ai_model and ai_model.endpoint:
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
api_url = ai_model.endpoint
|
||||
@@ -1333,6 +1443,7 @@ def get_models(db: Session = Depends(get_db)):
|
||||
"display_name": getattr(m, "display_name", None) or m.name,
|
||||
"provider_id": m.provider_id,
|
||||
"provider_name": provider.name if provider else "",
|
||||
"capabilities": m.capabilities or [],
|
||||
"status": m.status,
|
||||
"price_per_million_tokens": float(m.price_per_million_tokens or 0),
|
||||
"is_enabled": m.is_enabled,
|
||||
|
||||
+1123
-1047
File diff suppressed because it is too large.
Load diff
+1135
-1080
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user