Files
Wechat-Bot/wechat-bot/bot/agent_client.py

334 lines
12 KiB
Python
Raw Permalink 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 parse_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)
# ============================================================
# 显示格式化
# ============================================================
def _format_turn_for_display(text: str, use_emoji: bool = True) -> str:
"""
将一轮的原始文本( thinking... response...)格式化为微信展示文本。
use_emoji=True 用 💭 前缀(流式阶段,企微会渲染为可折叠的"已深度思考"
use_emoji=False 用纯文本标题(最终回复,避免触发"思考中"指示器)
"""
if not text:
return ""
prefix = "💭 思考过程:" if use_emoji else "【思考过程】"
# 查找 response 分隔标记
parts = text.split('\n response\n', 1)
if len(parts) == 2:
think_raw, body_raw = parts
think_clean = think_raw.strip()
if think_clean.startswith(' thinking'):
think_clean = think_clean[len(' thinking'):].lstrip('\n')
think_clean = think_clean.strip()
body_clean = body_raw.strip()
result = ""
if think_clean:
result = f"{prefix}\n{think_clean}\n\n"
if body_clean:
result += body_clean
return result
else:
# 还没有 response全是思考内容
think_clean = text.strip()
if think_clean.startswith(' thinking'):
think_clean = think_clean[len(' thinking'):].lstrip('\n')
think_clean = think_clean.strip()
return f"{prefix}\n{think_clean}" if think_clean else ""
# ============================================================
# 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 = "" # 累积非 STOP 的增量文本
turn_start = 0 # 当前轮在 full_text 中的起始位置
stop_texts: list[str] = [] # 每轮 STOP 时的完整文本快照
event_count = 0
last_sent_display = "" # 上次已发送到微信的展示文本
has_sent_final = False
first_event_sent = False # 是否已发送过流式更新用于5秒超时保护
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
event_count += 1
try:
data = json.loads(data_str)
except json.JSONDecodeError:
logger.warning("SSE #%d JSON 解析失败: %s", event_count, data_str[:200])
continue
finish_reason = data.get("finishReason", "")
content = data.get("content", {})
parts = content.get("parts", [])
# 日志:每个 SSE 事件的摘要
part_summary = []
for p in parts:
t = p.get("text", "")
part_summary.append(
f"text_len={len(t)}, thought={p.get('thought', False)}, "
f"preview={t[:50]!r}"
)
logger.info(
"SSE #%d | finish=%s | parts=%d | %s",
event_count, finish_reason, len(parts),
" | ".join(part_summary) if part_summary else "(无parts)",
)
# 错误处理
if "error" in data:
logger.error("Agent SSE 错误: %s", data["error"])
if "not found" in data["error"].lower():
clear_session(user_id)
return "抱歉,处理消息时出现错误,请稍后重试。"
is_final = finish_reason == "STOP"
if is_final:
# STOP 事件ADK 会重复发送完整文本,不追加到 full_text
# 保存当前轮的文本快照
turn_text = full_text[turn_start:]
if turn_text:
stop_texts.append(turn_text)
logger.info(
">>> STOP #%d,当前轮文本长度: %d前200字: %s",
len(stop_texts), len(turn_text), turn_text[:200],
)
turn_start = len(full_text)
last_sent_display = "" # 新轮次重置展示缓存
else:
# 非 STOP增量文本
for part in parts:
text = part.get("text", "")
if text:
full_text += text
# 流式更新微信消息
turn_text = full_text[turn_start:]
display = _format_turn_for_display(turn_text)
if display and display != last_sent_display:
try:
await ws_client.reply_stream(
frame, stream_id, display, False
)
last_sent_display = display
first_event_sent = True
except Exception:
pass
# === SSE 流结束,发送最终回复 ===
logger.info(
"=== SSE 流结束 === %d 个事件, %d 个 STOP, full_text 总长: %d",
event_count, len(stop_texts), len(full_text),
)
for idx, st in enumerate(stop_texts):
tc, bc = parse_reply(st)
logger.info(
"STOP[%d] 解析: 思考=%d, 正文=%d, 正文前100字: %s",
idx, len(tc), len(bc), bc[:100] if bc else "(空)",
)
# 从最后一个有正文的 STOP 构建最终内容
final_body = ""
for stop_text in reversed(stop_texts):
_, body_content = parse_reply(stop_text)
if body_content:
final_body = body_content
break
# 兜底
if not final_body and stop_texts:
_, final_body = parse_reply(stop_texts[-1])
if not final_body and full_text:
_, final_body = parse_reply(full_text[turn_start:])
if not final_body and full_text:
final_body = full_text[turn_start:]
if final_body:
try:
# 用纯正文 + finish=True 正常关闭流
# 思考过程在流式阶段已通过 💭 展示finish=True 替换为正文
await ws_client.reply_stream(
frame, stream_id, final_body, True
)
has_sent_final = True
logger.info(">>> 最终回复发送成功,正文长度: %d", len(final_body))
except Exception as e:
logger.warning("推送最终回复失败: %s", e)
_, body = parse_reply(full_text[turn_start:] if turn_start else full_text)
return body if body 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 "抱歉,处理消息时出现错误,请稍后重试。"