refactor: 拆分 ai_cli.py 为模块化 xcli_core 包

将 4130 行的单文件拆分为 13 个职责清晰的模块:
- constants.py: 常量 + 可选依赖导入
- sandbox.py: 安全代码沙箱 (AST检查器 + 子进程隔离)
- config.py: 配置管理 + 日志
- plugin_manager.py: LiuginManager 插件管理器
- cli_base.py: BaseAICLI 基类 (初始化/引擎/插件/命令)
- cli_clawli.py: ClawliMixin 远程模式
- cli_tools.py: ToolMixin 工具调用
- cli_code_exec.py: CodeExecMixin 代码沙箱执行
- cli_display.py: DisplayMixin 显示/输出/图片/帮助
- cli_history.py: HistoryMixin 聊天记录
- cli_core.py: AICLI 主类 + 对话循环 + main()
- tui.py: Textual TUI 界面
- __init__.py: 包导出汇总

ai_cli.py 保留为入口文件 (11行), 根目录整洁。
原逻辑完全不变, 仅做结构拆分。
This commit is contained in:
xiaoli-cli committed 2026-04-25 22:27:59 +08:00
1 parent 7efb6dfe8b
commit 04e2d26809
14 files changed
+3798 -4125

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
import os
import importlib.util
from colorama import Fore, Style
from .config import logger
class LiuginManager:
"""插件管理器"""
def __init__(self, liugins_dir="plugins"):
self.liugins_dir = liugins_dir
self.tools = []
def get_enabled_liugins(self):
"""获取启用的插件列表"""
# 检查环境变量中是否有启用的插件列表
enabled_liugins_env = os.environ.get("XIAOLI_ENABLED_PLUGINS")
if enabled_liugins_env is not None:
# 特殊处理:空字符串表示不启用任何插件
if enabled_liugins_env == "":
return []
return enabled_liugins_env.split(",")
# 检查是否有插件配置文件
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
config_file = os.path.join(project_dir, "plugins_config.py")
if os.path.exists(config_file):
try:
# 读取配置文件
with open(config_file, 'r', encoding='utf-8') as f:
config_content = f.read()
# 检查是否明确表示不启用任何插件
if "# 不启用任何插件" in config_content:
return []
# 解析启用的插件
enabled_liugins = []
for line in config_content.split('\n'):
if line.startswith('ENABLE_') and '= True' in line:
liugin_name = line.split('=')[0].replace('ENABLE_', '').strip().lower()
enabled_liugins.append(liugin_name)
return enabled_liugins
except (FileNotFoundError, PermissionError, UnicodeDecodeError) as e:
logger.error(f"读取插件配置文件失败: {e}")
print(f"{Fore.RED}读取插件配置文件失败: {e}{Style.RESET_ALL}")
except Exception as e:
logger.error(f"读取插件配置时发生未知错误: {e}")
print(f"{Fore.RED}读取插件配置文件失败: {e}{Style.RESET_ALL}")
# 如果没有配置,则返回None表示加载所有插件
return None
def load_liugins(self):
"""加载liugin"""
self.tools = []
# 获取启用的插件列表
enabled_liugins = self.get_enabled_liugins()
# 加载工具插件
if os.path.exists(self.liugins_dir):
for filename in os.listdir(self.liugins_dir):
if filename.endswith('.py') and filename != '__init__.py':
liugin_name = filename[:-3] # 移除.py扩展名
# 如果有启用的插件列表,检查当前插件是否在其中
if enabled_liugins is not None and liugin_name.lower() not in enabled_liugins:
print(f"{Fore.YELLOW}跳过liugin: {liugin_name} (未启用){Style.RESET_ALL}")
continue
plugin_path = os.path.join(self.liugins_dir, filename)
try:
spec = importlib.util.spec_from_file_location(liugin_name, plugin_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# 获取插件信息 - 兼容 Liugin 和 Plugin 两种类名
plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if plugin_class:
plugin_instance = plugin_class()
tool_info = plugin_instance.get_tool_info()
tool_info['handler'] = plugin_instance.handle
self.tools.append(tool_info)
print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}")
except (ImportError, AttributeError, TypeError) as e:
logger.error(f"加载liugin失败 {filename}: {e}")
print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}")
except FileNotFoundError as e:
logger.error(f"liugin文件未找到 {filename}: {e}")
print(f"{Fore.RED}liugin文件未找到 {filename}: {e}{Style.RESET_ALL}")
except Exception as e:
logger.error(f"加载liugin时发生未知错误 {filename}: {e}")
print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}")
print(f"{Fore.GREEN}总共加载了 {len(self.tools)} 个工具插件{Style.RESET_ALL}")
def search_tools(self, query, limit=5):
"""
搜索工具,支持关键词匹配
"""
if not self.tools:
return []
query_lower = query.lower()
matches = []
for tool in self.tools:
score = 0
tool_name = tool.get('name', '').lower()
tool_desc = tool.get('description', '').lower()
tool_keywords = [kw.lower() for kw in tool.get('keywords', [])]
if query_lower == tool_name:
score += 100
if query_lower in tool_name:
score += 50
if query_lower in tool_desc:
score += 30
for keyword in tool_keywords:
if query_lower in keyword:
score += 20
break
if score > 0:
matches.append({
'score': score,
'name': tool.get('name', ''),
'description': tool.get('description', ''),
'usage': tool.get('usage', ''),
'keywords': tool.get('keywords', [])
})
matches.sort(key=lambda x: x['score'], reverse=True)
return matches[:limit]
def get_tool_by_name(self, tool_name):
"""根据名称获取工具"""
for tool in self.tools:
if tool.get('name', '').lower() == tool_name.lower():
return tool
return None