refactor: centralize llm backend configuration

This commit is contained in:
liuwei
2026-04-08 13:43:41 +08:00
parent df1939d60b
commit aecb62cb4d
19 changed files with 945 additions and 792 deletions

View File

@@ -6,8 +6,6 @@ from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, Any, Tuple, Optional, List
import aiohttp
from aiohttp import ClientTimeout
from loguru import logger
from base.plugin_common.message_plugin_interface import MessagePluginInterface
@@ -22,6 +20,7 @@ from utils.markdown_to_image import convert_md_str_to_image
from utils.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
from utils.string_utils import remove_reasoning_content, remove_trailing_content
from utils.ai.unified_llm import UnifiedLLMClient
from utils.wechat.contact_manager import ContactManager
from utils.wechat.message_to_db import MessageStorage
from wechat_ipad import WechatAPIClient
@@ -93,6 +92,10 @@ class MessageSummaryPlugin(MessagePluginInterface):
self._connect_timeout_seconds = int(api_config.get("connect_timeout_seconds", 10))
self._request_timeout_seconds = int(api_config.get("request_timeout_seconds", 180))
self._retry_delays_seconds = api_config.get("retry_delays_seconds", [10, 20])
self.llm_client = UnifiedLLMClient(api_config)
self._api_mode = self.llm_client.mode or self._api_mode
self._response_mode = self.llm_client.response_mode or self._response_mode
self._workflow_output_key = self.llm_client.workflow_output_key or self._workflow_output_key
self.message_storage = MessageStorage()
db_manager = context.get("db_manager")
if db_manager:
@@ -221,81 +224,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
sanitized_name = "群聊"
return sanitized_name
async def _parse_streaming_response(self, response: aiohttp.ClientResponse) -> Dict[str, Any]:
"""解析 Dify 的 SSE 流式响应"""
answer_parts: List[str] = []
metadata: Dict[str, Any] = {}
final_payload: Dict[str, Any] = {}
buffer = ""
async for chunk in response.content.iter_any():
if not chunk:
continue
buffer += chunk.decode("utf-8", errors="ignore")
while "\n\n" in buffer:
raw_event, buffer = buffer.split("\n\n", 1)
raw_event = raw_event.strip()
if not raw_event:
continue
data_lines = []
for line in raw_event.splitlines():
line = line.strip()
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if not data_lines:
continue
payload_text = "\n".join(data_lines).strip()
if not payload_text or payload_text == "[DONE]":
continue
try:
payload = json.loads(payload_text)
except json.JSONDecodeError:
self.LOG.warning(f"无法解析流式响应片段: {payload_text[:200]}")
continue
event_name = str(payload.get("event", "")).strip()
if event_name in {"message", "agent_message"}:
chunk_text = payload.get("answer", "")
if chunk_text:
answer_parts.append(chunk_text)
elif event_name in {"message_end", "workflow_finished"}:
final_payload = payload
if self._api_mode == "workflow":
payload_data = payload.get("data", {}) if isinstance(payload.get("data"), dict) else {}
outputs = payload_data.get("outputs", {}) if isinstance(payload_data.get("outputs"), dict) else {}
if outputs:
for key in filter(None, [self._workflow_output_key, "text", "answer", "result_json", "result"]):
if outputs.get(key) is not None:
answer_parts = [self._stringify_output(outputs.get(key))]
break
metadata = payload.get("metadata", {}) or payload.get("data", {}).get("metadata", {}) or metadata
elif event_name == "error":
raise RuntimeError(payload.get("message") or payload.get("error") or "流式总结生成失败")
else:
if self._api_mode == "workflow":
payload_data = payload.get("data", {}) if isinstance(payload.get("data"), dict) else {}
outputs = payload_data.get("outputs", {}) if isinstance(payload_data.get("outputs"), dict) else {}
for key in filter(None, [self._workflow_output_key, "text", "answer", "result_json", "result"]):
if outputs.get(key) is not None:
chunk_text = self._stringify_output(outputs.get(key))
if chunk_text:
answer_parts.append(chunk_text)
break
answer = "".join(answer_parts)
return {
"answer": answer,
"metadata": metadata,
"data": final_payload.get("data", {}) if isinstance(final_payload, dict) else {},
"event": final_payload.get("event", "") if isinstance(final_payload, dict) else "",
}
def _append_usage_info(self, answer: str, metadata: Dict[str, Any]) -> str:
"""把 token 统计追加到总结文本末尾"""
if not answer or not answer.strip():
@@ -440,7 +368,6 @@ class MessageSummaryPlugin(MessagePluginInterface):
async def _generate_summary(self, chat_content: str, group_name: str) -> Tuple[str, Optional[str]]:
"""生成总结"""
# Dify API配置
content_compress = chat_content
try:
content_compress = compress_chat_data(chat_content)
@@ -449,96 +376,50 @@ class MessageSummaryPlugin(MessagePluginInterface):
self.LOG.error(f"压缩内容失败:{e}")
prompt = f"请根据[{group_name}]群的群聊记录生成一份总结:\n\n{content_compress}"
if self._api_mode == "workflow":
data = {
"inputs": {
"query": prompt,
"group_name": group_name,
"chat_content": content_compress,
},
"response_mode": self._response_mode,
"user": group_name if group_name is not None else "message_summary_bot",
}
else:
data = {
"inputs": {},
"query": prompt,
"response_mode": self._response_mode,
"conversation_id": "",
"user": group_name if group_name is not None else "message_summary_bot",
"files": []
}
self.LOG.info(f"群聊总结内容:{data}")
# 设置请求头
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream" if self._response_mode == "streaming" else "application/json"
inputs = {
"query": prompt,
"group_name": group_name,
"chat_content": content_compress,
}
self.LOG.info(f"群聊总结请求准备: group={group_name}, mode={self._api_mode}, response_mode={self._response_mode}")
max_retries = len(self._retry_delays_seconds) + 1
for attempt in range(1, max_retries + 1):
try:
custom_timeout = ClientTimeout(
total=None,
connect=self._connect_timeout_seconds,
sock_read=self._request_timeout_seconds
response = await asyncio.to_thread(
self.llm_client.run,
prompt,
group_name if group_name is not None else "message_summary_bot",
inputs,
f"message_summary:{group_name}",
)
conn = aiohttp.TCPConnector(keepalive_timeout=60) # 保持连接活跃
async with aiohttp.ClientSession(connector=conn, timeout=custom_timeout) as session:
async with session.post(self._api_url, headers=headers, json=data) as response:
response.raise_for_status() # 检查请求是否成功
if self._response_mode == "streaming":
response_data = await self._parse_streaming_response(response)
else:
response_data = await response.json()
if not response or not response.get("text"):
raise RuntimeError(self.llm_client.last_error or "LLM 未返回有效总结内容")
self.LOG.info(f"Dify API响应状态码: {response.status}, attempt={attempt}")
self.LOG.debug(f"响应数据: {json.dumps(response_data, ensure_ascii=False, indent=2)}")
answer = self._clean_summary_output(response.get("text", ""))
metadata = {"usage": response.get("usage", {}) or {}}
spath = ""
answer = self._append_usage_info(answer, metadata)
if self._api_mode == "workflow":
answer, metadata = self._parse_workflow_response(response_data)
else:
answer = response_data.get("answer", "")
metadata = response_data.get("metadata", {})
if answer and len(answer.strip()) > 0:
try:
timestamp = int(time.time())
output_path = f"summary_{timestamp}.png"
self.LOG.info(f"开始生成图片: {output_path}")
spath = await convert_md_str_to_image(answer, output_path)
self.LOG.info(f"成功生成图片: {spath}")
except Exception as e:
self.LOG.error(f"生成图片失败: {e}", exc_info=True)
max_length = 2000
if len(answer) > max_length:
answer = answer[:max_length] + "\n\n... (内容过长,已截断)"
self.LOG.info("图片生成失败,将发送文本消息作为备选方案")
spath = None
else:
spath = None
return answer, spath
if not answer or not answer.strip():
raise RuntimeError("Dify 未返回有效总结内容")
answer = self._clean_summary_output(answer)
spath = ""
answer = self._append_usage_info(answer, metadata)
if answer and len(answer.strip()) > 0:
try:
# 使用唯一文件名并指定完整路径
timestamp = int(time.time())
output_path = f"summary_{timestamp}.png"
self.LOG.info(f"开始生成图片: {output_path}")
spath = await convert_md_str_to_image(answer, output_path)
self.LOG.info(f"成功生成图片: {spath}")
except Exception as e:
self.LOG.error(f"生成图片失败: {e}", exc_info=True)
try:
max_length = 2000
if len(answer) > max_length:
answer = answer[:max_length] + "\n\n... (内容过长,已截断)"
self.LOG.info("图片生成失败,将发送文本消息作为备选方案")
spath = None
except Exception as fallback_error:
self.LOG.error(f"备选文本发送也失败: {fallback_error}")
spath = None
else:
spath = None
# 返回文本内容和图片路径
return answer, spath
except aiohttp.ClientError as e:
self.LOG.error(f"请求Dify API时出错: attempt={attempt}/{max_retries}, error={e}")
except json.JSONDecodeError as e:
self.LOG.error(f"解析Dify API响应时出错: attempt={attempt}/{max_retries}, error={e}")
except Exception as e:
self.LOG.error(f"处理总结时出现未知错误: attempt={attempt}/{max_retries}, error={e}")