fix(media): AGNES 图像/视频生成加韧性层——指数退避重试+友好错误翻译+Bot解析加固
根因(实测复现): AGNES 外部服务不稳定, 非平台代码问题: - 视频创建常返 500 publish_video_queue_failed / context deadline exceeded - 连接偶发重置 Server disconnected - 视频硬限频 每分钟 1 次 (429 rate limit) 改动: 1. chat.py 新增 _agnes_post_json(): 对 5xx/连接中断做 3 次指数退避重试(2/4s); 4xx(参数错误)不重试直接返回 2. 新增 _friendly_media_err(): 把 AGNES 原始 HTTP/JSON 错误 转译为对用户友好的中文(限频/连接失败/队列繁忙), 不再透传原始报错 3. _generate_media 图像/视频分支改用上述重试+友好错误 4. im_llm.py Bot 媒体结果解析加固: strip 后判 <img>/<video>, 成功返回干净链接; 失败透传友好中文, 绝不把原始提示词/标签甩给用户 验证(容器内实测): - WEB_IMG OK / WEB_VID OK / BOT_IMG OK (均返回真实 png/mp4) - 429/断开/队列三类错误均正确转译为友好中文
This commit is contained in:
1 parent
b88f883d44
commit
ccc5f799e1
2 files changed
+109
-51
No files matched your search
@@ -239,6 +239,49 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
|
||||
return stripped
|
||||
|
||||
|
||||
def _friendly_media_err(kind: str, raw: str) -> str:
|
||||
"""把 AGNES 原始错误转译为对用户友好的中文。
|
||||
|
||||
AGNES 常见错误:429 限频(每分钟1次视频) / Server disconnected(连接重置) /
|
||||
publish_video_queue_failed + context deadline exceeded(队列超时)。这些都不是
|
||||
平台代码问题,统一给出可操作的提示,不把原始 HTTP/JSON 甩给用户。
|
||||
"""
|
||||
r = (raw or "").lower()
|
||||
if "429" in raw or "rate limit" in r:
|
||||
return (f"⚠️ {kind}生成请求过于频繁(AGNES 限制每分钟 1 次),"
|
||||
f"请稍等 1 分钟后再试。")
|
||||
if "server disconnected" in r or "连接" in raw or "reset" in r:
|
||||
return f"⚠️ {kind}生成服务暂时连接失败,请稍后重试。"
|
||||
if "publish_video" in r or "queue" in r or "deadline" in r or "context" in r:
|
||||
return f"⚠️ {kind}生成队列繁忙(AGNES 处理超时),请稍后重试。"
|
||||
return f"⚠️ {kind}生成失败(请稍后重试):{(raw or '')[:120]}"
|
||||
|
||||
|
||||
async def _agnes_post_json(api_url: str, headers: dict, payload: dict, timeout) -> tuple:
|
||||
"""直连 AGNES 发 POST,对 5xx / 连接中断做指数退避重试。
|
||||
|
||||
AGNES 视频/图像队列极不稳定:经常返回 500 publish_video_queue_failed /
|
||||
context deadline exceeded,或连接被重置(Server disconnected)。这类错误是 AGNES
|
||||
侧瞬时抖动,重试通常立刻成功。4xx(参数错误)不重试,直接返回。
|
||||
返回 (resp, err):成功(resp.status_code<500) 时 err=None;耗尽重试时 resp=None。
|
||||
"""
|
||||
last_err = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout, proxy=_proxy_for_url(api_url), trust_env=False
|
||||
) as client:
|
||||
resp = await client.post(api_url, headers=headers, json=payload)
|
||||
if resp.status_code < 500:
|
||||
return resp, None
|
||||
last_err = f"HTTP {resp.status_code}: {resp.text[:200]}"
|
||||
except Exception as e:
|
||||
last_err = str(e)[:200]
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(2 * (attempt + 1))
|
||||
return None, last_err
|
||||
|
||||
|
||||
async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str:
|
||||
"""图像/视频生成模型统一入口:按 capabilities 调用 AGNES 等媒体生成 API。
|
||||
返回可直接在 AI 聊天中渲染的富文本(含 <img>/<video> 标签)。"""
|
||||
@@ -265,25 +308,27 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
|
||||
"prompt": optimized_prompt,
|
||||
"size": "1024x768",
|
||||
}
|
||||
resp, err = await _agnes_post_json(api_url, headers, payload, timeout)
|
||||
if resp is None:
|
||||
# 3 次重试后仍失败 → AGNES 侧持续抖动,提示稍后重试而非甩原始错误
|
||||
return _friendly_media_err("图像", err)
|
||||
if resp.status_code != 200:
|
||||
return _friendly_media_err("图像", f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=_proxy_for_url(api_url), trust_env=False) 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]}"
|
||||
except Exception:
|
||||
return f"⚠️ 图像生成失败:响应解析错误 {resp.text[:200]}"
|
||||
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)}')
|
||||
|
||||
# ============ 视频生成 (异步任务 + 轮询) ============
|
||||
if "video" in caps:
|
||||
@@ -303,38 +348,45 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
|
||||
"num_frames": 121,
|
||||
"frame_rate": 24,
|
||||
}
|
||||
resp, err = await _agnes_post_json(api_url, headers, payload, timeout)
|
||||
if resp is None:
|
||||
return _friendly_media_err("视频", err)
|
||||
if resp.status_code != 200:
|
||||
return _friendly_media_err("视频", f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=_proxy_for_url(api_url), trust_env=False) 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 分钟)。AGNES 视频状态端点为 GET {base}/v1/videos/{video_id}
|
||||
# 该端点顶层返回 status(queued/in_progress/completed/failed),成片地址在 metadata.url
|
||||
result_url = None
|
||||
err_msg = None
|
||||
for _ in range(60):
|
||||
await asyncio.sleep(5)
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
return f"⚠️ 视频生成失败:响应解析错误 {resp.text[:200]}"
|
||||
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 分钟)。AGNES 视频状态端点为 GET {base}/v1/videos/{video_id}
|
||||
# 该端点顶层返回 status(queued/in_progress/completed/failed),成片地址在 metadata.url
|
||||
result_url = None
|
||||
err_msg = None
|
||||
for _ in range(60):
|
||||
await asyncio.sleep(5)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout, proxy=_proxy_for_url(f"{base}/v1/videos/{video_id}"), trust_env=False
|
||||
) as client:
|
||||
r2 = await client.get(f"{base}/v1/videos/{video_id}", headers=headers)
|
||||
if r2.status_code != 200:
|
||||
continue
|
||||
rd = r2.json()
|
||||
st = rd.get("status")
|
||||
if st == "completed":
|
||||
result_url = (rd.get("metadata") or {}).get("url") or rd.get("url")
|
||||
break
|
||||
if st == "failed":
|
||||
err_msg = rd.get("error") or (rd.get("metadata") or {}).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]}"
|
||||
except Exception:
|
||||
continue
|
||||
if r2.status_code != 200:
|
||||
continue
|
||||
rd = r2.json()
|
||||
st = rd.get("status")
|
||||
if st == "completed":
|
||||
result_url = (rd.get("metadata") or {}).get("url") or rd.get("url")
|
||||
break
|
||||
if st == "failed":
|
||||
err_msg = rd.get("error") or (rd.get("metadata") or {}).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 '生成超时,请稍后在对话中重试'}"
|
||||
|
||||
return "⚠️ 未知的媒体模型类型。"
|
||||
|
||||
|
||||
@@ -78,11 +78,17 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
result = f"(媒体生成失败:{e})"
|
||||
if result:
|
||||
# IM 不渲染 HTML,把 <img>/<video> 还原为纯文本链接返回
|
||||
if result.startswith("<img") or result.startswith("<video"):
|
||||
m = _re.search(r'src="([^"]+)"', result)
|
||||
s = result.strip()
|
||||
if s.startswith("<img") or s.startswith("<video"):
|
||||
m = _re.search(r'src="([^"]+)"', s)
|
||||
url = m.group(1) if m else ""
|
||||
label = "图片" if result.startswith("<img") else "视频"
|
||||
return f"已为你生成{label}:{url}" if url else result
|
||||
label = "图片" if s.startswith("<img") else "视频"
|
||||
return f"已为你生成{label}:{url}" if url else "(生成完成,但未能提取媒体地址)"
|
||||
# 媒体生成失败:_generate_media 已返回友好中文(如限频/队列繁忙),
|
||||
# 直接透传,绝不把原始提示词/标签/JSON 透传给用户
|
||||
if s.startswith("⚠️") or s.startswith("(媒体"):
|
||||
return s
|
||||
return "(媒体生成未能返回有效内容,请重试)"
|
||||
return result
|
||||
|
||||
# ===== 2) 文本回复 + 技能自动发现(Issue 8)=====
|
||||
|
||||
Reference in new issue
Block a user