diff --git a/main_v2.py b/main_v2.py index e9f6cdc..7767e9f 100644 --- a/main_v2.py +++ b/main_v2.py @@ -1,6 +1,6 @@ """ -AI对话系统 v2.0.0 - 主应用 -支持:大模型池、Agent管理、渠道独立绑定、思考功能开关 +AI对话系统 v3.0.0 - 主应用 +支持:大模型池、Agent管理、渠道独立绑定、思考功能开关、Function Calling(LLM自主调用工具) """ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse @@ -122,6 +122,7 @@ async def get_providers(db: Session = Depends(get_db)): "thinking_model": p.thinking_model, "supports_vision": p.supports_vision, "vision_model": p.vision_model, + "supports_function_calling": p.supports_function_calling, "max_tokens": p.max_tokens, "temperature": p.temperature, "is_active": p.is_active, @@ -832,7 +833,7 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): conversation_id = data.get("conversation_id") enable_thinking = data.get("enable_thinking", True) agent_id_override = data.get("agent_id") - disabled_tools = data.get("disabled_tools", []) # 禁用的工具列表 + # v3.0: 移除 disabled_tools,由LLM自主决定 if agent_id_override: agent = agent_service.get_agent(agent_id_override) @@ -846,48 +847,41 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): if not message.strip() and not files: continue - # 处理文件内容,添加到消息 - image_contents = [] # 图片内容(用于视觉模型) - text_contents = [] # 文本文件内容 - image_paths = [] # 图片服务器路径(用于历史记录显示) + # 处理文件内容 + image_contents = [] + text_contents = [] + image_paths = [] if files: for f in files: if f.get('type') and f['type'].startswith('image/'): - # 图片:记录 base64 数据,用于视觉模型 image_contents.append({ 'name': f['name'], 'type': f['type'], - 'data': f.get('content', '') # base64 数据 + 'data': f.get('content', '') }) - # 记录服务器路径(用于历史记录) if f.get('serverPath'): image_paths.append({ 'name': f['name'], 'type': f['type'], - 'url': f['serverPath'] # 服务器文件路径 + 'url': f['serverPath'] }) - # 不添加文件名文本,图片信息保存在 extra_data 中 elif f.get('content'): - # 文本文件:直接添加内容,不带文件名前缀 text_contents.append(f['content'][:3000]) if len(f['content']) > 3000: text_contents[-1] += "...(内容过长已截断)" - # 如果有文本文件内容,追加到消息后面 if text_contents: for content in text_contents: message += f"\n\n{content}" - # 保存图片和文件信息到 extra_data(用于历史记录) + # 保存文件信息到 extra_data extra_data_for_msg = None if image_paths: - # 图片保存服务器路径URL,历史记录可以显示 extra_data_for_msg = { 'images': image_paths, 'files': [{'name': f['name'], 'type': f['type']} for f in files if not f['type'].startswith('image/')] } elif image_contents: - # 没有服务器路径但有问题(可能上传失败) extra_data_for_msg = { 'images': [{'name': i['name'], 'type': i['type']} for i in image_contents], 'files': [{'name': f['name'], 'type': f['type']} for f in files if not f['type'].startswith('image/')] @@ -896,8 +890,9 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): # 1. 获取Agent配置 agent_config = agent_service.get_agent_config(current_agent_id) agent_tools = agent_config.get('agent', {}).get('tools', []) + supports_function_calling = agent_config.get('provider', {}).get('supports_function_calling', False) - # 2. 获取或创建会话(先有 conversation_id) + # 2. 获取或创建会话 if conversation_id: conversation = conv_service.get_conversation(conversation_id) else: @@ -908,12 +903,12 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): "conversation_id": conversation_id }) - # 3. 广播用户消息(前端立即看到) + # 3. 广播用户消息 await manager.send_to_user(MAIN_USER_ID, { "type": "user_message", "conversation_id": conversation_id, "message": { - "id": None, # 临时,后面会保存 + "id": None, "role": "user", "content": message, "source": "web", @@ -921,118 +916,45 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): } }) - # 4. 执行搜索并发送搜索结果 - search_context = None - search_results_for_client = None # 用于发送给前端和保存 - logger.info(f"检查搜索条件: agent_tools={agent_tools}, disabled_tools={disabled_tools}") - - if 'search' in agent_tools and 'search' not in disabled_tools: - logger.info("搜索条件满足,开始执行搜索") - - tool_service = ToolService(db) - search_tool = tool_service.get_default_tool('search') - logger.info(f"获取到搜索工具: {search_tool.name if search_tool else 'None'}") - - if search_tool and search_tool.config.get('api_key'): - import httpx - import time - start_time = time.time() - try: - logger.info(f"执行搜索: query={message}") - tavily_url = "https://api.tavily.com/search" - config = search_tool.config - payload = { - "api_key": config.get('api_key'), - "query": message, - "max_results": config.get('max_results', 5), - "search_depth": config.get('search_depth', 'basic') - } - - with httpx.Client(timeout=30) as client: - resp = client.post(tavily_url, json=payload) - search_result = resp.json() - - duration_ms = int((time.time() - start_time) * 1000) - - if search_result.get("results"): - # 构建搜索上下文(给LLM) - max_for_llm = config.get('max_results', 5) - search_context = "\n\n【搜索结果】\n" - for i, r in enumerate(search_result["results"][:max_for_llm], 1): - search_context += f"{i}. {r.get('title', 'N/A')}\n {r.get('content', r.get('snippet', 'N/A'))[:200]}\n 来源: {r.get('url', 'N/A')}\n" - logger.info(f"搜索完成: {len(search_result['results'])} 条结果,使用 {min(len(search_result['results']), max_for_llm)} 条") - - # 发送搜索结果给前端(按配置的数量) - max_display = config.get('max_results', 5) - search_results_for_client = [ - { - "title": r.get('title', 'N/A'), - "snippet": r.get('content', r.get('snippet', ''))[:150], - "url": r.get('url', 'N/A') - } - for r in search_result["results"][:max_display] - ] - await websocket.send_json({ - "type": "search_results", - "conversation_id": conversation_id, - "results": search_results_for_client, - "query": message - }) - - # 更新统计和日志 - tool_service.increment_stats(search_tool.id, True) - tool_service.log_usage({ - 'tool_id': search_tool.id, - 'tool_type': 'search', - 'query': message, - 'success': True, - 'result_summary': f'{len(search_result["results"])} results', - 'conversation_id': conversation_id, - 'agent_id': current_agent_id, - 'duration_ms': duration_ms - }) - except Exception as e: - duration_ms = int((time.time() - start_time) * 1000) - logger.error(f"搜索失败: {e}") - tool_service.increment_stats(search_tool.id, False) - tool_service.log_usage({ - 'tool_id': search_tool.id, - 'tool_type': 'search', - 'query': message, - 'success': False, - 'error_message': str(e), - 'conversation_id': conversation_id, - 'duration_ms': duration_ms - }) - - # 5. 保存用户消息到数据库 - extra_data_to_save = None - if search_results_for_client: - extra_data_to_save = {'search_results': search_results_for_client, 'search_query': message} - if extra_data_for_msg: - if extra_data_to_save: - extra_data_to_save.update(extra_data_for_msg) - else: - extra_data_to_save = extra_data_for_msg - + # 4. 保存用户消息 user_msg = conv_service.add_message( conversation_id=conversation.id, role='user', content=message, source='web', - extra_data=extra_data_to_save + extra_data=extra_data_for_msg ) - # 6. 获取对话历史(包含刚保存的用户消息) + # 5. 获取对话历史 history = conv_service.get_conversation_history(conversation_id, limit=agent_config['agent'].get('max_history', 20)) - # 7. 如果有搜索结果,添加到消息中 - if search_context: - modified_system_prompt = agent_config['agent'].get('system_prompt', '') + "\n\n如果提供了搜索结果,请基于搜索结果回答用户问题,并注明信息来源。" - agent_config['agent']['system_prompt'] = modified_system_prompt - history.append({"role": "system", "content": f"以下是搜索到的相关信息,请参考这些内容回答用户问题:{search_context}"}) + # 6. 构建工具 schema(Function Calling) + tools_schema = [] + if supports_function_calling and agent_tools: + # 搜索工具 + if 'search' in agent_tools: + tool_service = ToolService(db) + search_tool = tool_service.get_default_tool('search') + if search_tool and search_tool.config.get('api_key'): + tools_schema.append({ + "type": "function", + "function": { + "name": "web_search", + "description": "搜索互联网获取实时信息、新闻、数据等。当用户询问需要最新信息的问题时使用此工具。", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "搜索关键词或问题" + } + }, + "required": ["query"] + } + } + }) - # 8. 调用LLM返回回复 + # 7. 调用LLM(Function Calling模式) if not agent_config or not agent_config.get('provider'): await websocket.send_json({ "type": "error", @@ -1041,17 +963,185 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): continue try: - response, thinking_content = await llm_service.chat( - messages=history, - provider_config=agent_config['provider'], - agent_config=agent_config['agent'], - enable_thinking=enable_thinking, - images=image_contents # 传递图片数据给多模态模型 - ) + response = None + thinking_content = None + tool_calls_record = [] + + # 第一阶段:让LLM决定是否调用工具 + if tools_schema: + response, thinking_content, tool_calls = await llm_service.chat_with_tools( + messages=history, + provider_config=agent_config['provider'], + agent_config=agent_config['agent'], + tools=tools_schema, + enable_thinking=enable_thinking, + images=image_contents + ) + + # 如果LLM请求调用工具 + if tool_calls: + logger.info(f"LLM请求调用工具: {tool_calls}") + + # 发送工具调用通知给前端 + await websocket.send_json({ + "type": "tool_calls", + "conversation_id": conversation_id, + "tool_calls": [ + {"name": tc['name'], "arguments": tc['arguments']} + for tc in tool_calls + ] + }) + + # 执行工具调用 + tool_results = [] + tool_service = ToolService(db) + search_tool = tool_service.get_default_tool('search') + + for tc in tool_calls: + if tc['name'] == 'web_search': + query = tc['arguments'].get('query', message) + logger.info(f"执行搜索: query={query}") + + import httpx + import time + start_time = time.time() + + try: + tavily_url = "https://api.tavily.com/search" + config = search_tool.config + payload = { + "api_key": config.get('api_key'), + "query": query, + "max_results": config.get('max_results', 5), + "search_depth": config.get('search_depth', 'basic') + } + + with httpx.Client(timeout=30) as client: + resp = client.post(tavily_url, json=payload) + search_result = resp.json() + + duration_ms = int((time.time() - start_time) * 1000) + + if search_result.get("results"): + # 构建搜索结果 + search_content = [] + for i, r in enumerate(search_result["results"][:5], 1): + search_content.append({ + "title": r.get('title', 'N/A'), + "content": r.get('content', r.get('snippet', ''))[:300], + "url": r.get('url', 'N/A') + }) + + tool_results.append({ + "tool_call_id": tc['id'], + "content": json.dumps(search_content) + }) + + # 发送搜索结果给前端 + await websocket.send_json({ + "type": "search_results", + "conversation_id": conversation_id, + "results": [ + {"title": r.get('title'), "snippet": r.get('content', '')[:150], "url": r.get('url')} + for r in search_result["results"][:5] + ], + "query": query + }) + + # 记录日志 + tool_service.increment_stats(search_tool.id, True) + tool_service.log_usage({ + 'tool_id': search_tool.id, + 'tool_type': 'search', + 'query': query, + 'success': True, + 'result_summary': f'{len(search_result["results"])} results', + 'conversation_id': conversation_id, + 'agent_id': current_agent_id, + 'duration_ms': duration_ms + }) + + tool_calls_record.append({ + "name": "web_search", + "query": query, + "results_count": len(search_result["results"]) + }) + + except Exception as e: + logger.error(f"搜索失败: {e}") + duration_ms = int((time.time() - start_time) * 1000) + tool_service.increment_stats(search_tool.id, False) + tool_service.log_usage({ + 'tool_id': search_tool.id, + 'tool_type': 'search', + 'query': query, + 'success': False, + 'error_message': str(e), + 'conversation_id': conversation_id, + 'duration_ms': duration_ms + }) + tool_results.append({ + "tool_call_id": tc['id'], + "content": json.dumps({"error": str(e)}) + }) + + # 将工具调用消息添加到历史 + # 注意:这里需要将 assistant 的 tool_calls 消息添加到历史 + # 但我们用的是简化的历史格式,需要重新构建 + + # 第二阶段:将工具结果返回给LLM + if tool_results: + # 重新获取完整历史(包含工具调用) + history_with_tools = history.copy() + # 添加 assistant 的 tool_calls 消息 + history_with_tools.append({ + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": tc['id'], + "type": "function", + "function": { + "name": tc['name'], + "arguments": json.dumps(tc['arguments']) + } + } + for tc in tool_calls + ] + }) + # 添加工具结果 + for tr in tool_results: + history_with_tools.append({ + "role": "tool", + "tool_call_id": tr['tool_call_id'], + "content": tr['content'] + }) + + response, thinking_content = await llm_service.chat_with_tool_results( + messages=history_with_tools, + provider_config=agent_config['provider'], + agent_config=agent_config['agent'], + tool_results=tool_results, + enable_thinking=enable_thinking + ) + + # 如果不支持 Function Calling 或没有工具,直接调用普通 chat + if response is None: + response, thinking_content = await llm_service.chat( + messages=history, + provider_config=agent_config['provider'], + agent_config=agent_config['agent'], + enable_thinking=enable_thinking, + images=image_contents + ) logger.info(f"LLM响应: response长度={len(response)}, thinking长度={len(thinking_content) if thinking_content else 0}") # 保存AI回复 + extra_data_to_save = None + if tool_calls_record: + extra_data_to_save = {'tool_calls': tool_calls_record} + assistant_msg = conv_service.add_message( conversation_id=conversation.id, role='assistant', @@ -1059,7 +1149,8 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): source='web', thinking_content=thinking_content if thinking_content else None, agent_id=current_agent_id, - model_used=agent_config['provider'].get('default_model') + model_used=agent_config['provider'].get('default_model'), + extra_data=extra_data_to_save ) # 发送AI回复 @@ -1074,6 +1165,7 @@ async def websocket_endpoint(websocket: WebSocket, user_id: str): "source": "web", "agent_id": current_agent_id, "agent_name": agent_config['agent'].get('display_name'), + "tool_calls": tool_calls_record, # v3.0: 返回工具调用记录 "created_at": assistant_msg.created_at.isoformat() } }) diff --git a/models_v2.py b/models_v2.py index ffbdd07..6b2da31 100644 --- a/models_v2.py +++ b/models_v2.py @@ -36,6 +36,9 @@ class LLMProvider(Base): supports_vision = Column(Boolean, default=False) # 是否支持图片理解(多模态) vision_model = Column(String(100), nullable=True) # 视觉模型名(如与默认模型不同) + # Function Calling 支持 + supports_function_calling = Column(Boolean, default=False) # 是否支持函数调用(工具自主调用) + # 配额和限制 max_tokens = Column(Integer, default=4096) temperature = Column(Float, default=0.7) diff --git a/services/llm_service.py b/services/llm_service.py index 76a20d6..2209335 100644 --- a/services/llm_service.py +++ b/services/llm_service.py @@ -382,5 +382,200 @@ class LLMService: yield {"type": "content", "text": buffer} + async def chat_with_tools( + self, + messages: List[Dict], + provider_config: dict, + agent_config: dict, + tools: List[Dict] = None, + enable_thinking: bool = True, + images: List[Dict] = None + ) -> Tuple[str, Optional[str], Optional[List[Dict]]]: + """ + 支持Function Calling的对话 + + Args: + messages: 对话历史 + provider_config: LLM Provider配置 + agent_config: Agent配置 + tools: 工具定义列表(OpenAI Function Calling格式) + enable_thinking: 是否启用思考 + images: 图片数据列表 + + Returns: + Tuple[str, Optional[str], Optional[List[Dict]]]: (回复内容, 思考过程, 工具调用记录) + """ + api_base = provider_config.get('api_base') + api_key = provider_config.get('api_key') + model = agent_config.get('model_override') or provider_config.get('default_model', 'auto') + supports_function_calling = provider_config.get('supports_function_calling', False) + + max_tokens = provider_config.get('max_tokens', 4096) + temperature = agent_config.get('temperature_override') or provider_config.get('temperature', 0.7) + + # 如果不支持Function Calling,直接调用普通chat + if not supports_function_calling or not tools: + response, thinking = await self.chat(messages, provider_config, agent_config, enable_thinking, images) + return response, thinking, None + + # 构建消息 + final_messages = messages.copy() + system_prompt = agent_config.get('system_prompt', '你是一个有用的AI助手。') + if final_messages and final_messages[0]['role'] != 'system': + final_messages.insert(0, {"role": "system", "content": system_prompt}) + + # 处理图片(多模态) + if images and len(images) > 0: + for i in range(len(final_messages) - 1, -1, -1): + if final_messages[i]['role'] == 'user': + original_text = final_messages[i]['content'] + multimodal_content = [{"type": "text", "text": original_text if original_text else "请描述这张图片"}] + for img in images: + multimodal_content.append({ + "type": "image_url", + "image_url": {"url": img['data']} + }) + final_messages[i]['content'] = multimodal_content + break + + # 第一次调用:让LLM决定是否调用工具 + url = f"{api_base.rstrip('/')}/chat/completions" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": final_messages, + "temperature": temperature, + "max_tokens": max_tokens, + "tools": tools # 传入工具定义 + } + + logger.info(f"Function Calling调用: url={url}, model={model}, tools={len(tools)}") + + tool_calls_record = [] # 记录工具调用 + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, headers=headers, json=payload) + + if response.status_code != 200: + logger.error(f"API返回错误: status={response.status_code}, body={response.text[:500]}") + response.raise_for_status() + + data = response.json() + + if 'choices' not in data or len(data['choices']) == 0: + raise ValueError("API响应格式错误:缺少choices") + + message = data['choices'][0]['message'] + + # 检查是否有工具调用 + if 'tool_calls' in message and message['tool_calls']: + logger.info(f"LLM请求调用工具: {len(message['tool_calls'])} 个") + + # 将LLM的工具调用消息添加到历史 + final_messages.append({ + "role": "assistant", + "content": None, + "tool_calls": message['tool_calls'] + }) + + # 记录工具调用 + for tc in message['tool_calls']: + tool_calls_record.append({ + "id": tc['id'], + "name": tc['function']['name'], + "arguments": json.loads(tc['function']['arguments']) + }) + + # 返回工具调用记录,由调用方执行工具 + return None, None, tool_calls_record + + # 没有工具调用,直接返回内容 + content = message.get('content', '') + + # 处理思考内容(如果有) + thinking_content = None + # 这里可以添加思考内容提取逻辑 + + return content, thinking_content, None + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP错误: {e.response.status_code}, {e.response.text}") + raise + except Exception as e: + logger.error(f"Function Calling调用异常: {type(e).__name__}: {e}") + raise + + async def chat_with_tool_results( + self, + messages: List[Dict], + provider_config: dict, + agent_config: dict, + tool_results: List[Dict], + enable_thinking: bool = True + ) -> Tuple[str, Optional[str]]: + """ + 第二阶段调用:将工具执行结果返回给LLM + + Args: + messages: 对话历史(包含工具调用和结果) + provider_config: LLM Provider配置 + agent_config: Agent配置 + tool_results: 工具执行结果 [{"tool_call_id": "xxx", "content": "..."}] + + Returns: + Tuple[str, Optional[str]]: (回复内容, 思考过程) + """ + api_base = provider_config.get('api_base') + api_key = provider_config.get('api_key') + model = agent_config.get('model_override') or provider_config.get('default_model', 'auto') + max_tokens = provider_config.get('max_tokens', 4096) + temperature = agent_config.get('temperature_override') or provider_config.get('temperature', 0.7) + + # 将工具结果添加到消息历史 + final_messages = messages.copy() + for result in tool_results: + final_messages.append({ + "role": "tool", + "tool_call_id": result['tool_call_id'], + "content": result['content'] + }) + + # 调用LLM生成最终回复 + url = f"{api_base.rstrip('/')}/chat/completions" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": final_messages, + "temperature": temperature, + "max_tokens": max_tokens + } + + logger.info(f"工具结果返回LLM: url={url}, model={model}") + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, headers=headers, json=payload) + + if response.status_code != 200: + logger.error(f"API返回错误: status={response.status_code}") + response.raise_for_status() + + data = response.json() + content = data['choices'][0]['message']['content'] + + return content, None + + except Exception as e: + logger.error(f"工具结果调用异常: {e}") + raise + + # 全局实例 llm_service = LLMService() \ No newline at end of file diff --git a/templates/admin_v2/index.html b/templates/admin_v2/index.html index 1eee3f8..f4e4f8c 100644 --- a/templates/admin_v2/index.html +++ b/templates/admin_v2/index.html @@ -58,8 +58,8 @@
| 名称 | API地址 | 默认模型 | 思考 | 视觉 | 状态 | 操作 | |
|---|---|---|---|---|---|---|---|
| 加载中... | |||||||
| 名称 | API地址 | 默认模型 | 思考 | 视觉 | FC | 状态 | 操作 |
| 加载中... | |||||||