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

+35
View File
@@ -0,0 +1,35 @@
"""
xcli_core - 小狸 Pro-CLI 核心模块
从 ai_cli.py 拆分优化的模块化架构。
"""
from .constants import *
from .sandbox import *
from .config import *
from .plugin_manager import LiuginManager
from .cli_core import AICLI, main
def ask_user_confirmation(prompt="确认执行?", default=False):
"""请求用户确认(兼容性存根,供插件调用)"""
try:
suffix = " [Y/n] " if default else " [y/N] "
response = input(prompt + suffix).strip().lower()
if not response:
return default
return response in ('y', 'yes', '是', '确认')
except (EOFError, KeyboardInterrupt):
return False
__all__ = [
'AICLI',
'LiuginManager',
'main',
'load_config',
'save_config',
'get_system_config',
'set_system_config',
'ask_user_confirmation',
]
+389
View File
@@ -0,0 +1,389 @@
import os
import sys
import importlib.util
import uuid
import shutil
from colorama import init, Fore, Style
from .constants import (
DEFAULT_MAX_HISTORY, WEBSOCKET_AVAILABLE, CLAWLI_SERVER_AVAILABLE,
UNIFIED_TOOL_MANAGER_AVAILABLE, TEXTUAL_AVAILABLE,
)
from .config import get_system_config, set_system_config, logger
from .plugin_manager import LiuginManager
# 可选导入
try:
import clawli_server
except ImportError:
clawli_server = None
try:
from unified_tool_manager import UnifiedToolManager
except ImportError:
UnifiedToolManager = None
class BaseAICLI:
"""AI CLI 基类 - 初始化、引擎/插件加载、资源管理"""
def __init__(self):
# 初始化colorama
init(autoreset=True)
# 确保插件管理器使用正确的路径
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
liugins_dir = os.path.join(project_dir, "plugins")
skills_dir = os.path.join(project_dir, "skills")
# 使用统一工具管理器(支持 Liugin 和 Skill 双协议)
if UNIFIED_TOOL_MANAGER_AVAILABLE:
self.liugin_manager = UnifiedToolManager(self)
self._use_unified_manager = True
else:
self.liugin_manager = LiuginManager(liugins_dir)
self._use_unified_manager = False
# 初始化引擎字典(先初始化为空字典)
self.engines = {}
# 初始化插件命令注册表
self.liugin_commands = {}
# 初始化系统引擎命令注册表
self.engine_commands = {}
# 注册默认的引擎命令
self._register_engine_commands()
# 动态加载AI引擎插件
self.load_ai_engines()
# 从配置文件获取默认引擎设置
default_engine_name = self._get_default_engine_name()
# 设置默认引擎,优先使用配置文件指定的引擎
if default_engine_name in self.engines:
self.current_engine = self.engines[default_engine_name]
print(f"{Fore.GREEN}使用配置的默认AI引擎: {default_engine_name}{Style.RESET_ALL}")
elif self.engines:
first_engine_name = next(iter(self.engines))
self.current_engine = self.engines[first_engine_name]
print(f"{Fore.GREEN}使用默认AI引擎: {first_engine_name}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}警告: 没有可用的AI引擎,请检查ai_engines目录{Style.RESET_ALL}")
self.current_engine = None
if self.current_engine:
self.current_engine.cli = self # 设置引用以便访问插件
# 创建聊天记录目录
self.chat_history_dir = os.path.join(project_dir, "chat_history")
if not os.path.exists(self.chat_history_dir):
os.makedirs(self.chat_history_dir)
# 获取 skills 目录路径
skills_dir = os.path.join(project_dir, "skills")
self.load_liugins(liugins_dir, skills_dir)
# 初始化共享对话历史
self.shared_conversation_history = []
# 设置最大历史记录数
self.max_history = DEFAULT_MAX_HISTORY
# 为所有已加载的引擎设置共享对话历史
self._set_shared_conversation_history()
# 初始化代码执行相关功能
self.code_execution_enabled = True
self.code_execution_timeout = 30 # 默认超时30秒
self.code_execution_memory_limit = 128 * 1024 * 1024 # 128MB内存限制
self.code_execution_whitelist = [
'json', 'math', 'datetime', 'random', 'collections',
'itertools', 'functools', 'operator', 'typing', 're'
]
self.code_execution_history = []
self.liugin_code_requests = {}
self.user_input_queue = {}
self.is_clawli_mode = False # Clawli 远程模式标志
self._clawli_monitor_thread = None
self._clawli_monitor_running = False
# TUI 输出回调(用于 TUI 模式下的实时输出)
self.tui_output_callback = None
# 初始化用户ID(基于机器硬件动态生成)
self.user_id = str(uuid.getnode())
# ── 资源清理 ──
def cleanup_resources(self):
"""清理资源,确保在程序退出时正确关闭所有资源"""
print(f"{Fore.YELLOW}正在清理资源...{Style.RESET_ALL}")
self._stop_clawli_monitor()
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.stop_server()
print(f"{Fore.GREEN}资源清理完成{Style.RESET_ALL}")
def __del__(self):
"""析构函数,确保资源被清理"""
self.cleanup_resources()
# ── 引擎管理 ──
def _get_default_engine_name(self):
"""从配置文件获取默认引擎名称"""
try:
default_engine = get_system_config('default_engine')
if default_engine:
return default_engine
return 'ollama'
except Exception as e:
print(f"{Fore.YELLOW}读取引擎配置失败,使用默认引擎: {e}{Style.RESET_ALL}")
return 'ollama'
def load_ai_engines(self):
"""动态加载AI引擎插件"""
print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}")
ai_engines_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ai_engines")
if os.path.exists(ai_engines_dir):
for filename in os.listdir(ai_engines_dir):
if filename.endswith('.py') and filename != '__init__.py':
engine_path = os.path.join(ai_engines_dir, filename)
engine_name = filename[:-3]
try:
spec = importlib.util.spec_from_file_location(engine_name, engine_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class_name = self._find_engine_class(module, engine_name)
if class_name and hasattr(module, class_name):
engine_instance = getattr(module, class_name)()
engine_instance.cli = self
engine_key = getattr(engine_instance, 'name', engine_name)
if engine_key not in self.engines:
self.engines[engine_key] = engine_instance
print(f"{Fore.GREEN}已加载AI引擎: {engine_key} ({class_name}){Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}AI引擎 {engine_key} 已存在,跳过加载{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}AI引擎插件 {filename} 中未找到合适的引擎类{Style.RESET_ALL}")
except (ImportError, AttributeError, TypeError) as e:
logger.error(f"加载AI引擎插件失败 {filename}: {e}")
print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}")
except FileNotFoundError as e:
logger.error(f"AI引擎liugin文件未找到 {filename}: {e}")
print(f"{Fore.RED}AI引擎liugin文件未找到 {filename}: {e}{Style.RESET_ALL}")
except Exception as e:
logger.error(f"加载AI引擎插件时发生未知错误 {filename}: {e}")
print(f"{Fore.RED}加载AI引擎插件失败 {filename}: {e}{Style.RESET_ALL}")
print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}")
def _find_engine_class(self, module, engine_name):
"""查找AI引擎类,使用动态方式避免硬编码类名"""
possible_names = [
engine_name.capitalize() + "AI",
engine_name.replace('_', '').replace('-', '').capitalize() + "AI",
engine_name.replace('_', '').capitalize() + "AI",
engine_name.replace('_engine', '').capitalize() + "AI",
engine_name.replace('-', '').replace('_', '').capitalize() + "AI",
engine_name.replace('-', '').capitalize() + "AI",
''.join(word.capitalize() for word in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI",
''.join(word.capitalize() for word in engine_name.replace('_engine', '').replace('-', ' ').replace('_', ' ').split()) + "AI",
]
for name in possible_names:
if hasattr(module, name):
return name
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (hasattr(attr, '__class__') and
isinstance(attr, type) and
attr_name.endswith('AI') and
hasattr(attr, 'generate_response')):
return attr_name
return None
def load_liugins(self, liugins_dir=None, skills_dir=None):
"""加载liugin和技能"""
print(f"{Fore.GREEN}正在加载工具...{Style.RESET_ALL}")
if self._use_unified_manager:
self.liugin_manager.initialize(liugins_dir or "plugins", skills_dir or "skills")
else:
self.liugin_manager.tools = []
enabled_liugins = self.liugin_manager.get_enabled_liugins()
plugins_path = liugins_dir or self.liugin_manager.liugins_dir
if os.path.exists(plugins_path):
for filename in os.listdir(plugins_path):
if filename.endswith('.py') and filename != '__init__.py':
liugin_name = filename[:-3]
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(plugins_path, 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)
plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if plugin_class:
plugin_instance = plugin_class()
if hasattr(plugin_instance, 'set_cli'):
plugin_instance.set_cli(self)
tool_info = plugin_instance.get_tool_info()
tool_info['handler'] = plugin_instance.handle
self.liugin_manager.tools.append(tool_info)
print(f"{Fore.GREEN}已加载liugin: {liugin_name}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}加载liugin失败 {filename}: {e}{Style.RESET_ALL}")
print(f"{Fore.GREEN}总共加载了 {len(self.liugin_manager.tools)} 个工具插件{Style.RESET_ALL}")
print(f"{Fore.GREEN}工具加载完成!{Style.RESET_ALL}")
# ── 命令注册 ──
def register_liugin_command(self, command, handler):
"""注册插件命令"""
self.liugin_commands[command] = handler
print(f"{Fore.GREEN}已注册插件命令: {command}{Style.RESET_ALL}")
def unregister_liugin_command(self, command):
"""注销插件命令"""
if command in self.liugin_commands:
del self.liugin_commands[command]
print(f"{Fore.GREEN}已注销插件命令: {command}{Style.RESET_ALL}")
def _register_engine_commands(self):
"""注册默认引擎命令"""
self.engine_commands['list'] = self._handle_engine_list
self.engine_commands['switch'] = self._handle_engine_switch
def register_engine_command(self, command, handler):
"""注册引擎命令"""
self.engine_commands[command] = handler
print(f"{Fore.GREEN}已注册引擎命令: {command}{Style.RESET_ALL}")
def unregister_engine_command(self, command):
"""注销引擎命令"""
if command in self.engine_commands:
del self.engine_commands[command]
print(f"{Fore.GREEN}已注销引擎命令: {command}{Style.RESET_ALL}")
def _handle_engine_list(self, args):
"""处理引擎列表命令"""
print(f"{Fore.GREEN}可用的AI引擎:{Style.RESET_ALL}")
for engine_name in self.engines.keys():
status = " (当前使用)" if engine_name == getattr(self.current_engine, 'name', 'spark') else ""
print(f"{Fore.GREEN} - {engine_name}{status}{Style.RESET_ALL}")
def _handle_engine_switch(self, args):
"""处理引擎切换命令"""
if args:
engine_name = args.strip()
self.switch_engine(engine_name)
else:
print(f"{Fore.RED}请提供引擎名称.用法: /engine switch <引擎名>{Style.RESET_ALL}")
def _get_engine_commands_help(self):
"""获取引擎命令帮助信息"""
help_lines = []
for cmd, handler in self.engine_commands.items():
if cmd == 'list':
help_lines.append(f" /engine {cmd:<10} - 查看可用的AI引擎")
elif cmd == 'switch':
help_lines.append(f" /engine {cmd:<10} - 切换AI引擎")
else:
help_lines.append(f" /engine {cmd:<10} - 执行{cmd}命令")
return '\n'.join(help_lines) if help_lines else " 无可用引擎管理命令"
# ── 引擎切换 ──
def get_available_engines(self):
"""获取可用引擎列表"""
return list(self.engines.keys())
def get_current_engine_name(self):
"""获取当前引擎名称"""
return getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置'
def get_liugin_manager(self):
"""获取插件管理器"""
return self.liugin_manager
def switch_engine(self, engine_name):
"""切换AI引擎"""
if engine_name in self.engines:
old_engine = self.current_engine
self.current_engine = self.engines[engine_name]
if hasattr(self.current_engine, 'cli'):
self.current_engine.cli = self
if hasattr(self.current_engine, 'shared_conversation_history'):
self.current_engine.shared_conversation_history = self.shared_conversation_history
self.current_engine.max_history = self.max_history
if old_engine != self.current_engine and self.shared_conversation_history:
print(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}")
context_message = {
"role": "system",
"content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n"
}
if self.shared_conversation_history:
print(f"{Fore.YELLOW}当前对话历史已传递给新引擎:{Style.RESET_ALL}")
for msg in self.shared_conversation_history[-self.max_history:]:
if isinstance(msg, dict) and "role" in msg and "content" in msg:
role = msg["role"]
content = msg["content"]
if role == "user":
print(f"{Fore.CYAN}用户: {content}{Style.RESET_ALL}")
elif role == "assistant":
print(f"{Fore.GREEN}AI: {content}{Style.RESET_ALL}")
elif role == "tool":
print(f"{Fore.MAGENTA}工具结果: {content}{Style.RESET_ALL}")
else:
print(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}")
try:
set_system_config('default_engine', engine_name)
print(f"{Fore.CYAN}已将 {engine_name} 设置为默认引擎{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}")
def _set_shared_conversation_history(self):
"""为所有引擎设置共享对话历史"""
for engine in self.engines.values():
engine.shared_conversation_history = self.shared_conversation_history
engine.max_history = self.max_history
def handle_engine_command(self, command):
"""处理引擎特定命令"""
if not hasattr(self.current_engine, 'handle_command'):
return False
engine_name = getattr(self.current_engine, 'name', '')
if engine_name and command.startswith(engine_name + ' '):
actual_command = command[len(engine_name) + 1:].strip()
return self.current_engine.handle_command(actual_command)
return self.current_engine.handle_command(command)
# ── 插件消息处理 ──
def handle_liugin_message(self, message: str, **kwargs) -> str:
"""处理插件转发的消息(如 QQ 群 @ 消息)"""
try:
print(f"{Fore.CYAN}[插件消息] {message[:100]}...{Style.RESET_ALL}")
system_prompt = """你是一个智能助手,正在通过 QQ 机器人与用户交流。
请简洁、友好地回复用户的消息。回复时注意:
1. 回复要简洁明了,适合 QQ 群聊场景
2. 不要提及你是 AI 或机器人
3. 直接回答问题,不要加多余的前缀"""
if self.current_engine:
response = self.current_engine.generate_response(message, system_prompt=system_prompt)
if response:
print(f"{Fore.GREEN}[AI 回复] {response[:50]}...{Style.RESET_ALL}")
return response
else:
return "抱歉,我暂时无法回复。"
else:
print(f"{Fore.RED}[插件消息] 没有可用的 AI 引擎{Style.RESET_ALL}")
return "AI 引擎未初始化"
except Exception as e:
print(f"{Fore.RED}[插件消息] 处理失败: {e}{Style.RESET_ALL}")
return f"处理消息时出错: {e}"
+320
View File
@@ -0,0 +1,320 @@
import threading
import time
from colorama import Fore, Style
from .constants import CLAWLI_SERVER_AVAILABLE, WEBSOCKET_AVAILABLE
try:
import clawli_server
except ImportError:
clawli_server = None
try:
import websocket_server
except ImportError:
websocket_server = None
class ClawliMixin:
"""Clawli 远程模式相关方法"""
def _start_clawli_monitor(self):
"""启动 Clawli 消息监控线程"""
if self._clawli_monitor_running:
return
self._clawli_monitor_running = True
print(f"{Fore.CYAN}[Clawli] 消息监控线程已启动{Style.RESET_ALL}")
def monitor_loop():
while self._clawli_monitor_running and self.is_clawli_mode:
try:
if CLAWLI_SERVER_AVAILABLE and clawli_server:
messages = clawli_server.get_messages()
if messages:
print(f"[Clawli Monitor] 收到 {len(messages)} 条消息")
for msg in messages:
self._handle_clawli_message(msg)
time.sleep(0.1) # 100ms 检查间隔
except Exception as e:
print(f"[Clawli Monitor] 错误: {e}")
self._clawli_monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
self._clawli_monitor_thread.start()
def _stop_clawli_monitor(self):
"""停止 Clawli 消息监控线程"""
self._clawli_monitor_running = False
if self._clawli_monitor_thread:
self._clawli_monitor_thread.join(timeout=1)
self._clawli_monitor_thread = None
def _handle_clawli_message(self, msg: dict):
"""处理来自手机端的消息"""
try:
msg_type = msg.get("type", "")
if msg_type == "message":
content = msg.get("content", "")
self._process_clawli_user_message(content)
elif msg_type == "file":
file_path = msg.get("path", "")
text = msg.get("text", "")
category = msg.get("category", "file")
if category == "image":
self._process_clawli_image(file_path, text)
else:
self._process_clawli_user_message(f"@{file_path}\n{text}")
except Exception as e:
print(f"[Clawli] 处理消息错误: {e}")
def _process_clawli_user_message(self, content: str):
"""处理 Clawli 用户消息"""
if not self.current_engine:
return
full_content = f"{content}\n\n[此消息由 Clawli 手机端发送]"
self.shared_conversation_history.append({
"role": "user",
"content": full_content
})
system_prompt = self._build_system_prompt(self.get_liugin_usage_prompts())
response = self.current_engine.generate_response(full_content, system_prompt=system_prompt)
self._process_clawli_response(response)
def _process_clawli_response(self, response: str):
"""处理 Clawli AI 响应"""
text_content, json_data = self._parse_mixed_response(response)
if json_data:
tool_calls = self._extract_tool_calls(json_data)
if tool_calls:
for tool_data in tool_calls:
tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '')
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.send_tool_status(tool_name, tool_args, "calling")
result = self.process_tool_call(tool_data)
result_text = result.get('result', '无结果')
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
self.shared_conversation_history.append({
"role": "assistant",
"content": response
})
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]])
self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。")
return
final_response = text_content if text_content else response
self.shared_conversation_history.append({
"role": "assistant",
"content": final_response
})
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.send_message(final_response)
def _process_clawli_image(self, image_path: str, text: str):
"""处理 Clawli 图片消息"""
try:
image_engine = getattr(self, 'image_engine', None)
if not image_engine:
try:
from image_engine import get_image_engine
image_engine = get_image_engine()
except:
image_engine = None
if image_engine:
with open(image_path, 'rb') as f:
image_data = f.read()
result = image_engine.analyze(image_data, text or "请描述这张图片")
self._process_clawli_user_message(f"@{image_path}\n图片内容: {result}\n\n{text}")
else:
self._process_clawli_user_message(f"@{image_path}\n{text}")
except Exception as e:
self._process_clawli_user_message(f"@{image_path}\n图片处理失败: {e}\n{text}")
def _handle_remote_command(self, args):
"""处理远程连接命令"""
if not WEBSOCKET_AVAILABLE:
print(f"{Fore.RED}错误: websockets 库未安装{Style.RESET_ALL}")
print(f"{Fore.YELLOW}请运行: pip install websockets{Style.RESET_ALL}")
return
args = args.strip()
if not args or args == '':
print(f"\n{Fore.CYAN}{'='*55}{Style.RESET_ALL}")
print(f"{Fore.CYAN} 远程连接命令帮助{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'='*55}{Style.RESET_ALL}")
print(f"\n{Fore.WHITE}本地模式 (局域网直连):{Style.RESET_ALL}")
print(f"{Fore.YELLOW} /remote start <端口> <密码>{Style.RESET_ALL}")
print(f"{Fore.WHITE} 端口: 监听端口 (默认9079){Style.RESET_ALL}")
print(f"{Fore.WHITE} 密码: PC密码 (安卓端需要输入){Style.RESET_ALL}")
print(f"\n{Fore.WHITE}代理模式 (无公网IP,通过中继服务器):{Style.RESET_ALL}")
print(f"{Fore.YELLOW} /remote proxy <服务器> <服务器密码> <PC端口> <PC密码>{Style.RESET_ALL}")
print(f"{Fore.WHITE} 服务器: 中继服务器地址:端口{Style.RESET_ALL}")
print(f"{Fore.WHITE} 服务器密码: 连接中继服务器的密码{Style.RESET_ALL}")
print(f"{Fore.WHITE} PC端口: 在服务器上使用的端口{Style.RESET_ALL}")
print(f"{Fore.WHITE} PC密码: 安卓端连接时需要验证{Style.RESET_ALL}")
print(f"\n{Fore.WHITE}其他命令:{Style.RESET_ALL}")
print(f"{Fore.WHITE} /remote stop - 停止服务{Style.RESET_ALL}")
print(f"{Fore.WHITE} /remote status - 查看状态{Style.RESET_ALL}")
print(f"{Fore.WHITE} /remote ip - 查看本机IP{Style.RESET_ALL}")
print(f"\n{Fore.CYAN}{'='*55}{Style.RESET_ALL}")
print(f"\n{Fore.GREEN}示例:{Style.RESET_ALL}")
print(f"{Fore.WHITE} /remote start 9079 mypassword{Style.RESET_ALL}")
print(f"{Fore.WHITE} /remote proxy relay.example.com:9079 serverpwd 12345 pcpwd{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'='*55}{Style.RESET_ALL}\n")
return
parts = args.split()
cmd = parts[0].lower()
if cmd == 'start':
port = 9079
password = ""
if len(parts) == 2:
arg = parts[1]
if arg.isdigit():
port = int(arg)
print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}")
print(f"{Fore.YELLOW}用法: /remote start {port} <密码>{Style.RESET_ALL}")
return
else:
password = arg
elif len(parts) >= 3:
try:
port = int(parts[1])
password = parts[2]
except ValueError:
password = parts[1]
if not password:
print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}")
print(f"{Fore.YELLOW}用法: /remote start <端口> <密码>{Style.RESET_ALL}")
return
if len(password) < 4:
print(f"{Fore.RED}错误: 密码至少4个字符{Style.RESET_ALL}")
return
if CLAWLI_SERVER_AVAILABLE and clawli_server:
success = clawli_server.start_server("0.0.0.0", port, password)
if success:
self.is_clawli_mode = True
self._start_clawli_monitor()
elif websocket_server:
websocket_server.start_local_server(self, port, password)
elif cmd == 'proxy':
if len(parts) < 5:
print(f"{Fore.RED}参数不足{Style.RESET_ALL}")
print(f"{Fore.YELLOW}用法: /remote proxy <服务器> <服务器密码> <PC端口> <PC密码>{Style.RESET_ALL}")
print(f"{Fore.WHITE}示例: /remote proxy relay.example.com:9079 serverpwd 12345 pcpwd{Style.RESET_ALL}")
return
server_addr = parts[1]
server_password = parts[2]
pc_port = 0
pc_password = parts[4] if len(parts) > 4 else ""
try:
pc_port = int(parts[3])
if pc_port < 10000 or pc_port > 19999:
print(f"{Fore.YELLOW}建议端口范围: 10000-19999{Style.RESET_ALL}")
except ValueError:
print(f"{Fore.RED}无效的端口号: {parts[3]}{Style.RESET_ALL}")
return
if not pc_password:
print(f"{Fore.RED}错误: 请设置PC密码{Style.RESET_ALL}")
return
if len(pc_password) < 4:
print(f"{Fore.RED}错误: PC密码至少4个字符{Style.RESET_ALL}")
return
if ':' in server_addr:
server_host, port_str = server_addr.rsplit(':', 1)
try:
server_port = int(port_str)
except ValueError:
print(f"{Fore.RED}无效的服务器端口: {port_str}{Style.RESET_ALL}")
return
else:
server_host = server_addr
server_port = 9079
print(f"{Fore.CYAN}正在连接中继服务器...{Style.RESET_ALL}")
if websocket_server:
websocket_server.start_proxy_client(
self, server_host, server_port,
server_password, pc_port, pc_password
)
elif cmd == 'stop':
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.stop_server()
self.is_clawli_mode = False
elif websocket_server:
websocket_server.stop_all()
print(f"{Fore.GREEN}远程服务已停止{Style.RESET_ALL}")
elif cmd == 'status':
if CLAWLI_SERVER_AVAILABLE and clawli_server:
status = clawli_server.get_status()
elif websocket_server:
status = websocket_server.get_status()
else:
status = {}
if status.get('running'):
mode = status.get('mode', 'local')
if mode == 'proxy':
print(f"{Fore.GREEN}远程服务状态: 运行中 (代理模式){Style.RESET_ALL}")
print(f"{Fore.WHITE}服务器: {Fore.YELLOW}{status.get('server', '未知')}{Style.RESET_ALL}")
print(f"{Fore.WHITE}PC端口: {Fore.YELLOW}{status.get('port', '未知')}{Style.RESET_ALL}")
else:
print(f"{Fore.GREEN}远程服务状态: 运行中 (本地模式){Style.RESET_ALL}")
print(f"{Fore.WHITE}本机IP: {Fore.YELLOW}{status.get('local_ip', '未知')}{Style.RESET_ALL}")
print(f"{Fore.WHITE}端口: {Fore.YELLOW}{status.get('port', 9079)}{Style.RESET_ALL}")
print(f"{Fore.WHITE}已连接客户端: {Fore.YELLOW}{status.get('connected_clients', 0)}{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}远程服务状态: 未启动{Style.RESET_ALL}")
print(f"{Fore.WHITE}使用 {Fore.CYAN}/remote start <端口> <密码>{Fore.WHITE} 启动本地服务{Style.RESET_ALL}")
print(f"{Fore.WHITE}使用 {Fore.CYAN}/remote proxy ...{Fore.WHITE} 连接代理服务器{Style.RESET_ALL}")
elif cmd == 'ip':
import socket
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
print(f"{Fore.GREEN}本机IP地址: {Fore.YELLOW}{local_ip}{Style.RESET_ALL}")
print(f"{Fore.WHITE}在 Clawli 应用中输入此IP即可连接 (仅限局域网){Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}获取IP失败: {e}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}未知命令: {cmd}{Style.RESET_ALL}")
print(f"{Fore.WHITE}可用命令: start, proxy, stop, status, ip{Style.RESET_ALL}")
+206
View File
@@ -0,0 +1,206 @@
import os
import sys
import time
import json
import subprocess
import traceback
from datetime import datetime
from colorama import Fore, Style
from .config import logger
from .sandbox import (
_sandbox_checker, _make_sandbox_worker, _sandbox_work_dir,
_HAS_RESOURCE, _SAFE_WHITELIST,
)
try:
import resource as _resource_mod
except ImportError:
_resource_mod = None
class CodeExecMixin:
"""代码沙箱执行相关方法"""
def request_code_execution(self, liugin_name, code, timeout=None, variables=None, context=None):
"""
插件代码执行请求接口
"""
if not self.code_execution_enabled:
return {
'success': False,
'result': '代码执行功能已禁用',
'stdout': '',
'stderr': '',
'execution_time': 0,
'memory_usage': 0
}
if not self._is_code_safe(code):
return {
'success': False,
'result': '代码包含潜在危险操作,已阻止执行',
'stdout': '',
'stderr': '',
'execution_time': 0,
'memory_usage': 0
}
exec_timeout = timeout if timeout is not None else self.code_execution_timeout
execution_id = f"{liugin_name}_{int(time.time())}"
execution_record = {
'id': execution_id,
'liugin_name': liugin_name,
'code': code,
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
'context': context
}
try:
result = self._execute_code_safely(code, exec_timeout, variables)
execution_record.update({
'success': result['success'],
'execution_time': result['execution_time'],
'memory_usage': result['memory_usage']
})
self.code_execution_history.append(execution_record)
if len(self.code_execution_history) > 100:
self.code_execution_history.pop(0)
if result['success']:
logger.info(f"代码执行成功 - 插件: {liugin_name}, 耗时: {result['execution_time']:.3f}s")
else:
logger.warning(f"代码执行失败 - 插件: {liugin_name}, 错误: {result['result']}")
return result
except Exception as e:
error_msg = f"代码执行异常: {str(e)}"
logger.error(f"{error_msg} - 插件: {liugin_name}")
return {
'success': False,
'result': error_msg,
'stdout': '',
'stderr': error_msg,
'execution_time': 0,
'memory_usage': 0
}
def _is_code_safe(self, code):
"""检查代码是否安全(使用 v2 AST+正则 沙箱检查器)"""
safe, violations = _sandbox_checker.check(code)
if not safe:
logger.warning(f"代码安全检查失败: {violations}")
return safe
def _execute_code_safely(self, code, timeout, variables=None):
"""
在安全沙箱中执行代码(v2 子进程隔离)
"""
safe, violations = _sandbox_checker.check(code)
if not safe:
return {
'success': False,
'result': '代码包含潜在危险操作,已阻止执行',
'stdout': '', 'stderr': '; '.join(violations[:5]),
'execution_time': 0, 'memory_usage': 0
}
vars_json = json.dumps(variables or {}, ensure_ascii=False, default=str)
wl = getattr(self, 'code_execution_whitelist', None) or _SAFE_WHITELIST
whitelist_json = json.dumps(wl)
script = _make_sandbox_worker(code, vars_json, whitelist_json, _sandbox_work_dir)
safe_env = {
'PATH': '/usr/bin:/bin',
'LANG': 'en_US.UTF-8', 'LC_ALL': 'en_US.UTF-8',
'HOME': _sandbox_work_dir,
'TMPDIR': _sandbox_work_dir, 'TEMP': _sandbox_work_dir, 'TMP': _sandbox_work_dir,
'PYTHONNOUSERSITE': '1', 'PYTHONDONTWRITEBYTECODE': '1',
}
for key in ('TERM', 'COLUMNS', 'LINES', 'SHELL'):
val = os.environ.get(key)
if val:
safe_env[key] = val
start_time = time.time()
try:
proc = subprocess.run(
[sys.executable, '-c', script],
capture_output=True, timeout=timeout,
cwd=_sandbox_work_dir, env=safe_env,
preexec_fn=CodeExecMixin._sandbox_set_limits if _HAS_RESOURCE else None,
)
elapsed = time.time() - start_time
stdout = proc.stdout.decode('utf-8', errors='replace')[:1000000]
stderr = proc.stderr.decode('utf-8', errors='replace')[:1000000]
try:
data = json.loads(stdout)
return {
'success': data.get('success', False),
'result': data.get('result', ''),
'stdout': data.get('stdout', ''),
'stderr': data.get('stderr', ''),
'execution_time': elapsed,
'memory_usage': 0
}
except json.JSONDecodeError:
return {
'success': proc.returncode == 0,
'result': stdout[:2000] if stdout else 'no output',
'stdout': stdout, 'stderr': stderr,
'execution_time': elapsed, 'memory_usage': 0
}
except subprocess.TimeoutExpired:
return {
'success': False,
'result': f'代码执行超时(超过{timeout}秒)',
'stdout': '', 'stderr': '',
'execution_time': timeout, 'memory_usage': 0
}
except Exception as e:
return {
'success': False,
'result': f'代码执行异常: {str(e)}',
'stdout': '', 'stderr': traceback.format_exc(),
'execution_time': time.time() - start_time, 'memory_usage': 0
}
@staticmethod
def _sandbox_set_limits():
"""子进程资源限制"""
if not _HAS_RESOURCE or not _resource_mod:
return
try:
mem = 256 * 1024 * 1024 # 256MB
_resource_mod.setrlimit(_resource_mod.RLIMIT_AS, (mem, mem))
_resource_mod.setrlimit(_resource_mod.RLIMIT_CPU, (30, 35))
_resource_mod.setrlimit(_resource_mod.RLIMIT_NPROC, (0, 0))
_resource_mod.setrlimit(_resource_mod.RLIMIT_FSIZE, (50*1024*1024, 50*1024*1024))
except (ValueError, OSError):
pass
def get_code_execution_history(self, limit=10):
"""获取代码执行历史"""
return self.code_execution_history[-limit:]
def clear_code_execution_history(self):
"""清空代码执行历史"""
self.code_execution_history = []
logger.info("代码执行历史已清空")
def set_code_execution_timeout(self, timeout):
"""设置代码执行超时时间"""
if timeout > 0:
self.code_execution_timeout = timeout
logger.info(f"代码执行超时时间已设置为 {timeout} 秒")
def set_code_execution_whitelist(self, modules):
"""设置允许导入的模块白名单"""
self.code_execution_whitelist = modules
logger.info(f"代码执行模块白名单已更新: {modules}")
+961
View File
@@ -0,0 +1,961 @@
import os
import sys
import json
import re
import time
import shutil
import threading
import random
from colorama import Fore, Style
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
from .config import get_system_config, logger
from .cli_base import BaseAICLI
from .cli_clawli import ClawliMixin
from .cli_tools import ToolMixin
from .cli_code_exec import CodeExecMixin
from .cli_display import DisplayMixin
from .cli_history import HistoryMixin
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin):
"""AI CLI主程序 - 组合所有 Mixin"""
# ── 深度思考处理 ──
def process_thinking_response(self, response):
"""处理深度思考AI返回的内容, 分离思考和回复内容"""
try:
response_data = json.loads(response)
if isinstance(response_data, dict) and response_data.get('action') == 'use_tool':
return response
if isinstance(response_data, dict):
if 'answer' in response_data:
extracted_content = response_data['answer']
elif 'response' in response_data:
extracted_content = response_data['response']
elif 'content' in response_data:
extracted_content = response_data['content']
elif 'text' in response_data:
extracted_content = response_data['text']
else:
return json.dumps(response_data, ensure_ascii=False, indent=2)
return self._process_content_with_thinking(extracted_content)
except json.JSONDecodeError:
return self._process_content_with_thinking(response)
def _process_content_with_thinking(self, content):
"""处理包含思考标记的内容"""
thinking_start_marker = getattr(self.current_engine, 'thinking_start_marker', None) if self.current_engine else None
thinking_end_marker = getattr(self.current_engine, 'thinking_end_marker', None) if self.current_engine else None
if '<think/>' in content:
parts = content.split('<think/>')
if len(parts) >= 2:
thinking_content = parts[0].strip()
reply_content = parts[1].strip()
if thinking_content:
print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}")
return reply_content if reply_content else ""
if thinking_start_marker and thinking_end_marker and thinking_start_marker in content and thinking_end_marker in content:
parts = content.split(thinking_start_marker)
if len(parts) > 1:
remaining = parts[1]
if thinking_end_marker in remaining:
thinking_part, reply_part = remaining.split(thinking_end_marker, 1)
thinking_content = thinking_part.strip()
reply_content = reply_part.strip()
if thinking_content:
print(f"{Fore.LIGHTBLACK_EX}{Style.DIM}[思考: {thinking_content}]{Style.RESET_ALL}")
return reply_content if reply_content else ""
return content
# ── 系统提示构建 ──
def _build_system_prompt(self, liugin_prompts=None):
"""构建系统提示词 - 增强版,支持编程任务"""
base_prompt = """你叫小狸,是一个强大的智能编程助手。你具备以下核心能力:
1. **精准代码编辑** - 使用 code_editor 工具进行搜索替换、多文件批量编辑
2. **代码搜索与理解** - 使用 code_search 工具跨文件搜索、提取符号、分析依赖
3. **Git 版本控制** - 使用 git_tools 工具管理代码版本
4. **Shell 命令执行** - 使用 cmd_executor 执行系统命令
5. **文件管理** - 使用 file_manager 管理文件和目录
你在说话时可以适当加上'nyan'来表现可爱性格,但不要过度使用。"""
has_tool_search = any(t.get('name') == 'tool_search' for t in self.liugin_manager.tools)
tool_search_rule = ""
if has_tool_search:
tool_search_rule = """【工具查询规则】
调用不熟悉的工具前,先用 tool_search 查询用法:
{"action": "use_tool", "tool": "tool_search", "args": "工具名"}
例外:ai_search、code_editor、code_search、git_tools 可以直接使用。"""
if liugin_prompts:
prompt = f"""{base_prompt}
{liugin_prompts}
{tool_search_rule}
【工具调用格式】
在回复中包含 JSON 指令来执行操作:
1. 纯 JSON: {{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}}
2. 文本+JSON: 好的,我来修改。{{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old <<<>>> new"}}
【编程工作流】
1. 理解需求 → 用 code_search.structure 查看项目结构
2. 定位代码 → 用 code_search.find 或 code_search.regex 搜索
3. 查看上下文 → 用 code_editor.read_range 查看相关代码
4. 精准修改 → 用 code_editor.edit 替换代码(old <<<>>> new 分隔)
5. 验证变更 → 用 git_tools.diff 查看变更
6. 提交代码 → 用 git_tools.commit 提交
【code_editor 编辑格式】
edit 操作使用 <<<>>> 分隔旧代码和新代码:
{{"action": "use_tool", "tool": "code_editor", "args": "edit path/to/file.py
def old_function():
pass
<<<>>>
def new_function():
return True"}}
【批量编辑】
multi 操作支持一次修改多处:
{{"action": "use_tool", "tool": "code_editor", "args": "multi path/to/file.py [{{\\"old\\": \\"old1\\", \\"new\\": \\"new1\\"}}, {{\\"old\\": \\"old2\\", \\"new\\": \\"new2\\"}}]"}}
【搜索代码】
- 关键词搜索: {{"action": "use_tool", "tool": "code_search", "args": "find . keyword *.py"}}
- 正则搜索: {{"action": "use_tool", "tool": "code_search", "args": "regex . def\\\\s+\\\\w+ *.py"}}
- 查看结构: {{"action": "use_tool", "tool": "code_search", "args": "structure ."}}
- 查找调用: {{"action": "use_tool", "tool": "code_search", "args": "callers . function_name"}}
【Git 操作】
- 查看状态: {{"action": "use_tool", "tool": "git_tools", "args": "status"}}
- 查看变更: {{"action": "use_tool", "tool": "git_tools", "args": "diff"}}
- 提交代码: {{"action": "use_tool", "tool": "git_tools", "args": "commit 描述信息"}}
- 查看历史: {{"action": "use_tool", "tool": "git_tools", "args": "log --oneline 10"}}
【注意事项】
- code_editor.edit 的 old_text 必须与文件中的内容完全匹配(包括缩进)
- 如果 edit 失败,先用 read_range 查看实际内容再重试
- args 必须是字符串
- 可以一次返回多个 JSON 指令
- 使用 continue 补充回答: {{"action": "continue", "content": "补充内容"}}"""
else:
prompt = f"""{base_prompt}
{tool_search_rule}
【工具调用格式】
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
【核心工具】
- code_editor: edit/search/diff/insert/create/write/read_range
- code_search: find/regex/symbols/imports/structure/stats
- git_tools: status/diff/log/add/commit/branch
- cmd_executor: 执行 shell 命令
- file_manager: 文件管理
【code_editor.edit 格式】
{{"action": "use_tool", "tool": "code_editor", "args": "edit 文件路径
旧代码内容
<<<>>>
新代码内容"}}
【continue 格式】
{{"action": "continue", "content": "要补充的内容"}}"""
return prompt
# ── JSON 解析 ──
def _clean_json_response(self, response):
"""清理AI响应中的Markdown代码块标记"""
if response.startswith("```json"):
response = response[7:]
elif response.startswith("```"):
response = response[3:]
if response.endswith("```"):
response = response[:-3]
return response.strip()
def get_liugin_usage_prompts(self):
"""获取插件和技能提示词"""
if not self.liugin_manager.tools:
return "当前没有可用的工具插件."
prompts = []
prompts.append("可用的工具列表:\n")
plugins = []
skills = []
for tool in self.liugin_manager.tools:
protocol = tool.get('protocol', 'liugin')
if protocol == 'skill':
skills.append(tool)
else:
plugins.append(tool)
if plugins:
prompts.append("## Plugin 工具(使用 tool_search 查询详细用法):")
for tool in plugins:
tool_name = tool.get('name', '')
tool_desc = tool.get('description', '')
prompts.append(f"- {tool_name}: {tool_desc}")
if skills:
prompts.append("\n## Agent Skills(已加载详细指令):")
for tool in skills:
tool_name = tool.get('name', '')
tool_desc = tool.get('description', '')
prompts.append(f"\n### {tool_name}")
prompts.append(f"描述: {tool_desc}")
instance = tool.get('instance')
if instance:
instructions = getattr(instance, 'instructions', '')
if instructions:
if len(instructions) > 2000:
instructions = instructions[:2000] + "\n...(内容已截断)"
prompts.append(f"\n{instructions}")
if self.is_clawli_mode:
prompts.append("\n## Clawli 远程模式专属工具:")
prompts.append("""
### send_image - 发送图片到手机端
当用户请求发送图片到手机时,使用此工具:
```json
{
"action": "use_tool",
"tool": "send_image",
"args": {
"image_path": "图片文件的绝对路径",
"caption": "图片说明文字(可选)"
}
}
```
支持的图片格式:JPG、PNG、GIF、BMP、WEBP
""")
return "\n".join(prompts)
def _parse_mixed_response(self, response):
"""
解析混合响应,处理同时包含文本和JSON指令的内容
返回格式: (text_content, json_data)
"""
response = response.strip()
# 方法0: 处理 <tool_call>... 格式
if response.startswith("<tool_call>"):
json_str = response[11:]
json_str = "{" + json_str
try:
parsed = json.loads(json_str)
if isinstance(parsed, dict):
return "", parsed
except json.JSONDecodeError:
pass
# 方法1: 直接解析为纯JSON
try:
parsed = json.loads(response)
if isinstance(parsed, (list, dict)):
return "", parsed
except json.JSONDecodeError:
pass
# 方法2: 修复JSON中的控制字符问题
try:
def fix_json_string(match):
content = match.group(1)
content = content.replace('\\', '\\\\')
content = content.replace('\n', '\\n')
content = content.replace('\r', '\\r')
content = content.replace('\t', '\\t')
content = content.replace('"', '\\"')
return f'"{content}"'
fixed_response = re.sub(r'"([^"]*(?:\n[^"]*)*)"', fix_json_string, response, flags=re.DOTALL)
parsed = json.loads(fixed_response)
if isinstance(parsed, (list, dict)):
return "", parsed
except:
pass
# 方法3: 查找代码块中的JSON
json_pattern = r"```(?:json)?\s*({.*?})\s*```"
matches = re.findall(json_pattern, response, re.DOTALL)
if matches:
try:
json_data = json.loads(matches[-1])
if isinstance(json_data, (dict, list)):
text_content = re.sub(json_pattern, "", response, flags=re.DOTALL).strip()
text_content = re.sub(r"\n\s*\n", "\n\n", text_content).strip()
return text_content, json_data
except json.JSONDecodeError:
pass
# 方法4: 查找每行独立的JSON对象
lines_resp = response.split("\n")
json_objects = []
text_lines = []
for line in lines_resp:
line = line.strip()
if line.startswith("{") and line.endswith("}"):
try:
parsed = json.loads(line)
if isinstance(parsed, dict):
json_objects.append(parsed)
continue
except json.JSONDecodeError:
pass
text_lines.append(line)
if json_objects:
text_content = "\n".join(text_lines).strip()
if len(json_objects) == 1:
return text_content, json_objects[0]
else:
return text_content, json_objects
# 方法5: 查找内联的JSON对象
json_start_markers = [
'{"action": "use_tool"', '{"action": "continue"',
'{"action":"use_tool"', '{"action":"continue"',
'{ "action": "use_tool"', '{ "action": "continue"',
'{ "action":"use_tool"', '{ "action":"continue"'
]
for marker in json_start_markers:
json_start = response.find(marker)
if json_start != -1:
brace_count = 0
in_json = False
for i in range(json_start, len(response)):
char = response[i]
if char == '{':
if not in_json:
in_json = True
brace_count += 1
elif char == '}':
brace_count -= 1
if in_json and brace_count == 0:
json_str = response[json_start:i+1]
try:
json_data = json.loads(json_str)
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
text_content = response[:json_start].strip()
return text_content, json_data
except json.JSONDecodeError:
pass
break
# 方法6: 通用JSON对象查找
potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL)
for json_str in potential_jsons:
try:
json_data = json.loads(json_str)
if isinstance(json_data, dict) and json_data.get('action') in ['use_tool', 'continue']:
json_start = response.find(json_str)
text_content = response[:json_start].strip()
return text_content, json_data
except json.JSONDecodeError:
continue
# 方法7: 批量JSON
try:
cleaned = response.strip()
for action in ['use_tool', 'continue']:
last_brace_pos = cleaned.rfind(f'{{"action": "{action}"')
if last_brace_pos == -1:
last_brace_pos = cleaned.rfind(f'{{"action":"{action}"')
if last_brace_pos != -1:
json_part = cleaned[last_brace_pos:]
if json_part.startswith('['):
parsed = json.loads(json_part)
if isinstance(parsed, list):
text_content = cleaned[:last_brace_pos].strip()
return text_content, parsed
else:
parsed = json.loads(json_part)
if isinstance(parsed, dict):
text_content = cleaned[:last_brace_pos].strip()
return text_content, parsed
except:
pass
return response, None
# ── 对话处理 ──
def process_conversation(self, user_input):
"""处理完整的对话循环, 支持工具调用循环和AI继续操作循环"""
self.shared_conversation_history.append({
"role": "user",
"content": user_input
})
liugin_prompts = self.get_liugin_usage_prompts()
loop_count = 0
current_input = user_input
max_loops = 20
while loop_count < max_loops:
response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts)
processed_response = self.process_thinking_response(response)
processed_response = self._process_code_blocks(processed_response)
try:
text_content, json_data = self._parse_mixed_response(processed_response)
if json_data:
if text_content:
text_content = self._process_code_blocks(text_content)
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content)
if isinstance(json_data, dict):
response_data = json_data
if response_data.get('action') == 'use_tool':
canceled = self._execute_single_tool(response_data)
if canceled:
break
break
if (response_data.get('continue') is True or
response_data.get('need_continue') is True or
response_data.get('think_more') is True):
continue_message = response_data.get('message', '')
if continue_message:
processed_response = continue_message
self.shared_conversation_history.append({
"role": "assistant",
"content": processed_response
})
self._display_response(processed_response, is_continue=True)
loop_count += 1
elif response_data.get('message'):
message = response_data.get('message')
if text_content:
text_content = self._process_code_blocks(text_content)
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content)
self.shared_conversation_history.append({
"role": "assistant",
"content": message
})
self._display_response(message)
break
else:
if text_content:
text_content = self._process_code_blocks(text_content)
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content)
else:
self.shared_conversation_history.append({
"role": "assistant",
"content": processed_response
})
self._display_response(processed_response)
break
elif isinstance(json_data, list):
tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool']
if tool_calls:
self._execute_concurrent_tools(tool_calls)
break
continue_items = [item for item in json_data if isinstance(item, dict) and
(item.get('continue') is True or
item.get('need_continue') is True or
item.get('think_more') is True)]
if continue_items:
continue_message = continue_items[0].get('message', '')
if continue_message:
processed_response = continue_message
self.shared_conversation_history.append({
"role": "assistant",
"content": processed_response
})
self._display_response(processed_response, is_continue=True)
loop_count += 1
else:
if text_content:
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content or processed_response)
break
else:
display_content = text_content if text_content else processed_response
self.shared_conversation_history.append({
"role": "assistant",
"content": display_content
})
self._display_response(display_content)
break
except json.JSONDecodeError:
text_content, json_data = self._parse_mixed_response(processed_response)
if json_data:
if text_content:
text_content = self._process_code_blocks(text_content)
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content)
if isinstance(json_data, dict):
response_data = json_data
if response_data.get('action') == 'continue':
continue_content = response_data.get('content', '')
if continue_content:
self.shared_conversation_history.append({
"role": "assistant",
"content": continue_content
})
self._display_response(continue_content)
continue
if response_data.get('action') == 'use_tool':
canceled = self._execute_single_tool(response_data)
if canceled:
break
break
if (response_data.get('continue') is True or
response_data.get('need_continue') is True or
response_data.get('think_more') is True):
continue_message = response_data.get('message', '')
if continue_message:
processed_response = continue_message
self.shared_conversation_history.append({
"role": "assistant",
"content": processed_response
})
self._display_response(processed_response, is_continue=True)
loop_count += 1
else:
message = response_data.get('message', '')
if message:
self.shared_conversation_history.append({
"role": "assistant",
"content": message
})
self._display_response(message)
loop_count += 1
else:
if text_content:
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content or processed_response)
break
elif isinstance(json_data, list):
tool_calls = [item for item in json_data if isinstance(item, dict) and item.get('action') == 'use_tool']
if tool_calls:
self._execute_concurrent_tools(tool_calls)
break
continue_items = [item for item in json_data if isinstance(item, dict) and
(item.get('continue') is True or
item.get('need_continue') is True or
item.get('think_more') is True)]
if continue_items:
continue_message = continue_items[0].get('message', '')
if continue_message:
processed_response = continue_message
self.shared_conversation_history.append({
"role": "assistant",
"content": processed_response
})
self._display_response(processed_response, is_continue=True)
loop_count += 1
else:
if text_content:
self.shared_conversation_history.append({
"role": "assistant",
"content": text_content
})
self._display_response(text_content or processed_response)
break
else:
display_content = text_content if text_content else processed_response
self.shared_conversation_history.append({
"role": "assistant",
"content": display_content
})
self._display_response(display_content)
break
else:
self._output(f"AI 已达到最大循环次数 ({max_loops}),已停止自动处理")
self.shared_conversation_history.append({
"role": "assistant",
"content": f"已达到最大处理次数 ({max_loops}),已自动停止。如需继续处理,请重新输入。"
})
def _generate_response_with_animation(self, current_input, liugin_prompts=None):
"""生成AI响应并显示等待动画"""
# TUI 模式:不做动画
if self.tui_output_callback:
system_prompt = self._build_system_prompt(liugin_prompts)
if not self.current_engine:
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt)
if response and self.shared_conversation_history is not None:
self.shared_conversation_history.append({
"role": "assistant",
"content": response
})
return response
# CLI 模式:带动画和 ESC 检测
animation_running = True
esc_pressed = threading.Event()
love_sentences = []
love_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), LOVE_FILE_PATH)
if os.path.exists(love_file_path):
try:
with open(love_file_path, 'r', encoding='utf-8') as f:
love_sentences = [line.strip() for line in f.readlines() if line.strip()]
except Exception as e:
print(f"读取love.txt文件时出错: {e}")
if not love_sentences:
love_sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."]
def check_for_esc():
try:
import msvcrt
while animation_running:
if msvcrt.kbhit():
key = msvcrt.getch()
if ord(key) == 27:
esc_pressed.set()
break
time.sleep(0.1)
except ImportError:
pass
def show_animation():
last_change_time = time.time()
current_sentence = random.choice(love_sentences)
while animation_running:
current_time = time.time()
if current_time - last_change_time >= 5:
current_sentence = random.choice(love_sentences)
last_change_time = current_time
animation_chars = "|/-\\"
char_idx = int((current_time * 10) % len(animation_chars))
display_sentence = current_sentence[:47] + "..." if len(current_sentence) > 50 else current_sentence
print(f"\r{Fore.MAGENTA}{display_sentence} {animation_chars[char_idx]}{Style.RESET_ALL}", end="", flush=True)
time.sleep(0.1)
animation_thread = threading.Thread(target=show_animation)
animation_thread.daemon = True
animation_thread.start()
esc_thread = threading.Thread(target=check_for_esc)
esc_thread.daemon = True
esc_thread.start()
try:
system_prompt = self._build_system_prompt(liugin_prompts)
if not self.current_engine:
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt)
if response:
self._typeprint(response, Fore.CYAN)
if esc_pressed.is_set():
print(f"\n{Fore.YELLOW}[AI请求已取消]{Style.RESET_ALL}")
return "[AI请求已取消]"
if response and self.shared_conversation_history is not None:
self.shared_conversation_history.append({
"role": "assistant",
"content": response
})
return response
finally:
animation_running = False
animation_thread.join()
esc_thread.join()
print("\r" + " " * 50 + "\r", end="", flush=True)
# ── CLI 主循环 ──
def run_tui(self):
"""运行 TUI 模式"""
if not TEXTUAL_AVAILABLE:
print(f"{Fore.RED}TUI 模式不可用:Textual 库未安装{Style.RESET_ALL}")
print(f"{Fore.YELLOW}请运行: pip install textual rich{Style.RESET_ALL}")
return
from .tui import XiaoliTUI
app = XiaoliTUI(self)
app.run()
def run(self):
"""运行 CLI"""
import uuid
self.user_id = str(uuid.getnode())
print(f"{Fore.GREEN}小狸 Pro-CLI 已启动!{Style.RESET_ALL}")
print(f"{Fore.GREEN}用户ID: {self.user_id}{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/file.read <文件名> [行数]' 直接读取文件内容{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '@文件路径' 自动读取文件内容并发送给AI{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '@图片路径' 自动分析图片并发送描述给AI{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/image engines' 查看图像识别引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat save <名称>' 保存当前聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}")
current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置'
print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}")
loaded_engines = list(self.engines.keys())
if len(loaded_engines) > 1:
print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}")
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
self._run_cli_loop()
def _run_cli_loop(self):
"""运行 CLI 主循环"""
while True:
try:
user_input = input(f"\n{Fore.WHITE}> {Style.RESET_ALL}").strip()
if user_input == '/quit':
print(f"{Fore.GREEN}再见!{Style.RESET_ALL}")
break
if user_input == '/help':
self.show_help()
continue
if user_input.startswith('/help '):
liugin_name = user_input[6:].strip()
self.show_liugin_help(liugin_name)
continue
if user_input.startswith('/engine '):
engine_command = user_input[8:].strip()
parts = engine_command.split(' ', 1)
cmd = parts[0]
args = parts[1] if len(parts) > 1 else ''
if cmd in self.engine_commands:
self.engine_commands[cmd](args)
else:
available_commands = ', '.join(self.engine_commands.keys())
print(f"{Fore.RED}未知的引擎命令.可用命令: {available_commands}{Style.RESET_ALL}")
continue
if user_input.startswith('/engine.'):
engine_command = user_input[8:].strip()
if not self.handle_engine_command(engine_command):
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
continue
if user_input.startswith('/file.read '):
args = user_input[11:].strip()
parts = args.split(' ', 2)
if len(parts) < 1:
print(f"{Fore.RED}请提供文件名.用法: /file.read <文件名> [行数]{Style.RESET_ALL}")
continue
filename = parts[0]
max_lines = None
if len(parts) >= 2:
try:
max_lines = int(parts[1])
if max_lines <= 0:
print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}")
continue
except ValueError:
print(f"{Fore.RED}行数必须是正整数{Style.RESET_ALL}")
continue
result = self._read_file_direct(filename, max_lines)
print(f"{Fore.GREEN}{result}{Style.RESET_ALL}")
continue
if user_input.startswith('/chat '):
chat_command = user_input[6:].strip()
if chat_command.startswith('save '):
name = chat_command[5:].strip()
if name:
self.save_chat_history(name)
else:
print(f"{Fore.RED}请提供聊天记录名称.用法: /chat save <名称>{Style.RESET_ALL}")
continue
elif chat_command == 'list':
self.list_chat_history()
continue
elif chat_command.startswith('open '):
name = chat_command[5:].strip()
if name:
self.load_chat_history(name)
else:
print(f"{Fore.RED}请提供聊天记录名称.用法: /chat open <名称>{Style.RESET_ALL}")
continue
else:
print(f"{Fore.RED}未知的聊天命令.可用命令: save, list, open{Style.RESET_ALL}")
continue
if user_input.startswith('/remote'):
remote_command = user_input[7:].strip()
self._handle_remote_command(remote_command)
continue
if user_input == '/about':
self._read_about_file()
continue
if user_input == '/tui':
print(f"{Fore.CYAN}正在切换到 TUI 模式...{Style.RESET_ALL}")
self.run_tui()
print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}")
continue
if user_input.startswith('/'):
command_parts = user_input[1:].split(' ', 1)
command = command_parts[0]
args = command_parts[1] if len(command_parts) > 1 else ""
if command in self.liugin_commands:
try:
result = self.liugin_commands[command](args)
if result:
result = self._limit_output_lines(result, max_lines=5)
print(f"{Fore.GREEN}{result}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}插件命令执行失败: {e}{Style.RESET_ALL}")
continue
if user_input.startswith('/thinking '):
thinking_input = user_input[10:].strip()
processed_response = self.process_thinking_response(thinking_input)
processed_response = self._process_code_blocks(processed_response)
user_id_display = f"[用户ID: {self.user_id}]"
response_lines = processed_response.split('\n')
for i, line in enumerate(response_lines):
if i == 0:
line = "✦ " + line
if i == len(response_lines) - 1:
print(f"{line} {user_id_display}")
else:
print(f"{line}")
continue
if '@' in user_input:
processed_input = self._process_file_paths(user_input)
if processed_input != user_input:
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
user_input = processed_input
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
self.process_conversation(user_input)
except KeyboardInterrupt:
user_id_display = f"[用户ID: {self.user_id}]"
print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}")
break
except Exception as e:
user_id_display = f"[用户ID: {self.user_id}]"
print(f"{Fore.RED}发生错误: {e} {user_id_display}{Style.RESET_ALL}")
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
"""主入口函数"""
import time
import sys
art_lines = [
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"███████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████",
"██████████ ▓████████████████",
"██████████ ▒███████████████",
"██████████ ░██████████████",
"██████████ ░█████████████",
"██████████ ████████████",
"██████████ ███████████",
"██████████ ▒████████████████████████████████████████████████████",
"██████████ ░██████████████████████████████████████████████████████",
"██████████ ████████████████████████████████████████████████████████",
"██████████ ▓█████████████████████████████████████████████████████████",
"██████████ ▒███████████████████████████████████████████████████████████",
"██████████ ███████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████",
"██████████ ███████████▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒██████████",
"██████████ ▓██████████▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████",
"██████████ ░███████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒██████████",
"██████████ ███████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▓▒██████████",
"██████████ ███████████▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▓██████████",
"██████████ ▓██████████▓▒▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▓██████████",
"██████████ ░██████████▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▓██████████",
"██████████ ███████████▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▒▒▒▒▓▒▓▒▓██████████",
"██████████ ███████████▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▓▒▒▒▓▓██████████",
"██████████ ░██████████▒▓▒▓▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████",
"██████████ █████████▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒██████████",
"██████████ █████████▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████",
"██████████ ▓████████▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▓▒▒▒██████████",
"██████████ █████████▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒██████████",
"██████████ █████████▒▓▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒▒▒██████████",
"██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒██████████",
"██████████ █████████▒▓▒▒▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▓▒▒▒▒▒▒▒██████████",
"██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▒▓▒▒▒▒▒▓▒██████████",
"██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▒▒▒▒▓██████████",
"██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ █████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████",
"██████████ █████████▒▓▒▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ █████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▓▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ █████████▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"██████████ ▓████████▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"███████████ █████████▒▓▒▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"█████████████▓ █████████▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"█████████████████ ▓████████▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"███████████████████▒ █████████▒▒▓▒▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▒▒▒▓▒▒▓▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▒▒▒██████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
"█████████████████████████████████████████████████████████████████████████████████████████████████████",
]
if sys.platform == 'win32':
import codecs
sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict')
for line in art_lines:
try:
print(line, flush=True)
except UnicodeEncodeError:
print(line.encode('gbk', errors='ignore').decode('gbk'), flush=True)
time.sleep(0.05)
cli = AICLI()
cli.run()
+625
View File
@@ -0,0 +1,625 @@
import os
import sys
import shutil
import time
import subprocess
import platform
import re
from colorama import Fore, Style
from .constants import MAX_FILE_SIZE, VIDEO_FRAME_DELAY
from .config import logger
class DisplayMixin:
"""显示、输出、图片、帮助相关方法"""
def _process_code_blocks(self, text):
"""处理包含""" """语法的文本,将代码块部分显示为红色"""
pattern = r'(\"\"\".*?\"\"\")'
def replace_match(match):
full_match = match.group(1)
inner_content = full_match[3:-3]
return f'{Fore.RED}{inner_content}{Style.RESET_ALL}'
result = re.sub(pattern, replace_match, text, flags=re.DOTALL)
return result
def _limit_output_lines(self, text, max_lines=2):
"""限制输出行数为指定的最大行数"""
lines = text.split('\n')
if len(lines) <= max_lines:
return text
return '\n'.join(lines[:max_lines]) + f"\n... (已截断,共{len(lines)}行)"
def _output(self, message):
"""统一输出方法,支持 TUI 和 CLI 模式"""
if self.tui_output_callback:
self.tui_output_callback(message)
else:
print(message)
def _display_response(self, content, is_continue=False):
"""统一显示 AI 响应,支持 TUI 和 CLI 模式"""
user_id_display = f"[用户ID: {self.user_id}]"
response_lines = content.split('\n')
if self.tui_output_callback:
for i, line in enumerate(response_lines):
if i == 0:
prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] "
line = prefix + line
if i == len(response_lines) - 1:
self._output(f"{line} {user_id_display}")
else:
self._output(line)
self._output("")
else:
for i, line in enumerate(response_lines):
if i == 0:
prefix = f"{Fore.YELLOW}✦{Style.RESET_ALL} " if is_continue else "✦ "
line = prefix + line
if i == len(response_lines) - 1:
print(f"{line} {user_id_display}")
else:
print(f"{line}")
print()
def _display_tool_result(self, tool_name, tool_args, full_result, current_time):
"""显示工具执行结果"""
if full_result is None:
full_result = "无结果"
self._output("")
user_id_display = f"[用户ID: {self.user_id}]"
is_error = full_result.startswith("错误:") or full_result.startswith("错误:") or full_result.startswith("工具执行错误")
if self.tui_output_callback:
if is_error:
self._output(f"[red] X 工具: {tool_name}[/]")
self._output(f"[red] 参数: {tool_args} {user_id_display}[/]")
else:
self._output(f"[green] OK 工具: {tool_name}[/]")
self._output(f"[green] 参数: {tool_args} {user_id_display}[/]")
else:
if is_error:
self._output(f"{Fore.RED} X 工具: {tool_name}{Style.RESET_ALL}")
self._output(f"{Fore.RED} 参数: {tool_args} {user_id_display}{Style.RESET_ALL}")
else:
self._output(f"{Fore.GREEN} OK 工具: {tool_name}{Style.RESET_ALL}")
self._output(f"{Fore.GREEN} 参数: {tool_args} {user_id_display}{Style.RESET_ALL}")
display_result = self._limit_output_lines(full_result, max_lines=2)
color_tag = "red" if is_error else "green"
for line in display_result.split('\n'):
if self.tui_output_callback:
self._output(f"[{color_tag}] {line}[/]")
else:
color = Fore.RED if is_error else Fore.GREEN
self._output(f"{color} {line}{Style.RESET_ALL}")
def _typeprint(self, text, color=None, delay=0.01):
"""逐字输出文本,实现打字机效果"""
for char in text:
if color:
print(f"{color}{char}{Style.RESET_ALL}", end="", flush=True)
else:
print(char, end="", flush=True)
time.sleep(delay)
print()
# ── 图片显示 ──
def display_image_in_terminal(self, image_path, width=None):
"""在终端中显示图片"""
try:
if not os.path.exists(image_path):
print(f"{Fore.RED}错误: 图片文件不存在: {image_path}{Style.RESET_ALL}")
return False
valid_extensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
if not os.path.splitext(image_path)[1].lower() in valid_extensions:
print(f"{Fore.RED}错误: 不支持的图片格式。支持的格式: {', '.join(valid_extensions)}{Style.RESET_ALL}")
return False
try:
from PIL import Image
with Image.open(image_path) as img:
img_size = img.size
img_format = img.format
file_size = os.path.getsize(image_path)
print(f"\n{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}")
print(f"{Fore.CYAN}图片预览: {os.path.basename(image_path)}{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}")
print(f"{Fore.GREEN} 格式: {img_format}{Style.RESET_ALL}")
print(f"{Fore.GREEN} 尺寸: {img_size[0]} x {img_size[1]} 像素{Style.RESET_ALL}")
print(f"{Fore.GREEN} 大小: {file_size / 1024:.2f} KB{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'=' * 60}{Style.RESET_ALL}")
except ImportError:
print(f"\n{Fore.CYAN}图片预览: {os.path.basename(image_path)}{Style.RESET_ALL}")
# 方法1: 使用色块显示
try:
from PIL import Image
terminal_size = shutil.get_terminal_size()
terminal_width = terminal_size.columns
terminal_height = terminal_size.lines - 5
if width is None:
width = min(250, terminal_width)
with Image.open(image_path) as img:
img_width, img_height = img.size
pixel_width = width * 2
pixel_height = (terminal_height - 2) * 2
scale = min(pixel_width / img_width, pixel_height / img_height)
scale = min(scale * 1.1, 1.0)
new_width = max(int(img_width * scale), 80)
new_height = max(int(img_height * scale), 40)
img = img.resize((new_width, new_height), Image.LANCZOS)
if img.mode != 'RGB':
img = img.convert('RGB')
pixels = img.load()
print(f"{Fore.YELLOW}正在生成色块图片 ({new_width}x{new_height} 像素)...{Style.RESET_ALL}")
block_chars = {
(False, False, False, False): ' ',
(True, False, False, False): '▘',
(False, True, False, False): '▝',
(True, True, False, False): '▀',
(False, False, True, False): '▖',
(True, False, True, False): '▌',
(False, True, True, False): '▞',
(True, True, True, False): '▛',
(False, False, False, True): '▗',
(True, False, False, True): '▚',
(False, True, False, True): '▐',
(True, True, False, True): '▜',
(False, False, True, True): '▄',
(True, False, True, True): '▙',
(False, True, True, True): '▟',
(True, True, True, True): '█',
}
for y in range(0, new_height, 2):
line = []
for x in range(0, new_width, 2):
r1, g1, b1 = pixels[x, y] if y < new_height and x < new_width else (0, 0, 0)
r2, g2, b2 = pixels[x + 1, y] if y < new_height and x + 1 < new_width else (0, 0, 0)
r3, g3, b3 = pixels[x, y + 1] if y + 1 < new_height and x < new_width else (0, 0, 0)
r4, g4, b4 = pixels[x + 1, y + 1] if y + 1 < new_height and x + 1 < new_width else (0, 0, 0)
has_tl = y < new_height and x < new_width
has_tr = y < new_height and x + 1 < new_width
has_bl = y + 1 < new_height and x < new_width
has_br = y + 1 < new_height and x + 1 < new_width
all_pixels = [(r1, g1, b1), (r2, g2, b2), (r3, g3, b3), (r4, g4, b4)]
avg_r = sum(p[0] for p in all_pixels) // 4
avg_g = sum(p[1] for p in all_pixels) // 4
avg_b = sum(p[2] for p in all_pixels) // 4
char = block_chars.get((has_tl, has_tr, has_bl, has_br), ' ')
fg_color = f"\033[38;2;{avg_r};{avg_g};{avg_b}m"
reset = "\033[0m"
line.append(f"{fg_color}{char}{reset}")
print(''.join(line))
print(f"{Fore.GREEN}✓ 已使用色块显示 ({new_width}x{new_height} 像素){Style.RESET_ALL}\n")
return True
except ImportError:
print(f"{Fore.YELLOW}未安装 ascii-magic 库,尝试使用系统默认图片查看器...{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW}ASCII 艺术显示失败: {e},尝试使用系统默认图片查看器...{Style.RESET_ALL}")
# 方法2: 使用系统默认图片查看器
system = platform.system()
if system == 'Windows':
os.startfile(image_path)
elif system == 'Darwin':
subprocess.run(['open', image_path])
else:
subprocess.run(['xdg-open', image_path])
print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n")
return True
except Exception as e:
print(f"{Fore.RED}显示图片失败: {str(e)}{Style.RESET_ALL}")
return False
# ── 文件读取 ──
def _read_file_direct(self, filename, max_lines=None):
"""直接读取文件内容, 支持指定行数"""
try:
if not os.path.exists(filename):
return f"错误:文件 '{filename}' 不存在。"
if not os.path.isfile(filename):
return f"错误:'{filename}' 不是一个文件。"
with open(filename, 'r', encoding='utf-8') as f:
if max_lines is not None:
lines = []
for i, line in enumerate(f):
if i >= max_lines:
break
lines.append(line.rstrip('\n'))
content = '\n'.join(lines)
info = f"(文件共{len(lines)}行)" if len(lines) < max_lines else f"(已读取前{max_lines}行)"
return f"文件 '{filename}' 的内容 {info}:\n{content}"
else:
content = f.read()
MAX_CONTENT_LENGTH = 10000
if len(content) > MAX_CONTENT_LENGTH:
content = content[:10000] + f"\n... (内容已截断, 共{len(content)}字符)"
return f"文件 '{filename}' 的内容:\n{content}"
except UnicodeDecodeError:
file_size = os.path.getsize(filename)
return f"文件 '{filename}' 是二进制文件, 大小: {file_size} 字节。无法直接读取文本内容。"
except PermissionError as e:
logger.error(f"权限错误,无法读取文件 '{filename}': {e}")
return f"权限错误:无法读取文件 '{filename}',可能需要管理员权限或文件被占用。"
except OSError as e:
logger.error(f"操作系统错误,无法读取文件 '{filename}': {e}")
return f"操作系统错误:无法读取文件 '{filename}',原因: {str(e)}"
except Exception as e:
logger.error(f"读取文件 '{filename}' 失败: {e}")
return f"读取文件 '{filename}' 失败: {str(e)}"
def _process_file_paths(self, user_input):
"""处理用户输入中的 @文件路径 语法"""
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
pattern = r'@"([^"]+)"|@([^\s]+)'
matches = list(re.finditer(pattern, user_input))
if not matches:
return user_input
result_parts = []
last_pos = 0
for match in matches:
if match.start() > last_pos:
result_parts.append(user_input[last_pos:match.start()])
file_path = match.group(1) or match.group(2)
if not file_path:
continue
file_path = os.path.normpath(file_path)
try:
if os.path.exists(file_path):
if os.path.isdir(file_path):
print(f"{Fore.CYAN}检测到目录: {file_path}{Style.RESET_ALL}")
try:
entries = os.listdir(file_path)
files = [e for e in entries if os.path.isfile(os.path.join(file_path, e))]
if not files:
result_parts.append(f"[目录: {file_path} 为空]")
else:
file_list_text = f"\n[目录: {file_path} - 共 {len(files)} 个文件]\n"
file_list_text += "=" * 50 + "\n"
for i, filename in enumerate(sorted(files), 1):
full_path = os.path.join(file_path, filename)
file_size = os.path.getsize(full_path)
file_list_text += f"{i}. {filename} ({file_size} 字节)\n"
file_list_text += "=" * 50 + "\n"
result_parts.append(file_list_text)
except PermissionError:
result_parts.append(f"[错误: 无权限访问目录 {file_path}]")
except Exception as e:
result_parts.append(f"[错误: 无法读取目录 {file_path} - {str(e)}]")
else:
file_ext = os.path.splitext(file_path)[1].lower()
is_image = file_ext in image_extensions
if is_image:
print(f"{Fore.CYAN}检测到图片文件: {file_path}{Style.RESET_ALL}")
try:
self.display_image_in_terminal(file_path)
except Exception as e:
print(f"{Fore.YELLOW}图片预览失败: {e}{Style.RESET_ALL}")
image_plugin = None
for tool in self.liugin_manager.tools:
tool_name = tool.get('name', '').lower()
tool_desc = tool.get('description', '').lower()
if 'image' in tool_name or 'analyze' in tool_name or '图片' in tool_desc:
image_plugin = tool
break
if image_plugin:
try:
print(f"{Fore.YELLOW}正在分析图片...{Style.RESET_ALL}")
image_result = image_plugin['handler'](f"analyze {file_path}")
if image_result:
result_parts.append(f" {image_result}")
else:
result_parts.append(f" [图片: {file_path}]")
except Exception as e:
result_parts.append(f"[错误: 图片分析失败 - {str(e)}]")
else:
result_parts.append(f"[错误: 图像分析插件未加载]")
else:
file_size = os.path.getsize(file_path)
if file_size > MAX_FILE_SIZE:
result_parts.append(f"[错误: 文件 {file_path} 超过大小限制 ({MAX_FILE_SIZE} 字节)]")
else:
try:
with open(file_path, 'r', encoding='utf-8') as f:
file_content = f.read()
result_parts.append(f"\n[文件: {file_path}]\n{file_content}\n")
except UnicodeDecodeError:
try:
with open(file_path, 'r', encoding='gbk') as f:
file_content = f.read()
result_parts.append(f"\n[文件: {file_path}]\n{file_content}\n")
except Exception as e:
result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]")
except Exception as e:
result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]")
else:
result_parts.append(f"[错误: 文件 {file_path} 不存在]")
last_pos = match.end()
except Exception as e:
result_parts.append(f"[错误: 处理文件路径时出错 - {str(e)}]")
last_pos = match.end()
if last_pos < len(user_input):
result_parts.append(user_input[last_pos:])
return ''.join(result_parts)
# ── 帮助系统 ──
def show_help(self):
"""显示帮助信息"""
help_file_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "HELP.md")
if os.path.exists(help_file_path):
try:
with open(help_file_path, 'r', encoding='utf-8') as f:
help_content = f.read()
print(help_content)
except Exception as e:
print(f"读取帮助文件失败: {e}")
self._show_basic_help()
else:
self._show_basic_help()
def show_liugin_help(self, liugin_name):
"""显示特定插件的帮助信息"""
if liugin_name in self.engines:
engine = self.engines[liugin_name]
print(f"{Fore.GREEN}AI引擎 '{liugin_name}' 的帮助信息:{Style.RESET_ALL}")
engine_name = getattr(engine, 'name', liugin_name)
print(f"{Fore.GREEN}引擎名称: {engine_name}{Style.RESET_ALL}")
if hasattr(engine, 'get_help_info'):
help_info = engine.get_help_info()
if isinstance(help_info, str):
print(f"{Fore.GREEN}帮助信息:{Style.RESET_ALL}")
print(help_info)
elif isinstance(help_info, dict):
for key, value in help_info.items():
print(f"{Fore.GREEN}{key}: {value}{Style.RESET_ALL}")
else:
print(f"{Fore.GREEN}可用命令:{Style.RESET_ALL}")
print(f" /engine.{liugin_name} <命令> - 执行{liugin_name}引擎的特定命令")
return
for tool in self.liugin_manager.tools:
if tool.get('name') == liugin_name:
print(f"{Fore.GREEN}插件 '{liugin_name}' 的帮助信息:{Style.RESET_ALL}")
description = tool.get('description', '无描述')
print(f"{Fore.GREEN}描述: {description}{Style.RESET_ALL}")
if 'usage' in tool:
print(f"{Fore.GREEN}用法:{Style.RESET_ALL}")
print(f" {tool['usage']}")
if 'keywords' in tool:
keywords = tool['keywords']
if isinstance(keywords, list):
print(f"{Fore.GREEN}关键词: {', '.join(keywords)}{Style.RESET_ALL}")
else:
print(f"{Fore.GREEN}关键词: {keywords}{Style.RESET_ALL}")
return
print(f"{Fore.RED}未找到插件或引擎 '{liugin_name}'{Style.RESET_ALL}")
print(f"{Fore.YELLOW}可用AI引擎:{Style.RESET_ALL}")
for engine_name in self.engines.keys():
print(f"{Fore.YELLOW} - {engine_name}{Style.RESET_ALL}")
if self.liugin_manager.tools:
print(f"{Fore.YELLOW}可用插件:{Style.RESET_ALL}")
for tool in self.liugin_manager.tools:
tool_name = tool.get('name', '未知插件')
tool_description = tool.get('description', '无描述')
print(f"{Fore.YELLOW} - {tool_name}: {tool_description}{Style.RESET_ALL}")
def _show_basic_help(self):
"""显示基本帮助信息"""
help_text = """
小狸 Pro-CLI 帮助信息
==================
基本命令:
/help - 显示此帮助信息
/help <插件名> - 显示特定插件的帮助信息
/quit - 退出程序
/tui - 切换到 TUI 模式
/model <引擎名> - 切换AI引擎 (当前支持: """
available_engines = list(self.engines.keys())
help_text += ", ".join(available_engines) + ")"
help_text += r"""
文件操作命令:
/file.read <文件名> [行数] - 直接读取文件内容
@文件路径 - 发送时自动读取文件内容并拼接
图像分析命令:
/image analyze <图片路径> - 详细分析图片
/image ocr <图片路径> - OCR文字识别
/image quick <图片路径> - 快速描述
引擎管理命令:
""" + self._get_engine_commands_help() + """
/engine.<命令> - 执行引擎特定命令
聊天记录命令:
/chat save <名称> - 保存当前聊天记录
/chat list - 查看所有已保存的聊天记录
/chat open <名称> - 加载聊天记录
插件工具:
"""
if self.liugin_manager.tools:
for tool in self.liugin_manager.tools:
tool_name = tool.get('name', '未知工具')
tool_description = tool.get('description', '无描述')
help_text += f" {tool_name}: {tool_description}\n"
if 'usage' in tool:
help_text += f" 用法: {tool['usage']}\n"
else:
help_text += " 当前没有可用的工具插件\n"
print(help_text)
def _read_about_file(self):
"""读取并返回about.txt文件的内容,逐字显示"""
try:
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
about_file_path = os.path.join(project_dir, "about.txt")
if not os.path.exists(about_file_path):
return "错误:about.txt 文件不存在。"
with open(about_file_path, 'r', encoding='utf-8') as f:
content = f.read()
for char in content:
sys.stdout.write(char)
sys.stdout.flush()
if char in [',', '。', '!', '?', ',', '.', '!', '?', '\n', '\r']:
time.sleep(0.1)
else:
time.sleep(VIDEO_FRAME_DELAY)
print()
return ""
except UnicodeDecodeError:
return "错误:about.txt 文件编码格式不支持。"
except Exception as e:
return f"读取 about.txt 文件时发生错误: {str(e)}"
# ── 视频播放 ──
def start_video_player(self):
"""启动视频播放窗口"""
import threading
video_thread = threading.Thread(target=self._show_video_window, daemon=True)
video_thread.start()
def _show_video_window(self):
"""显示视频播放窗口"""
try:
import tkinter as tk
import cv2
import numpy as np
root = tk.Tk()
root.overrideredirect(True)
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
video_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "Video_1765688252672.mp4")
if not os.path.exists(video_path):
error_width, error_height = 400, 100
x = (screen_width // 2) - (error_width // 2)
y = (screen_height // 2) - (error_height // 2)
root.geometry(f"{error_width}x{error_height}+{x}+{y}")
canvas = tk.Canvas(root, width=error_width, height=error_height)
canvas.pack(fill=tk.BOTH, expand=True)
canvas.create_text(error_width//2, error_height//2, text=f"视频文件不存在:\n{video_path}", fill="red", font=("Arial", 12))
root.mainloop()
return
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
error_width, error_height = 400, 100
x = (screen_width // 2) - (error_width // 2)
y = (screen_height // 2) - (error_height // 2)
root.geometry(f"{error_width}x{error_height}+{x}+{y}")
canvas = tk.Canvas(root, width=error_width, height=error_height)
canvas.pack(fill=tk.BOTH, expand=True)
canvas.create_text(error_width//2, error_height//2, text="无法打开视频文件", fill="red", font=("Arial", 12))
root.mainloop()
return
video_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
video_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = int(cap.get(cv2.CAP_PROP_FPS))
max_width = int(screen_width * 0.4)
max_height = int(screen_height * 0.4)
scale = min(max_width / video_width, max_height / video_height)
window_width = int(video_width * scale)
window_height = int(video_height * scale)
x = (screen_width // 2) - (window_width // 2)
y = (screen_height // 2) - (window_height // 2)
root.geometry(f"{window_width}x{window_height}+{x}+{y}")
root.wm_attributes("-transparentcolor", "black")
canvas = tk.Canvas(root, width=window_width, height=window_height, highlightthickness=0, bg='black')
canvas.pack(fill=tk.BOTH, expand=True)
def update_frame():
nonlocal cap
try:
ret, frame = cap.read()
if ret:
frame = cv2.resize(frame, (window_width, window_height), interpolation=cv2.INTER_CUBIC)
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
lower_black = np.array([0, 0, 0])
upper_black = np.array([30, 30, 30])
mask = cv2.inRange(frame_rgb, lower_black, upper_black)
frame_rgb[mask > 0] = [0, 0, 0]
img = tk.PhotoImage(data=cv2.imencode('.ppm', frame_rgb)[1].tobytes())
canvas.delete("all")
canvas.create_image(0, 0, anchor=tk.NW, image=img)
canvas.image = img
delay = int(1000 / fps) if fps > 0 else 33
root.after(delay, update_frame)
else:
cap.release()
self._fade_out(root)
except tk.TclError:
cap.release()
try:
root.destroy()
except:
pass
except Exception as e:
print(f"更新视频帧时出错: {e}")
cap.release()
try:
root.destroy()
except:
pass
update_frame()
root.mainloop()
except Exception as e:
print(f"播放视频时出错: {e}")
try:
if 'cap' in locals() and cap.isOpened():
cap.release()
except:
pass
try:
if 'root' in locals():
root.after(100, root.destroy)
except:
pass
def _fade_out(self, root, duration=1000):
"""实现窗口渐隐效果"""
try:
alpha = float(root.wm_attributes("-alpha"))
except:
alpha = 1.0
alpha -= 0.1
if alpha <= 0:
try:
root.destroy()
except:
pass
else:
root.wm_attributes("-alpha", alpha)
root.after(50, self._fade_out, root, duration)
+60
View File
@@ -0,0 +1,60 @@
import os
import time
import json
from colorama import Fore, Style
class HistoryMixin:
"""聊天记录管理相关方法"""
def save_chat_history(self, name):
"""保存当前聊天记录到文件"""
try:
chat_data = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"conversation": self.shared_conversation_history.copy()
}
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(chat_data, f, ensure_ascii=False, indent=2, default=str)
print(f"{Fore.GREEN}聊天记录已保存为: {name}{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}保存聊天记录失败: {e}{Style.RESET_ALL}")
return False
def list_chat_history(self):
"""列出所有已保存的聊天记录"""
try:
chat_files = [f for f in os.listdir(self.chat_history_dir) if f.endswith('.json')]
if not chat_files:
print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}")
for i, file in enumerate(chat_files, 1):
file_path = os.path.join(self.chat_history_dir, file)
mod_time = os.path.getmtime(file_path)
mod_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mod_time))
chat_name = file[:-5]
print(f"{Fore.GREEN}{i}. {chat_name} (保存时间: {mod_time_str}){Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}列出聊天记录失败: {e}{Style.RESET_ALL}")
def load_chat_history(self, name):
"""加载指定的聊天记录"""
try:
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
if not os.path.exists(file_path):
print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}")
return False
with open(file_path, 'r', encoding='utf-8') as f:
chat_data = json.load(f)
self.shared_conversation_history = chat_data.get("conversation", [])
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}")
if self.shared_conversation_history:
print(f"{Fore.CYAN}加载了 {len(self.shared_conversation_history)} 条对话记录{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
return False
+277
View File
@@ -0,0 +1,277 @@
import sys
import time
import threading
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from colorama import Fore, Style
from .constants import UNIFIED_TOOL_MANAGER_AVAILABLE
class ToolMixin:
"""工具调用相关方法"""
def _extract_tool_calls(self, json_data) -> list:
"""从 JSON 数据中提取工具调用列表(支持多种格式)"""
tool_calls = []
if isinstance(json_data, dict):
# 格式1: 小狸自有格式
if json_data.get('action') == 'use_tool':
tool_calls.append(json_data)
# 格式2: 简化格式
elif 'tool' in json_data and 'args' in json_data:
tool_calls.append(json_data)
# 格式3: OpenAI function_call 格式
elif 'function_call' in json_data:
fc = json_data['function_call']
if isinstance(fc, dict):
args = fc.get('arguments', '')
if isinstance(args, str) and args.startswith('{'):
try:
args = json.loads(args)
except:
pass
tool_calls.append({
'tool': fc.get('name', ''),
'args': args if isinstance(args, str) else '',
'arguments': args if isinstance(args, dict) else None
})
# 格式4: MCP/Anthropic 格式
elif 'name' in json_data and 'arguments' in json_data:
args = json_data.get('arguments', '')
if isinstance(args, str) and args.startswith('{'):
try:
args = json.loads(args)
except:
pass
tool_calls.append({
'tool': json_data.get('name', ''),
'args': args if isinstance(args, str) else '',
'arguments': args if isinstance(args, dict) else None
})
# 格式5: OpenAI tool_calls 格式
elif json_data.get('type') == 'function' and 'function' in json_data:
func = json_data['function']
args = func.get('arguments', '')
if isinstance(args, str) and args.startswith('{'):
try:
args = json.loads(args)
except:
pass
tool_calls.append({
'tool': func.get('name', ''),
'args': args if isinstance(args, str) else '',
'arguments': args if isinstance(args, dict) else None
})
elif isinstance(json_data, list):
for item in json_data:
if isinstance(item, dict):
if item.get('action') == 'use_tool':
tool_calls.append(item)
elif 'tool' in item and 'args' in item:
tool_calls.append(item)
elif 'name' in item and 'arguments' in item:
args = item.get('arguments', '')
if isinstance(args, str) and args.startswith('{'):
try:
args = json.loads(args)
except:
pass
tool_calls.append({
'tool': item.get('name', ''),
'args': args if isinstance(args, str) else '',
'arguments': args if isinstance(args, dict) else None
})
return tool_calls if tool_calls else None
def process_tool_call(self, tool_call_data):
"""处理工具调用(支持 Liugin 和 MCP 两种格式)"""
try:
tool_name = tool_call_data.get('tool')
args = tool_call_data.get('args', '')
arguments = tool_call_data.get('arguments')
if UNIFIED_TOOL_MANAGER_AVAILABLE and hasattr(self.liugin_manager, 'execute'):
if arguments and isinstance(arguments, dict):
result = self.liugin_manager.execute(tool_name, args, **arguments)
else:
result = self.liugin_manager.execute(tool_name, args)
return result
# 回退:直接调用 handler(旧逻辑)
for tool in self.liugin_manager.tools:
if tool['name'] == tool_name:
try:
result = tool['handler'](args)
if result is None:
result = "无结果"
return {"result": result}
except Exception as e:
return {"result": f"工具执行错误: {e}"}
return {"result": f"未找到工具: {tool_name}"}
except Exception as e:
return {"result": f"处理工具调用时出错: {e}"}
def process_tool_calls_concurrent(self, tool_calls_list):
"""并发批量执行工具调用,按顺序返回结果"""
if not tool_calls_list:
return []
cancel_event = threading.Event()
history_lock = threading.Lock()
def execute_single_tool(tool_data, index):
if cancel_event.is_set():
return {"index": index, "result": "工具执行已取消"}
try:
result = self.process_tool_call(tool_data)
tool_result = result.get('result', '无结果')
with history_lock:
self.shared_conversation_history.append({
"role": "assistant",
"content": f"工具{index+1}执行结果:\n{tool_result}"
})
return {"index": index, "result": tool_result}
except Exception as e:
error_msg = f"工具执行错误: {e}"
with history_lock:
self.shared_conversation_history.append({
"role": "assistant",
"content": error_msg
})
return {"index": index, "result": error_msg}
def supports_esc_detection():
return sys.platform == 'win32'
def check_key_input():
try:
import msvcrt
return msvcrt.kbhit()
except:
return False
def get_key():
try:
import msvcrt
return msvcrt.getch()
except:
return None
def detect_esc_key():
if supports_esc_detection():
while not cancel_event.is_set() and not all(f.done() for f in futures):
if check_key_input():
key = get_key()
if key == b'\x1b':
cancel_event.set()
break
time.sleep(0.1)
with ThreadPoolExecutor(max_workers=min(len(tool_calls_list), 10)) as executor:
futures = {executor.submit(execute_single_tool, tool_data, i): i
for i, tool_data in enumerate(tool_calls_list)}
esc_thread = threading.Thread(target=detect_esc_key, daemon=True)
esc_thread.start()
results = []
for future in as_completed(futures):
if cancel_event.is_set():
for f in futures:
if not f.done():
f.cancel()
break
try:
result = future.result()
results.append(result)
except Exception as e:
index = futures[future]
results.append({"index": index, "result": f"工具执行错误: {e}"})
results.sort(key=lambda x: x['index'])
return [r['result'] for r in results]
def _execute_single_tool(self, tool_data):
"""执行单个工具(带延迟和ESC检测)"""
tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '')
if len(tool_args) > 30:
tool_args_display = tool_args[:27] + "..."
else:
tool_args_display = tool_args
# TUI 模式下不做延迟和 ESC 检测
if not self.tui_output_callback:
try:
import msvcrt
canceled = False
spinners = ['⊶', '⊷']
spinner_index = 0
for i in range(25):
if msvcrt.kbhit():
key = msvcrt.getch()
if key == b'\x1b':
canceled = True
break
spinner = spinners[spinner_index % 2]
spinner_index += 1
loading_message = f" 正在执行: {spinner} {tool_name} {tool_args_display} (按ESC取消)"
sys.stdout.write(f"\r{loading_message}")
sys.stdout.flush()
time.sleep(0.2)
sys.stdout.write("\r" + " " * 60 + "\r")
sys.stdout.flush()
if canceled:
print(f"{Fore.YELLOW}工具执行已取消(用户按ESC键){Style.RESET_ALL}")
cancel_message = f"工具 {tool_name} 执行已被用户取消(按ESC键)。工具参数: {tool_args}"
self.shared_conversation_history.append({
"role": "assistant",
"content": cancel_message
})
return True
except ImportError:
pass
tool_results = self.process_tool_call(tool_data)
full_result = tool_results.get('result', '无结果')
from datetime import datetime
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._display_tool_result(tool_name, tool_args, full_result, current_time)
current_input = f"工具调用结果 (时间: {current_time}):\n{full_result}"
self.shared_conversation_history.append({
"role": "assistant",
"content": f"工具调用结果 (时间: {current_time}):\n{full_result}"
})
self.process_conversation(current_input)
def _execute_concurrent_tools(self, tool_calls_list):
"""并发批量执行工具调用"""
if not tool_calls_list:
return
self._output(f" 正在批量执行 {len(tool_calls_list)} 个工具")
results = self.process_tool_calls_concurrent(tool_calls_list)
from datetime import datetime
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for i, (tool_data, result) in enumerate(zip(tool_calls_list, results)):
tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '')
self._display_tool_result(tool_name, tool_args, result, current_time)
all_results = "\n".join([f"工具{i+1}结果:\n{result}" for i, result in enumerate(results)])
current_input = f"批量工具调用结果 (时间: {current_time}):\n{all_results}"
self.shared_conversation_history.append({
"role": "assistant",
"content": f"批量工具调用结果 (时间: {current_time}):\n{all_results}"
})
self.process_conversation(current_input)
+54
View File
@@ -0,0 +1,54 @@
import os
import json
import logging
from colorama import Fore, Style
# 配置文件路径
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
def load_config():
"""加载配置文件"""
try:
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
except json.JSONDecodeError:
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
def save_config(config):
"""保存配置文件"""
try:
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存配置失败: {e}")
def get_system_config(key, default=None):
"""获取系统配置"""
config = load_config()
return config.get("system", {}).get(key, default)
def set_system_config(key, value):
"""设置系统配置"""
config = load_config()
if "system" not in config:
config["system"] = {}
config["system"][key] = value
save_config(config)
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('xiaoli_cli.log', encoding='utf-8'),
logging.StreamHandler() # 同时输出到控制台
]
)
logger = logging.getLogger(__name__)
+94
View File
@@ -0,0 +1,94 @@
# 常量定义
WEB_SERVER_DEFAULT_PORT = 8080
MAX_FILE_SIZE = 52428800 # 50MB
MAX_VIDEO_DURATION = 10000 # 10秒
VIDEO_FRAME_DELAY = 0.04 # 40毫秒
LOVE_FILE_PATH = "love.txt"
DEFAULT_MAX_HISTORY = 999999 # 无限制对话历史
import os
import sys
import importlib.util
import json
import re
import requests
import shutil
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from openai import OpenAI
from colorama import init, Fore, Style
# tkinter 仅在视频播放功能中使用,服务器环境可能不可用
try:
import tkinter as tk
from tkinter import ttk
TKINTER_AVAILABLE = True
except ImportError:
TKINTER_AVAILABLE = False
# cv2 仅在视频播放功能中使用
try:
import cv2
CV2_AVAILABLE = True
except ImportError:
CV2_AVAILABLE = False
import numpy as np
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import socket
import webbrowser
from datetime import datetime
import logging
# 图像显示相关(可选)
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
try:
from ascii_magic import AsciiArt
ASCII_MAGIC_AVAILABLE = True
except ImportError:
ASCII_MAGIC_AVAILABLE = False
# WebSocket 服务端
try:
import websocket_server
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
# Clawli 独立进程服务器
try:
import clawli_server
CLAWLI_SERVER_AVAILABLE = True
except ImportError:
CLAWLI_SERVER_AVAILABLE = False
# 统一工具管理器 - 支持 Liugin 和 Skill 双协议
try:
from unified_tool_manager import UnifiedToolManager
UNIFIED_TOOL_MANAGER_AVAILABLE = True
except ImportError:
UNIFIED_TOOL_MANAGER_AVAILABLE = False
# Textual TUI 支持
import asyncio
from typing import Optional
from io import StringIO
try:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree
from textual.widgets.tree import TreeNode
from textual.binding import Binding
from textual.events import Mount
TEXTUAL_AVAILABLE = True
except ImportError:
TEXTUAL_AVAILABLE = False
print(f"{Fore.YELLOW}Textual 未安装,TUI 模式不可用。请运行: pip install textual{Style.RESET_ALL}")
+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
+183
View File
@@ -0,0 +1,183 @@
# ── 安全代码沙箱 v2(子进程隔离) ──
import ast as _ast
import re
import textwrap as _textwrap
import tempfile as _tempfile
import resource as _resource_mod
_HAS_RESOURCE = hasattr(_resource_mod, 'setrlimit')
_BLOCKED_MODULES = frozenset({
'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle',
'marshal', 'ctypes', 'multiprocessing', 'threading',
'signal', 'resource', 'posix', 'nt', 'builtins',
'importlib', 'code', 'codeop', 'bdb', 'pdb',
'compileall', 'py_compile', 'zipimport', 'pkgutil',
'http', 'urllib', 'ftplib', 'smtplib', 'xmlrpc',
'asyncio', 'concurrent', '_thread',
})
_BLOCKED_NAMES = frozenset({
'exec', 'eval', 'compile', 'execfile', 'open',
'globals', 'locals', 'vars', 'getattr', 'setattr',
'delattr', '__import__', '__builtins__', '__subclasses__',
'__loader__', '__spec__', '__file__', '__name__',
'breakpoint', 'exit', 'quit', 'help',
})
_BLOCKED_AST_PATTERNS = [
r'__', r'\beval\b', r'\bexec\b', r'\bcompile\b',
r'\bglobals\b', r'\blocals\b', r'\bgetattr\b',
r'\bsetattr\b', r'\b__import__\b', r'\bbreakpoint\b',
]
_SAFE_WHITELIST = [
'json', 'math', 'datetime', 'random', 'collections',
'itertools', 'functools', 'operator', 'typing', 're',
'string', 'textwrap', 'copy', 'decimal', 'fractions',
'statistics', 'bisect', 'heapq', 'array', 'struct',
'hashlib', 'hmac', 'secrets', 'base64', 'binascii',
'uuid', 'pathlib', 'enum', 'dataclasses', 'contextlib',
]
class _SandboxASTChecker(_ast.NodeVisitor):
"""AST 级安全分析"""
def __init__(self):
self.violations = []
def _check(self, name, node):
if name in _BLOCKED_NAMES:
self.violations.append(f"blocked name: {name} (line {getattr(node,'lineno',0)})")
if name.split('.')[0] in _BLOCKED_MODULES:
self.violations.append(f"blocked module: {name} (line {getattr(node,'lineno',0)})")
def visit_Import(self, node):
for alias in node.names:
self._check(alias.name, node)
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.module:
self._check(node.module, node)
self.generic_visit(node)
def visit_Call(self, node):
if isinstance(node.func, _ast.Name):
self._check(node.func.id, node)
elif isinstance(node.func, _ast.Attribute):
if isinstance(node.func.value, _ast.Name):
if node.func.value.id in _BLOCKED_NAMES:
self.violations.append(f"blocked call: {node.func.value.id}.{node.func.attr} (line {getattr(node,'lineno',0)})")
self.generic_visit(node)
def visit_Name(self, node):
self._check(node.id, node)
self.generic_visit(node)
def visit_Attribute(self, node):
if node.attr.startswith('__') and node.attr.endswith('__'):
_SAFE_DUNDERS = ('__init__','__str__','__repr__','__len__','__contains__',
'__iter__','__eq__','__ne__','__lt__','__gt__','__le__',
'__ge__','__hash__','__bool__','__add__','__sub__',
'__mul__','__truediv__','__floordiv__','__mod__','__pow__',
'__enter__','__exit__')
if node.attr not in _SAFE_DUNDERS:
self.violations.append(f"blocked dunder: {node.attr} (line {getattr(node,'lineno',0)})")
self.generic_visit(node)
def visit_Delete(self, node):
self.violations.append(f"blocked del (line {getattr(node,'lineno',0)})")
self.generic_visit(node)
def visit_Global(self, node):
self.violations.append(f"blocked global (line {getattr(node,'lineno',0)})")
self.generic_visit(node)
def visit_Nonlocal(self, node):
self.violations.append(f"blocked nonlocal (line {getattr(node,'lineno',0)})")
self.generic_visit(node)
def check(self, code):
self.violations = []
for p in _BLOCKED_AST_PATTERNS:
if re.search(p, code, re.IGNORECASE):
self.violations.append(f"regex match: {p}")
try:
tree = _ast.parse(code)
except SyntaxError as e:
return False, [f"syntax error: {e}"]
self.visit(tree)
return len(self.violations) == 0, self.violations
def _make_sandbox_worker(code, variables_json, whitelist_json, work_dir):
"""生成子进程隔离脚本"""
return _textwrap.dedent(f'''\
import sys, os, json, io, traceback
from contextlib import redirect_stdout, redirect_stderr
os.chdir({work_dir!r})
class _FakeSocket:
def __getattr__(self, name):
raise OSError("network access blocked by sandbox")
class _FakeSocketModule:
AF_INET = 0; SOCK_STREAM = 0
def __getattr__(self, name):
raise OSError("network access blocked by sandbox")
def socket(self, *a, **kw):
raise OSError("network access blocked by sandbox")
import types
_fake = _FakeSocketModule()
sys.modules['socket'] = _fake
sys.modules['_socket'] = _fake
WHITELIST = json.loads({whitelist_json!r})
safe_modules = {{}}
for m in WHITELIST:
try: safe_modules[m] = __import__(m)
except ImportError: pass
safe_builtins = {{
'print': print, 'len': len, 'str': str, 'int': int,
'float': float, 'list': list, 'dict': dict, 'tuple': tuple,
'set': set, 'frozenset': frozenset, 'range': range,
'enumerate': enumerate, 'zip': zip, 'sorted': sorted,
'reversed': reversed, 'sum': sum, 'max': max, 'min': min,
'abs': abs, 'round': round, 'pow': pow, 'divmod': divmod,
'type': type, 'isinstance': isinstance, 'issubclass': issubclass,
'hasattr': hasattr, 'callable': callable, 'id': id,
'chr': chr, 'ord': ord, 'hex': hex, 'oct': oct, 'bin': bin,
'bool': bool, 'bytes': bytes, 'bytearray': bytearray,
'map': map, 'filter': filter, 'any': any, 'all': all,
'True': True, 'False': False, 'None': None,
}}
user_vars = json.loads({variables_json!r})
exec_vars = {{}}
exec_vars.update(safe_builtins)
exec_vars.update(safe_modules)
exec_vars.update(user_vars)
stdout_buf = io.StringIO()
stderr_buf = io.StringIO()
result_data = {{"success": True, "result": "", "stdout": "", "stderr": "", "error": ""}}
try:
with redirect_stdout(stdout_buf), redirect_stderr(stderr_buf):
exec({code!r}, exec_vars)
val = exec_vars.get('result', None)
result_data["result"] = str(val) if val is not None else "executed ok"
except Exception as e:
result_data["success"] = False
result_data["error"] = str(e)
result_data["stderr"] = traceback.format_exc()
result_data["stdout"] = stdout_buf.getvalue()
result_data["stderr"] = stderr_buf.getvalue() or result_data.get("stderr", "")
max_out = 500000
if len(result_data["stdout"]) > max_out:
result_data["stdout"] = result_data["stdout"][:max_out] + "\\n... (truncated)"
if len(result_data["stderr"]) > max_out:
result_data["stderr"] = result_data["stderr"][:max_out] + "\\n... (truncated)"
sys.stdout.write(json.dumps(result_data, ensure_ascii=False))
''')
_sandbox_work_dir = _tempfile.mkdtemp(prefix='xiaoli_sandbox_')
_sandbox_checker = _SandboxASTChecker()
+456
View File
@@ -0,0 +1,456 @@
"""TUI 界面模块 - Textual TUI 实现"""
from .constants import TEXTUAL_AVAILABLE
if TEXTUAL_AVAILABLE:
import re
import asyncio
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll, Container
from textual.widgets import (
Header, Footer, Input, Static, Rule, Label,
DataTable, ProgressBar, TabbedContent, TabPane
)
from textual.reactive import reactive, var
from textual.message import Message
from textual.binding import Binding
from textual import work, on
from rich.text import Text
from rich.markdown import Markdown as RichMarkdown
from rich.syntax import Syntax
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree as RichTree
from rich.box import ROUNDED, HEAVY, DOUBLE
# ── TUI 主题色 ──
class _Theme:
BG = "#0d1117"
BG_LIGHT = "#161b22"
BG_INPUT = "#0d1117"
BORDER = "#30363d"
BORDER_FOCUS = "#58a6ff"
TEXT = "#c9d1d9"
TEXT_DIM = "#484f58"
TEXT_MUTED = "#8b949e"
ACCENT = "#58a6ff"
SUCCESS = "#3fb950"
WARNING = "#d29922"
ERROR = "#f85149"
USER = "#79c0ff"
AI = "#c9d1d9"
TOOL = "#56d364"
TOOL_ERR = "#f85149"
CODE_BG = "#161b22"
_TUI_CSS = f"""
Screen {{ background: {_Theme.BG}; }}
#app-container {{ height: 100%; width: 100%; }}
#main {{ width: 1fr; height: 1fr; }}
#chat-scroll {{
height: 1fr; background: {_Theme.BG};
scrollbar-color: {_Theme.BORDER};
scrollbar-color-hover: {_Theme.TEXT_MUTED};
padding: 0 1;
}}
#input-area {{
height: auto; min-height: 4; max-height: 10; padding: 1 1 0 1;
}}
#user-input {{
height: auto; min-height: 3;
background: {_Theme.BG_INPUT};
border: tall {_Theme.BORDER};
color: {_Theme.TEXT}; padding: 0 1;
}}
#user-input:focus {{ border: tall {_Theme.BORDER_FOCUS}; }}
#input-hint {{
height: 1; color: {_Theme.TEXT_DIM};
padding: 0 1; text-size: 80%;
}}
#sidebar {{
width: 32; min-width: 32; height: 1fr;
background: {_Theme.BG_LIGHT};
border-left: wide {_Theme.BORDER};
display: block;
}}
#sidebar.hidden {{ display: none; width: 0; min-width: 0; }}
.sidebar-header {{
width: 100%; text-align: center;
color: {_Theme.ACCENT}; text-style: bold;
padding: 1 0 0 0; text-size: 90%;
}}
.sidebar-section {{ height: auto; padding: 0 1; margin: 0 0 1 0; }}
.engine-item {{ padding: 0 1; color: {_Theme.TEXT_MUTED}; text-size: 85%; }}
.engine-item-active {{ padding: 0 1; color: {_Theme.SUCCESS}; text-style: bold; text-size: 85%; }}
.tool-item {{ padding: 0 0 0 1; color: {_Theme.TEXT_MUTED}; text-size: 80%; }}
.tool-item-name {{ color: {_Theme.TEXT}; text-style: bold; }}
#status-bar {{
height: 1; width: 100%; dock: bottom;
background: {_Theme.BG_LIGHT};
color: {_Theme.TEXT_MUTED}; padding: 0 1; text-size: 80%;
}}
.msg-user {{ color: {_Theme.USER}; padding: 1 0 0 1; text-style: bold; }}
.msg-ai {{ color: {_Theme.AI}; padding: 0 1; }}
.msg-system {{ color: {_Theme.ACCENT}; padding: 0 1; text-size: 85%; }}
.msg-tool-ok {{ color: {_Theme.TOOL}; padding: 0 1; text-size: 85%; }}
.msg-tool-err {{ color: {_Theme.TOOL_ERR}; padding: 0 1; text-size: 85%; }}
.msg-thinking {{ color: {_Theme.TEXT_DIM}; text-style: italic; padding: 0 1; }}
.msg-error {{ color: {_Theme.ERROR}; padding: 0 1; }}
.msg-dim {{ color: {_Theme.TEXT_DIM}; padding: 0 1; text-size: 85%; }}
.msg-welcome {{ color: {_Theme.ACCENT}; padding: 0 1; text-style: bold; }}
.code-block {{
background: {_Theme.CODE_BG};
border: wide {_Theme.BORDER};
padding: 0 1; margin: 0 2 0 2; text-size: 85%;
}}
Tab {{ background: {_Theme.BG}; }}
Tab.-active {{ background: {_Theme.BG_LIGHT}; }}
TabbedContent > Tabs {{ background: {_Theme.BG}; }}
"""
class _TUIBridge:
"""桥接 TUI ↔ AICLI"""
def __init__(self, cli):
self.cli = cli
def engines(self) -> list:
return list(self.cli.engines.keys())
def current_engine(self) -> str:
return self.cli.get_current_engine_name()
def tools(self) -> list:
return self.cli.liugin_manager.tools
def history(self) -> list:
return self.cli.shared_conversation_history
def switch_engine(self, name: str) -> bool:
if name in self.cli.engines:
self.cli.switch_engine(name)
return True
return False
class XiaoliTUI(App):
"""小狸 TUI v2 - GitHub 暗色主题"""
CSS = _TUI_CSS
TITLE = "🐱 小狸 Pro-CLI"
SUB_TITLE = "智能编程助手"
BINDINGS = [
Binding("ctrl+c", "quit", "退出", show=True),
Binding("ctrl+l", "clear", "清屏", show=True),
Binding("ctrl+n", "new_chat", "新对话", show=True),
Binding("f1", "toggle_sidebar", "侧栏", show=True),
Binding("escape", "cancel", "取消", show=False),
]
sidebar_visible = var(True)
is_generating = var(False)
def __init__(self, cli):
super().__init__()
self.cli = cli
self.bridge = _TUIBridge(cli)
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal(id="app-container"):
with Vertical(id="main"):
yield VerticalScroll(id="chat-scroll")
with Vertical(id="input-area"):
yield Input(
placeholder=" 输入消息... (Enter 发送, /help 帮助)",
id="user-input"
)
yield Static(
" Tab 补全 | ↑↓ 历史 | Ctrl+L 清屏 | F1 侧栏",
id="input-hint"
)
with Vertical(id="sidebar"):
yield Static("⚙️ 引擎", classes="sidebar-header")
yield Vertical(id="engine-list", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static("🔧 工具", classes="sidebar-header")
yield Vertical(id="tool-list", classes="sidebar-section")
yield Rule(line_style="heavy")
yield Static("📊 状态", classes="sidebar-header")
yield Vertical(id="status-info", classes="sidebar-section")
yield Static(" 就绪 | Ctrl+C 退出", id="status-bar")
def on_mount(self):
self._render_welcome()
self._update_sidebar()
self.query_one("#user-input").focus()
def _render_welcome(self):
scroll = self.query_one("#chat-scroll")
lines = [
("", "msg-dim"),
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ║ 🐱 小狸 Pro-CLI v5.0 ║", "msg-welcome"),
(" ║ 智能编程助手 · 对标 Claude Code ║", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
("", "msg-dim"),
(" 💡 代码编辑 · 代码搜索 · Git 集成 · 多引擎", "msg-system"),
(" 📝 输入 /help 查看命令 | /model 切换引擎", "msg-system"),
(f" 🔧 当前引擎: {self.bridge.current_engine()} | 工具: {len(self.bridge.tools())} 个", "msg-system"),
("", "msg-dim"),
]
for text, cls in lines:
scroll.mount(Static(text, classes=cls))
def _update_sidebar(self):
engine_container = self.query_one("#engine-list")
engine_container.remove_children()
current = self.bridge.current_engine()
for name in self.bridge.engines():
if name == current:
engine_container.mount(Static(f" ▸ {name}", classes="engine-item-active"))
else:
engine_container.mount(Static(f" {name}", classes="engine-item"))
tool_container = self.query_one("#tool-list")
tool_container.remove_children()
for tool in self.bridge.tools():
name = tool.get('name', '?')
tool_container.mount(Static(f" • {name}", classes="tool-item"))
status_container = self.query_one("#status-info")
status_container.remove_children()
status_container.mount(Static(f" 对话: {len(self.bridge.history())} 条", classes="tool-item"))
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
def _update_status(self, text: str):
bar = self.query_one("#status-bar")
engine = self.bridge.current_engine()
bar.update(f" {text} | {engine} | {len(self.bridge.history())} 条对话")
def _append(self, widget):
scroll = self.query_one("#chat-scroll")
scroll.mount(widget)
scroll.scroll_end(animate=False)
def _add(self, text, cls="msg-dim"):
self._append(Static(text, classes=cls))
def _user_msg(self, text):
self._add(f" 👤 {text}", "msg-user")
def _ai_msg(self, text):
if "```" in text:
self._render_with_code(text)
else:
self._add(f" ✦ {text}", "msg-ai")
def _render_with_code(self, text):
parts = re.split(r'```(\w*)\n(.*?)```', text, flags=re.DOTALL)
i = 0
while i < len(parts):
if i % 3 == 0:
if parts[i].strip():
for line in parts[i].strip().split('\n'):
self._add(f" ✦ {line}", "msg-ai")
elif i % 3 == 2:
code = parts[i]
lang = parts[i-1] if i > 1 else ""
try:
syntax = Syntax(code, lang or "python", theme="monokai",
line_numbers=True, word_wrap=True)
self._append(Panel(syntax, border_style=f"dim {_Theme.BORDER}",
box=ROUNDED, padding=(0, 1)))
except:
for line in code.split('\n'):
self._add(f" {line}", "msg-dim")
i += 1
def _tool_ok(self, name, args):
self._add(f" ✅ {name}: {args[:60]}", "msg-tool-ok")
def _tool_err(self, name, args):
self._add(f" ❌ {name}: {args[:60]}", "msg-tool-err")
def _thinking(self):
self._add(" 💭 思考中...", "msg-thinking")
def _system(self, text):
self._add(f" ℹ️ {text}", "msg-system")
def _error(self, text):
self._add(f" ⚠️ {text}", "msg-error")
@on(Input.Submitted, "#user-input")
def on_input(self, event):
text = event.value.strip()
if not text:
return
event.input.value = ""
if text.startswith('/'):
self._handle_command(text)
return
self._user_msg(text)
self._thinking()
self.is_generating = True
self._update_status("🔄 思考中...")
self.run_worker(self._generate(text), exclusive=True)
def _handle_command(self, cmd):
parts = cmd[1:].split(maxsplit=1)
name = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
cmds = {
'help': lambda: self._show_help(),
'quit': lambda: self.exit(),
'q': lambda: self.exit(),
'exit': lambda: self.exit(),
'cli': lambda: self._switch_cli(),
'clear': lambda: self.action_clear(),
'cls': lambda: self.action_clear(),
'model': lambda: self._switch_model(args),
'engine': lambda: self._switch_model(args),
'about': lambda: self._system("🐱 小狸 Pro-CLI v5.0 - 智能编程助手"),
'status': lambda: self._show_status(),
'tools': lambda: self._show_tools(),
'engines': lambda: self._show_engines(),
'tui': lambda: self._system("已在 TUI 模式中"),
}
handler = cmds.get(name)
if handler:
handler()
else:
if name in self.cli.liugin_commands:
try:
result = self.cli.liugin_commands[name](args)
if result:
self._system(result[:500])
except Exception as e:
self._error(f"插件命令失败: {e}")
else:
self._error(f"未知命令: /{name},输入 /help 查看帮助")
def _show_help(self):
help_text = """ 📖 命令:
/help 帮助信息
/quit 退出
/cli 切换命令行模式
/model <引擎> 切换 AI 引擎
/engines 列出引擎
/tools 列出工具
/status 系统状态
/clear 清屏
⌨️ 快捷键:
Ctrl+C 退出 Ctrl+L 清屏
Ctrl+N 新对话 F1 侧栏
Escape 取消生成"""
self._system(help_text)
def _switch_cli(self):
self._system("切换到命令行模式...")
self.cli.tui_output_callback = None
self.exit()
def _switch_model(self, name):
name = name.strip()
if not name:
self._show_engines()
return
if self.bridge.switch_engine(name):
self._system(f"已切换到: {name}")
self._update_sidebar()
else:
self._error(f"未找到引擎: {name}")
def _show_engines(self):
current = self.bridge.current_engine()
lines = [" 可用引擎:"]
for name in self.bridge.engines():
marker = "▸" if name == current else " "
lines.append(f" {marker} {name}")
self._system('\n'.join(lines))
def _show_tools(self):
tools = self.bridge.tools()
lines = [f" 可用工具 ({len(tools)} 个):"]
for t in tools:
name = t.get('name', '?')
desc = t.get('description', '')[:40]
lines.append(f" • {name}: {desc}")
self._system('\n'.join(lines))
def _show_status(self):
self._system(f""" 系统状态:
引擎: {self.bridge.current_engine()}
引擎数: {len(self.bridge.engines())}
工具数: {len(self.bridge.tools())}
对话数: {len(self.bridge.history())}""")
async def _generate(self, user_input):
try:
self.call_after_refresh(self._remove_thinking)
def tui_output(msg):
self.call_after_refresh(self._write_raw, msg)
original = self.cli.tui_output_callback
self.cli.tui_output_callback = tui_output
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None, self.cli.process_conversation, user_input
)
finally:
self.cli.tui_output_callback = original
except Exception as e:
self.call_after_refresh(self._error, str(e))
finally:
self.is_generating = False
self.call_after_refresh(lambda: self._update_status("就绪"))
self.call_after_refresh(self._update_sidebar)
def _remove_thinking(self):
scroll = self.query_one("#chat-scroll")
for child in reversed(list(scroll.children)):
if '思考中' in str(getattr(child, 'renderable', '')):
child.remove()
break
def _write_raw(self, msg):
if '✅' in msg or 'OK 工具' in msg:
self._add(f" {msg}", "msg-tool-ok")
elif '❌' in msg or 'X 工具' in msg or '错误' in msg:
self._add(f" {msg}", "msg-tool-err")
elif '✦' in msg:
self._add(f" {msg}", "msg-ai")
elif '工具' in msg and ('调用' in msg or '执行' in msg):
self._add(f" {msg}", "msg-tool-ok")
else:
self._add(f" {msg}", "msg-dim")
def action_clear(self):
scroll = self.query_one("#chat-scroll")
scroll.remove_children()
self._render_welcome()
def action_new_chat(self):
self.cli.shared_conversation_history.clear()
self.action_clear()
self._system("已开始新对话")
def action_toggle_sidebar(self):
sidebar = self.query_one("#sidebar")
sidebar.visible = not sidebar.visible
def action_cancel(self):
if self.is_generating:
self._system("已取消")
self.is_generating = False
else:
# Textual 不可用时的占位类
class XiaoliTUI:
pass