Files
Wechat-Bot/wechat-bot/bot/agent_client.py
2026-04-08 11:00:31 +08:00

238 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent HTTP 客户端模块
负责与远程 ADK Agent 服务通信,包括:
- HTTP 客户端生命周期管理
- 用户会话管理(创建、缓存、清除)
- SSE 流式调用(主方案)
- 同步调用(降级方案)
"""
import json
import logging
from typing import Optional
import httpx
from config import Config
from bot.reply_parser import extract_final_reply
logger = logging.getLogger(__name__)
# 用户会话映射: user_id -> session_id
_user_sessions: dict[str, str] = {}
# Agent HTTP 客户端
_http_client: Optional[httpx.AsyncClient] = None
def get_http_client() -> httpx.AsyncClient:
"""获取或创建 HTTP 客户端(全局单例)"""
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(
base_url=Config.AGENT_BASE_URL,
timeout=Config.AGENT_TIMEOUT,
)
return _http_client
async def close_http_client():
"""关闭 HTTP 客户端"""
global _http_client
if _http_client:
await _http_client.aclose()
_http_client = None
# ============================================================
# 会话管理
# ============================================================
async def get_or_create_session(user_id: str) -> str:
"""获取或创建用户的 Agent 会话"""
if user_id in _user_sessions:
return _user_sessions[user_id]
client = get_http_client()
try:
url = f"/apps/{Config.AGENT_APP_NAME}/users/{user_id}/sessions"
resp = await client.post(url, json={})
resp.raise_for_status()
data = resp.json()
session_id = data["id"]
_user_sessions[user_id] = session_id
logger.info("为用户 %s 创建 Agent 会话: %s", user_id, session_id)
return session_id
except Exception as e:
logger.error("创建 Agent 会话失败: %s", e)
raise
def clear_session(user_id: str):
"""清除用户的会话缓存(会话失效时调用)"""
_user_sessions.pop(user_id, None)
# ============================================================
# Agent 调用
# ============================================================
async def call_agent_stream(
ws_client,
frame: dict,
user_id: str,
message: str,
) -> str:
"""
通过 SSE 流式调用 Agent实现打字机效果。
失败时降级为同步调用。
返回完整的回复文本。
"""
from aibot import generate_req_id
session_id = await get_or_create_session(user_id)
client = get_http_client()
try:
req_body = {
"app_name": Config.AGENT_APP_NAME,
"user_id": user_id,
"session_id": session_id,
"new_message": {
"role": "user",
"parts": [{"text": message}],
},
"streaming": True,
}
async with client.stream(
"POST",
"/run_sse",
json=req_body,
timeout=Config.AGENT_TIMEOUT,
) as response:
if response.status_code != 200:
logger.warning(
"SSE 流式调用返回 %d,降级为同步调用",
response.status_code,
)
return await call_agent_sync(user_id, message)
stream_id = generate_req_id("stream")
full_text = ""
has_sent_final = False
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
# 错误处理
if "error" in data:
logger.error("Agent SSE 错误: %s", data["error"])
if "not found" in data["error"].lower():
clear_session(user_id)
return "抱歉,处理消息时出现错误,请稍后重试。"
# 收集所有文本(含思考过程)
parts = data.get("content", {}).get("parts", [])
for part in parts:
text = part.get("text", "")
if not text:
continue
if part.get("thought"):
continue
full_text += text
# 实时推送"正在思考"状态
if full_text and not has_sent_final:
try:
await ws_client.reply_stream(
frame, stream_id, "💭 正在思考...", False
)
except Exception:
pass
# 最终完成时提取正式回复并发送
is_final = data.get("finishReason") == "STOP"
if is_final and full_text and not has_sent_final:
clean_reply = extract_final_reply(full_text)
logger.info("Agent 回复完成,长度: %d", len(clean_reply))
try:
await ws_client.reply_stream(
frame, stream_id, clean_reply, True
)
has_sent_final = True
except Exception as e:
logger.warning("推送最终回复失败: %s", e)
# 兜底:如果流结束但未发送最终回复
if full_text and not has_sent_final:
clean_reply = extract_final_reply(full_text)
logger.info("Agent SSE 兜底发送,长度: %d", len(clean_reply))
try:
await ws_client.reply_stream(
frame, stream_id, clean_reply, True
)
except Exception as e:
logger.warning("推送兜底回复失败: %s", e)
return extract_final_reply(full_text) if full_text else ""
except httpx.ConnectError:
logger.error("无法连接到 Agent 服务: %s", Config.AGENT_BASE_URL)
return "抱歉Agent 服务连接失败,请稍后重试。"
except Exception as e:
logger.error("SSE 流式调用异常,降级为同步: %s", e)
return await call_agent_sync(user_id, message)
async def call_agent_sync(user_id: str, message: str) -> str:
"""
同步调用 Agent/run 接口),作为流式调用的降级方案。
"""
session_id = await get_or_create_session(user_id)
client = get_http_client()
def _build_body(sid: str) -> dict:
return {
"app_name": Config.AGENT_APP_NAME,
"user_id": user_id,
"session_id": sid,
"new_message": {
"role": "user",
"parts": [{"text": message}],
},
}
def _extract_reply(events: list) -> str:
"""从 Event[] 中提取最终回复(跳过 thought"""
for event in events:
parts = event.get("content", {}).get("parts", [])
for part in parts:
if part.get("text") and not part.get("thought"):
return part["text"]
return ""
try:
resp = await client.post("/run", json=_build_body(session_id))
resp.raise_for_status()
return _extract_reply(resp.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
clear_session(user_id)
new_session = await get_or_create_session(user_id)
resp = await client.post("/run", json=_build_body(new_session))
resp.raise_for_status()
return _extract_reply(resp.json())
logger.error("Agent HTTP 错误 [%d]: %s", e.response.status_code, e)
return "抱歉,服务暂时不可用,请稍后重试。"
except Exception as e:
logger.error("同步调用 Agent 失败: %s", e)
return "抱歉,处理消息时出现错误,请稍后重试。"