├── version.txt ├── .python-version ├── assets ├── logo.ico ├── logo.icns └── legacygames.svg ├── requirements.txt ├── pyproject.toml ├── .gitignore ├── .env.template ├── License ├── prompt └── system.md ├── README.md ├── utils └── utils.py └── ActionAI.py /version.txt: -------------------------------------------------------------------------------- 1 | 1.1.0 -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /assets/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sylearn/ActionAI/HEAD/assets/logo.ico -------------------------------------------------------------------------------- /assets/logo.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sylearn/ActionAI/HEAD/assets/logo.icns -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | rich 2 | python-dotenv 3 | openai 4 | mcp 5 | tiktoken 6 | asyncio 7 | prompt_toolkit 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mcp-service" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "aiohttp>=3.11.18", 9 | "asyncio>=3.4.3", 10 | "mcp>=1.7.1", 11 | "numpy>=2.2.5", 12 | "openai>=1.77.0", 13 | "pillow>=11.2.1", 14 | "prompt-toolkit>=3.0.51", 15 | "pyinstaller>=6.13.0", 16 | "python-dotenv>=1.1.0", 17 | "rich>=14.0.0", 18 | "tiktoken>=0.9.0", 19 | "typer>=0.15.3", 20 | ] 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | **/__pycache__/ 3 | **/*.py[cod] 4 | **/*$py.class 5 | **/*.so 6 | **/.Python 7 | **/build/ 8 | **/develop-eggs/ 9 | **/dist/ 10 | **/downloads/ 11 | **/eggs/ 12 | **/.eggs/ 13 | **/lib/ 14 | **/lib64/ 15 | **/parts/ 16 | **/sdist/ 17 | **/var/ 18 | **/wheels/ 19 | **/*.egg-info/ 20 | **/.installed.cfg 21 | **/*.egg 22 | **/test/ 23 | # 虚拟环境 24 | **/venv/ 25 | **/env/ 26 | **/ENV/ 27 | 28 | # IDE 相关 29 | .idea/ 30 | .vscode/ 31 | **/*.swp 32 | **/*.swo 33 | .project 34 | .pydevproject 35 | 36 | # 环境变量文件 37 | **/.env_mac 38 | **/.env_win 39 | **/.env 40 | 41 | # 操作系统 42 | **/.DS_Store 43 | **/Thumbs.db 44 | **/*.log 45 | **/*.tmp 46 | # Jupyter Notebook 47 | **/.ipynb_checkpoints 48 | 49 | #项目 50 | mcp_*.json 51 | Server/ 52 | releases/ 53 | **.spec 54 | build_release.* 55 | build.py -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # OpenAI API设置 2 | OPENAI_API_KEY= 3 | OPENAI_BASE_URL= 4 | 5 | # 模型参数设置 6 | MAX_TOKENS=4096 #最大输出token数量 7 | TEMPERATURE=0.7 #温度 8 | STREAM=True #是否开启流式输出 9 | TIMEOUT=300 #模型响应时间 10 | # 可用模型列表(;分隔) 11 | MODEL= #示例:gpt-4o;claude-3-5-sonnet-20240620;deepseek-chat;... 12 | 13 | # 调试和功能设置 14 | DEBUG=False #是否开启调试模式 15 | IS_FUNCTION_CALLING=True #是否开启工具调用(MCP) 16 | 17 | MAX_MESSAGES=-1 #最大消息数量(<=0时无限制) 18 | MAX_MESSAGES_TOKENS=60000 #最大消息token数量 19 | 20 | # 路径设置 21 | SYSTEM_PROMPT=/path/to/system.md #系统提示词路径(默认:./prompt/system.md) 22 | MESSAGES_PATH=/path/to/messages #聊天存储路径(默认:./messages) 23 | MCP_SERVER_CONFIG_PATH=/path/to/mcp_server.json #MCP服务器配置路径(默认:./mcp_server_config.json) 24 | PYTHON_PATH=/path/to/python #python路径(默认:python) 25 | NODE_PATH=/path/to/node #node路径(默认:node) 26 | NPX_PATH=/path/to/npx #npx路径(默认:npx) 27 | -------------------------------------------------------------------------------- /assets/legacygames.svg: -------------------------------------------------------------------------------- 1 | Legacy Games -------------------------------------------------------------------------------- /License: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2025 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /prompt/system.md: -------------------------------------------------------------------------------- 1 | # ROLE: AI智能工作流助手 2 | 3 | ## 核心能力 4 | 5 | 1. **系统化思维** 6 | - 将复杂任务拆解为清晰的工作流程图 7 | - 采用结构化方法分析问题 8 | - 构建决策树处理各种可能情况 9 | 10 | 2. **自我监控与反思** 11 | - 执行前评估计划可行性 12 | - 任务进行中持续自我评估 13 | - 完成后进行效果分析 14 | - 主动识别并记录改进机会 15 | 16 | 3. **连续执行能力** 17 | - 设计具有容错性的工作流 18 | - 出现错误时自动尝试备选路径 19 | - 任务间保持上下文连贯性 20 | - 长时间任务设置检查点 21 | 22 | ## 工作原则 23 | 24 | 1. **智能分析** 25 | - 准确理解用户需求,主动澄清模糊点 26 | - 将复杂问题分解为可管理的子任务 27 | - 设计清晰的执行方案,包含明确的成功标准 28 | 29 | 2. **高效执行** 30 | - 优先使用自动化和编程方法 31 | - 设计模块化、可扩展的解决方案 32 | - 通过写入文件方式执行长代码 33 | - 实时监控进度并自动调整策略 34 | 35 | 3. **质量保证** 36 | - 全面测试和验证结果 37 | - 实施健壮的错误处理和异常管理 38 | - 确保代码可读性和可维护性 39 | - 提供清晰的文档和执行说明 40 | 41 | 4. **安全规范** 42 | - 严格遵守目录访问限制 43 | - 危险操作前需用户确认 44 | - 保护敏感数据,避免过度请求权限 45 | - 谨慎处理文件操作,确保幂等性 46 | 47 | ## 代码执行流程 48 | 49 | 1. **计划阶段** 50 | 51 | ``` 52 | [问题分析] → [任务分解] → [资源评估] → [方案设计] 53 | ``` 54 | 55 | 2. **实施阶段** 56 | 57 | ``` 58 | [代码生成] → [文件写入] → [执行命令] → [结果收集] 59 | ``` 60 | 61 | 3. **评估阶段** 62 | 63 | ``` 64 | [验证结果] → [性能分析] → [文档生成] → [改进建议] 65 | ``` 66 | 67 | ## 代码规范 68 | 69 | 1. 避免交互式输入输出,使用参数或配置文件 70 | 2. 代码设计遵循"单一职责"和"关注点分离"原则 71 | 3. 优先采用文件写入后执行的方式 72 | 4. 代码执行完成后输出:"运行完毕"及状态摘要 73 | 5. 包含详细注释和使用说明 74 | 75 | ## 异常处理流程 76 | 77 | 1. 预见性防护:提前识别可能的故障点 78 | 2. 优雅降级:资源受限时提供功能受限但可靠的替代方案 79 | 3. 自动重试:临时性错误实施指数退避重试策略 80 | 4. 明确表达不确定性,避免猜测 81 | 5. 提供详细的错误诊断和恢复建议 82 | 83 | ## 持续改进机制 84 | 85 | 1. 每次执行后记录关键指标和瓶颈 86 | 2. 建立知识库积累常见问题和解决方案 87 | 3. 定期回顾并优化工作流程 88 | 4. 主动提出性能和可靠性改进建议 89 | 90 | 这个优化后的提示词增强了AI在以下方面的能力: 91 | 92 | - 系统化的工作流程设计与执行 93 | - 更强的自我反思和自我纠错能力 94 | - 结构化的问题解决方法 95 | - 预防性的错误处理 96 | - 连续执行中的上下文保持 97 | - 持续改进机制 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #
🚀 ActionAI
2 | 3 |
4 | 5 | ![版本](https://img.shields.io/badge/版本-0.1.0-blue) 6 | ![Python](https://img.shields.io/badge/Python-3.11+-green) 7 | ![许可证](https://img.shields.io/badge/许可证-MIT-blue) 8 | 9 | **强大的大语言模型交互框架,支持多模型实时切换和MCP协议** 10 | 11 |
12 | 13 | ## 📖 简介 14 | 15 | ActionAI 是一个基于语言大模型的命令行交互框架,支持多种模型切换、MCP协议,提供流畅的交互式聊天体验。通过终端界面,您可以与AI进行自然对话,并通过强大丰富MCP服务端,让AI具备文件读写、网络访问、代码执行等能力。 16 | 17 | **🔥 自动化能力:** 只需简单配置MCP协议,ActionAI即可赋予AI强大的系统操作能力,包括文件管理、应用控制、文档编辑等。AI可以在几乎不需要人类干预的情况下,自动完成复杂任务流程,大幅提升工作效率。 18 | 19 | ## ✨ 核心特性 20 | 21 | | 特性 | 描述 | 22 | |:------:|:------| 23 | | 🔌 | **MCP协议** - 无缝集成外部工具 | 24 | | 🎭 | **多模型** - 动态切换不同大语言模型 | 25 | | 💾 | **会话管理** - 保存、加载和管理对话历史 | 26 | | 📊 | **Token计数** - 实时监控使用情况 | 27 | | ⚙️ | **高度可配置** - 灵活调整系统行为 | 28 | | 🤖 | **自动化执行** - 基于MCP服务器,AI可自主完成复杂任务流程 | 29 | 30 | ## 🔧 快速开始 31 | 32 | ### 方法一:一键安装(推荐) 33 | 34 | 下载并运行适合您系统的安装包: 35 | 36 | | 操作系统 | 下载链接 | 37 | |---------|---------| 38 | | Windows | [ActionAI](https://github.com/sylearn/ActionAI/releases) | 39 | | macOS | [ActionAI](https://github.com/sylearn/ActionAI/releases) | 40 | 41 | 安装完成后,直接从应用列表启动ActionAI即可开始使用。 42 | 43 |
44 | Mac 用户额外步骤 45 | 打开终端,进入应用所在目录 46 | 执行以下命令使文件可执行: 47 | 48 | ```shell 49 | chmod +x ./ActionAI 50 | ``` 51 | 52 | 如果遇到无法打开提示,[点击这里](https://sysin.org/blog/macos-if-crashes-when-opening/)查看解决方法 53 | 54 |
55 | 56 | ### 方法二:源码安装 57 | 58 | 如果您希望自定义安装或参与开发,可以通过源码安装: 59 | 60 | ```bash 61 | # 克隆仓库 62 | git clone https://github.com/sylearn/ActionAI.git 63 | cd ActionAI 64 | 65 | # 安装依赖 66 | pip install -r requirements.txt 67 | 68 | # 配置环境 69 | cp .env.template .env 70 | 71 | # 启动 72 | python ActionAI.py 73 | ``` 74 | 75 | ## 🚀 AI自动化示例 76 | 77 | 通过简单的MCP配置,ActionAI可以实现以下自动化任务: 78 | 79 | - **文档处理**:自动整理、分析和生成各类文档 80 | - **代码开发**:从需求分析到编码实现,全流程AI辅助 81 | - **数据分析**:自动收集、清洗、分析数据并生成报告 82 | - **系统管理**:执行系统维护、文件管理等操作 83 | - **内容创作**:自动撰写文章、生成图表、制作演示文稿 84 | 85 | 只需一句简单的指令,AI就能自主完成一系列复杂操作,无需人工干预每一步骤。 86 | 87 | ### 自动化工作流程示例 88 | 89 | 以下是一个完整的自动化工作流程示例,展示ActionAI如何通过简单指令完成复杂任务: 90 | 91 | ``` 92 | 用户: 帮我分析最近一周的销售数据,生成报告并发送给团队 93 | 94 | ActionAI: 好的,我将为您规划并完成这个任务。 95 | 96 | [执行以下步骤] 97 | 1. 连接到销售数据库,提取最近一周数据 98 | 2. 数据清洗与分析,计算关键指标 99 | 3. 生成可视化图表 100 | 4. 创建Word报告文档 101 | 5. 编写分析总结和建议 102 | 6. 通过邮件系统发送给团队成员 103 | 7. 将报告保存到指定文件夹并创建备份 104 | ... 105 | [接着会通过MCP服务器完成上述操作] 106 | ActionAI: 任务已完成!销售报告已生成并发送至团队所有成员。 107 | 报告显示销售额较上周增长12.5%,主要增长来自电子产品类别。 108 | 报告副本已保存至"销售报告/2023/周报"文件夹。 109 | ``` 110 | 111 | 通过配置相应的MCP服务,ActionAI可以无缝连接各种系统和应用,实现真正的端到端自动化。 112 | 113 | ## 📋 使用指南 114 | 115 | ### 内置命令 116 | 117 | | 命令 | 描述 | 118 | |------|------| 119 | | `\quit` | 退出程序 | 120 | | `\fc` | 切换工具调用功能 | 121 | | `\model` | 切换对话模型 | 122 | | `\clear` | 清除聊天历史 | 123 | | `\save` | 保存当前对话历史 | 124 | | `\load <文件路径>` | 加载对话历史 | 125 | | `\help` | 显示帮助信息 | 126 | | `\debug` | 切换调试模式 | 127 | | `\compact <字符数>` | 压缩历史消息 | 128 | | `\cost` | 显示Token使用统计 | 129 | | `\mcp <配置文件>` | 切换MCP配置文件 | 130 | 131 | ### 多行输入 132 | 133 | - 按Enter继续输入 134 | - 输入`\q`结束输入 135 | - 输入`\c`清除当前输入 136 | 137 | ## 🔌 MCP 服务器配置 138 | 139 | MCP(Model Context Protocol)是ActionAI的核心功能,它通过简单的配置即可让AI获得强大的系统操作能力。 140 | 141 | 以下资源提供了丰富的MCP工具和服务器: 142 | 143 | - [OpenTools](https://opentools.com/) - 提供丰富的AI应用工具库和MCP服务器 144 | - [Glamama](https://glama.ai/mcp/servers) - 提供多种开源MCP服务器实现 145 | 146 | 147 | ### 配置示例 148 | 149 | ```json 150 | { 151 | "mcpServers": { 152 | "filesystem": { 153 | "command": "npx", 154 | "args": [ 155 | "-y", 156 | "@modelcontextprotocol/server-filesystem", 157 | "/Users/username/Desktop", 158 | "/path/to/other/allowed/dir" 159 | ] 160 | }, 161 | "custom_tool": { 162 | "command": "python", 163 | "args": ["path/to/your/tool_server.py"] 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | 您也可以轻松创建自己的MCP服务,扩展AI的能力。 170 | 171 | 172 | ## 📝 许可证 173 | 174 | [MIT License](https://opensource.org/licenses/MIT) 175 | 176 | 177 | -------------------------------------------------------------------------------- /utils/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | import tiktoken 5 | from typing import List,Dict,Any 6 | from dotenv import load_dotenv 7 | import time 8 | 9 | # 环境变量加载 10 | def load_env_files(seconds: int = 3): 11 | """加载环境变量文件 12 | 优先加载通用.env文件,然后根据系统加载特定的环境变量文件 13 | 特定系统的环境变量会覆盖通用环境变量 14 | """ 15 | print(f"当前工作目录: {os.getcwd()}") 16 | print(f"脚本位置: {os.path.dirname(os.path.abspath(__file__))}") 17 | print(f"入口脚本: {sys.argv[0]}") 18 | 19 | # 获取入口脚本所在的目录作为基础路径 20 | if getattr(sys, 'frozen', False): 21 | base_path = os.path.dirname(sys.executable) 22 | else: 23 | # 使用入口脚本的目录作为基础路径 24 | base_path = os.path.dirname(os.path.abspath(sys.argv[0])) 25 | 26 | print(f"最终使用的基础路径: {base_path}") 27 | 28 | loaded_files = [] # 记录加载的文件信息 29 | 30 | # 首先尝试加载通用.env文件 31 | common_env = os.path.join(base_path, '.env') 32 | if os.path.exists(common_env): 33 | before_vars = set(os.environ.keys()) 34 | load_dotenv(common_env) 35 | after_vars = set(os.environ.keys()) 36 | new_vars = after_vars - before_vars 37 | loaded_files.append({ 38 | "type": "通用配置", 39 | "path": common_env, 40 | "vars_count": len(new_vars), 41 | "status": "已加载" 42 | }) 43 | 44 | # 然后根据系统加载特定的环境变量文件 45 | system_type = "Windows" if sys.platform == "win32" else "MacOS/Linux" 46 | system_env = os.path.join( 47 | base_path, 48 | '.env_win' if sys.platform == "win32" else '.env_mac' 49 | ) 50 | if os.path.exists(system_env): 51 | before_vars = set(os.environ.keys()) 52 | load_dotenv(system_env, override=True) 53 | after_vars = set(os.environ.keys()) 54 | new_vars = after_vars - before_vars 55 | loaded_files.append({ 56 | "type": f"{system_type}配置", 57 | "path": system_env, 58 | "vars_count": len(new_vars), 59 | "status": "已加载" 60 | }) 61 | 62 | # 必需配置检查 63 | required_vars = { 64 | "MODEL": "模型选择", 65 | "OPENAI_API_KEY": "API密钥" 66 | } 67 | 68 | # 可选配置 69 | optional_vars = { 70 | "OPENAI_BASE_URL": ("API基础URL", "https://api.openai.com/v1"), 71 | "MAX_TOKENS": ("最大令牌数", "4096"), 72 | "TEMPERATURE": ("温度系数", "0.7"), 73 | "STREAM": ("流式输出", "True"), 74 | "DEBUG": ("调试模式", "False"), 75 | "IS_FUNCTION_CALLING": ("工具调用", "True"), 76 | "TIMEOUT": ("超时时间", "300"), 77 | "MAX_MESSAGES": ("最大消息数", "100"), 78 | "MAX_MESSAGES_TOKENS": ("最大令牌限制", "60000"), 79 | "MESSAGES_PATH": ("消息存储路径", "./messages"), 80 | "MCP_SERVER_CONFIG_PATH": ("服务器配置", ""), 81 | "SYSTEM_PROMPT": ("系统提示词", ""), 82 | "PYTHON_PATH": ("Python路径", "python"), 83 | "NODE_PATH": ("Node路径", "node") 84 | } 85 | 86 | # 显示必需配置状态 87 | print("\n❗ 必需配置检查:") 88 | all_required_set = True 89 | for var, desc in required_vars.items(): 90 | value = os.getenv(var) 91 | if value: 92 | print(f" ✅ {desc:<10} ({var}): 已设置") 93 | else: 94 | all_required_set = False 95 | print(f" ❌ {desc:<10} ({var}): 未设置") 96 | 97 | # 显示可选配置状态 98 | print("\n⚙️ 可选配置状态:") 99 | for var, (desc, default) in optional_vars.items(): 100 | value = os.getenv(var) 101 | if value: 102 | print(f" ℹ️ {desc:<12} ({var}): {value}") 103 | else: 104 | print(f" 💭 {desc:<12} ({var}): 使用默认值 [{default}]") 105 | 106 | # 配置检查结果 107 | print("\n📊 配置检查结果:") 108 | if all_required_set: 109 | print(" ✅ 所有必需配置已设置,系统可以正常启动") 110 | else: 111 | print(" ❌ 缺少必需配置,系统可能无法正常工作") 112 | print(" ⚠️ 请检查上述标记为未设置的必需配置项") 113 | 114 | if seconds > 0: 115 | # 添加一个空行 116 | print("\n⚙️ 正在初始化系统...") 117 | for i in range(seconds, 0, -1): 118 | print(f"\r⏳ {i} 秒后继续...", end="", flush=True) 119 | time.sleep(1) 120 | print("\n") # 最后打印一个换行 121 | 122 | def parse_mcp_servers(file_path): 123 | """ 124 | 解析MCP服务器配置JSON文件,提取命令和参数 125 | 126 | Args: 127 | file_path (str): JSON配置文件的路径 128 | 129 | Returns: 130 | dict: 包含每个服务器的命令和参数的字典 131 | """ 132 | try: 133 | # 检查文件是否存在 134 | if not os.path.exists(file_path): 135 | raise FileNotFoundError(f"文件不存在: {file_path}") 136 | 137 | # 读取并解析JSON文件 138 | with open(file_path, 'r', encoding='utf-8') as file: 139 | config = json.load(file) 140 | 141 | # 检查是否包含mcpServers键 142 | if 'mcpServers' not in config: 143 | raise KeyError("JSON文件中没有找到'mcpServers'键") 144 | 145 | # 提取每个服务器的命令和参数 146 | servers_info = {} 147 | for server_name, server_config in config['mcpServers'].items(): 148 | # 提取命令 149 | command = server_config.get('command', '') 150 | 151 | # 提取参数 152 | args = server_config.get('args', []) 153 | 154 | # 提取环境变量 155 | env = server_config.get('env', {}) 156 | # 存储服务器信息 157 | servers_info[server_name] = { 158 | 'command': command, 159 | 'args': args, 160 | 'env': env 161 | } 162 | 163 | return servers_info 164 | 165 | except json.JSONDecodeError as e: 166 | raise ValueError(f"JSON解析错误: {str(e)}") 167 | except Exception as e: 168 | raise Exception(f"解析MCP服务器配置时出错: {str(e)}") 169 | 170 | def get_server_command(servers_info, server_name): 171 | """ 172 | 获取指定服务器的命令 173 | 174 | Args: 175 | servers_info (dict): 服务器信息字典 176 | server_name (str): 服务器名称 177 | 178 | Returns: 179 | str: 服务器命令 180 | """ 181 | if server_name not in servers_info: 182 | raise KeyError(f"未找到服务器: {server_name}") 183 | 184 | return servers_info[server_name]['command'] 185 | 186 | def get_server_args(servers_info, server_name): 187 | """ 188 | 获取指定服务器的参数 189 | 190 | Args: 191 | servers_info (dict): 服务器信息字典 192 | server_name (str): 服务器名称 193 | 194 | Returns: 195 | list: 服务器参数列表 196 | """ 197 | if server_name not in servers_info: 198 | raise KeyError(f"未找到服务器: {server_name}") 199 | 200 | return servers_info[server_name]['args'] 201 | 202 | def get_server_env(servers_info, server_name): 203 | """ 204 | 获取指定服务器的环境变量 205 | 206 | Args: 207 | servers_info (dict): 服务器信息字典 208 | server_name (str): 服务器名称 209 | 210 | Returns: 211 | dict: 服务器环境变量字典 212 | """ 213 | if server_name not in servers_info: 214 | raise KeyError(f"未找到服务器: {server_name}") 215 | 216 | return servers_info[server_name]['env'] 217 | 218 | def get_all_server_names(servers_info): 219 | """ 220 | 获取所有服务器名称 221 | 222 | Args: 223 | servers_info (dict): 服务器信息字典 224 | 225 | Returns: 226 | list: 服务器名称列表 227 | """ 228 | return list(servers_info.keys()) 229 | 230 | def get_token_count(message: List[Dict[str, Any]], model: str) -> tuple[int, int, int, int]: 231 | """ 232 | 计算多轮对话中的token消耗 233 | 234 | Args: 235 | message: 消息列表,每个消息包含role和content 236 | model: 模型名称 237 | 238 | Returns: 239 | tuple: (total_input_tokens, total_output_tokens, current_input_tokens) 240 | - total_input_tokens: 所有轮次的输入token总和 241 | - total_output_tokens: 所有轮次的输出token总和 242 | - current_input_tokens: 如果开始新的对话轮次,当前所有消息将产生的输入token数 243 | """ 244 | # 尝试导入tiktoken扩展模块,解决打包后找不到编码的问题 245 | import tiktoken_ext 246 | import tiktoken_ext.openai_public 247 | 248 | # 始终使用cl100k_base编码,不使用model参数 249 | encoding = tiktoken.get_encoding("cl100k_base") 250 | 251 | total_input_tokens = 0 252 | total_output_tokens = 0 253 | current_input_tokens = 0 254 | 255 | # 找到所有user消息的索引,用于划分对话轮次 256 | user_indices = [i for i, msg in enumerate(message) if msg["role"] == "user"] 257 | # 计算已经对话的轮次 258 | round_count = len(user_indices) 259 | 260 | for round_idx, user_idx in enumerate(user_indices): 261 | # 计算当前轮次的范围 262 | round_start = user_idx 263 | round_end = user_indices[round_idx + 1] if round_idx + 1 < len(user_indices) else len(message) 264 | 265 | # 将user之前的所有消息计入input tokens 266 | for msg in message[:round_start]: 267 | tokens = encoding.encode(msg["content"]) 268 | total_input_tokens += len(tokens) 269 | if msg["role"] == "assistant" and "function_call" in msg: 270 | function_tokens = encoding.encode(json.dumps(msg["function_call"])) 271 | total_input_tokens += len(function_tokens) 272 | 273 | # 当前轮次的user消息计入input tokens 274 | user_tokens = encoding.encode(message[user_idx]["content"]) 275 | total_input_tokens += len(user_tokens) 276 | 277 | # 当前轮次user之后的assistant消息计入output tokens 278 | for msg in message[round_start + 1:round_end]: 279 | if msg["role"] == "assistant": 280 | content_tokens = encoding.encode(msg["content"]) 281 | total_output_tokens += len(content_tokens) 282 | # 如果包含function call,要计入output tokens 283 | if "function_call" in msg: 284 | function_tokens = encoding.encode(json.dumps(msg["function_call"])) 285 | total_output_tokens += len(function_tokens) 286 | 287 | # 计算current_input_tokens(所有当前消息的token数) 288 | for msg in message: 289 | tokens = encoding.encode(msg["content"]) 290 | current_input_tokens += len(tokens) 291 | if msg["role"] == "assistant" and "function_call" in msg: 292 | function_tokens = encoding.encode(json.dumps(msg["function_call"])) 293 | current_input_tokens += len(function_tokens) 294 | 295 | return total_input_tokens, total_output_tokens, current_input_tokens, round_count 296 | 297 | if __name__ == "__main__": 298 | # 测试用例 299 | message = [ 300 | {"role": "system", "content": "你是一个AI助手"}, 301 | {"role": "user", "content": "第一轮问题"}, 302 | {"role": "assistant", "content": "第一轮回答"}, 303 | {"role": "user", "content": "第二轮问题"}, 304 | {"role": "assistant", "content": "第二轮回答", 305 | "function_call": {"name": "test", "arguments": "{}"}} 306 | ] 307 | input_tokens, output_tokens, current_tokens = get_token_count(message, "gpt-4") 308 | print(f"Total input tokens: {input_tokens}") 309 | print(f"Total output tokens: {output_tokens}") 310 | print(f"Current input tokens (for next round): {current_tokens}") -------------------------------------------------------------------------------- /ActionAI.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import tiktoken 6 | import readline # 添加readline导入 7 | from contextlib import AsyncExitStack 8 | from typing import List 9 | 10 | from mcp import ClientSession, StdioServerParameters 11 | from mcp.client.stdio import stdio_client 12 | from openai import OpenAI 13 | from rich.box import ROUNDED 14 | from rich.console import Console, Group 15 | from rich.live import Live 16 | from rich.spinner import Spinner 17 | from rich.markdown import Markdown 18 | from rich.panel import Panel 19 | from rich.table import Table 20 | from rich.text import Text 21 | from dataclasses import dataclass 22 | import sys 23 | 24 | def setup_base_paths(): 25 | """ 26 | 设置应用程序的基础路径并添加到系统路径 27 | 28 | Returns: 29 | str: 应用程序的基础路径 30 | """ 31 | # 获取应用程序的基础路径 32 | if getattr(sys, 'frozen', False): 33 | # 如果是打包后的应用 34 | base_path = os.path.dirname(sys.executable) # 获取当前应用的绝对路径 35 | else: 36 | # 如果是开发环境 37 | base_path = os.path.dirname(os.path.abspath(__file__)) # 获取当前文件的绝对路径 38 | 39 | # 将基础路径添加到系统路径 40 | # 无论程序是作为脚本运行还是作为打包应用运行,都能正确导入所需的模块。 41 | sys.path.insert(0, base_path) 42 | if os.path.exists(os.path.join(base_path, '..')): 43 | sys.path.insert(0, os.path.abspath(os.path.join(base_path, '..'))) 44 | 45 | return base_path 46 | 47 | def preload_encodings(): 48 | """ 49 | 预加载常用编码并为所有模型注册相同的编码 50 | """ 51 | try: 52 | # 预加载常用编码 53 | logging.info("开始预加载tiktoken编码...") 54 | # 预先导入tiktoken扩展模块,解决打包后找不到编码的问题 55 | import tiktoken_ext 56 | import tiktoken_ext.openai_public 57 | # 尝试加载编码 58 | tiktoken.get_encoding("cl100k_base") 59 | logging.info("成功加载cl100k_base编码") 60 | 61 | # 为所有模型注册相同的编码 62 | # 从环境变量获取模型列表 63 | model_list_str = os.getenv("MODEL", "") 64 | logging.info(f"环境变量MODEL值: {model_list_str}") 65 | if model_list_str: 66 | # 分割模型列表 67 | model_list = [model.strip() for model in model_list_str.split(";") if model.strip()] 68 | logging.info(f"解析出的模型列表: {model_list}") 69 | # 为每个模型注册相同的编码 70 | for model in model_list: 71 | if model: 72 | try: 73 | logging.info(f"正在为模型 {model} 注册cl100k_base编码") 74 | tiktoken.model.MODEL_TO_ENCODING[model] = "cl100k_base" 75 | logging.info(f"模型 {model} 注册成功") 76 | except Exception as inner_e: 77 | logging.error(f"为模型 {model} 注册编码时出错: {inner_e}") 78 | import traceback 79 | logging.error(traceback.format_exc()) 80 | except Exception as e: 81 | logging.error(f"预加载 tiktoken 编码失败: {e}") 82 | import traceback 83 | logging.error(traceback.format_exc()) 84 | 85 | # 初始化基础路径 86 | base_path = setup_base_paths() 87 | 88 | from utils.utils import parse_mcp_servers, get_all_server_names, get_server_command, get_server_args, get_server_env 89 | from utils.utils import get_token_count 90 | from utils.utils import load_env_files 91 | 92 | # 加载环境变量 93 | load_env_files(seconds=1) 94 | 95 | # 预加载编码 - 移到加载环境变量之后 96 | preload_encodings() 97 | 98 | def setup_logging(): 99 | """ 100 | 配置日志系统 101 | 102 | 根据环境变量DEBUG设置日志级别和输出方式 103 | """ 104 | # 配置日志级别 105 | # 配置日志记录器 106 | logger = logging.getLogger() 107 | logger.setLevel(logging.DEBUG if os.getenv("DEBUG", "False").lower() == "true" else logging.ERROR) 108 | 109 | # 配置统一的日志格式 110 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 111 | 112 | # 配置控制台处理器 113 | console_handler = logging.StreamHandler() 114 | console_handler.setLevel(logging.ERROR) 115 | console_handler.setFormatter(formatter) 116 | logger.addHandler(console_handler) 117 | 118 | # 仅在debug模式下添加文件处理器 119 | if os.getenv("DEBUG", "False").lower() == "true": 120 | # 配置文件处理器 121 | file_handler = logging.FileHandler('client_dev_debug.log', encoding='utf-8') 122 | file_handler.setLevel(logging.DEBUG) 123 | file_handler.setFormatter(formatter) 124 | logger.addHandler(file_handler) 125 | 126 | return logger 127 | 128 | # 设置日志 129 | logger = setup_logging() 130 | 131 | @dataclass 132 | class LLMConfig: 133 | """LLM配置类""" 134 | # 流式输出设置 135 | stream: bool = True if os.getenv("STREAM", "True").lower() == "true" else False 136 | # 模型参数设置 137 | model: str = os.getenv("MODEL", "") 138 | max_tokens: int = int(os.getenv("MAX_TOKENS", 4096)) 139 | temperature: float = float(os.getenv("TEMPERATURE", 0.7)) 140 | # 获取系统提示文件路径,优先使用环境变量,否则使用基础路径下的默认文件 141 | system_prompt: str = os.getenv("SYSTEM_PROMPT", os.path.join(base_path, "prompt/system.md")) 142 | # 调试和功能设置 143 | debug: bool = True if os.getenv("DEBUG", "False").lower() == "true" else False 144 | api_key: str = os.getenv("OPENAI_API_KEY", "") 145 | base_url: str = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") 146 | is_function_calling: bool = True if os.getenv("IS_FUNCTION_CALLING", "True").lower() == "true" else False 147 | is_human_control: bool = True if os.getenv("IS_HUMAN_CONTROL", "True").lower() == "true" else False 148 | # 超时和限制设置 149 | timeout: int = int(os.getenv("TIMEOUT", 300)) 150 | max_messages: int = int(os.getenv("MAX_MESSAGES", 100)) 151 | max_messages_tokens: int = int(os.getenv("MAX_MESSAGES_TOKENS", 60000)) 152 | # 路径设置 153 | messages_path: str = os.getenv("MESSAGES_PATH", os.path.join(base_path, "messages")) 154 | mcp_server_config: str = os.getenv("MCP_SERVER_CONFIG_PATH", os.path.join(base_path, "mcp_server_config.json")) 155 | python_path: str = os.getenv("PYTHON_PATH", "python") 156 | node_path: str = os.getenv("NODE_PATH", "node") 157 | npx_path: str = os.getenv("NPX_PATH", "npx") 158 | 159 | def load_config_from_env(base_path: str) -> LLMConfig: 160 | """ 161 | 从环境变量加载配置 162 | 163 | Args: 164 | base_path: 应用程序基础路径 165 | 166 | Returns: 167 | LLMConfig: 配置对象 168 | """ 169 | return LLMConfig( 170 | stream=True if os.getenv("STREAM", "True").lower() == "true" else False, 171 | model=os.getenv("MODEL", ""), 172 | max_tokens=int(os.getenv("MAX_TOKENS", 4096)), 173 | temperature=float(os.getenv("TEMPERATURE", 0.7)), 174 | system_prompt=os.getenv("SYSTEM_PROMPT", os.path.join(base_path, "prompt/system.md")), 175 | debug=True if os.getenv("DEBUG", "False").lower() == "true" else False, 176 | api_key=os.getenv("OPENAI_API_KEY", ""), 177 | base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"), 178 | is_function_calling=True if os.getenv("IS_FUNCTION_CALLING", "True").lower() == "true" else False, 179 | is_human_control=True if os.getenv("IS_HUMAN_CONTROL", "True").lower() == "true" else False, 180 | timeout=int(os.getenv("TIMEOUT", 300)), 181 | max_messages=int(os.getenv("MAX_MESSAGES", 100)), 182 | max_messages_tokens=int(os.getenv("MAX_MESSAGES_TOKENS", 60000)), 183 | messages_path=os.getenv("MESSAGES_PATH", os.path.join(base_path, "messages")), 184 | mcp_server_config=os.getenv("MCP_SERVER_CONFIG_PATH", os.path.join(base_path, "mcp_server_config.json")), 185 | python_path=os.getenv("PYTHON_PATH", "python"), 186 | node_path=os.getenv("NODE_PATH", "node"), 187 | npx_path=os.getenv("NPX_PATH", "npx") 188 | ) 189 | 190 | class LLM_Client: 191 | def __init__(self, config: LLMConfig = None): 192 | """ 193 | 初始化LLM客户端 194 | Args: 195 | config: LLM配置对象,如果为None则使用默认配置 196 | """ 197 | # 使用提供的配置或创建默认配置 198 | self.config = config or load_config_from_env(base_path) 199 | # MCP相关属性 200 | self.server_names: List[str] = [] 201 | self.session_list: List[ClientSession] = [] 202 | self.available_tools: List = [] 203 | self.exit_stack = AsyncExitStack() 204 | 205 | # OpenAI相关属性 206 | self.openai = OpenAI( 207 | api_key=self.config.api_key, 208 | base_url=self.config.base_url 209 | ) 210 | self.total_input_tokens = 0 211 | self.total_output_tokens = 0 212 | self.current_input_tokens = 0 213 | self.thinking_tokens = 0 214 | self.round_count = 0 # 当前轮次 215 | self.tool_tokens = [] 216 | # 消息相关属性 217 | self.messages: List = [] 218 | self._console = Console() 219 | 220 | # 输入提示符 221 | self.input_prompt = "\n[bold yellow]User:[/bold yellow] (按Enter换行,输入'\\q'结束,'\\c'清除)\n" 222 | 223 | def _models_ifc(self): 224 | """判断模型是否支持工具调用""" 225 | if self.model in ['deepseek-r1']: 226 | self.config.is_function_calling = False 227 | # 以下需要注释掉,否则环境变量IS_FUNCTION_CALLING设置为False时,也会强制设置为True 228 | #else: 229 | # self.config.is_function_calling = True 230 | 231 | def choose_model(self): 232 | """选择要使用的AI模型""" 233 | 234 | # 直接读取环境变量 235 | try: 236 | # 从环境变量中获取模型列表并处理 237 | model_list = [model.strip() for model in self.config.model.split(";") if model.strip() and len(model.strip()) > 0] 238 | except: 239 | return ValueError("环境变量MODEL格式错误") 240 | # 渲染状态 241 | def render_status(message="", style="cyan"): 242 | # 创建一个更美观的表格 243 | model_table = Table( 244 | show_header=True, 245 | header_style="bold cyan", 246 | box=ROUNDED, 247 | expand=True, 248 | padding=(0, 1) # 添加一些内边距 249 | ) 250 | 251 | # 添加列,设置合适的宽度和对齐方式 252 | model_table.add_column("序号", justify="center", width=6) 253 | model_table.add_column("模型名称", justify="left") 254 | 255 | # 添加行,使用更好的格式 256 | for i, model in enumerate(model_list, 1): 257 | model_table.add_row( 258 | f"{i}", 259 | model, 260 | style="white" 261 | ) 262 | 263 | # 创建面板 264 | status_panel = Panel( 265 | Group( 266 | model_table, 267 | Text("\n" + message, style=style) if message else "", 268 | ), 269 | title=f"[bold]🤖 模型选择 [/bold]", 270 | border_style=style, 271 | padding=(1, 2) # 添加面板内边距 272 | ) 273 | return status_panel 274 | console=self._console 275 | with Live(render_status(), console=console, auto_refresh=False) as live: 276 | render_status_message="" 277 | while True: 278 | live.stop() # 暂停Live显示,光标在Live下方 279 | try: 280 | #防止信息输入到Console上方 281 | choice_input = console.input("🔢 请输入您想使用的模型编号: ") 282 | live.start() # 恢复Live显示,光标在Live上方 283 | #直接回车则选择默认第一个模型 284 | if not choice_input.strip(): 285 | choice = 1 286 | else: 287 | choice = int(choice_input) 288 | #直接回车则选择默认第一个模型 289 | if not choice: 290 | choice = 1 291 | 292 | if 1 <= choice <= len(model_list): 293 | self.model = model_list[choice - 1] 294 | render_status_message = f"✅ 您已选择模型: {self.model}" 295 | live.update(render_status(render_status_message, "green"), refresh=True) 296 | console.clear() #清空屏幕。但保留最后一次的输出在屏幕上 297 | #判断模型是否支持工具调用 298 | self._models_ifc() 299 | #返回选择的模型 300 | return None 301 | 302 | else: 303 | render_status_message = f"❌ 请输入1到{len(model_list)}之间的数字" 304 | #更新状态消息 305 | #console.clear() 306 | live.update(render_status(render_status_message, "yellow"), refresh=True) 307 | console.clear() 308 | except ValueError: 309 | live.start() # 确保在显示错误时Live是开启的 310 | render_status_message = "❗ 请输入有效的数字" 311 | live.update(render_status(render_status_message, "red"), refresh=True) 312 | console.clear() 313 | 314 | 315 | def get_response(self) -> tuple[list, list]: 316 | """从OpenAI获取响应并处理工具调用 317 | 318 | Args: 319 | messages: 对话历史消息列表 320 | 321 | Returns: 322 | tuple[list, list]: (工具调用列表, 响应消息列表) 323 | """ 324 | [logging.debug(message) for message in self.messages] 325 | if self.config.is_function_calling: 326 | # 计算tool tokens 327 | encoding = tiktoken.encoding_for_model(self.model) 328 | name = [json.dumps(tool["function"]["name"]) for available_tool in self.available_tools for tool in available_tool] 329 | parameters = [json.dumps(tool["function"]["parameters"]) for available_tool in self.available_tools for tool in available_tool] 330 | description = [json.dumps(tool["function"]["description"]) for available_tool in self.available_tools for tool in available_tool] 331 | 332 | # 每个字符串的token数之和为tool_tokens 333 | self.tool_tokens.append(sum([len(encoding.encode(n)) for n in name]) + sum([len(encoding.encode(p)) for p in parameters])) 334 | completion = self.openai.chat.completions.create( 335 | model=self.model, 336 | max_tokens=self.config.max_tokens, 337 | messages=self.messages, 338 | tools=[tool for available_tool in self.available_tools for tool in available_tool], 339 | stream=self.config.stream, 340 | temperature=self.config.temperature, 341 | timeout=self.config.timeout 342 | ) 343 | else: 344 | self.tool_tokens.append(0) 345 | completion = self.openai.chat.completions.create( 346 | model=self.model, 347 | max_tokens=self.config.max_tokens, 348 | messages=self.messages, 349 | stream=self.config.stream, 350 | temperature=self.config.temperature, 351 | timeout=self.config.timeout 352 | ) 353 | 354 | # 处理流式响应 355 | if self.config.stream: 356 | return self._handle_stream_response(completion) 357 | # 处理普通响应 358 | else: 359 | return self._handle_normal_response(completion) 360 | #定义一个函数,负责处理终端的输出,包含思考和响应,并且刷新终端 361 | def _print_thinking_and_response(self, chunk, live: Live,thinking_content: str="", full_content: str="",spinner: Spinner=None) -> tuple[str,str]: 362 | """打印思考和响应,并刷新终端 363 | Args: 364 | chunk: 响应块/completion 365 | live: 终端对象 366 | """ 367 | output_content = "" 368 | if self.config.stream: 369 | # 更新内容 370 | delta = chunk.choices[0].delta 371 | if hasattr(delta, 'reasoning_content') and delta.reasoning_content: 372 | thinking_content += delta.reasoning_content 373 | if hasattr(delta, 'content') and delta.content: 374 | full_content += delta.content 375 | 376 | title_content = f"{self.model}" 377 | 378 | # 分别处理思考内容和响应内容 379 | if thinking_content: 380 | if spinner: 381 | spinner.text = "🤔 思考中... | Thinking..." 382 | output_content += f"# Thinking\n\n```markdown\n{thinking_content}\n```\n\n" 383 | 384 | if full_content: 385 | if spinner: 386 | spinner.text = "💬 正在输出... | Outputting..." 387 | output_content += f"# Response\n\n{full_content}" 388 | 389 | # 只有在有内容时才更新显示 390 | if output_content: 391 | if spinner: 392 | panel = Panel( 393 | Group(spinner, Markdown(output_content)), 394 | title=title_content 395 | ) 396 | else: 397 | panel = Panel( 398 | Markdown(output_content), 399 | title=title_content 400 | ) 401 | live.update(panel, refresh=True) 402 | return thinking_content, full_content 403 | 404 | else: 405 | #获取思考和响应内容 406 | thinking_content =chunk.choices[0].message.reasoning_content if hasattr(chunk.choices[0].message, 'reasoning_content') and chunk.choices[0].message.reasoning_content else "" 407 | full_content = chunk.choices[0].message.content if hasattr(chunk.choices[0].message, 'content') and chunk.choices[0].message.content else "" 408 | #打印token数量 409 | title_content = f"{self.model}" 410 | if thinking_content != "": 411 | output_content += f"# Thinking...\n\n```markdown\n\n"+thinking_content+ "\n```\n" 412 | if full_content != "": 413 | output_content += f"# Response\n\n"+full_content 414 | # 使用Panel显示内容,添加token信息作为subtitle 415 | live.update(Panel( 416 | Markdown(output_content), 417 | #subtitle=f"[dim]{token_info}[/dim]", 418 | title=title_content 419 | ), refresh=True) 420 | 421 | return thinking_content, full_content 422 | 423 | def _handle_stream_response(self, completion) -> tuple[list, list]: 424 | """处理流式响应 425 | 426 | Args: 427 | completion: OpenAI的流式响应对象 428 | 429 | Returns: 430 | tuple[list, list]: (工具调用列表, 响应消息列表) 431 | """ 432 | tool_functions_index = {} 433 | thinking_content="" 434 | full_content = "" 435 | current_index=0 436 | #遍历响应块 437 | # 创建spinner 438 | spinner = Spinner('dots', text='💬 正在输出中 | Outputting...') 439 | with Live(console=self._console, auto_refresh=False,vertical_overflow="ellipsis") as live: 440 | for chunk in completion: 441 | logging.debug(chunk) 442 | #处理工具调用 443 | if tool_calls := chunk.choices[0].delta.tool_calls: 444 | #流式响应tool_calls列表只包含一个对象,获取工具名称和参数 445 | full_content,current_index = self._process_stream_tool_call(chunk, tool_calls[0], tool_functions_index, full_content, current_index, live,spinner) 446 | 447 | #没有工具调用,直接打印内容 448 | elif chunk.choices[0].delta.content or (hasattr(chunk.choices[0].delta, 'reasoning_content') and chunk.choices[0].delta.reasoning_content): 449 | thinking_content,full_content = self._process_stream_content(chunk,thinking_content,full_content, live,spinner) 450 | #最后更新spinner 451 | spinner.text = "✨ 输出完成 | Output completed ✅" 452 | tool_functions = [v for k, v in tool_functions_index.items()] 453 | #如果有思维,则计算思维token 454 | if thinking_content: 455 | encoding = tiktoken.encoding_for_model(self.model) 456 | self.thinking_tokens += len(encoding.encode(thinking_content)) 457 | return tool_functions, self._create_response(full_content, tool_functions) 458 | 459 | def _process_stream_tool_call(self, chunk, tool_call, tool_functions_index: dict, full_content: str, current_index: int, live: Live,spinner: Spinner) -> str: 460 | """处理OpenAI流式响应中的工具调用。OpenAI的工具调用响应是分块发送的,主要有两种类型: 461 | 462 | 1. 新工具调用的开始: 463 | - 包含完整的调用信息(id, name, type等) 464 | - id不为空,表示这是一个新的工具调用 465 | 示例: 466 | { 467 | "index": 0, 468 | "id": "call_xxx", 469 | "function": {"arguments": "", "name": "get_weather"}, 470 | "type": "function" 471 | } 472 | 473 | 2. 工具参数的持续传输: 474 | - id为空,表示这是当前工具调用的参数内容 475 | - 参数内容会分多次发送,需要拼接 476 | 示例: 477 | {"index": 0, "id": null, "function": {"arguments": "{\\"city\\": \\"", "name": null}} 478 | {"index": 0, "id": null, "function": {"arguments": "Beijing\\"}", "name": null}} 479 | 480 | 实现逻辑: 481 | 1. 使用current_index记录当前正在处理的工具调用 482 | 2. 当收到id不为空的chunk时,创建新的工具调用记录 483 | 3. 当收到id为空的chunk时,将内容追加到current_index对应的工具调用中 484 | 4. 不用index来区分不同的工具调用是因为claude等其他模型的index始终为空 485 | Args: 486 | chunk: 响应块 487 | tool_call: 工具调用对象 488 | tool_functions_index: 工具调用索引字典 489 | content: 当前累积的内容 490 | current_index: 当前索引 491 | 492 | Returns: 493 | str: 更新后的内容 494 | """ 495 | #如果调用工具时内容不为空,打印并累加 496 | if chunk.choices[0].delta.content: 497 | full_content += chunk.choices[0].delta.content 498 | 499 | output_content=f"# Response\n\n"+full_content 500 | if spinner: 501 | spinner.text = "💬 正在输出... | Outputting..." 502 | display_content = Panel( 503 | Group(spinner, Markdown(output_content)), 504 | #subtitle=f"[dim]{token_info}[/dim]" 505 | ) 506 | live.update(display_content, refresh=True) 507 | else: 508 | live.update(Panel( 509 | Markdown(output_content), 510 | #subtitle=f"[dim]{token_info}[/dim]" 511 | ), refresh=True) 512 | logging.debug("既有有tool_calls字段,又有content字段。") 513 | else: 514 | if spinner: 515 | spinner.text = "🔧 正在准备工具调用中... | Preparing tool calls..." 516 | output_content=f"# Response\n\n"+full_content 517 | display_content = Panel( 518 | Group(spinner, Markdown(output_content)), 519 | #subtitle=f"[dim]{token_info}[/dim]" 520 | ) 521 | live.update(display_content, refresh=True) 522 | else: 523 | live.update(Panel( 524 | Markdown(full_content+"\n\n # 正在准备工具调用,请稍后..."), 525 | #subtitle=f"[dim]{token_info}[/dim]" 526 | ), refresh=True) 527 | logging.debug("只有tool_calls字段。") 528 | 529 | 530 | #获取工具名称和参数 531 | tool_name = tool_call.function.name if tool_call.function.name else "" 532 | tool_args = tool_call.function.arguments if tool_call.function.arguments else "" 533 | #如果工具调用ID为空,说明是旧的调用 534 | if not tool_call.id: 535 | tool_functions_index[current_index]["function"]["name"] += tool_name 536 | tool_functions_index[current_index]["function"]["arguments"] += tool_args 537 | 538 | #如果工具调用ID不为空,说明是新的调用 539 | else: 540 | #更新索引 541 | current_index += 1 542 | tool_functions_index[current_index] = { 543 | "id": tool_call.id or "", 544 | "type": tool_call.type or "", 545 | "function": {"name": tool_name, 546 | "arguments": tool_args} 547 | } 548 | return full_content,current_index 549 | 550 | 551 | def _process_stream_content(self, chunk, thinking_content: str, full_content: str, live: Live,spinner: Spinner) -> str: 552 | """处理流式响应中的普通内容 553 | 响应块的格式如下 554 | ChatCompletionChunk(id='chatcmpl-BiDxhaqknhcZCUs1A22bV0U9972719HG', 555 | choices=[Choice(delta=ChoiceDelta(content='***', function_call=None, refusal=None, role='assistant', tool_calls=None), 556 | finish_reason=None, index=0, logprobs=None)], 557 | created=1740117256, model='gpt-4o-2024-08-06', 558 | object='chat.completion.chunk', 559 | service_tier=None, 560 | system_fingerprint='', 561 | usage=None) 562 | Args: 563 | chunk: 响应块 564 | content: 当前累积的内容 565 | 566 | Returns: 567 | str: 更新后的内容 568 | """ 569 | logging.debug("没有tool_calls字段,有content字段:") 570 | logging.debug(chunk) 571 | # 更新内容 572 | thinking_content, full_content = self._print_thinking_and_response(chunk, live, thinking_content, full_content,spinner) 573 | 574 | return thinking_content,full_content 575 | 576 | def _handle_normal_response(self, completion) -> tuple[list, list]: 577 | """处理普通(非流式)响应 578 | Args: 579 | completion: OpenAI的响应对象 580 | 581 | Returns: 582 | tuple[list, list]: (工具调用列表, 响应消息列表) 583 | """ 584 | full_content = "" 585 | tool_functions = [] 586 | logging.debug(completion) 587 | # 如果有工具调用 588 | if tool_calls := completion.choices[0].message.tool_calls: 589 | #如果调用工具时候还有文本内容,则打印 590 | if completion.choices[0].message.content: 591 | with Live(console=self._console, auto_refresh=False) as live: 592 | thinking_content, full_content = self._print_thinking_and_response(completion, live) 593 | #遍历工具调用 594 | for tool_call in tool_calls: 595 | tool_functions.append({ 596 | #"index": tool_call.index, 597 | "id": tool_call.id, 598 | "type": tool_call.type, 599 | "function": { 600 | "name": tool_call.function.name, 601 | "arguments": tool_call.function.arguments 602 | } 603 | }) 604 | 605 | # 没有工具调用 606 | else: 607 | with Live(console=self._console, auto_refresh=False) as live: 608 | thinking_content, full_content = self._print_thinking_and_response(completion, live) 609 | 610 | return tool_functions, self._create_response(full_content, tool_functions) 611 | 612 | def _create_response(self, full_content: str, tool_functions: list) -> list: 613 | """创建响应消息列表 614 | 615 | Args: 616 | content: 响应内容 617 | tool_functions: 工具调用列表 618 | 619 | Returns: 620 | list: 响应消息列表 621 | """ 622 | if not tool_functions: 623 | return [{ 624 | "role": "assistant", 625 | "content": full_content, 626 | }] 627 | 628 | response = [] 629 | for tool_function in tool_functions: 630 | response.append({ 631 | "role": "assistant", 632 | "content": full_content or f"Tool Function Called: {tool_function['function']['name']}\nArguments: {tool_function['function']['arguments'][:300]}{'...' if len(tool_function['function']['arguments']) > 200 else ''}", # Claude requires non-empty content 633 | "function_call": { 634 | "name": tool_function["function"]["name"], 635 | "arguments": tool_function["function"]["arguments"] 636 | } 637 | }) 638 | return response 639 | 640 | async def get_tools(self): 641 | for session in self.session_list: 642 | # 列出可用工具 643 | available_tools = [] 644 | response = await session.list_tools() 645 | for tool in response.tools: 646 | available_tools.append({ 647 | "type": "function", 648 | "function": { 649 | "name": tool.name, 650 | "description": tool.description, 651 | "parameters": tool.inputSchema 652 | } 653 | }) 654 | self.available_tools.append(available_tools) 655 | 656 | if not self.available_tools: 657 | self._console.print(Panel("[yellow]警告: 没有找到任何可用工具。[/yellow]")) 658 | else: 659 | total_tools = sum(len(tools) for tools in self.available_tools) 660 | self._console.print(Panel(f"[green]成功获取到 {len(self.session_list)} 个服务器,总计 {total_tools} 个工具。[/green]")) 661 | async def connect_to_server(self): 662 | """连接到 MCP 服务器 663 | 664 | 参数: 665 | server_script_path: 服务器脚本路径 (.py 或 .js) 666 | """ 667 | if self.config.mcp_server_config: 668 | try: 669 | # 解析配置文件 670 | servers_info = parse_mcp_servers(self.config.mcp_server_config) 671 | # 获取所有服务器名称 672 | server_names = get_all_server_names(servers_info) 673 | #print(f"服务器列表: {server_names}") 674 | 675 | # 遍历每个服务器,为每个服务器创建单独的连接 676 | for server_name in server_names: 677 | command = get_server_command(servers_info, server_name) 678 | args = get_server_args(servers_info, server_name) 679 | env = get_server_env(servers_info, server_name) 680 | 681 | # 检查命令是否为 npx 682 | if command.lower() == "npx": 683 | command = self.config.npx_path 684 | # 如果是 npx 命令,检查是否需要连接到网络 685 | if any("@modelcontextprotocol" in arg for arg in args): 686 | # 这是一个需要从网络下载的包,可能会遇到网络问题 687 | self._console.print(Panel( 688 | Markdown(f"🌐 **正在从网络下载并安装服务: {server_name}**\n\n请耐心等待,这可能需要一些时间..."), 689 | title="[bold blue]远程安装进行中[/bold blue]", 690 | border_style="blue", 691 | expand=False 692 | )) 693 | self._console.print(f"[dim]命令: {command} {' '.join(args)}[/dim]") 694 | elif command.lower() == "python": 695 | command = self.config.python_path 696 | self._console.print(f"[dim]命令: {command} {' '.join(args)}[/dim]") 697 | elif command.lower() == "node": 698 | command = self.config.node_path 699 | self._console.print(f"[dim]命令: {command} {' '.join(args)}[/dim]") 700 | else: 701 | # 对于其他命令,也使用原始字符串 702 | command = rf"{command}" 703 | self._console.print(f"[dim]命令: {command} {' '.join(args)}[/dim]") 704 | 705 | # 创建 StdioServerParameters 对象 706 | server_params = StdioServerParameters( 707 | command=command, 708 | args=args, 709 | env={ 710 | **os.environ.copy(), # 复制当前环境变量 711 | # 确保关键路径配置被正确传递 712 | "PYTHON_PATH": self.config.python_path, 713 | "NODE_PATH": self.config.node_path, 714 | "NPX_PATH": self.config.npx_path 715 | } 716 | ) 717 | 718 | try: 719 | # 使用 stdio_client 创建与服务器的 stdio 传输 720 | studiocilent=stdio_client(server_params) 721 | stdio_transport = await self.exit_stack.enter_async_context(studiocilent) 722 | # 解包 stdio_transport,获取读取和写入句柄 723 | stdio, write = stdio_transport 724 | # 创建 ClientSession 对象,用于与服务器通信 725 | session = await self.exit_stack.enter_async_context(ClientSession(stdio, write)) 726 | # 初始化会话 727 | await session.initialize() 728 | self.session_list.append(session) 729 | # 列出可用工具 730 | response = await session.list_tools() 731 | tools = response.tools 732 | # 构建工具列表字符串 733 | tools_list = [] 734 | for tool in tools: 735 | tool_str = f"- **{tool.name}**:\n ```\n {tool.description}\n ```" 736 | tools_list.append(tool_str) 737 | tools_text = "\n".join(tools_list) 738 | 739 | self._console.print(Panel( 740 | Markdown(f""" 741 | # 服务器连接成功 ✅ 742 | 743 | ## 服务器信息 744 | - **名称**: {server_name} 745 | - **状态**: 在线 🟢 746 | 747 | ## 可用工具 🛠️ 748 | {tools_text} 749 | """), 750 | title="[green]服务器连接状态[/green]", 751 | border_style="green" 752 | )) 753 | self.server_names.append(server_name) 754 | except FileNotFoundError as e: 755 | self._console.print(Panel( 756 | Markdown(f"❌ 找不到文件: `{str(e)}`\n\n请检查命令和参数是否正确。"), 757 | title=f"[bold red]服务器 {server_name} 启动失败[/bold red]", 758 | border_style="red", 759 | expand=False 760 | )) 761 | except Exception as e: 762 | self._console.print(Panel( 763 | Markdown(f"❌ 连接失败: `{str(e)}`"), 764 | title=f"[bold red]服务器 {server_name} 启动失败[/bold red]", 765 | border_style="red", 766 | expand=False 767 | )) 768 | except Exception as e: 769 | self._console.print(Panel( 770 | Markdown(f"❌ MCP配置解析失败: `{str(e)}`\n\n将以无服务器模式运行。"), 771 | title="[bold red]配置错误[/bold red]", 772 | border_style="red", 773 | padding=(1, 2) 774 | )) 775 | 776 | async def process_query(self, query: str) -> str: 777 | """使用 OpenAI 和可用工具处理查询""" 778 | 779 | # 创建消息列表 780 | self.messages.append({"role": "user", "content": query}) 781 | 782 | # 处理消息 783 | tool_functions,response = self.get_response() 784 | # 如果有工具调用 785 | while tool_functions: 786 | self.messages.extend(response) 787 | #迭代调用工具· 788 | for tool_function in tool_functions: 789 | tool_call_id = tool_function["id"] 790 | tool_name = tool_function["function"]["name"] 791 | logging.debug(f"工具函数原始数据: {tool_function}") 792 | logging.debug(f"工具函数参数: {tool_function['function'].get('arguments', '')}") 793 | 794 | try: 795 | tool_args = json.loads(tool_function["function"].get("arguments") or "{}") 796 | except json.JSONDecodeError as e: 797 | print(f"JSON解析错误: {e}") 798 | print(f"原始参数字符串: '{tool_function['function'].get('arguments', '')}'") 799 | tool_args = {} 800 | # 处理参数显示 801 | args_str = json.dumps(tool_args, ensure_ascii=False, indent=2) 802 | if len(args_str) > 1000: 803 | args_str = args_str[:1000] + "..." 804 | 805 | self._console.print(Panel( 806 | Markdown(f"**Tool Call**\n\n调用工具: {tool_name}\n\n参数:\n\n```json\n{args_str}\n```"), 807 | title="工具调用", 808 | expand=False 809 | )) 810 | 811 | # 执行工具调用结果 812 | tool_to_session = {tool["function"]["name"]: idx for idx, tools in enumerate(self.available_tools) for tool in tools} 813 | session_idx = tool_to_session.get(tool_name) 814 | if session_idx is not None: 815 | session = self.session_list[session_idx] 816 | 817 | if self.config.is_human_control: 818 | # 如果有人工控制,则需要人工确认(回车默认确认,否则输入的是拒绝理由) 819 | #用户确认洁面 820 | is_confirm = self._console.input("是否确认调用工具? (回车确认|拒绝理由): ") 821 | 822 | if is_confirm == "": 823 | result = await session.call_tool(tool_name, tool_args) 824 | else: 825 | 826 | self._console.print(Panel( 827 | Markdown(f"❌ 工具调用已取消\n\n**拒绝理由**:\n> {is_confirm}"), 828 | title="[red]工具调用取消[/red]", 829 | border_style="red", 830 | expand=False 831 | )) 832 | # 递归调用process_query,重新获取工具调用 833 | query = f"我拒绝你申请的调用工具:{tool_name},参数:{args_str[:200]} ;理由是:{is_confirm}" 834 | return await self.process_query(query) 835 | 836 | else: 837 | result = await session.call_tool(tool_name, tool_args) 838 | logging.debug(f"工具调用完整结果: {result}") 839 | else: 840 | raise ValueError(f"Tool '{tool_name}' not found in available tools") 841 | self._console.print(Panel( 842 | Markdown("**Result**\n\n" + "\n\n".join(content.text for content in result.content) + f"\n\nMeta: {str(result.meta)}\nIsError: {str(result.isError)}"), 843 | title="工具调用结果", 844 | expand=False 845 | )) 846 | 847 | #将工具调用结果添加到消息 848 | if not self.model.startswith("gpt"): 849 | self.messages.append( 850 | { 851 | "role": "user", # gpt可以用function 852 | "tool_call_id": tool_call_id, 853 | "name": tool_name, 854 | "content": "Tool call result:\n"+str( 855 | { 856 | "type": "tool_result", 857 | "tool_name": tool_name, 858 | "tool_use_id": tool_call_id, 859 | "result": [content.text for content in result.content], 860 | "meta": str(result.meta), 861 | "isError": str(result.isError) 862 | }) 863 | } 864 | ) 865 | 866 | 867 | else: 868 | self.messages.append({ 869 | "role": "user", 870 | "tool_call_id": tool_call_id, 871 | "name": tool_name, 872 | "content": str( 873 | { 874 | "type": "tool_result", 875 | "tool_use_id": tool_call_id, 876 | "result": [content.text for content in result.content], 877 | "meta": str(result.meta), 878 | "isError": str(result.isError) 879 | }) 880 | } 881 | ) 882 | # 将调用结果返回给LLM,获取回复 883 | tool_functions,response = self.get_response() 884 | 885 | # 最终响应 886 | self.messages.extend(response) 887 | return self.messages 888 | 889 | async def cleanup(self): 890 | """清理资源""" 891 | await self.exit_stack.aclose() 892 | 893 | def save_messages(self): 894 | """保存对话消息到文件""" 895 | import time 896 | timestamp = int(time.time()) 897 | filename = f"messages_{timestamp}.json" 898 | save_path = self.config.messages_path 899 | os.makedirs(save_path, exist_ok=True) 900 | with open(os.path.join(save_path, filename), "w", encoding="utf-8") as f: 901 | json.dump(self.messages, f, ensure_ascii=False, indent=4) 902 | 903 | return filename 904 | 905 | def manage_message_history(self): 906 | if self.config.max_messages_tokens > 0: 907 | # Trim message history if it exceeds the maximum length 908 | if len(self.messages) > self.config.max_messages: 909 | self.messages = self.messages[-self.config.max_messages:] 910 | 911 | # Ensure system prompt is at the beginning of the message history 912 | if self.config.system_prompt and (not self.messages or self.messages[0]["role"] != "system"): 913 | # 检查系统提示文件是否存在 914 | if os.path.exists(self.config.system_prompt): 915 | try: 916 | with open(self.config.system_prompt, "r", encoding="utf-8") as f: 917 | system_prompt = f.read() 918 | logging.debug(f"成功从文件加载系统提示: {self.config.system_prompt}") 919 | except Exception as e: 920 | logging.error(f"读取系统提示文件出错: {e}") 921 | system_prompt = "You are a helpful assistant." 922 | else: 923 | # 如果文件不存在,使用默认提示 924 | logging.warning(f"系统提示文件不存在: {self.config.system_prompt}") 925 | system_prompt = "You are a helpful assistant." 926 | self.messages.insert(0, {"role": "system", "content": system_prompt}) 927 | else: 928 | # 如果设置小于等于0,则不限制消息长度 929 | pass 930 | 931 | async def handle_command(self, command: str): 932 | if command == 'quit' or command == 'exit': 933 | return 'exit' 934 | elif command == 'fc': 935 | self.config.is_function_calling = not self.config.is_function_calling 936 | status = "启动" if self.config.is_function_calling else "取消" 937 | # 使用主控制台显示状态更新 938 | self._console.print(Panel( 939 | Markdown(f"🛠️ 工具调用功能已{status}"), 940 | title="[bold green]功能状态更新[/bold green]", 941 | border_style="green" 942 | )) 943 | return 'fc' 944 | elif command == 'model': 945 | self.choose_model() 946 | return 'model' 947 | elif command == 'save': 948 | filename = self.save_messages() 949 | self._console.print(Panel( 950 | Markdown(f"📁 消息历史已成功保存\n\n📄 文件名: `{filename}`"), 951 | title="[bold blue]保存成功[/bold blue]", 952 | border_style="cyan" 953 | )) 954 | return 'save' 955 | elif command == 'human': 956 | self.config.is_human_control = not self.config.is_human_control 957 | status = "启动" if self.config.is_human_control else "取消" 958 | self._console.print(Panel( 959 | Markdown(f"👤 人类干预已{status}"), 960 | title="[bold green]功能状态更新[/bold green]", 961 | border_style="green" 962 | )) 963 | return 'human' 964 | elif command.startswith("compact "): 965 | # 获取用户指定的字符数限制,默认200 966 | parts = command[8:].strip().split(" ") 967 | char_limit = 200 # 默认值 968 | if len(parts) > 0: 969 | try: 970 | char_limit = int(parts[0]) 971 | if char_limit <= 0: 972 | raise ValueError("字符数限制必须大于0") 973 | except ValueError as e: 974 | self._console.print(Panel( 975 | f"[red]错误: {str(e)}\n使用默认值200[/red]", 976 | title="[bold red]参数错误[/bold red]", 977 | border_style="red" 978 | )) 979 | char_limit = 200 980 | 981 | # 统计压缩信息 982 | compressed_count = 0 983 | total_saved = 0 984 | 985 | # 压缩消息历史 986 | for message in self.messages: 987 | # 检查是否有content字段 988 | if "content" not in message: 989 | continue 990 | 991 | content_length = len(message["content"]) 992 | if content_length > char_limit: 993 | original_length = len(message["content"]) 994 | message["content"] = message["content"][:char_limit] + "..." 995 | compressed_count += 1 996 | total_saved += original_length - len(message["content"]) 997 | 998 | # 显示详细的压缩结果 999 | self._console.print(Panel( 1000 | Markdown(f""" 1001 | # 📝 消息历史压缩完成 1002 | 1003 | - 字符数限制: `{char_limit}` 1004 | - 压缩消息数: `{compressed_count}` 1005 | - 节省字符数: `{total_saved}` 1006 | - 压缩比例: `{compressed_count/len(self.messages)*100:.1f}%` 1007 | """), 1008 | title="[bold green]压缩成功[/bold green]", 1009 | border_style="green" 1010 | )) 1011 | return 'compact' 1012 | 1013 | elif command.startswith('cost'): 1014 | self.total_input_tokens,self.total_output_tokens,self.current_input_tokens,self.round_count=get_token_count(self.messages,self.model) 1015 | self.total_input_tokens += sum(self.tool_tokens) 1016 | if self.config.is_function_calling : 1017 | # 计算tool tokens 1018 | encoding = tiktoken.encoding_for_model(self.model) 1019 | name=[json.dumps(tool["function"]["name"]) for available_tool in self.available_tools for tool in available_tool] 1020 | parameters=[json.dumps(tool["function"]["parameters"]) for available_tool in self.available_tools for tool in available_tool] 1021 | description=[json.dumps(tool["function"]["description"]) for available_tool in self.available_tools for tool in available_tool] 1022 | #每个字符串的token数之和为tool_tokens 1023 | tool_tokens=sum([len(encoding.encode(n)) for n in name])+sum([len(encoding.encode(p)) for p in parameters]) 1024 | self.current_input_tokens += tool_tokens 1025 | self._console.print(Panel( 1026 | Markdown(f""" 1027 | # 📊 Token 使用统计报告 1028 | 1029 | - 🔹 输入消耗总计: `{self.total_input_tokens}` Tokens 1030 | - 🔸 输出消耗总计: `{self.total_output_tokens+self.thinking_tokens}` Tokens 1031 | - 💭 思维链消耗: `{self.thinking_tokens}` Tokens 1032 | - ⏳ 下轮预估消耗: `{self.current_input_tokens}` Tokens 1033 | - 🔁 当前对话轮次: `{self.round_count}` 1034 | """), 1035 | title="[bold green]Token消耗统计[/bold green]", 1036 | border_style="green" 1037 | )) 1038 | return 'cost' 1039 | elif command.startswith('load '): 1040 | file_path = command[5:].strip() 1041 | try: 1042 | file_path = os.path.expanduser(file_path) 1043 | with open(file_path, "r", encoding="utf-8") as f: 1044 | loaded_messages = json.load(f) 1045 | self.messages = loaded_messages 1046 | self._console.print(Panel( 1047 | Markdown(f"📂 消息历史已成功加载\n\n📄 文件路径: `{file_path}`"), 1048 | title="[bold blue]加载成功[/bold blue]", 1049 | border_style="cyan" 1050 | )) 1051 | 1052 | except FileNotFoundError: 1053 | self._console.print(f"[bold red]错误:文件 '{file_path}' 不存在[/bold red]") 1054 | except json.JSONDecodeError: 1055 | self._console.print(f"[bold red]错误:文件 '{file_path}' 不是有效的 JSON 格式[/bold red]") 1056 | except Exception as e: 1057 | self._console.print(f"[bold red]加载文件时出错:{str(e)}[/bold red]") 1058 | return 'load' 1059 | elif command.startswith('mcp '): 1060 | try: 1061 | parts = command[4:].strip().split(" ") 1062 | if not parts or not parts[0]: 1063 | self._console.print(Panel( 1064 | Markdown("请提供MCP配置文件路径"), 1065 | title="[bold yellow]提示[/bold yellow]", 1066 | border_style="yellow" 1067 | )) 1068 | return 'mcp_error' 1069 | 1070 | new_mcp_config_path = parts[0] 1071 | if not os.path.exists(new_mcp_config_path): 1072 | self._console.print(Panel( 1073 | Markdown(f"配置文件 '{new_mcp_config_path}' 不存在"), 1074 | title="[bold red]错误[/bold red]", 1075 | border_style="red" 1076 | )) 1077 | return 'mcp_error' 1078 | 1079 | self._console.print(Panel( 1080 | Markdown(f"正在切换新的MCP配置文件: {new_mcp_config_path}"), 1081 | title="[bold green]切换配置文件[/bold green]", 1082 | border_style="green" 1083 | )) 1084 | 1085 | # 清理旧的资源 1086 | await self.cleanup() 1087 | 1088 | # 重置会话相关的属性 1089 | self.server_names = [] 1090 | self.session_list = [] 1091 | self.available_tools = [] 1092 | self.exit_stack = AsyncExitStack() # 创建新的 exit_stack 1093 | 1094 | # 更新配置 1095 | self.config.mcp_server_config = new_mcp_config_path 1096 | 1097 | try: 1098 | # 重新连接服务器 1099 | await self.connect_to_server() 1100 | # 获取新的工具列表 1101 | await self.get_tools() 1102 | 1103 | self._console.print(Panel( 1104 | Markdown("✅ MCP配置切换完成"), 1105 | title="[bold green]成功[/bold green]", 1106 | border_style="green" 1107 | )) 1108 | return 'mcp' 1109 | except Exception as e: 1110 | self._console.print(Panel( 1111 | Markdown(f"服务器连接失败:\n\n```\n{str(e)}\n```"), 1112 | title="[bold red]错误[/bold red]", 1113 | border_style="red" 1114 | )) 1115 | return 'mcp_error' 1116 | 1117 | except Exception as e: 1118 | import traceback 1119 | self._console.print(Panel( 1120 | Markdown(f"切换MCP配置文件时发生错误:\n\n```\n{str(e)}\n{traceback.format_exc()}\n```"), 1121 | title="[bold red]错误[/bold red]", 1122 | border_style="red" 1123 | )) 1124 | return 'mcp_error' 1125 | 1126 | 1127 | 1128 | elif command == "help": 1129 | self._console.print(Panel( 1130 | Markdown(self.get_help_text()), 1131 | title="[bold cyan]🔍 命令帮助中心[/bold cyan]", 1132 | subtitle="[dim]输入 \\ 可查看简洁命令列表[/dim]", 1133 | border_style="cyan", 1134 | padding=(1, 2) 1135 | )) 1136 | return 'help' 1137 | elif command == "clear": 1138 | # 清空历史消息 1139 | self.messages = [] 1140 | self._console.print(Panel( 1141 | "🧹 对话历史已清空", 1142 | title="[bold green]操作成功[/bold green]", 1143 | border_style="green" 1144 | )) 1145 | return 'clear' 1146 | elif command == "debug": 1147 | self.config.debug = not self.config.debug 1148 | logging.basicConfig(level=logging.DEBUG if self.config.debug else logging.ERROR) 1149 | self._console.print(Panel( 1150 | f"调试模式{'开启' if self.config.debug else '关闭'}", 1151 | title="[bold blue]操作成功[/bold blue]", 1152 | border_style="cyan" 1153 | )) 1154 | return 'debug' 1155 | else: 1156 | self._console.print(Panel( 1157 | f"未知命令:\\{command}\n使用 \\help 查看可用命令", 1158 | title="[bold yellow]提示[/bold yellow]", 1159 | border_style="yellow" 1160 | )) 1161 | return 'help' 1162 | 1163 | def get_multiline_input(self) -> str: 1164 | """获取多行输入 1165 | 1166 | 支持以下特性: 1167 | 1. 多行输入,按Enter继续输入 1168 | 2. 输入 \\q 单独一行来结束输入 1169 | 3. 输入 \\c 单独一行来清除当前输入 1170 | 4. 空行会被保留 1171 | 5. 命令行(以\\开头)会直接执行,不需要\\q 1172 | 6. 输入\\时会显示命令提示 1173 | 1174 | Returns: 1175 | str: 用户输入的文本 1176 | """ 1177 | # 初始化readline 1178 | try: 1179 | # 定义一个空的补全函数 1180 | def empty_completer(text, state): 1181 | return None 1182 | 1183 | # 完全禁用readline的补全功能 1184 | readline.set_completer(empty_completer) 1185 | readline.parse_and_bind('bind ^I rl_complete') # 禁用tab补全 1186 | readline.parse_and_bind('set disable-completion on') # 禁用所有补全 1187 | readline.parse_and_bind('set show-all-if-ambiguous off') # 禁用模糊匹配显示 1188 | readline.parse_and_bind('set show-all-if-unmodified off') # 禁用未修改时的显示 1189 | readline.parse_and_bind('set completion-ignore-case off') # 禁用大小写忽略 1190 | readline.parse_and_bind('set completion-query-items 0') # 禁用补全项查询 1191 | except Exception as e: 1192 | logging.warning(f"readline初始化失败: {e}") 1193 | 1194 | self._console.print(self.input_prompt, end="") 1195 | lines = [] 1196 | while True: 1197 | try: 1198 | # 使用readline获取输入 1199 | line = input().strip() 1200 | # 处理命令(以\开头) 1201 | if line.startswith('\\'): 1202 | if len(lines) == 0: # 只有在第一行时才处理命令 1203 | # 如果只输入了\,显示命令提示 1204 | if line == '\\': 1205 | self._show_command_suggestions() 1206 | self._console.print(self.input_prompt, end="") 1207 | continue 1208 | return line 1209 | elif line == r'\q': # 使用原始字符串来判断 1210 | # 显示输入完成的提示 1211 | self._console.print(Panel( 1212 | Group( 1213 | Spinner('dots', text='正在处理您的输入...', style="cyan"), 1214 | Text("\n输入已完成,共 {} 行".format(len(lines)), style="dim") 1215 | ), 1216 | title="[bold green]✨ 输入完成[/bold green]", 1217 | border_style="green", 1218 | padding=(1, 2) 1219 | )) 1220 | break 1221 | elif line == r'\c': # 使用原始字符串来判断 1222 | lines = [] 1223 | self._console.print("[yellow]已清除当前输入[/yellow]") 1224 | self._console.print(self.input_prompt, end="") 1225 | continue 1226 | else: # 其他\开头的内容作为普通文本处理 1227 | lines.append(line) 1228 | else: 1229 | lines.append(line) 1230 | except EOFError: 1231 | break 1232 | except KeyboardInterrupt: 1233 | self._console.print("\n[yellow]已取消当前输入[/yellow]") 1234 | return "" 1235 | 1236 | # 合并所有行 1237 | return '\n'.join(lines) 1238 | 1239 | def _show_command_suggestions(self): 1240 | """显示命令提示""" 1241 | # 定义命令分类 1242 | command_categories = { 1243 | "基础命令": [ 1244 | ("", "显示此命令列表"), 1245 | ("quit/exit", "退出系统"), 1246 | ("clear", "清空当前会话历史"), 1247 | ("help", "显示详细帮助信息") 1248 | ], 1249 | "模型与工具": [ 1250 | ("model", "切换语言模型"), 1251 | ("fc", "开启/关闭工具调用"), 1252 | ("human", "开启/关闭人类干预"), 1253 | ("mcp <路径>", "切换MCP配置文件") 1254 | ], 1255 | "会话管理": [ 1256 | ("save", "保存当前会话"), 1257 | ("compact <字符数>", "压缩消息历史"), 1258 | ("load <路径>", "加载历史会话") 1259 | ], 1260 | "统计与调试": [ 1261 | ("cost", "显示Token统计"), 1262 | ("debug", "切换调试模式") 1263 | ] 1264 | } 1265 | 1266 | # 创建表格 1267 | table = Table( 1268 | title="快速命令参考", 1269 | box=ROUNDED, 1270 | expand=False, 1271 | padding=(0, 1), 1272 | border_style="cyan", 1273 | highlight=True 1274 | ) 1275 | 1276 | # 添加列 1277 | table.add_column("分类", style="bold cyan", justify="left", no_wrap=True) 1278 | table.add_column("命令", style="green", justify="left") 1279 | table.add_column("描述", style="white", justify="left") 1280 | 1281 | # 添加行 1282 | for category, commands in command_categories.items(): 1283 | for i, (cmd, desc) in enumerate(commands): 1284 | if i == 0: 1285 | # 第一行显示分类 1286 | table.add_row( 1287 | f"[bold]{category}[/bold]", 1288 | f"[cyan]\\{cmd}[/cyan]" if cmd else "[cyan]\\[/cyan]", 1289 | desc 1290 | ) 1291 | else: 1292 | # 后续行不显示分类 1293 | table.add_row( 1294 | "", 1295 | f"[cyan]\\{cmd}[/cyan]", 1296 | desc 1297 | ) 1298 | 1299 | # 显示表格 1300 | self._console.print(Panel( 1301 | Group( 1302 | table, 1303 | Text("\n💡 提示: 输入 [cyan]\\help[/cyan] 获取更详细的命令说明", style="dim") 1304 | ), 1305 | title="[bold cyan]🔍 可用命令[/bold cyan]", 1306 | border_style="cyan", 1307 | padding=(1, 2) 1308 | )) 1309 | 1310 | async def chat_loop(self): 1311 | """运行交互式聊天循环""" 1312 | server_message = "## 🖥️ 已连接服务器\n" + "\n".join(f"- `{path}`" for path in self.server_names) 1313 | 1314 | # ASCII艺术标志 1315 | ascii_logo = """ 1316 | █████╗ ██████╗████████╗██╗ ██████╗ ███╗ ██╗ █████╗ ██╗ 1317 | ██╔══██╗██╔════╝╚══██╔══╝██║██╔═══██╗████╗ ██║██╔══██╗██║ 1318 | ███████║██║ ██║ ██║██║ ██║██╔██╗ ██║███████║██║ 1319 | ██╔══██║██║ ██║ ██║██║ ██║██║╚██╗██║██╔══██║██║ 1320 | ██║ ██║╚██████╗ ██║ ██║╚██████╔╝██║ ╚████║██║ ██║██║ 1321 | ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ 1322 | """ 1323 | 1324 | # 创建欢迎界面组件 1325 | welcome_components = [ 1326 | Text(ascii_logo, style="cyan bold"), 1327 | Text(""), 1328 | Text(" 智能AI助手 | 强大工具链 | 多模型支持", style="green bold"), 1329 | Text(""), 1330 | Text("✨ 输入 \\ 查看所有可用命令", style="yellow"), 1331 | Text("📚 输入 \\help 获取详细帮助", style="yellow"), 1332 | Text("🔄 输入 \\model 切换AI模型", style="yellow") 1333 | ] 1334 | 1335 | # 如果有服务器连接,添加服务器信息 1336 | if self.server_names: 1337 | welcome_components.extend([ 1338 | Text(""), 1339 | Markdown(server_message) 1340 | ]) 1341 | 1342 | # 显示欢迎界面 1343 | self._console.print(Panel( 1344 | Group(*welcome_components), 1345 | title="[bold cyan]ActionAI 智能助手[/bold cyan]", 1346 | subtitle="[dim]输入问题开始对话,输入 \\ 查看命令[/dim]", 1347 | border_style="cyan", 1348 | padding=(1, 2) 1349 | )) 1350 | 1351 | while True: 1352 | try: 1353 | 1354 | # 先获取用户输入 1355 | query = self.get_multiline_input().strip() 1356 | 1357 | # 如果直接回车,则跳过 1358 | if not query: 1359 | continue 1360 | 1361 | # 处理所有\开头的命令 1362 | if query.startswith('\\'): 1363 | command = query[1:].strip().lower() 1364 | result = await self.handle_command(command) 1365 | if result == 'exit': 1366 | break 1367 | # 其余命令跳过执行问答 1368 | continue 1369 | 1370 | # 在处理实际对话之前检查token限制 1371 | self.manage_message_history() 1372 | self.total_input_tokens, self.total_output_tokens, self.current_input_tokens, self.round_count = get_token_count(self.messages, self.model) 1373 | # 如果此次对话是工具调用,则需要计算tool tokens 1374 | if self.config.is_function_calling: 1375 | encoding = tiktoken.encoding_for_model(self.model) 1376 | name=[json.dumps(tool["function"]["name"]) for available_tool in self.available_tools for tool in available_tool] 1377 | parameters=[json.dumps(tool["function"]["parameters"]) for available_tool in self.available_tools for tool in available_tool] 1378 | description=[json.dumps(tool["function"]["description"]) for available_tool in self.available_tools for tool in available_tool] 1379 | #每个字符串的token数之和为tool_tokens 1380 | tool_tokens=sum([len(encoding.encode(n)) for n in name])+sum([len(encoding.encode(p)) for p in parameters]) 1381 | self.current_input_tokens += tool_tokens 1382 | 1383 | #计算query的token数 1384 | encoding = tiktoken.encoding_for_model(self.model) 1385 | query_tokens = encoding.encode(query) 1386 | self.current_input_tokens += len(query_tokens) 1387 | 1388 | if self.current_input_tokens > self.config.max_messages_tokens: 1389 | self._console.print(Panel( 1390 | Markdown(""" 1391 | # ⚠️ 对话长度超出限制 1392 | 1393 | 当前Token数:{current} 1394 | 最大限制:{max} 1395 | 1396 | 建议操作: 1397 | 1. 使用 `\\clear` 清空对话历史 1398 | 2. 使用 `\\compact` 压缩历史消息 1399 | 3. 使用 `\\save` 保存当前对话后再清空 1400 | """.format( 1401 | current=self.current_input_tokens, 1402 | max=self.config.max_messages_tokens 1403 | )), 1404 | title="[bold red]警告[/bold red]", 1405 | border_style="red" 1406 | )) 1407 | continue 1408 | 1409 | # 处理正常的对话 1410 | await self.process_query(query) 1411 | 1412 | except Exception as e: 1413 | self._console.print(f"[bold red]错误: {str(e)}[/bold red]") 1414 | 1415 | def get_help_text(self): 1416 | """返回帮助信息文本""" 1417 | return """ 1418 | # 🚀 ActionAI 命令指南 1419 | 1420 | ## 📋 基础命令 1421 | 1422 | | 命令 | 描述 | 示例 | 1423 | |------|------|------| 1424 | | `\\` | 显示简洁命令列表 | `\\` | 1425 | | `\\quit` 或 `\\exit` | 退出系统 | `\\quit` | 1426 | | `\\clear` | 清空当前会话历史 | `\\clear` | 1427 | | `\\help` | 显示此帮助信息 | `\\help` | 1428 | 1429 | ## 🤖 模型与工具控制 1430 | 1431 | | 命令 | 描述 | 示例 | 1432 | |------|------|------| 1433 | | `\\model` | 切换语言模型 | `\\model` | 1434 | | `\\fc` | 开启/关闭工具调用 | `\\fc` | 1435 | | `\\human` | 开启/关闭人类干预模式 | `\\human` | 1436 | | `\\mcp <配置文件路径>` | 切换MCP配置文件 | `\\mcp ./config.json` | 1437 | 1438 | ## 💾 会话管理 1439 | 1440 | | 命令 | 描述 | 示例 | 1441 | |------|------|------| 1442 | | `\\save` | 保存当前会话到文件 | `\\save` | 1443 | | `\\load <文件路径>` | 加载历史会话 | `\\load ./messages_1234567890.json` | 1444 | | `\\compact <字符数>` | 压缩消息历史 | `\\compact 200` | 1445 | 1446 | ## 📊 统计与调试 1447 | 1448 | | 命令 | 描述 | 示例 | 1449 | |------|------|------| 1450 | | `\\cost` | 显示Token使用统计 | `\\cost` | 1451 | | `\\debug` | 切换调试模式 | `\\debug` | 1452 | 1453 | ## 💡 输入技巧 1454 | 1455 | - 多行输入:按Enter继续输入 1456 | - 结束输入:输入 `\\q` 单独一行 1457 | - 清除输入:输入 `\\c` 单独一行 1458 | """ 1459 | 1460 | async def main(): 1461 | """ 1462 | 主函数 1463 | """ 1464 | # 创建客户端实例 1465 | config = load_config_from_env(base_path) 1466 | client = LLM_Client(config) 1467 | #选择模型 1468 | client.choose_model() 1469 | try: 1470 | #连接服务器 1471 | await client.connect_to_server() 1472 | # 列出可用工具 1473 | await client.get_tools() 1474 | # 运行聊天循环 1475 | await client.chat_loop() 1476 | finally: 1477 | # 确保在任何情况下都清理资源 1478 | await client.cleanup() 1479 | 1480 | if __name__ == "__main__": 1481 | asyncio.run(main()) 1482 | --------------------------------------------------------------------------------