diff --git a/aibox/README.md b/aibox/README.md index 2174088..8588190 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -67,6 +67,9 @@ aibox/ ├── entry.html # 前端入口页 ├── entry1.html # 前端入口页(变体) ├── frontend.html # 前端主页面 +├── llm_client.py # 大模型客户端抽象层(多厂商切换) +├── common/ # 基础设施包(i18n / 配置 / 常量 / 日志) +├── tools/ # 工具系统基础包(Tool 基类 + ToolRegistry 注册器) ├── skills/ # 内置扩展技能(*.py 可执行脚本 / *.md Markdown 技能) │ ├── hello-world.py │ ├── aes-crypto.py @@ -621,7 +624,7 @@ Server -> Client: {type: "dialogue_reset", content: "✅ 对话状态已完全 ### 添加新工具 -1. 继承 `Tool` 基类(位于 `aibox.py`) +1. 继承 `Tool` 基类(位于 `tools/base.py`) 2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()` 3. 可选实现 `execute_streaming()` 支持流式输出(通过 `output_queue` 发送 `("line", content)` 和 `("complete", final_result)`) 4. 在 `DeepSeekChat._register_tools()` 中注册 diff --git a/aibox/aibox.py b/aibox/aibox.py index f053dd0..07021c8 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -41,6 +41,9 @@ from llm_client import create_llm_client # 基础设施(多语言 / 配置 / 常量 / 日志)已抽离至 common 模块 from common import i18n, I18n, ConfigManager, Constants, constants, Logger +# 工具基类与注册器已抽离至 tools 模块 +from tools import Tool, ToolRegistry + # ==================== 提示词管理器 ==================== class PromptManager: @@ -2959,238 +2962,6 @@ class SubprocessCommandExecutor(CommandExecutor): with self._process_lock: self._current_process = None -# ==================== 工具基类 ==================== - -class Tool(ABC): - """工具基类""" - - def __init__(self, logger: Optional[Logger] = None): - self.logger = logger or Logger(self.__class__.__name__) - self.config = ConfigManager() - self.streaming_output = False # 标记是否正在流式输出 - self.output_queue = None # 添加输出队列属性 - - @abstractmethod - def get_name(self) -> str: - """返回工具名称""" - pass - - @abstractmethod - def get_description(self) -> str: - """返回工具描述""" - pass - - @abstractmethod - def get_parameters(self) -> Dict: - """返回工具参数定义(JSON Schema格式)""" - pass - - @abstractmethod - def execute(self, **kwargs) -> str: - """执行工具并返回结果""" - pass - - def execute_streaming(self, output_queue: queue.Queue, **kwargs): - """流式执行工具,通过队列输出结果""" - self.output_queue = output_queue # 保存队列引用 - self.streaming_output = True - try: - # 执行具体的工具逻辑,子类可以重写这个方法 - result = self.execute(**kwargs) - # 如果结果不为空,发送到队列 - if result: - # 对于非流式输出,直接发送完整结果 - output_queue.put(("complete", result)) - finally: - self.streaming_output = False - self.output_queue = None - - def validate_params(self, required: List[str], **kwargs) -> Optional[str]: - """验证必需参数""" - missing = [p for p in required if p not in kwargs or kwargs[p] is None] - if missing: - return f"❌ 错误:缺少必需参数: {', '.join(missing)}" - return None - - def to_function_call_format(self) -> Dict: - """转换为OpenAI函数调用格式""" - return { - "type": "function", - "function": { - "name": self.get_name(), - "description": self.get_description(), - "parameters": self.get_parameters() - } - } - - def format_result(self, success: bool, message: str, data: Dict = None) -> str: - """统一结果格式化""" - result = [] - emoji = "✅" if success else "❌" - result.append(f"{emoji} {message}") - - if data: - for key, value in data.items(): - if isinstance(value, str) and "\n" in value: - result.append(f" {key}:") - for line in value.split("\n"): - result.append(f" {line}") - else: - result.append(f" {key}: {value}") - - return "\n".join(result) - - def check_path_safety(self, filepath: str) -> Tuple[bool, str, str]: - """检查路径安全性(简化版)""" - try: - abs_path = os.path.abspath(os.path.expanduser(filepath)) - - # 检查文件大小 - if os.path.exists(abs_path) and os.path.isfile(abs_path): - file_size = os.path.getsize(abs_path) - max_size = constants.MAX_FILE_SIZE - if file_size > max_size: - return False, abs_path, f"❌ 文件太大 ({file_size/1024/1024:.1f}MB)" - - return True, abs_path, "" - except Exception as e: - return False, filepath, f"❌ 路径检查错误: {str(e)}" - - def send_line(self, line: str): - """发送一行输出到队列(如果存在)""" - if self.output_queue: - self.output_queue.put(("line", line)) - - def send_complete(self, result: str): - """发送完成结果到队列(如果存在)""" - if self.output_queue: - self.output_queue.put(("complete", result)) - - -class ToolRegistry: - """工具注册器 - 支持技能热重载""" - - def __init__(self, logger: Optional[Logger] = None): - self._tools: Dict[str, Tool] = {} - self._builtin_tools: Dict[str, Tool] = {} # 内置工具单独存储 - self.logger = logger or Logger("ToolRegistry") - self.current_output_queue = None # 用于流式输出的队列 - self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用 - - def register(self, tool: Tool, is_builtin: bool = False): - """注册工具,可以标记是否为内置工具""" - self._tools[tool.get_name()] = tool - if is_builtin: - self._builtin_tools[tool.get_name()] = tool - self.logger.info(f"工具已注册: {tool.get_name()} {'(内置)' if is_builtin else ''}") - - def register_many(self, tools: List[Tool], is_builtin: bool = False): - """批量注册工具""" - for tool in tools: - self.register(tool, is_builtin) - - def get_tool(self, name: str) -> Optional[Tool]: - """获取工具""" - return self._tools.get(name) - - def get_all_tools(self) -> List[Tool]: - """获取所有工具""" - return list(self._tools.values()) - - def get_tools_schemas(self) -> List[Dict]: - """获取所有工具的OpenAI函数调用格式""" - return [tool.to_function_call_format() for tool in self._tools.values()] - - def set_output_queue(self, queue: queue.Queue): - """设置输出队列""" - self.current_output_queue = queue - # 同时设置所有工具的队列 - for tool in self._tools.values(): - tool.output_queue = queue - - def execute_tool(self, name: str, **kwargs) -> str: - """执行工具""" - tool = self.get_tool(name) - if not tool: - return f"❌ 错误:找不到工具 '{name}'" - - self.logger.debug(f"工具调用: {name}({kwargs})") - start_time = time.time() - - try: - # 检查是否有队列可用,并且工具支持流式输出 - if self.current_output_queue and hasattr(tool, 'execute_streaming'): - # 设置工具的流式输出属性 - tool.streaming_output = True - tool.output_queue = self.current_output_queue - - # 创建队列来收集流式输出 - output_queue = queue.Queue() - - # 执行流式输出 - thread = threading.Thread( - target=tool.execute_streaming, - args=(output_queue,), - kwargs=kwargs - ) - thread.daemon = True - thread.start() - - # 从队列收集输出并发送到当前队列 - final_result = None - while thread.is_alive() or not output_queue.empty(): - try: - msg_type, content = output_queue.get(timeout=0.1) - if msg_type == "line": - # 发送到当前队列(会通过WebSocket发送) - if self.current_output_queue: - self.current_output_queue.put(("line", content)) - elif msg_type == "complete": - final_result = content - # 也发送完整结果 - if self.current_output_queue: - self.current_output_queue.put(("complete", content)) - except queue.Empty: - continue - - thread.join(timeout=5) - if thread.is_alive(): - return "❌ 工具执行超时" - - result = final_result or "" - tool.streaming_output = False - tool.output_queue = None - else: - # 普通执行 - result = tool.execute(**kwargs) - - elapsed = time.time() - start_time - self.logger.debug(f"工具结果 ({elapsed:.2f}s): {result[:100]}...") - return result - except TypeError as e: - return f"❌ 工具执行错误: 参数错误 - {str(e)}" - except Exception as e: - return f"❌ 工具执行错误: {str(e)}" - - def reload_skills(self, skills_dir: str = None) -> int: - """ - 重新加载所有扩展技能(热重载)。 - 委托给 SkillManager 重新扫描目录并执行 --help 收集简介。 - """ - self.logger.info("开始重新加载扩展技能...") - if self.skill_manager is not None: - count = self.skill_manager.reload() - self.logger.info(i18n.get('skills_reloaded', count)) - return count - self.logger.warning("skill_manager 未初始化,无法重载技能") - return 0 - - def get_skills_count(self) -> Tuple[int, int]: - """获取内置工具和扩展技能的数量""" - builtin_count = len(self._builtin_tools) - skill_count = len(self._tools) - builtin_count - return builtin_count, skill_count - # ==================== 扩展技能管理器(支持 Markdown 格式) ==================== diff --git a/aibox/tools/__init__.py b/aibox/tools/__init__.py new file mode 100644 index 0000000..d8e5e0b --- /dev/null +++ b/aibox/tools/__init__.py @@ -0,0 +1,17 @@ +""" +tools - aibox 工具系统基础模块(包) + +抽离自 aibox.py 的工具基类与注册器,供 aibox.py 及后续迁移的 +工具子类导入。当前包含: + - base.py : Tool 抽象基类 + - registry.py : ToolRegistry 工具注册器 +形成单向依赖:common <- tools <- aibox。 + +采用"重导出"策略:外部统一用 `from tools import Tool, ToolRegistry`, +调用点无需关心具体子模块所在文件。 +""" + +from .base import Tool +from .registry import ToolRegistry + +__all__ = ["Tool", "ToolRegistry"] diff --git a/aibox/tools/base.py b/aibox/tools/base.py new file mode 100644 index 0000000..e3b7f76 --- /dev/null +++ b/aibox/tools/base.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +tools.base - 工具抽象基类 + +抽离自 aibox.py 的 Tool 基类。仅依赖 common 与标准库, +不反向依赖 aibox 任何符号。 +""" + +import os +import queue +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple + +from common import ConfigManager, Logger, constants + + +class Tool(ABC): + """工具基类""" + + def __init__(self, logger: Optional[Logger] = None): + self.logger = logger or Logger(self.__class__.__name__) + self.config = ConfigManager() + self.streaming_output = False # 标记是否正在流式输出 + self.output_queue = None # 添加输出队列属性 + + @abstractmethod + def get_name(self) -> str: + """返回工具名称""" + pass + + @abstractmethod + def get_description(self) -> str: + """返回工具描述""" + pass + + @abstractmethod + def get_parameters(self) -> Dict: + """返回工具参数定义(JSON Schema格式)""" + pass + + @abstractmethod + def execute(self, **kwargs) -> str: + """执行工具并返回结果""" + pass + + def execute_streaming(self, output_queue: queue.Queue, **kwargs): + """流式执行工具,通过队列输出结果""" + self.output_queue = output_queue # 保存队列引用 + self.streaming_output = True + try: + # 执行具体的工具逻辑,子类可以重写这个方法 + result = self.execute(**kwargs) + # 如果结果不为空,发送到队列 + if result: + # 对于非流式输出,直接发送完整结果 + output_queue.put(("complete", result)) + finally: + self.streaming_output = False + self.output_queue = None + + def validate_params(self, required: List[str], **kwargs) -> Optional[str]: + """验证必需参数""" + missing = [p for p in required if p not in kwargs or kwargs[p] is None] + if missing: + return f"❌ 错误:缺少必需参数: {', '.join(missing)}" + return None + + def to_function_call_format(self) -> Dict: + """转换为OpenAI函数调用格式""" + return { + "type": "function", + "function": { + "name": self.get_name(), + "description": self.get_description(), + "parameters": self.get_parameters() + } + } + + def format_result(self, success: bool, message: str, data: Dict = None) -> str: + """统一结果格式化""" + result = [] + emoji = "✅" if success else "❌" + result.append(f"{emoji} {message}") + + if data: + for key, value in data.items(): + if isinstance(value, str) and "\n" in value: + result.append(f" {key}:") + for line in value.split("\n"): + result.append(f" {line}") + else: + result.append(f" {key}: {value}") + + return "\n".join(result) + + def check_path_safety(self, filepath: str) -> Tuple[bool, str, str]: + """检查路径安全性(简化版)""" + try: + abs_path = os.path.abspath(os.path.expanduser(filepath)) + + # 检查文件大小 + if os.path.exists(abs_path) and os.path.isfile(abs_path): + file_size = os.path.getsize(abs_path) + max_size = constants.MAX_FILE_SIZE + if file_size > max_size: + return False, abs_path, f"❌ 文件太大 ({file_size/1024/1024:.1f}MB)" + + return True, abs_path, "" + except Exception as e: + return False, filepath, f"❌ 路径检查错误: {str(e)}" + + def send_line(self, line: str): + """发送一行输出到队列(如果存在)""" + if self.output_queue: + self.output_queue.put(("line", line)) + + def send_complete(self, result: str): + """发送完成结果到队列(如果存在)""" + if self.output_queue: + self.output_queue.put(("complete", result)) diff --git a/aibox/tools/registry.py b/aibox/tools/registry.py new file mode 100644 index 0000000..f8d86b9 --- /dev/null +++ b/aibox/tools/registry.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +tools.registry - 工具注册器 + +抽离自 aibox.py 的 ToolRegistry 类。仅依赖 common 与标准库, +不反向依赖 aibox 任何符号(skill_manager 为前向引用,运行期注入)。 +""" + +import queue +import threading +import time +from typing import Dict, List, Optional, Tuple + +from common import Logger, i18n + +from .base import Tool + + +class ToolRegistry: + """工具注册器 - 支持技能热重载""" + + def __init__(self, logger: Optional[Logger] = None): + self._tools: Dict[str, Tool] = {} + self._builtin_tools: Dict[str, Tool] = {} # 内置工具单独存储 + self.logger = logger or Logger("ToolRegistry") + self.current_output_queue = None # 用于流式输出的队列 + self.skill_manager: Optional['SkillManager'] = None # 技能管理器引用 + + def register(self, tool: Tool, is_builtin: bool = False): + """注册工具,可以标记是否为内置工具""" + self._tools[tool.get_name()] = tool + if is_builtin: + self._builtin_tools[tool.get_name()] = tool + self.logger.info(f"工具已注册: {tool.get_name()} {'(内置)' if is_builtin else ''}") + + def register_many(self, tools: List[Tool], is_builtin: bool = False): + """批量注册工具""" + for tool in tools: + self.register(tool, is_builtin) + + def get_tool(self, name: str) -> Optional[Tool]: + """获取工具""" + return self._tools.get(name) + + def get_all_tools(self) -> List[Tool]: + """获取所有工具""" + return list(self._tools.values()) + + def get_tools_schemas(self) -> List[Dict]: + """获取所有工具的OpenAI函数调用格式""" + return [tool.to_function_call_format() for tool in self._tools.values()] + + def set_output_queue(self, queue: queue.Queue): + """设置输出队列""" + self.current_output_queue = queue + # 同时设置所有工具的队列 + for tool in self._tools.values(): + tool.output_queue = queue + + def execute_tool(self, name: str, **kwargs) -> str: + """执行工具""" + tool = self.get_tool(name) + if not tool: + return f"❌ 错误:找不到工具 '{name}'" + + self.logger.debug(f"工具调用: {name}({kwargs})") + start_time = time.time() + + try: + # 检查是否有队列可用,并且工具支持流式输出 + if self.current_output_queue and hasattr(tool, 'execute_streaming'): + # 设置工具的流式输出属性 + tool.streaming_output = True + tool.output_queue = self.current_output_queue + + # 创建队列来收集流式输出 + output_queue = queue.Queue() + + # 执行流式输出 + thread = threading.Thread( + target=tool.execute_streaming, + args=(output_queue,), + kwargs=kwargs + ) + thread.daemon = True + thread.start() + + # 从队列收集输出并发送到当前队列 + final_result = None + while thread.is_alive() or not output_queue.empty(): + try: + msg_type, content = output_queue.get(timeout=0.1) + if msg_type == "line": + # 发送到当前队列(会通过WebSocket发送) + if self.current_output_queue: + self.current_output_queue.put(("line", content)) + elif msg_type == "complete": + final_result = content + # 也发送完整结果 + if self.current_output_queue: + self.current_output_queue.put(("complete", content)) + except queue.Empty: + continue + + thread.join(timeout=5) + if thread.is_alive(): + return "❌ 工具执行超时" + + result = final_result or "" + tool.streaming_output = False + tool.output_queue = None + else: + # 普通执行 + result = tool.execute(**kwargs) + + elapsed = time.time() - start_time + self.logger.debug(f"工具结果 ({elapsed:.2f}s): {result[:100]}...") + return result + except TypeError as e: + return f"❌ 工具执行错误: 参数错误 - {str(e)}" + except Exception as e: + return f"❌ 工具执行错误: {str(e)}" + + def reload_skills(self, skills_dir: str = None) -> int: + """ + 重新加载所有扩展技能(热重载)。 + 委托给 SkillManager 重新扫描目录并执行 --help 收集简介。 + """ + self.logger.info("开始重新加载扩展技能...") + if self.skill_manager is not None: + count = self.skill_manager.reload() + self.logger.info(i18n.get('skills_reloaded', count)) + return count + self.logger.warning("skill_manager 未初始化,无法重载技能") + return 0 + + def get_skills_count(self) -> Tuple[int, int]: + """获取内置工具和扩展技能的数量""" + builtin_count = len(self._builtin_tools) + skill_count = len(self._tools) - builtin_count + return builtin_count, skill_count