conversation_manager.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """
  2. 多轮对话管理器 - 处理包含工具调用的多轮对话
  3. """
  4. import asyncio
  5. from typing import Dict, List, Any, Optional
  6. import httpx
  7. from anthropic import Anthropic
  8. from mcp_client import MCPClient
  9. from tool_converter import ToolConverter
  10. from tool_handler import ToolCallHandler
  11. # ========== 基础 System Prompt ==========
  12. # 不包含组件列表和 MCP 工具说明,由 _build_system_prompt 动态构建
  13. BASE_SYSTEM_PROMPT = """你是一个 AI 助手,可以通过调用 MCP 工具来帮助用户完成任务。
  14. ## 当前状态
  15. {MCP_STATUS}
  16. {MCP_TOOLS_GUIDE}
  17. """
  18. # 小说相关操作说明(只有启用相关 MCP 时才显示)
  19. NOVEL_TOOLS_GUIDE = """### 小说相关操作(重要)
  20. 当用户说"查看小说:xxx"、"小说详情:xxx"或点击小说卡片时:
  21. 1. 从消息中提取小说标题或 ID
  22. 2. 使用 `get_novel_detail` 工具获取小说数据(需要提供 novel_id 参数)
  23. 3. 使用 `novel-detail` 组件展示结果,格式如下:
  24. ```json
  25. {
  26. "type": "novel-detail",
  27. "novel": {
  28. "id": "小说ID",
  29. "title": "小说标题",
  30. "author": "作者",
  31. "category": "分类",
  32. "description": "简介",
  33. "status": "状态",
  34. "chapterCount": 章节数,
  35. "wordCount": 字数,
  36. "viewCount": 阅读量,
  37. "isVip": 是否VIP
  38. }
  39. }
  40. ```
  41. """
  42. # 无 MCP 工具时的提示
  43. NO_TOOLS_GUIDE = """### 注意
  44. 当前没有启用任何 MCP 服务器,因此你没有可用的工具。你只能:
  45. - 进行普通对话
  46. - 返回 json-render 组件来展示信息
  47. 如果用户询问关于翻译、小说等功能,请告知用户需要先在 MCP 管理页面启用相关服务器。
  48. """
  49. def create_anthropic_client(api_key: str, base_url: str) -> Anthropic:
  50. """
  51. 创建 Anthropic 客户端,支持自定义认证格式
  52. 自定义 API 代理需要 'Authorization: Bearer <token>' 格式,
  53. 而不是 Anthropic SDK 默认的 'x-api-key' header。
  54. """
  55. # 创建自定义 httpx client,设置正确的 Authorization header
  56. http_client = httpx.Client(
  57. headers={"Authorization": f"Bearer {api_key}"},
  58. timeout=120.0
  59. )
  60. return Anthropic(base_url=base_url, http_client=http_client)
  61. class ConversationManager:
  62. """管理包含工具调用的多轮对话"""
  63. def __init__(
  64. self,
  65. api_key: str,
  66. base_url: str,
  67. model: str,
  68. session_id: str = None,
  69. mcp_tokens: dict = None,
  70. components_prompt: str = None,
  71. enabled_mcp_list: list = None # 新增:前端传递的已启用 MCP 列表
  72. ):
  73. self.api_key = api_key
  74. self.base_url = base_url
  75. self.model = model
  76. self.session_id = session_id
  77. self.mcp_tokens = mcp_tokens or {} # MCP 服务器 token 映射
  78. self.enabled_mcp_list = enabled_mcp_list # 前端传递的已启用 MCP 列表
  79. # 组件提示词(由前端动态提供)
  80. self.components_prompt = components_prompt # 前端必须提供,无 fallback
  81. # 构建完整的系统提示词
  82. self.system_prompt = self._build_system_prompt()
  83. # DEBUG: 打印接收到的 token 和启用列表
  84. print(f"[DEBUG ConversationManager.__init__] mcp_tokens keys: {list(self.mcp_tokens.keys())}")
  85. print(f"[DEBUG ConversationManager.__init__] enabled_mcp_list: {self.enabled_mcp_list}")
  86. print(f"[DEBUG ConversationManager.__init__] components_prompt length: {len(components_prompt) if components_prompt else 0} chars")
  87. for k, v in self.mcp_tokens.items():
  88. print(f"[DEBUG ConversationManager.__init__] {k}: {v[:30] if v else 'None'}...")
  89. self.tool_handler = ToolCallHandler(session_id=session_id, mcp_tokens=mcp_tokens)
  90. self._cached_tools = None
  91. self._tool_to_server_map = {} # 工具名到服务器 ID 的映射
  92. # 使用自定义 client,支持 Bearer token 认证
  93. self.client = create_anthropic_client(api_key, base_url)
  94. def _build_system_prompt(self) -> str:
  95. """构建完整的系统提示词(基础提示 + MCP 状态 + 工具指南 + 组件列表)"""
  96. # 根据 enabled_mcp_list 构建 MCP 状态提示和工具指南
  97. if self.enabled_mcp_list is not None and len(self.enabled_mcp_list) == 0:
  98. # 所有 MCP 都被禁用
  99. mcp_status = """**重要:当前没有启用任何 MCP 服务器**
  100. 你没有可用的 MCP 工具。如果用户询问关于翻译、小说、组件等功能,请告诉用户:
  101. - 当前没有启用任何 MCP 服务器
  102. - 请前往 MCP 管理页面启用需要的服务器
  103. - 启用后刷新页面即可使用相关功能
  104. 你仍然可以使用基础的对话能力和返回 json-render 组件来帮助用户。"""
  105. mcp_tools_guide = NO_TOOLS_GUIDE
  106. elif self.enabled_mcp_list is not None and len(self.enabled_mcp_list) > 0:
  107. # 部分或全部 MCP 已启用
  108. enabled_names = ", ".join(self.enabled_mcp_list)
  109. mcp_status = f"""**已启用的 MCP 服务器**: {enabled_names}
  110. 你可以调用这些 MCP 服务器的工具来帮助用户完成任务。"""
  111. # 检查是否有小说相关的 MCP 启用
  112. has_novel_mcp = any('novel-platform' in mcp or 'novel' in mcp for mcp in self.enabled_mcp_list)
  113. if has_novel_mcp:
  114. mcp_tools_guide = NOVEL_TOOLS_GUIDE
  115. else:
  116. mcp_tools_guide = ""
  117. else:
  118. # enabled_mcp_list 是 None,使用配置文件的默认状态
  119. mcp_status = """你可以通过调用 MCP 工具来帮助用户完成任务。"""
  120. mcp_tools_guide = NOVEL_TOOLS_GUIDE # 默认显示小说工具指南
  121. # 替换占位符并添加组件列表
  122. prompt = BASE_SYSTEM_PROMPT.replace("{MCP_STATUS}", mcp_status)
  123. prompt = prompt.replace("{MCP_TOOLS_GUIDE}", mcp_tools_guide)
  124. return prompt + "\n\n" + self.components_prompt
  125. async def get_available_tools(self) -> List[Dict[str, Any]]:
  126. """获取可用的 Claude 格式工具列表(带缓存)"""
  127. if self._cached_tools is not None:
  128. return self._cached_tools
  129. # 从 MCP 服务器发现工具(带 token 和启用列表)
  130. mcp_tools = await MCPClient.get_all_tools_with_tokens_async(
  131. self.session_id,
  132. self.mcp_tokens,
  133. self.enabled_mcp_list # 传递前端传递的已启用 MCP 列表
  134. )
  135. # 转换为 Claude 格式
  136. claude_tools = []
  137. for tool in mcp_tools:
  138. claude_tool = ToolConverter.mcp_to_claude_tool(tool)
  139. claude_tools.append(claude_tool)
  140. # 构建工具名到服务器 ID 的映射
  141. server_id = tool.get("_server_id", "")
  142. if server_id:
  143. self._tool_to_server_map[claude_tool["name"]] = server_id
  144. self._cached_tools = claude_tools
  145. return claude_tools
  146. @classmethod
  147. async def get_tools_async(cls, session_id: str = None) -> List[Dict[str, Any]]:
  148. """
  149. 类方法:获取可用的工具列表(异步)
  150. 用于 API 端点直接调用,无需创建完整实例
  151. """
  152. mcp_tools = await MCPClient.get_all_tools_async(session_id)
  153. return ToolConverter.convert_mcp_tools(mcp_tools)
  154. @staticmethod
  155. def get_tools(session_id: str = None) -> List[Dict[str, Any]]:
  156. """
  157. 静态方法:获取可用的工具列表(同步)
  158. 用于 API 端点直接调用
  159. """
  160. return asyncio.run(ConversationManager.get_tools_async(session_id))
  161. async def chat(
  162. self,
  163. user_message: str,
  164. conversation_history: List[Dict[str, Any]] = None,
  165. max_turns: int = 5
  166. ) -> Dict[str, Any]:
  167. """
  168. 执行多轮对话(自动处理工具调用)
  169. Args:
  170. user_message: 用户消息
  171. conversation_history: 对话历史
  172. max_turns: 最大对话轮数(防止无限循环)
  173. Returns:
  174. 最终响应和对话历史
  175. """
  176. if conversation_history is None:
  177. conversation_history = []
  178. messages = conversation_history.copy()
  179. messages.append({
  180. "role": "user",
  181. "content": user_message
  182. })
  183. current_messages = messages
  184. response_text = ""
  185. tool_calls_made = []
  186. for turn in range(max_turns):
  187. # 获取可用工具
  188. tools = await self.get_available_tools()
  189. # 调用 Claude API
  190. if tools:
  191. response = self.client.messages.create(
  192. model=self.model,
  193. max_tokens=4096,
  194. system=self.system_prompt, # 使用动态系统提示
  195. messages=current_messages,
  196. tools=tools
  197. )
  198. else:
  199. response = self.client.messages.create(
  200. model=self.model,
  201. max_tokens=4096,
  202. system=self.system_prompt, # 使用动态系统提示
  203. messages=current_messages
  204. )
  205. # 检查响应中是否有 tool_use
  206. content_blocks = []
  207. tool_use_blocks = []
  208. text_blocks = []
  209. for block in response.content:
  210. block_type = getattr(block, "type", None)
  211. if block_type == "tool_use":
  212. # 工具调用块
  213. block_dict = {
  214. "type": "tool_use",
  215. "id": getattr(block, "id", ""),
  216. "name": getattr(block, "name", ""),
  217. "input": getattr(block, "input", {})
  218. }
  219. content_blocks.append(block_dict)
  220. tool_use_blocks.append(block_dict)
  221. else:
  222. # 文本块
  223. text_content = getattr(block, "text", "")
  224. if text_content:
  225. text_blocks.append({
  226. "type": "text",
  227. "text": text_content
  228. })
  229. content_blocks.append({
  230. "type": "text",
  231. "text": text_content
  232. })
  233. response_text += text_content
  234. # 如果没有工具调用,返回结果
  235. if not tool_use_blocks:
  236. return {
  237. "response": response_text,
  238. "messages": current_messages,
  239. "tool_calls": tool_calls_made
  240. }
  241. # 处理工具调用
  242. tool_results = await self.tool_handler.process_tool_use_blocks(
  243. tool_use_blocks,
  244. self._tool_to_server_map
  245. )
  246. # 记录工具调用
  247. for tr in tool_results:
  248. tool_calls_made.append({
  249. "tool": tr.get("tool_name"),
  250. "result": tr.get("result", {})
  251. })
  252. # 构建工具结果消息
  253. tool_result_message = ToolCallHandler.create_tool_result_message(
  254. tool_results
  255. )
  256. # 添加到消息历史
  257. current_messages.append({
  258. "role": "assistant",
  259. "content": content_blocks
  260. })
  261. current_messages.append(tool_result_message)
  262. # 达到最大轮数
  263. return {
  264. "response": response_text,
  265. "messages": current_messages,
  266. "tool_calls": tool_calls_made,
  267. "warning": "达到最大对话轮数"
  268. }
  269. @staticmethod
  270. def format_history_for_claude(history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  271. """
  272. 格式化对话历史为 Claude API 格式
  273. Args:
  274. history: 原始对话历史
  275. Returns:
  276. Claude API 格式的消息列表
  277. """
  278. formatted = []
  279. for msg in history:
  280. role = msg.get("role")
  281. content = msg.get("content")
  282. if role == "user":
  283. if isinstance(content, str):
  284. formatted.append({"role": "user", "content": content})
  285. elif isinstance(content, list):
  286. formatted.append({"role": "user", "content": content})
  287. elif role == "assistant":
  288. if isinstance(content, str):
  289. formatted.append({"role": "assistant", "content": content})
  290. elif isinstance(content, list):
  291. formatted.append({"role": "assistant", "content": content})
  292. return formatted