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:
1 parent
7efb6dfe8b
commit
04e2d26809
14 files changed
+3798
-4125
No files matched your search
@@ -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',
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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__)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user