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:
qyyfw committed 2026-07-10 15:47:03 +08:00
1 parent 774f1648dc
commit d62848ee98
3 files changed
+267 -25

No files matched your search

+112 -1
View File
@@ -12,6 +12,7 @@ import uuid
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import re import re
import json import json
import asyncio
import math import math
import httpx import httpx
import os import os
@@ -40,6 +41,108 @@ def cst_now():
return datetime.now(CST_TZ) return datetime.now(CST_TZ)
def _html_escape(s: str) -> str:
return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;").replace("'", "&#39;"))
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 # Request Models
# ============================================================ # ============================================================
@@ -881,7 +984,14 @@ async def send_message(
if model_id: if model_id:
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() 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() 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_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint 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, "display_name": getattr(m, "display_name", None) or m.name,
"provider_id": m.provider_id, "provider_id": m.provider_id,
"provider_name": provider.name if provider else "", "provider_name": provider.name if provider else "",
"capabilities": m.capabilities or [],
"status": m.status, "status": m.status,
"price_per_million_tokens": float(m.price_per_million_tokens or 0), "price_per_million_tokens": float(m.price_per_million_tokens or 0),
"is_enabled": m.is_enabled, "is_enabled": m.is_enabled,
+79 -3
View File
@@ -385,9 +385,46 @@
/* Markdown Rendering */ /* Markdown Rendering */
.markdown-body { .markdown-body {
display: flex; display: block;
flex-direction: column; line-height: 1.7;
gap: 6px; }
.md-p { margin: 0 0 8px; }
.md-p:last-child { margin-bottom: 0; }
.md-blockquote {
border-left: 3px solid #6C63FF;
padding: 4px 12px;
margin: 8px 0;
background: rgba(0,0,0,0.04);
border-radius: 0 4px 4px 0;
color: #5A6480;
}
.md-link {
color: #6C63FF;
text-decoration: underline;
}
.md-table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 13px;
}
.md-td {
border: 1px solid rgba(0,0,0,0.1);
padding: 6px 10px;
text-align: left;
}
.md-tr:first-child .md-td {
font-weight: 600;
background: rgba(0,0,0,0.03);
}
.md-ul {
margin: 4px 0 8px;
padding-left: 1.5em;
}
.md-ul .md-li {
margin: 2px 0;
list-style: disc;
} }
.message-bubble.user .markdown-body { .message-bubble.user .markdown-body {
@@ -630,6 +667,20 @@
font-weight: 500; font-weight: 500;
} }
/* 模型能力徽标(图像/视频) */
.model-cap-badge {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
line-height: 1;
padding: 3px 7px;
border-radius: 999px;
background: linear-gradient(135deg, #6C63FF 0%, #8B7CFF 100%);
color: #fff;
font-weight: 600;
letter-spacing: 0.3px;
}
.dropdown-item.danger { .dropdown-item.danger {
color: var(--color-error); color: var(--color-error);
} }
@@ -1045,3 +1096,28 @@
border-color: transparent; border-color: transparent;
transform: none; transform: none;
} }
/* AI 聊天中生成的图像 / 视频 */
.md-img {
display: block;
max-width: 100%;
max-height: 420px;
width: auto;
height: auto;
border-radius: var(--radius-md);
margin: var(--space-sm) 0;
box-shadow: var(--shadow-md);
background: #fff;
object-fit: contain;
}
.md-video {
display: block;
max-width: 100%;
width: 100%;
max-height: 460px;
border-radius: var(--radius-md);
margin: var(--space-sm) 0;
box-shadow: var(--shadow-md);
background: #000;
}
+76 -21
View File
@@ -4,40 +4,86 @@ import { chatAPI, knowledgeAPI, marketAPI, evolutionAPI } from '@/api';
import type { Conversation, Message } from '@/types'; import type { Conversation, Message } from '@/types';
import './ChatPage.css'; import './ChatPage.css';
// ===== Simple Markdown Renderer ===== // ===== Enhanced Markdown Renderer =====
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
const html = useMemo(() => { const html = useMemo(() => {
let text = content; let text = content;
// Code blocks (```)
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_: string, lang: string, code: string) => { // 1. Extract code blocks first (protect from other replacements)
return `<pre class="md-code-block"><code class="md-code">${escapeHtml(code.trim())}</code></pre>`; const codeBlocks: string[] = [];
text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_m: string, _lang: string, code: string) => {
const idx = codeBlocks.length;
codeBlocks.push('<pre class="md-code-block"><code class="md-code">' + escapeHtml(code.trim()) + '</code></pre>');
return '\x00CODEBLOCK' + idx + '\x00';
}); });
// Inline code (`)
text = text.replace(/`([^`]+)`/g, '<code class="md-inline-code">$1</code>'); // 2. Extract inline code
// Bold (**text**) const inlineCodes: string[] = [];
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); text = text.replace(/`([^`]+)`/g, (_m: string, code: string) => {
// Italic (*text*) const idx = inlineCodes.length;
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>'); inlineCodes.push('<code class="md-inline-code">' + escapeHtml(code) + '</code>');
// Headers (## heading) return '\x00INLINE' + idx + '\x00';
});
// 2.5 Extract media blocks (<img>/<video>) to protect from other replacements
const mediaBlocks: string[] = [];
text = text.replace(/<(img|video)[^>]*\/?>/gi, (_m: string) => {
const idx = mediaBlocks.length;
mediaBlocks.push(_m);
return '\x00MEDIA' + idx + '\x00';
});
// 3. Headers
text = text.replace(/^#### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>'); text = text.replace(/^### (.+)$/gm, '<h4 class="md-h4">$1</h4>');
text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>'); text = text.replace(/^## (.+)$/gm, '<h3 class="md-h3">$1</h3>');
text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>'); text = text.replace(/^# (.+)$/gm, '<h2 class="md-h2">$1</h2>');
// Unordered list items
text = text.replace(/^- (.+)$/gm, '<li class="md-li">$1</li>'); // 4. Horizontal rule
// Ordered list items text = text.replace(/^---+$/gm, '<hr class="md-hr">');
text = text.replace(/^\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Horizontal rule // 5. Blockquotes
text = text.replace(/^---$/gm, '<hr class="md-hr">'); text = text.replace(/^> (.+)$/gm, '<blockquote class="md-blockquote">$1</blockquote>');
// Paragraph spacing: double newlines
text = text.replace(/\n\n/g, '<br/><br/>'); // 6. Links [text](url)
// Single newlines to <br> text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a class="md-link" href="$2" target="_blank" rel="noopener">$1</a>');
// 7. Bold and italic
text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/(?<!\w)\*(?!\*)(.+?)(?<!\*)\*(?!\w)/g, '<em>$1</em>');
// 8. Lists
text = text.replace(/^\s*[-*] (.+)$/gm, '<li class="md-li">$1</li>');
text = text.replace(/^\s*\d+\. (.+)$/gm, '<li class="md-li">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(/((?:<li class="md-li">[\s\S]*?<\/li>\n?)+)/g, '<ul class="md-ul">$1</ul>');
// 9. Convert double newlines to paragraph breaks, single to <br/>
text = text.replace(/\n\n/g, '</p><p class="md-p">');
text = text.replace(/\n/g, '<br/>'); text = text.replace(/\n/g, '<br/>');
// 10. Restore code blocks and inline codes
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
text = text.replace(/\x00MEDIA(\d+)\x00/g, (_m: string, idx: string) => mediaBlocks[parseInt(idx)] || '');
// 11. Wrap in paragraph and clean up
text = '<p class="md-p">' + text + '</p>';
text = text.replace(/<p class="md-p"><\/p>/g, '');
text = text.replace(/<p class="md-p">(<h[234][\s\S]*?<\/h[234]>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<pre[\s\S]*?<\/pre>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<ul[\s\S]*?<\/ul>)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
return text; return text;
}, [content]); }, [content]);
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />; return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
}; };
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text return text
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -547,8 +593,10 @@ const ChatPage: React.FC = () => {
kbName: res.data.knowledge_base_name, kbName: res.data.knowledge_base_name,
kbId: res.data.knowledge_base_id, kbId: res.data.knowledge_base_id,
}); });
// 使用后端返回的真实计数(幂等,始终为 1),避免前端重复累加导致计数虚高
const newCount = res.data.knowledge_graph_count ?? 1;
setConversations(prev => setConversations(prev =>
prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: (c.knowledgeGraphCount || 0) + 1 } : c) prev.map(c => c.id === convId ? { ...c, knowledgeGraphCount: newCount } : c)
); );
} else { } else {
setKgError(res.message || '转换失败'); setKgError(res.message || '转换失败');
@@ -753,6 +801,13 @@ const ChatPage: React.FC = () => {
<div className="model-dropdown-item"> <div className="model-dropdown-item">
<span>{model.name}</span> <span>{model.name}</span>
<span className="model-provider-tag">{model.provider_name || model.providerName || ''}</span> <span className="model-provider-tag">{model.provider_name || model.providerName || ''}</span>
{(() => {
const caps = model.capabilities || [];
const label = caps.includes('image') && caps.includes('video') ? '图像/视频'
: caps.includes('image') ? '图像'
: caps.includes('video') ? '视频' : '';
return label ? <span className="model-cap-badge">{label}</span> : null;
})()}
</div> </div>
</button> </button>
))} ))}