commit e22652fbbb1a7608020e87a026ddc7e50f5f072b Author: Xiaoli Dev Date: Sat Apr 25 10:40:59 2026 +0800 feat: 增强编码能力 - 新增代码编辑器、代码搜索、Git集成 - 新增 plugins/code_editor.py: 精准代码编辑(搜索替换、批量编辑、diff对比) - 新增 plugins/code_search.py: 代码搜索与理解(跨文件搜索、符号提取、依赖分析) - 新增 plugins/git_tools.py: Git 版本控制(status/diff/log/commit/branch) - 新增 core/ 模块: 配置管理、引擎管理、对话处理、显示输出、代码沙箱 - 修复 requirements.txt: 使用正确的 pip 包名(Pillow、opencv-python 等) - 更新 unified_tool_manager.py: 兼容 Plugin 和 Liugin 两种类名 - 更新系统提示词: 增强编程工作流指导 本次更新由 mimo 自动化执行,与开发者无关。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9eaf230 --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +logs/ +*.log +xiaoli_cli.log + +# Temporary files +nul +temp/ +tmp/ +*.tmp + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Chat history +chat_history/ + +# Config with secrets +config.json + +# Executables +*.exe +*.dll +*.so +*.appx + +# Development files +love.txt +bug.txt + +# Screenshots +clawli_files/ + +# 大型第三方插件 (NapCat QQ / GLaDOS TTS) +plugins/napcat/ +plugins/glados_tts/ +plugins/glados_tts_plugin.py diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..0b64651 --- /dev/null +++ b/HELP.md @@ -0,0 +1,80 @@ +# 小狸 Pro-CLI 使用指南 + +## 基本操作 + +### 启动和退出 +- 启动程序: 运行 `python ai_cli.py` +- 退出程序: 输入 `/quit` + +### 帮助命令 +- `/help` - 显示帮助信息 + +## AI引擎管理 + +### 切换AI引擎 +- `/model spark` - 切换到讯飞星火AI引擎 + +## 聊天记录管理 + +### 保存聊天记录 +- `/chat save <名称>` - 将当前对话保存为指定名称的聊天记录 + +### 查看聊天记录 +- `/chat list` - 显示所有已保存的聊天记录 + +### 加载聊天记录 +- `/chat open <名称>` - 加载指定名称的聊天记录并继续对话 + +## 插件工具 + +AI可以自动调用以下工具来帮助您完成各种任务: + +### 文件管理工具 +- 功能:读取、写入、追加、删除、复制、移动文件和目录操作 +- 用法示例: + - `file_manager list` - 列出当前目录内容 + - `file_manager read test.txt` - 读取文件内容 + - `file_manager write test.txt 内容` - 写入文件 + - `file_manager append test.txt 内容` - 追加内容到文件 + - `file_manager delete test.txt` - 删除文件 + - `file_manager create test.txt` - 创建空文件 + - `file_manager mkdir new_dir` - 创建目录 + - `file_manager copy source.txt dest.txt` - 复制文件 + - `file_manager move source.txt dest.txt` - 移动文件 + - `file_manager info test.txt` - 查看文件信息 + - `file_manager tree` - 显示目录树结构 + +### 音频播放工具 +- 功能:播放音频文件 +- 用法示例: + - `audio_player play` - 播放Ciallo~(∠・ω- )⌒☆-.mp3音频文件 + - `/ciallo` - 播放音频文件 + +## 基本操作命令 + +### 系统信息命令 +- `/about` - 显示程序信息和版本详情 + +## 使用技巧 + +1. **自然语言交互**:直接用自然语言与AI对话,AI会理解您的意图并自动调用相应工具。 + +2. **工具调用**:当AI需要执行特定任务时,会自动调用相应的插件工具,并在屏幕上显示执行结果。 + +3. **聊天记录管理**:可以随时保存当前对话,在需要时加载之前的对话继续交流。 + +4. **路径操作**: + - 支持用户主目录下的绝对路径操作 + - 支持项目目录和当前目录下的相对路径操作 + - 文件大小限制为50MB + +5. **等待动画**:AI思考时会显示随机的温馨句子,每5秒更换一次。 + +6. **插件命令**:可以使用 `/命令名` 直接调用插件功能,如 `/file`、`/read`、`/write`、`/ciallo` 等。 + +## 注意事项 + +- 请勿尝试访问系统关键目录(如Windows系统目录、/etc等) +- 文件操作大小限制为50MB +- 音频播放需要系统支持相应的音频格式 +- 可以随时使用 `/help` 命令查看帮助信息 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f7ce518 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 shuiyu1123 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..8c5bc1e --- /dev/null +++ b/README.en.md @@ -0,0 +1,36 @@ +# xiaoli cli + +#### Description +本仓库用于提交xiaoli系列源代码,本仓库源于github 2024年九月项目,因账户2fa码丢失,现已迁移至gitee + +#### Software Architecture +Software architecture description + +#### Installation + +1. xxxx +2. xxxx +3. xxxx + +#### Instructions + +1. xxxx +2. xxxx +3. xxxx + +#### Contribution + +1. Fork the repository +2. Create Feat_xxx branch +3. Commit your code +4. Create Pull Request + + +#### Gitee Feature + +1. You can use Readme\_XXX.md to support different languages, such as Readme\_en.md, Readme\_zh.md +2. Gitee blog [blog.gitee.com](https://blog.gitee.com) +3. Explore open source project [https://gitee.com/explore](https://gitee.com/explore) +4. The most valuable open source project [GVP](https://gitee.com/gvp) +5. The manual of Gitee [https://gitee.com/help](https://gitee.com/help) +6. The most popular members [https://gitee.com/gitee-stars/](https://gitee.com/gitee-stars/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..63ed28f --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# xiaoli cli + +#请前往下载压缩包获取开源代码 + +#### 介绍 +本仓库用于提交xiaoli系列源代码,本仓库源于github 2024年九月项目,因账户2fa码丢失,现已迁移至gitee + +#### 软件架构 +软件架构说明 + + +#### 安装教程 + +1. 安装至少python 3.11以上版本 +1. 或点击发行版内启动器快速安装 + +#### 使用说明 + +1. 直接点击启动器启动 diff --git a/about.txt b/about.txt new file mode 100644 index 0000000..4384a2d --- /dev/null +++ b/about.txt @@ -0,0 +1,47 @@ +**产品标识** +- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI) +- **内部版本号**:20260212 +- **发布版本**:发行版3.5 + +**技术规格** +- **主要语言**:简体中文(UTF-8) +- **开发语言**:Python 3.12.5 +- **开发文档**:6 +- **兼容性**:Windows 10 x64、x86-64及以上版本操作系统 + +**产品概述** +小狸智能体 增强-终端(Xiaoli Pro-CLI)是一款基于人工智能的命令行交互工具,通过模块化插件架构实现系统操作与任务流程的自动化执行。 + +**使用条款与免责声明** + +1. **插件开发要求** + 开发自定义插件需具备专业的Python开发能力。缺乏必要的技术背景可能导致插件运行异常、系统稳定性问题或安全风险。 + +2. **技术支持渠道** + 开发者如需插件技术支持(包括但不限于接口扩展、功能调试、开发指导等),请加入QQ技术交流群:**1043779619**,开发团队将在群内提供技术支持与服务。 + +3. **测试版本声明** + - 本软件目前处于测试阶段,可能存在未知缺陷。对于因软件漏洞导致的系统故障、数据丢失或任何间接损失,开发团队不承担任何法律责任。 + - 即软件进入正式版本,因用户操作不当(如指令AI删除系统关键目录、修改核心配置文件等)造成的设备故障或数据损坏,责任由操作者自行承担。 + +4. **第三方插件责任豁免** + 开发团队仅对官方发布的插件提供技术支持和质量保证。任何由第三方插件引发的系统兼容性问题、安全漏洞、功能异常或数据损坏,均不属于官方责任范围,使用者应直接联系相应插件的开发者寻求解决方案。 + +5. **知识产权保护与使用限制** + 本软件及其全部组成要素(包括但不限于源代码、算法架构、技术文档)均受知识产权法保护。**严禁任何形式的逆向工程行为**,包括但不限于反编译、反汇编、反向分析或任何试图获取软件源代码、破解技术实现的手段。 + + **我们严正声明**:所有必要的开发接口和技术规范均已完整收录于四个版本的官方开发文档中。任何通过逆向工程试图探索的功能实现,均可通过官方文档获得标准、安全的技术方案。 + + 任何违反本条款的行为均构成严重侵权,我们将采取包括法律诉讼在内的一切必要手段维护自身权益,并追究侵权者的全部法律责任。 + +**特别鸣谢:** + +**所有开源贡献者,插件开发者,技术文档撰写者,早期测试用户** + +- **Python** - 项目核心开发语言,提供了简洁优雅的编程体验 +- **Microsoft** - Windows操作系统及开发工具链的稳定支持 +- **Visual Studio Code** - 主力开发环境,卓越的代码编辑体验 +- **阿里巴巴** - iFlow CLI等开发工具的技术支持 +- **DeepSeek** - 优秀的AI模型能力,为项目提供智能支持 + +**最后,感谢所有为技术进步付出努力的人,包括正在阅读这份文档的您。** \ No newline at end of file diff --git a/ai_cli.py b/ai_cli.py new file mode 100644 index 0000000..88a88e1 --- /dev/null +++ b/ai_cli.py @@ -0,0 +1,3844 @@ +# 常量定义 +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 +import tkinter as tk +from tkinter import ttk +import cv2 +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 +from PIL import Image +from ascii_magic import AsciiArt +# 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}") + + +class TUIBridge: + """桥接类:让 TUI 能够调用同步的 AICLI 方法""" + + def __init__(self, cli): + self.cli = cli + self._executor = ThreadPoolExecutor(max_workers=4) + + def get_engines(self) -> dict: + return self.cli.engines + + def get_current_engine_name(self) -> str: + return self.cli.get_current_engine_name() + + def get_tools(self) -> list: + return self.cli.liugin_manager.tools + + def get_conversation_history(self) -> list: + return self.cli.shared_conversation_history + + def switch_engine(self, engine_name: str) -> bool: + if engine_name in self.cli.engines: + self.cli.switch_engine(engine_name) + return True + return False + + +class TUIMainApp(App): + """TUI 主应用""" + + CSS = """ + Screen { background: $surface; } + #chat-log { height: 1fr; } + #user-input { height: 3; } + #right-panel { width: 30; } + #status-bar { height: 1; dock: bottom; } + """ + + BINDINGS = [ + Binding("ctrl+c", "quit", "退出"), + Binding("ctrl+t", "switch_to_cli", "切换CLI"), + ] + + def __init__(self, cli): + super().__init__() + self.cli = cli + self.bridge = TUIBridge(cli) + self.is_generating = False + + def compose(self) -> ComposeResult: + with Horizontal(): + with Vertical(id="chat-area"): + yield RichLog(id="chat-log", wrap=True, max_lines=10000) + yield Input(placeholder="输入消息后按 Enter 发送...", id="user-input") + with Vertical(id="right-panel"): + yield Static("[b]AI 引擎[/b]") + yield Tree("引擎列表", id="engine-status") + yield Static("[b]可用工具[/b]") + yield RichLog(id="tools-log", wrap=True, max_lines=100) + yield Static("", id="status-bar") + + def on_mount(self) -> None: + self.title = "小狸 CLI - TUI" + self._update_engine_tree() + self._update_tools_list() + chat_log = self.query_one("#chat-log", RichLog) + chat_log.write(f"[bold cyan]欢迎使用小狸 CLI TUI 模式![/bold cyan]") + chat_log.write(f"[dim]当前引擎: {self.bridge.get_current_engine_name()}[/dim]") + chat_log.write(f"[dim]输入 /cli 切换到 CLI 模式[/dim]") + chat_log.write("") + self.query_one("#user-input", Input).focus() + self.update_status() + + def _update_engine_tree(self) -> None: + tree = self.query_one("#engine-status", Tree) + tree.reset("引擎列表") + engines = self.bridge.get_engines() + current = self.bridge.get_current_engine_name() + + for name in engines: + node = tree.root.add(f"* {name}") + if name == current: + node.add("(当前)") + tree.root.expand() + + def _update_tools_list(self) -> None: + tools_log = self.query_one("#tools-log", RichLog) + tools_log.clear() + tools = self.bridge.get_tools() + if tools: + for tool in tools[:15]: + name = tool.get('name', '未知') + desc = tool.get('description', '')[:35] + tools_log.write(f"- {name}: {desc}") + else: + tools_log.write("暂无工具") + + def update_status(self) -> None: + status = self.query_one("#status-bar", Static) + engine = self.bridge.get_current_engine_name() + history_len = len(self.bridge.get_conversation_history()) + status.update(f"引擎: {engine} | {history_len} 条对话 | Ctrl+T: CLI | /cli: 切换模式") + + def on_input_submitted(self, event: Input.Submitted) -> None: + user_input = event.value.strip() + if not user_input: + return + + if user_input == '/cli': + self.action_switch_to_cli() + return + + if user_input.startswith('/engine switch '): + engine_name = user_input[14:].strip() + if self.bridge.switch_engine(engine_name): + chat_log = self.query_one("#chat-log", RichLog) + chat_log.write(f"[green]已切换到引擎: {engine_name}[/]") + self._update_engine_tree() + self.update_status() + else: + chat_log = self.query_one("#chat-log", RichLog) + chat_log.write(f"[red]切换失败,引擎 '{engine_name}' 不存在[/]") + return + + chat_log = self.query_one("#chat-log", RichLog) + chat_log.write(f"[cyan]你:[/] {user_input}") + chat_log.write("") + event.input.value = "" + chat_log.write("AI 正在思考...") + self.run_worker(self._generate_response_async(user_input)) + + async def _generate_response_async(self, user_input: str) -> None: + """异步生成响应 - 通过回调输出到 TUI""" + try: + # 清除"正在思考"消息 + self.call_after_refresh(self._clear_thinking) + + # 包装回调以在主线程执行 + def safe_output(message): + self.call_after_refresh(self._write_chat, message) + + # 临时设置 CLI 的输出回调 + original_callback = self.cli.tui_output_callback + self.cli.tui_output_callback = safe_output + + try: + # 在线程池中运行 CLI + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.cli.process_conversation, user_input) + finally: + # 恢复原始回调 + self.cli.tui_output_callback = original_callback + + self.call_after_refresh(self.update_status) + except Exception as e: + import traceback + self.call_after_refresh(self._write_chat, f"[red]错误: {str(e)}[/]") + self.call_after_refresh(self._write_chat, f"[dim]{traceback.format_exc()}[/]") + finally: + self.is_generating = False + + def _write_chat(self, message: str) -> None: + """在主线程写入聊天日志""" + try: + chat_log = self.query_one("#chat-log", RichLog) + # RichLog 支持 ANSI 颜色代码,直接写入 + chat_log.write(message) + except Exception as e: + print(f"写入聊天失败: {e}") + + def _clear_thinking(self) -> None: + """清除'正在思考'消息""" + try: + chat_log = self.query_one("#chat-log", RichLog) + # 移除最后一条"正在思考"消息 + children = list(chat_log.children) + if children and "正在思考" in str(children[-1]): + chat_log.remove_child(children[-1]) + except: + pass + + def action_switch_to_cli(self) -> None: + import subprocess + import sys + self.exit() + # 启动新的 CLI 进程 + subprocess.Popen([sys.executable, __file__]) + # 退出当前进程 + os._exit(0) + + +# 配置文件路径 +CONFIG_FILE = os.path.join(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__) + +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.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): + """ + 搜索工具,支持关键词匹配 + + 参数: + query: 搜索关键词 + limit: 返回结果数量限制 + + 返回: + 匹配的工具列表,每个工具包含 name、description、usage + """ + 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', []) + }) + + # 按分数排序并返回前 limit 个 + 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 + + +class AICLI: + """AI CLI主程序""" + + def __init__(self): + # 初始化colorama + init(autoreset=True) + + # 确保插件管理器使用正确的路径 + project_dir = 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 # Clawli 消息监控线程 + self._clawli_monitor_running = False # 监控线程运行标志 + # TUI 输出回调(用于 TUI 模式下的实时输出) + self.tui_output_callback = None + # 初始化用户ID(基于机器硬件动态生成) + import uuid + self.user_id = str(uuid.getnode()) + + 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: + messages = clawli_server.get_messages() + if messages: + print(f"[Clawli Monitor] 收到 {len(messages)} 条消息") + for msg in messages: + self._handle_clawli_message(msg) + import time + 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 + + # 添加 Clawli 标识 + full_content = f"{content}\n\n[此消息由 Clawli 手机端发送]" + + # 添加到历史 + self.shared_conversation_history.append({ + "role": "user", + "content": full_content + }) + + # 调用 AI 处理 + 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: + 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: + 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: + 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 _extract_tool_calls(self, json_data) -> list: + """从 JSON 数据中提取工具调用列表(支持多种格式)""" + tool_calls = [] + + if isinstance(json_data, dict): + # 格式1: 小狸自有格式 {"action": "use_tool", "tool": "...", "args": "..."} + if json_data.get('action') == 'use_tool': + tool_calls.append(json_data) + # 格式2: 简化格式 {"tool": "...", "args": "..."} + elif 'tool' in json_data and 'args' in json_data: + tool_calls.append(json_data) + # 格式3: OpenAI function_call 格式 {"function_call": {"name": "...", "arguments": "..."}} + elif 'function_call' in json_data: + fc = json_data['function_call'] + if isinstance(fc, dict): + args = fc.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + 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 格式 {"name": "...", "arguments": {...}} + elif 'name' in json_data and 'arguments' in json_data: + args = json_data.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + 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 格式 {"type": "function", "function": {...}} + 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', '') + # arguments 可能是 JSON 字符串,需要解析 + 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 cleanup_resources(self): + """清理资源,确保在程序退出时正确关闭所有资源""" + print(f"{Fore.YELLOW}正在清理资源...{Style.RESET_ALL}") + # 停止 Clawli 监控 + self._stop_clawli_monitor() + # 停止 Clawli 服务器 + if CLAWLI_SERVER_AVAILABLE: + clawli_server.stop_server() + print(f"{Fore.GREEN}资源清理完成{Style.RESET_ALL}") + + def __del__(self): + """析构函数,确保资源被清理""" + self.cleanup_resources() + + def _process_code_blocks(self, text): + """处理包含""" """语法的文本,将代码块部分显示为红色""" + import re + from colorama import Fore, Style + # 使用正则表达式查找 """ """ 包围的内容 + # 模式匹配:""" 任意内容(非贪婪) """ + 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 + # 只保留前max_lines行 + return '\n'.join(lines[:max_lines]) + f"\n... (已截断,共{len(lines)}行)" + + def _get_default_engine_name(self): + """从配置文件获取默认引擎名称""" + try: + # 优先从配置文件读取默认引擎 + default_engine = get_system_config('default_engine') + if default_engine: + return default_engine + # 如果配置文件中没有,使用默认值 ollama + 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引擎插件目录 + ai_engines_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ai_engines") + # 加载AI引擎插件 + 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] # 移除.py扩展名 + 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 # 设置CLI引用 + # 获取引擎名称 + 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", + # 特殊处理:qwen3-coder-plus -> Qwen3CoderPlusAI + ''.join(word.capitalize() for word in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI", + # 特殊处理:qwen3-coder-plus_engine -> Qwen3CoderPlusAI(先处理_engine后缀) + ''.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')): # AI引擎类有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: + # 兼容旧版 LiuginManager + 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) + # 兼容 Liugin 和 Plugin 两种类名 + 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 _handle_remote_command(self, args): + """处理远程连接命令 + + 本地模式: /remote start <端口> <密码> + 代理模式: /remote proxy <服务器:端口> <服务器密码> + """ + 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 <服务器> <服务器密码> {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': + # 本地模式: /remote 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: + success = clawli_server.start_server("0.0.0.0", port, password) + if success: + self.is_clawli_mode = True + # 启动消息监控线程 + self._start_clawli_monitor() + else: + websocket_server.start_local_server(self, port, password) + + elif cmd == 'proxy': + # 代理模式: /remote proxy <服务器> <服务器密码> + if len(parts) < 5: + print(f"{Fore.RED}参数不足{Style.RESET_ALL}") + print(f"{Fore.YELLOW}用法: /remote proxy <服务器> <服务器密码> {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}") + websocket_server.start_proxy_client( + self, server_host, server_port, + server_password, pc_port, pc_password + ) + + elif cmd == 'stop': + # 停止所有服务 + if CLAWLI_SERVER_AVAILABLE: + clawli_server.stop_server() + self.is_clawli_mode = False + else: + websocket_server.stop_all() + print(f"{Fore.GREEN}远程服务已停止{Style.RESET_ALL}") + + elif cmd == 'status': + # 查看状态 + if CLAWLI_SERVER_AVAILABLE: + status = clawli_server.get_status() + else: + status = websocket_server.get_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': + # 显示本机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}") + + 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] + + # 确保引擎有CLI引用 + 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" + } + + # 显示历史对话给用户,以便新AI可以参考 + 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): + """处理引擎特定命令,例如Ollama引擎的模型切换""" + if not hasattr(self.current_engine, 'handle_command'): + return False # 引擎不支持处理命令 + + # 检查命令是否以引擎名称开头(如 "ollama streaming on") + # 如果是,则移除引擎名称前缀 + 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 群 @ 消息) + + Args: + message: 插件转发的消息内容 + **kwargs: 额外的上下文信息(如 group_id, user_id 等) + + Returns: + AI 的回复内容 + """ + try: + print(f"{Fore.CYAN}[插件消息] {message[:100]}...{Style.RESET_ALL}") + + # 构建系统提示 + system_prompt = """你是一个智能助手,正在通过 QQ 机器人与用户交流。 +请简洁、友好地回复用户的消息。回复时注意: +1. 回复要简洁明了,适合 QQ 群聊场景 +2. 不要提及你是 AI 或机器人 +3. 直接回答问题,不要加多余的前缀""" + + # 调用当前 AI 引擎处理 + if self.current_engine: + # 使用 generate_response 方法(统一接口) + 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}" + + def process_tool_call(self, tool_call_data): + """处理工具调用(支持 Liugin 和 MCP 两种格式)""" + try: + tool_name = tool_call_data.get('tool') + args = tool_call_data.get('args', '') + + # 检查是否是 MCP 格式调用(包含 arguments 对象) + arguments = tool_call_data.get('arguments') + + # 使用 unified_tool_manager 的方法执行 + if UNIFIED_TOOL_MANAGER_AVAILABLE and hasattr(self.liugin_manager, 'execute'): + if arguments and isinstance(arguments, dict): + # MCP 格式调用 + result = self.liugin_manager.execute(tool_name, args, **arguments) + else: + # Liugin 格式调用 + 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} + + # ESC键检测辅助函数(Windows专用) + def supports_esc_detection(): + """检查是否支持ESC键检测""" + 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 + + # ESC键检测线程(仅Windows支持) + 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': # ESC键 + 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检测线程 + 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}"}) + + # 按索引排序,保持AI指定的顺序 + results.sort(key=lambda x: x['index']) + + return [r['result'] for r in results] + + def _execute_single_tool(self, tool_data): + """执行单个工具(带延迟和ESC检测)""" + import sys + import time + + 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) + # 将工具结果作为新的输入传递给AI + 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 + + import sys + import msvcrt + + # 显示批量执行提示 + 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) + + # 将所有结果合并传递给AI + 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) + + def request_code_execution(self, liugin_name, code, timeout=None, variables=None, context=None): + """ + 插件代码执行请求接口 + + Args: + liugin_name: 请求执行的插件名称 + code: 要执行的Python代码 + timeout: 超时时间(秒),默认使用配置的超时时间 + variables: 要传递给代码的变量字典 + context: 上下文信息(可选) + + Returns: + { + 'success': bool, # 执行是否成功 + 'result': str, # 执行结果或错误信息 + 'stdout': str, # 标准输出 + 'stderr': str, # 标准错误 + 'execution_time': float, # 执行时间(秒) + 'memory_usage': int # 内存使用(字节) + } + """ + 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'] + }) + + # 添加到历史记录(最多保留100条) + 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): + """ + 检查代码是否安全(使用 AST 语法分析) + + Args: + code: 要检查的Python代码 + + Returns: + bool: 代码是否安全 + """ + import ast + import re + + # 危险模块黑名单 + DANGEROUS_MODULES = { + 'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle', + 'marshal', 'ctypes', 'multiprocessing', 'threading', + 'signal', 'resource', 'posix', 'nt', 'builtins', + 'importlib', 'code', 'codeop', 'commands', 'popen2', + } + + # 危险函数/属性黑名单 + DANGEROUS_FUNCTIONS = { + 'exec', 'eval', 'compile', 'execfile', 'open', + 'input', 'raw_input', 'globals', 'locals', 'vars', + 'dir', 'getattr', 'setattr', 'delattr', 'hasattr', + '__import__', '__builtins__', '__class__', '__bases__', + '__subclasses__', '__mro__', '__dict__', '__getattribute__', + } + + # 危险属性名称模式 + DANGEROUS_ATTRS = { + '__builtins__', '__class__', '__bases__', '__subclasses__', + '__mro__', '__dict__', '__globals__', '__code__', '__closure__', + '__name__', '__file__', '__package__', '__loader__', + } + + # 首先进行快速字符串检查 + dangerous_string_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', + ] + + for pattern in dangerous_string_patterns: + if re.search(pattern, code, re.IGNORECASE): + return False + + # 使用 AST 进行更精确的分析 + try: + tree = ast.parse(code) + except SyntaxError: + # 语法错误,拒绝执行 + return False + + class SafetyVisitor(ast.NodeVisitor): + def __init__(self): + self.is_safe = True + self.violations = [] + + def check_name(self, name, context=""): + if name in DANGEROUS_FUNCTIONS or name in DANGEROUS_ATTRS: + self.is_safe = False + self.violations.append(f"危险名称: {name} ({context})") + + def visit_Import(self, node): + for alias in node.names: + module_name = alias.name.split('.')[0] + if module_name in DANGEROUS_MODULES: + self.is_safe = False + self.violations.append(f"危险导入: {alias.name}") + self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module: + module_name = node.module.split('.')[0] + if module_name in DANGEROUS_MODULES: + self.is_safe = False + self.violations.append(f"危险导入: from {node.module}") + self.generic_visit(node) + + def visit_Call(self, node): + if isinstance(node.func, ast.Name): + self.check_name(node.func.id, "函数调用") + elif isinstance(node.func, ast.Attribute): + self.check_name(node.func.attr, "属性调用") + self.generic_visit(node) + + def visit_Attribute(self, node): + self.check_name(node.attr, "属性访问") + self.generic_visit(node) + + def visit_Name(self, node): + self.check_name(node.id, "名称引用") + self.generic_visit(node) + + visitor = SafetyVisitor() + visitor.visit(tree) + + if not visitor.is_safe: + logger.warning(f"代码安全检查失败: {visitor.violations}") + + return visitor.is_safe + + def _execute_code_safely(self, code, timeout, variables=None): + """ + 在安全沙箱中执行代码 + + Args: + code: 要执行的Python代码 + timeout: 超时时间(秒) + variables: 要传递的变量字典 + + Returns: + 执行结果字典 + """ + import io + import traceback + from contextlib import redirect_stdout, redirect_stderr + + # 准备变量 + exec_vars = {} + if variables: + exec_vars.update(variables) + + # 添加安全的内置函数和模块 + safe_builtins = { + 'print': print, + 'len': len, + 'str': str, + 'int': int, + 'float': float, + 'list': list, + 'dict': dict, + 'tuple': tuple, + 'set': set, + 'range': range, + 'enumerate': enumerate, + 'zip': zip, + 'sorted': sorted, + 'sum': sum, + 'max': max, + 'min': min, + 'abs': abs, + 'round': round, + 'bool': bool, + 'type': type, + 'isinstance': isinstance, + 'hasattr': hasattr, + } + + # 添加用户输入确认函数(仅在安全沙箱中允许) + def ask_user_confirmation(prompt, operation_id): + """ + 在代码执行中请求用户确认 + + 参数: + prompt: 提示信息 + operation_id: 操作ID + + 返回: + 用户输入的确认信息 + """ + print(f"\n{Fore.YELLOW}{'='*60}{Style.RESET_ALL}") + print(f"{Fore.YELLOW}⚠️ 需要用户确认{Style.RESET_ALL}") + print(f"{Fore.YELLOW}{'='*60}{Style.RESET_ALL}\n") + print(f"{Fore.CYAN}{prompt}{Style.RESET_ALL}\n") + print(f"{Fore.GREEN}操作ID: {operation_id}{Style.RESET_ALL}\n") + print(f"{Fore.YELLOW}请输入您的选择(y/yes 允许,其他拒绝):{Style.RESET_ALL} ", end='', flush=True) + + # 获取用户输入 + user_input = input().strip().lower() + + # 存储用户决定 + self.user_input_queue[operation_id] = user_input + + print(f"\n{Fore.YELLOW}{'='*60}{Style.RESET_ALL}\n") + + return user_input + + safe_builtins['ask_user_confirmation'] = ask_user_confirmation + + # 添加白名单模块 + for module_name in self.code_execution_whitelist: + try: + safe_builtins[module_name] = __import__(module_name) + except ImportError: + pass + + exec_vars.update(safe_builtins) + + # 创建输出捕获器 + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + # 执行代码 + start_time = time.time() + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + # 使用超时执行 + result = {'success': True, 'error': None} + + def execute_with_timeout(): + try: + exec(code, exec_vars) + except Exception as e: + result['success'] = False + result['error'] = str(e) + result['traceback'] = traceback.format_exc() + + # 创建超时线程 + import threading + exec_thread = threading.Thread(target=execute_with_timeout) + exec_thread.daemon = True + exec_thread.start() + exec_thread.join(timeout=timeout) + + if exec_thread.is_alive(): + return { + 'success': False, + 'result': f'代码执行超时(超过{timeout}秒)', + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue(), + 'execution_time': timeout, + 'memory_usage': 0 + } + + # 获取执行结果 + if result['success']: + # 尝试获取代码中的 'result' 变量 + exec_result = exec_vars.get('result', None) + if exec_result is not None: + result_str = str(exec_result) + else: + result_str = '代码执行成功(无返回值)' + + return { + 'success': True, + 'result': result_str, + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue(), + 'execution_time': time.time() - start_time, + 'memory_usage': 0 # 简化版暂不跟踪内存 + } + else: + return { + 'success': False, + 'result': f"代码执行错误: {result['error']}", + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue() + '\n' + result['traceback'], + 'execution_time': time.time() - start_time, + 'memory_usage': 0 + } + + except Exception as e: + return { + 'success': False, + 'result': f"代码执行异常: {str(e)}", + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue() + '\n' + traceback.format_exc(), + 'execution_time': time.time() - start_time, + 'memory_usage': 0 + } + + def get_code_execution_history(self, limit=10): + """ + 获取代码执行历史 + + Args: + limit: 返回的最大条数 + + Returns: + 执行历史列表 + """ + 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): + """ + 设置代码执行超时时间 + + Args: + timeout: 超时时间(秒) + """ + if timeout > 0: + self.code_execution_timeout = timeout + logger.info(f"代码执行超时时间已设置为 {timeout} 秒") + + def set_code_execution_whitelist(self, modules): + """ + 设置允许导入的模块白名单 + + Args: + modules: 模块名称列表 + """ + self.code_execution_whitelist = modules + logger.info(f"代码执行模块白名单已更新: {modules}") + + 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: + # TUI 模式:使用 Textual 标记 + 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: + # CLI 模式:使用 colorama + 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 display_image_in_terminal(self, image_path, width=None): + """在终端中显示图片(优先使用 ASCII 艺术,然后是系统查看器) + + Args: + image_path: 图片文件路径 + width: 可选,指定ASCII图片的宽度(字符数)。如果为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 # 预留空间给信息 + + # 如果未指定宽度,则使用终端宽度的100% + if width is None: + width = min(250, terminal_width) + + # 读取图片 + with Image.open(image_path) as img: + # 调整大小以适应终端 + img_width, img_height = img.size + + # 使用四分之一块,每个字符显示 2x2 像素 + # 所以实际可以显示的像素是字符的 2 倍宽、2 倍高 + 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 = int(img_width * scale) + new_height = int(img_height * scale) + + # 确保至少有一定的尺寸 + new_width = max(new_width, 80) + new_height = max(new_height, 40) + + # 调整图片大小 + img = img.resize((new_width, new_height), Image.LANCZOS) + + # 转换为 RGB + if img.mode != 'RGB': + img = img.convert('RGB') + + # 获取像素数据 + pixels = img.load() + + print(f"{Fore.YELLOW}正在生成色块图片 ({new_width}x{new_height} 像素)...{Style.RESET_ALL}") + + # 使用四分之一块字符 + # 每个字符显示 2x2 像素块 + # 字符映射: + # 左上 右上 + # 左下 右下 + + 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): '█', # 全块 + } + + # 遍历 2x2 像素块 + for y in range(0, new_height, 2): + line = [] + for x in range(0, new_width, 2): + # 获取 2x2 像素的颜色 + # 左上 + if y < new_height and x < new_width: + r1, g1, b1 = pixels[x, y] + has_tl = True + else: + r1, g1, b1 = 0, 0, 0 + has_tl = False + + # 右上 + if y < new_height and x + 1 < new_width: + r2, g2, b2 = pixels[x + 1, y] + has_tr = True + else: + r2, g2, b2 = 0, 0, 0 + has_tr = False + + # 左下 + if y + 1 < new_height and x < new_width: + r3, g3, b3 = pixels[x, y + 1] + has_bl = True + else: + r3, g3, b3 = 0, 0, 0 + has_bl = False + + # 右下 + if y + 1 < new_height and x + 1 < new_width: + r4, g4, b4 = pixels[x + 1, y + 1] + has_br = True + else: + r4, g4, b4 = 0, 0, 0 + has_br = False + + # 计算平均颜色(用于前景色) + 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: 使用系统默认图片查看器(备用方案) + import platform + import subprocess + + system = platform.system() + + if system == 'Windows': + os.startfile(image_path) + print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n") + elif system == 'Darwin': # macOS + subprocess.run(['open', image_path]) + print(f"{Fore.GREEN}✓ 已在系统默认图片查看器中打开{Style.RESET_ALL}\n") + else: # Linux + 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 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)) + # 去掉.json扩展名显示 + chat_name = file[:-5] # 移除".json" + 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 + + def show_help(self): + """显示帮助信息""" + help_file_path = os.path.join(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): + """显示特定插件的帮助信息""" + # 首先检查是否为AI引擎插件 + 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}引擎的特定命令") + print(f"{Fore.GREEN}示例:{Style.RESET_ALL}") + print(f" /engine.{liugin_name} help - 查看{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']}") + else: + print(f"{Fore.YELLOW}该插件未提供具体用法信息{Style.RESET_ALL}") + # 显示插件关键词 + 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}") + else: + print(f"{Fore.YELLOW}当前没有加载任何插件{Style.RESET_ALL}") + + def _show_basic_help(self): + """显示基本帮助信息""" + help_text = """ +小狸 Pro-CLI 帮助信息 +================== +基本命令: + /help - 显示此帮助信息 + /help <插件名> - 显示特定插件的帮助信息 + /quit - 退出程序 + /tui - 切换到 TUI 模式 + /model <引擎名> - 切换AI引擎 (当前支持: """ + # 动态添加可用引擎 + available_engines = [] + for engine_name in self.engines.keys(): + available_engines.append(engine_name) + help_text += ", ".join(available_engines) + ")" + help_text += r""" +文件操作命令: + /file.read <文件名> [行数] - 直接读取文件内容, 可指定读取行数 + @文件路径 - 发送时自动读取文件内容并拼接(支持多文件) + 例如: 请分析这个文件 @c:\test.py + 例如: @c:\test.py @c:\config.json 帮我分析 + @图片路径 - 自动分析图片并获取描述(支持PNG/JPG/GIF等) + 例如: 请描述这张图片 @c:\photo.png + 例如: @c:\photo.png 这个图片里有什么? +图像分析命令: + /image analyze <图片路径> - 详细分析图片 + /image ocr <图片路径> - OCR文字识别 + /image quick <图片路径> - 快速描述 + /image model <模型名> - 切换视觉模型 + /image models - 列出可用模型 + /image engine <引擎名> - 切换识别引擎 + /image engines - 列出所有引擎 + /img <操作> <参数> - 简写形式 +引擎管理命令: +""" + self._get_engine_commands_help() + """ + /engine.<命令> - 执行引擎特定命令(如Ollama引擎的模型切换) +插件命令: + 插件可以注册自定义命令, 使用 /<命令名> 的格式调用 +聊天记录命令: + /chat save <名称> - 保存当前聊天记录 + /chat list - 查看所有已保存的聊天记录 + /chat open <名称> - 加载聊天记录 +深度思考处理命令: + /thinking <内容> - 处理深度思考AI返回的内容, 只提取回复部分 +插件工具: + AI可以自动调用以下工具来帮助您: +""" + # 显示可用的插件工具 + 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" + help_text += """ +使用说明: + - 直接输入自然语言与AI对话 + - AI会根据您的请求自动决定是否需要调用工具 + - 工具调用结果会显示在屏幕上 + - 可以随时保存和加载聊天记录 + - 使用/thinking命令可以处理深度思考AI返回的内容, 只显示回复部分 +""" + print(help_text) + + 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{Style.RESET_ALL}") + return + app = TUIMainApp(self) + app.run() + + def run(self): + """运行 CLI""" + # 获取机器码作为用户ID + 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}") + # 运行 CLI 主循环 + self._run_cli_loop() + + def _process_file_paths(self, user_input): + """处理用户输入中的 @文件路径 语法,读取文件内容并拼接 + + 格式: @文件路径 + 例如: @c:\test.py + + 返回: 处理后的文本内容 + """ + import re + + # 支持的图片格式 + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'} + + # 匹配 @文件路径 格式(支持带引号的路径和空格,以及Windows路径) + # 匹配模式: @"路径" 或 @路径(包括反斜杠) + # 注意:使用非贪婪匹配,避免匹配到后面的空格 + 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) + + # 检查file_path是否为None + if not file_path: + continue + + # 规范化路径(处理 Windows 路径) + 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 = [] + for entry in entries: + full_path = os.path.join(file_path, entry) + if os.path.isfile(full_path): + files.append(entry) + + if not files: + result_parts.append(f"[目录: {file_path} 为空]") + print(f"{Fore.YELLOW}目录为空: {file_path}{Style.RESET_ALL}") + 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) + print(f"{Fore.GREEN}已列出目录内容: {file_path} ({len(files)} 个文件){Style.RESET_ALL}") + + except PermissionError: + result_parts.append(f"[错误: 无权限访问目录 {file_path}]") + print(f"{Fore.RED}无权限访问目录: {file_path}{Style.RESET_ALL}") + except Exception as e: + result_parts.append(f"[错误: 无法读取目录 {file_path} - {str(e)}]") + print(f"{Fore.RED}无法读取目录 {file_path}: {e}{Style.RESET_ALL}") + + # 检查是否为图片文件 + 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: + print(f"{Fore.MAGENTA}图片分析结果: {image_result[:100]}...{Style.RESET_ALL}" if len(image_result) > 100 else f"{Fore.MAGENTA}图片分析结果: {image_result}{Style.RESET_ALL}") + # 将图片描述添加到结果 + result_parts.append(f" {image_result}") + print(f"{Fore.GREEN}图片分析完成,已添加到输入{Style.RESET_ALL}") + else: + print(f"{Fore.YELLOW}图片分析返回空结果{Style.RESET_ALL}") + result_parts.append(f" [图片: {file_path}]") + except Exception as e: + result_parts.append(f"[错误: 图片分析失败 - {str(e)}]") + print(f"{Fore.RED}图片分析失败: {e}{Style.RESET_ALL}") + else: + result_parts.append(f"[错误: 图像分析插件未加载]") + print(f"{Fore.RED}图像分析插件未加载{Style.RESET_ALL}") + + # 处理文本文件(非图片) + else: + # 检查文件大小 + file_size = os.path.getsize(file_path) + if file_size > MAX_FILE_SIZE: + result_parts.append(f"[错误: 文件 {file_path} 超过大小限制 ({MAX_FILE_SIZE} 字节)]") + print(f"{Fore.YELLOW}警告: 文件 {file_path} 超过大小限制,已跳过{Style.RESET_ALL}") + 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") + print(f"{Fore.GREEN}已读取文件: {file_path} ({len(file_content)} 字符){Style.RESET_ALL}") + 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") + print(f"{Fore.GREEN}已读取文件: {file_path} ({len(file_content)} 字符) [GBK编码]{Style.RESET_ALL}") + except Exception as e: + result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]") + print(f"{Fore.RED}无法读取文件 {file_path}: {e}{Style.RESET_ALL}") + except Exception as e: + result_parts.append(f"[错误: 无法读取文件 {file_path} - {str(e)}]") + print(f"{Fore.RED}无法读取文件 {file_path}: {e}{Style.RESET_ALL}") + else: + result_parts.append(f"[错误: 文件 {file_path} 不存在]") + print(f"{Fore.RED}文件不存在: {file_path}{Style.RESET_ALL}") + + last_pos = match.end() + except Exception as e: + result_parts.append(f"[错误: 处理文件路径时出错 - {str(e)}]") + print(f"{Fore.RED}处理文件路径时出错: {e}{Style.RESET_ALL}") + last_pos = match.end() + + # 添加最后的部分 + if last_pos < len(user_input): + result_parts.append(user_input[last_pos:]) + + # 合并所有部分 + return ''.join(result_parts) + + 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 '): + # 处理 /help <插件名> 命令 + liugin_name = user_input[6:].strip() # 移除 '/help ' + self.show_liugin_help(liugin_name) + continue + # 处理引擎相关命令 + if user_input.startswith('/engine '): + engine_command = user_input[8:].strip() # 移除 '/engine ' + # 使用动态注册的引擎命令处理 + 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 + # 处理引擎特定命令(如Ollama引擎的模型切换) + if user_input.startswith('/engine.'): + engine_command = user_input[8:].strip() # 移除 '/engine.' + 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() # 移除 '/file.read ' + 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() # 移除 '/chat ' + if chat_command.startswith('save '): + name = chat_command[5:].strip() # 移除 'save ' + 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() # 移除 'open ' + 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() # 移除 '/remote' + self._handle_remote_command(remote_command) + continue + # 处理 /about 命令 + if user_input == '/about': + self._read_about_file() + continue + # 处理 /tui 命令 - 切换到 TUI 模式 + 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: + # 限制输出为5行 + 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 + # 处理深度思考AI回复命令 + if user_input.startswith('/thinking '): + # 专门处理深度思考AI返回的内容,只提取回复部分 + thinking_input = user_input[10:].strip() # 移除 '/thinking ' + processed_response = self.process_thinking_response(thinking_input) + # 处理 """ """ 代码块语法 + processed_response = self._process_code_blocks(processed_response) + # 显示处理后的回复内容并附带用户ID(蓝色) + terminal_size = shutil.get_terminal_size() + terminal_width = terminal_size.columns + user_id_display = f"[用户ID: {self.user_id}]" + # 分行处理响应,确保换行符正确显示 + response_lines = processed_response.split('\n') + for i, line in enumerate(response_lines): + # 在第一行(AI响应)前添加符号并留空格 + if i == 0: + line = "✦ " + line + if i == len(response_lines) - 1: # 最后一行添加用户ID + 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}") + # 调试:打印处理后的输入 + print(f"{Fore.MAGENTA}处理后的输入: {user_input[:200]}...{Style.RESET_ALL}" if len(user_input) > 200 else f"{Fore.MAGENTA}处理后的输入: {user_input}{Style.RESET_ALL}") + + # 生成AI响应 - 实现完整的工具调用循环 + self.process_conversation(user_input) + except KeyboardInterrupt: + # 显示退出信息并附带用户ID + terminal_size = shutil.get_terminal_size() + terminal_width = terminal_size.columns + user_id_display = f"[用户ID: {self.user_id}]" + print(f"\n{Fore.GREEN}再见! {user_id_display}{Style.RESET_ALL}") + break + except Exception as e: + # 显示错误信息并附带用户ID + terminal_size = shutil.get_terminal_size() + terminal_width = terminal_size.columns + user_id_display = f"[用户ID: {self.user_id}]" + print(f"{Fore.RED}发生错误: {e} {user_id_display}{Style.RESET_ALL}") + + def _output(self, message): + """统一输出方法,支持 TUI 和 CLI 模式""" + if self.tui_output_callback: + # TUI 模式:发送到 TUI 显示 + self.tui_output_callback(message) + else: + # CLI 模式:直接打印 + 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: + # TUI 模式:使用 Textual 标记 + 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: + # CLI 模式:使用 colorama + 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 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 # 最大循环次数,防止AI无限循环 + while loop_count < max_loops: + # 生成AI响应,传递插件提示词 + # 添加等待动画 + 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: + # 首先尝试解析混合响应(文本+JSON指令) + text_content, json_data = self._parse_mixed_response(processed_response) + # 如果解析到了JSON数据,则优先处理JSON指令 + 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) + + # 判断JSON数据类型 + 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 + + # 检查是否AI需要继续思考/操作 + if (response_data.get('continue') is True or + response_data.get('need_continue') is True or + response_data.get('think_more') is True): + # AI需要继续操作,获取message字段的内容 + continue_message = response_data.get('message', '') + if continue_message: + # 使用message字段的内容作为AI的响应 + processed_response = continue_message + # 将AI的响应添加到对话历史 + self.shared_conversation_history.append({ + "role": "assistant", + "content": processed_response + }) + # 显示AI的中间响应 + self._display_response(processed_response, is_continue=True) + # 继续循环,让AI继续思考 + loop_count += 1 + # 检查是否有message字段,如果有则显示 + 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) + # 将AI响应添加到对话历史 + 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: + # 如果没有文本内容,将JSON响应添加到对话历史 + 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 + # 如果list里没有use_tool,检查是否有continue相关字段 + 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: + # 没有工具调用也没有continue,显示文本并结束 + if text_content: + self.shared_conversation_history.append({ + "role": "assistant", + "content": text_content + }) + self._display_response(text_content or processed_response) + break + else: + # 没有解析到JSON数据,直接显示文本内容或原始响应 + display_content = text_content if text_content else processed_response + # 将AI响应添加到对话历史 + self.shared_conversation_history.append({ + "role": "assistant", + "content": display_content + }) + # 显示最终结果 + self._display_response(display_content) + break + except json.JSONDecodeError: + # 当JSON解析失败时,尝试解析混合内容 + 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 检测 + import threading + import time + import sys + import random + import os + animation_running = True + esc_pressed = threading.Event() + love_sentences = [] + love_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "love.txt") + 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)) + if len(current_sentence) > 50: + display_sentence = current_sentence[:47] + "..." + else: + display_sentence = 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) + + 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 process_thinking_response(self, response): + """处理深度思考AI返回的内容, 分离思考和回复内容 + 这个方法用于处理那些包含思考过程和回复内容的AI响应 + 思考内容用当前引擎的特定标记包围,以灰色文本显示给用户但不处理 + """ + try: + # 尝试解析JSON格式的响应 + response_data = json.loads(response) + # 如果是工具调用,直接返回 + if isinstance(response_data, dict) and response_data.get('action') == 'use_tool': + return response + # 如果是其他JSON格式,尝试提取回复内容 + if isinstance(response_data, dict): + # 假设回复内容在'answer'或'response'字段中 + 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: + # 如果没有找到特定字段,返回整个JSON的字符串表示 + return json.dumps(response_data, ensure_ascii=False, indent=2) + + # 提取内容后,继续检查是否包含思考标记 + return self._process_content_with_thinking(extracted_content) + except json.JSONDecodeError: + # 不是JSON格式,直接处理 + 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 + + # 支持多种思考标记格式 + # 格式1: ... + # 格式2: + # 格式3: 自定义标记 + + # 检查 格式(Qwen3等模型使用) + if '' in content: + parts = content.split('') + 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'来表现可爱性格,但不要过度使用。""" + + # 检查 tool_search 是否存在 + 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 + + 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) + if len(lines) < max_lines: + info = f"(文件共{len(lines)}行)" + else: + info = 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: + # 如果UTF-8解码失败, 尝试二进制模式获取文件大小等信息 + 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 _clean_json_response(self, response): + """清理AI响应中的Markdown代码块标记""" + # 移除开头的```json标记 + if response.startswith("```json"): + response = response[7:] # 移除"```json" + elif response.startswith("```"): + response = response[3:] # 移除"```" + # 移除结尾的```标记 + if response.endswith("```"): + response = response[:-3] # 移除"```" + # 移除可能的前后空白字符 + return response.strip() + + def get_liugin_usage_prompts(self): + """获取插件和技能提示词 + + 返回所有工具的基本信息,包括 Liugin 和 Skill 两种协议。 + 对于 Skill 协议的工具,包含详细的指令内容。 + 在 Clawli 模式下,额外提供 send_image 工具说明。 + """ + if not self.liugin_manager.tools: + return "当前没有可用的工具插件." + + prompts = [] + prompts.append("可用的工具列表:\n") + + # 分离 Liugin 和 Skill + plugins = [] + skills = [] + for tool in self.liugin_manager.tools: + protocol = tool.get('protocol', 'liugin') + if protocol == 'skill': + skills.append(tool) + else: + plugins.append(tool) + + # Plugin 工具(基本信息) + 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}") + + # Skill 工具(包含指令) + 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}") + + # Clawli 模式特殊工具提示 + 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 _read_about_file(self): + """读取并返回about.txt文件的内容,逐字显示""" + try: + # 获取项目根目录路径 + project_dir = 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() + # 逐字显示内容 + import sys + import time + 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 _parse_mixed_response(self, response): + """ + 解析混合响应,处理同时包含文本和JSON指令的内容 + 返回格式: (text_content, json_data) + 其中text_content是普通文本部分,json_data是解析出的JSON指令(如果存在) + json_data可以是单个dict,也可以是dict列表(批量工具调用) + """ + import re + import json as json_module + response = response.strip() + + # 方法0: 处理 ... 格式(缺少开头的 {) + if response.startswith(""): + # 移除 标签,添加缺失的 { + json_str = response[11:] # 移除 + json_str = "{" + json_str # 添加缺失的 { + try: + parsed = json_module.loads(json_str) + if isinstance(parsed, dict): + return "", parsed + except json_module.JSONDecodeError: + # 如果还是失败,尝试更多修复 + pass + + # 方法1: 尝试直接解析为纯JSON + try: + parsed = json_module.loads(response) + if isinstance(parsed, list): + return "", parsed + elif isinstance(parsed, dict): + return "", parsed + except json_module.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_module.loads(fixed_response) + if isinstance(parsed, list): + return "", parsed + elif isinstance(parsed, 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_module.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_module.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_module.loads(line) + if isinstance(parsed, dict): + json_objects.append(parsed) + continue + except json_module.JSONDecodeError: + pass + text_lines.append(line) + + if json_objects: + if len(json_objects) == 1: + text_content = "\n".join(text_lines).strip() + return text_content, json_objects[0] + else: + text_content = "\n".join(text_lines).strip() + return text_content, json_objects + + # 方法5: 查找内联的JSON对象(使用计数器匹配嵌套括号) + # 查找 {"action": "use_tool" 或 {"action": "continue" 或 {"action":"use_tool" 或 {"action":"continue" + 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_module.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_module.JSONDecodeError: + pass + break + + # 方法6: 尝试查找任何以{开头并以}结尾的JSON对象(跨行) + # 使用更通用的方法:找到所有可能的JSON对象 + potential_jsons = re.findall(r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}', response, re.DOTALL) + for json_str in potential_jsons: + try: + json_data = json_module.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_module.JSONDecodeError: + continue + + # 方法7: 如果是连续多个JSON对象(批量调用) + # 尝试解析整个响应为JSON数组 + try: + # 清理可能的文本前缀 + cleaned = response.strip() + # 找到最后一个完整的JSON对象开始(支持use_tool和continue) + 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对象 + json_part = cleaned[last_brace_pos:] + # 尝试解析为JSON数组 + if json_part.startswith('['): + parsed = json_module.loads(json_part) + if isinstance(parsed, list): + text_content = cleaned[:last_brace_pos].strip() + return text_content, parsed + # 尝试解析为单个JSON + else: + parsed = json_module.loads(json_part) + if isinstance(parsed, dict): + text_content = cleaned[:last_brace_pos].strip() + return text_content, parsed + except: + pass + + # 如果都没有找到JSON,返回原始响应作为文本 + return response, None + + def start_video_player(self): + """启动视频播放窗口""" + # 在新线程中启动视频播放,避免阻塞主程序 + video_thread = threading.Thread(target=self._show_video_window, daemon=True) + video_thread.start() + + def _show_video_window(self): + """显示视频播放窗口""" + try: + # 创建tkinter窗口 + 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.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)) + # 计算窗口大小,保持原始比例,最大不超过屏幕的40% (之前是80%) + 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}") + # 设置窗口为透明(仅在Windows上有效) + 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 # 确保使用外层的cap变量 + try: + ret, frame = cap.read() + if ret: + # 保持原始比例缩放,使用更好的插值方法 + frame = cv2.resize(frame, (window_width, window_height), interpolation=cv2.INTER_CUBIC) + # 将BGR转换为RGB + 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) + # 创建透明色(白色或黑色区域,会被tkinter视为透明) + frame_rgb[mask > 0] = [0, 0, 0] # 黑色区域保持黑色,会被视为透明 + # 将numpy数组转换为PhotoImage + 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 + # 继续播放下一帧 + # 使用视频原始FPS + delay = int(1000 / fps) if fps > 0 else 33 # 默认约30fps + root.after(delay, update_frame) + else: + # 视频播放完毕,开始渐隐效果 + cap.release() + self._fade_out(root) # 调用渐隐方法 + except tk.TclError: + # 处理tkinter相关的错误,如窗口被意外关闭 + 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): + """实现窗口渐隐效果""" + # 获取当前透明度,如果没有设置则默认为1.0(完全不透明) + try: + alpha = float(root.wm_attributes("-alpha")) + except: + alpha = 1.0 + # 每次减少0.1的透明度,实现渐隐效果 + alpha -= 0.1 + if alpha <= 0: + # 透明度降到0,关闭窗口 + try: + root.destroy() + except: + pass # 窗口可能已经被销毁 + else: + # 设置新的透明度 + root.wm_attributes("-alpha", alpha) + # 继续渐隐效果,每50毫秒执行一次 + root.after(50, self._fade_out, root, duration) + + +# ============================================================================ +# Main Entry Point - CLI Mode +# ============================================================================ +if __name__ == "__main__": + import time + import sys + + # 启动时逐行输出艺术字 + art_lines = [ + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "███████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓██████████████████", + "██████████ ▓████████████████", + "██████████ ▒███████████████", + "██████████ ░██████████████", + "██████████ ░█████████████", + "██████████ ████████████", + "██████████ ███████████", + "██████████ ▒████████████████████████████████████████████████████", + "██████████ ░██████████████████████████████████████████████████████", + "██████████ ████████████████████████████████████████████████████████", + "██████████ ▓█████████████████████████████████████████████████████████", + "██████████ ▒███████████████████████████████████████████████████████████", + "██████████ ███████████▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓██████████", + "██████████ ███████████▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒██████████", + "██████████ ▓██████████▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", + "██████████ ░███████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒██████████", + "██████████ ███████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▓▓▒██████████", + "██████████ ███████████▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▓██████████", + "██████████ ▓██████████▓▒▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓▒▒▒▓██████████", + "██████████ ░██████████▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▒▓▓██████████", + "██████████ ███████████▒▓▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▒▓▒▓▒▒▒▒▒▒▓▒▓▒▓██████████", + "██████████ ███████████▒▒▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▓▒▒▓▒▒▒▒▒▓▒▓▒▓▒▒▒▓▓██████████", + "██████████ ░██████████▒▓▒▓▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▒▒▒▒▓▒▓▒▓▒▒▓▒▒▒▓▒▒▒██████████", + "██████████ █████████▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒██████████", + "██████████ █████████▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", + "██████████ ▓████████▒▓▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▓▒▒▒██████████", + "██████████ █████████▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▒▒▒▒▓▒██████████", + "██████████ █████████▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▓▒▒▒██████████", + "██████████ ▓████████▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▓▒▒▒▓██████████", + "██████████ █████████▒▓▒▒▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▓▒▒▒██████████", + "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▓▒▒▒██████████", + "██████████ ▓████████▒▓▒▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▒▒▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▓▒▒▒▒▓▒▓▒▒▒▓▒▓██████████", + "██████████ █████████▒▒▓▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓██████████", + "██████████ █████████▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▒▒██████████", + "██████████ ▓████████▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▒▓▒▓▒▒▓▒▒▓▓▒▒██████████", + "██████████ █████████▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▓██████████", + "██████████ █████████▒▒▓▒▒▓▒▒▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒██████████", + "██████████ ▓████████▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓██████████", + "██████████ █████████▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓██████████", + "██████████ █████████▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒▓▒▒▓▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▒▒▓██████████", + "██████████ ▓████████▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▓▒▓▒▒▒▓▒▓▒▒▓▒▓▓▓▒▒▒██████████", + "███████████ █████████▒▓▒▒▓▒▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▒▒▓▒▒██████████", + "█████████████▓ █████████▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▒▓▒▒▒██████████", + "█████████████████ ▓████████▒▓▒▒▓▒▒▒▓▒▒▓▒▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▓▓▒▒██████████", + "███████████████████▒ █████████▒▒▓▒▒▒▓▒▒▓▒▒▓▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▓▒▒▓▒▒▓▒▒▓▒▒▓██████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████", + "█████████████████████████████████████████████████████████████████████████████████████████████████████" + ] + + # 设置标准输出编码为 UTF-8 + 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() diff --git a/ai_engines/GLM_http_engine.py b/ai_engines/GLM_http_engine.py new file mode 100644 index 0000000..0c990e8 --- /dev/null +++ b/ai_engines/GLM_http_engine.py @@ -0,0 +1,210 @@ +import os +import json +import requests +from colorama import Fore, Style +from openai import OpenAI + +# 获取项目根目录 +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) + + +class Glm_http_engineAI: + """基于HTTP协议的智谱AI GLM引擎""" + requires_api_key = True # GLM HTTP引擎需要API密钥 + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = """ +GLM HTTP AI引擎插件帮助信息 +========================== + +引擎名称: glm_http +描述: 基于HTTP协议的智谱AI GLM(GLM-4.5-Flash)引擎,支持智谱AI服务 + +可用命令: + 无特定命令,直接与AI对话即可 + +使用说明: +1. 此引擎使用HTTP协议调用智谱AI API +2. 使用/engine.switch glm_http切换到此引擎 +3. 直接输入自然语言与AI对话 +4. 支持普通对话、流式对话、JSON模式等多种调用方式 +""" + return help_text + + def __init__(self): + self.name = "glm_http" + self.cli = None + + # 直接从 config.json 读取配置 + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("GLM_http", {}) + self.api_key = engine_config.get("api_key", "") + self.base_url = engine_config.get("base_url", "") + self.model_id = engine_config.get("model", "") + except Exception as e: + # 如果读取失败,使用空值 + self.api_key = "" + self.base_url = "" + self.model_id = "" + + # 初始化对话历史 + self.conversation_history = [] + # 共享对话历史引用(由CLI设置) + self.shared_conversation_history = None + # 设置最大历史记录数 + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + self.max_history = config.get("system", {}).get("max_history", 10) + except: + self.max_history = 10 + + # 初始化 OpenAI 客户端(配置从 config.json 动态读取) + try: + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url if self.base_url else None, + timeout=None + ) + except Exception as e: + print(f"初始化 OpenAI 客户端失败: {e}") + self.client = None + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI響應 - 使用智谱AI HTTP API""" + try: + # 如果有工具結果,將其作為上下文返回給AI處理 + if tool_results: + tool_result_text = f"工具執行結果: {tool_results.get('result', '無結果')}" + + # 構建包含歷史記錄的消息列表 + messages = self._build_messages_with_history(system_prompt, tool_result_text) + + # 調用API + response = self._call_api(messages) + + if response: + return response + else: + return "智谱AI HTTP API調用失敗" + + # 構建包含歷史記錄的消息列表 + messages = self._build_messages_with_history(system_prompt, user_input) + + # 調用API + response = self._call_api(messages) + + if response: + return response + else: + return "智谱AI HTTP API調用失敗" + + except Exception as e: + # 如果API調用失敗,返回錯誤信息 + return f"智谱AI HTTP API調用失敗: {str(e)}" + + def _call_api(self, messages, use_stream=False, extra_body=None): + """調用API的核心方法""" + if extra_body is None: + extra_body = {} + + try: + response = self.client.chat.completions.create( + model=self.model_id, + messages=messages, + stream=use_stream, + temperature=0.7, + max_tokens=4096, + extra_headers={"lora_id": "0"}, # 調用微調大模型時,對應替換為模型服務卡片上的resourceId + stream_options={"include_usage": True}, + extra_body=extra_body + ) + + if use_stream: + # 處理流式響應 + full_response = "" + for chunk in response: + if hasattr(chunk.choices[0].delta, 'content') and chunk.choices[0].delta.content: + content = chunk.choices[0].delta.content + full_response += content + return full_response + else: + # 處理非流式響應 + return response.choices[0].message.content + + except Exception as e: + print(f"智谱AI HTTP API請求出錯: {e}") + return None + + def _build_messages_with_history(self, system_prompt, user_input): + """構建包含歷史記錄的消息列表""" + messages = [] + + # 添加系統提示詞 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加歷史對話記錄(使用共享歷史) + if self.shared_conversation_history is not None: + # 確保歷史記錄格式正確 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正確,跳過該條記錄 + continue + + # 添加當前用戶輸入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + def _call_api_with_history(self, content, system_prompt=None): + """使用對話歷史調用API""" + # 構建消息列表 + messages = [] + # 添加系統提示詞(如果提供) + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + # 添加歷史對話記錄(使用共享歷史) + if self.shared_conversation_history is not None: + # 確保歷史記錄格式正確 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正確,跳過該條記錄 + continue + # 添加當前內容 + messages.append({ + "role": "tool", + "content": content + }) + + # 調用API + response = self._call_api(messages) + + if response: + return response + else: + return "智谱AI HTTP API調用失敗" + + def handle_command(self, command): + """處理引擎特定命令""" + # GLM HTTP引擎目前不支持特定命令 + print(f"{Fore.RED}GLM HTTP引擎不支持此命令{Style.RESET_ALL}") + return False \ No newline at end of file diff --git a/ai_engines/SDK_openAI.py b/ai_engines/SDK_openAI.py new file mode 100644 index 0000000..b2dba98 --- /dev/null +++ b/ai_engines/SDK_openAI.py @@ -0,0 +1,266 @@ +""" +小狸 Pro-CLI AI 引擎开发 SDK +提供快速开发 AI 引擎的工具包 +""" +import os +import sys +import json +import requests +from colorama import Fore, Style +from openai import OpenAI +import logging + +# 设置日志 +logger = logging.getLogger(__name__) + +# 添加项目根目录到sys.path +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) + + +class _AIEngineSDK: + """AI 引擎 SDK 内部实现""" + + def __init__(self): + self.name = "" + self.api_key = "" + self.base_url = "" + self.model = "" + self.is_online = True + self.max_history = 10 + self.conversation_history = [] + self.shared_conversation_history = None + self.cli = None + + # 深度思考引擎配置 + self.is_thinking_engine = False # 是否是深度思考引擎 + self.thinking_start_marker = "" # 思考开始标记 + self.thinking_end_marker = "" # 思考结束标记 + self.show_thinking = True # 是否显示思考过程 + + # 配置文件路径 + self.config_file = os.path.join(project_root, "config.json") + + def _load_config(self): + """从配置文件加载配置""" + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get(self.name, {}) + + if not self.api_key: + self.api_key = engine_config.get("api_key", "") + if not self.base_url: + self.base_url = engine_config.get("base_url", "") + if not self.model: + self.model = engine_config.get("model", "") + + self.max_history = config.get("system", {}).get("max_history", 10) + except Exception as e: + logger.error(f"加载配置失败: {e}") + self.max_history = 10 + + def _call_openai_api(self, messages): + """调用 OpenAI 格式的 API""" + try: + self._load_config() + + # 构建请求体 + body = { + "model": self.model, + "messages": messages, + "stream": False + } + + # 构建请求头 + headers = { + 'Content-Type': 'application/json' + } + + if self.api_key: + headers['Authorization'] = f'Bearer {self.api_key}' + + # 确定 API 地址 + api_url = self.base_url + if not api_url.endswith("/chat/completions"): + api_url = api_url.rstrip('/') + "/chat/completions" + + # 发送请求 + response = requests.post( + url=api_url, + json=body, + headers=headers, + timeout=None + ) + + # 处理响应 + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 如果是深度思考引擎,处理思考内容 + if self.is_thinking_engine: + ai_response = self._process_thinking_content(ai_response) + + return ai_response + else: + return f"AI 返回格式错误: {response_data}" + else: + return f"AI 调用失败: {response.status_code} - {response.text}" + + except Exception as e: + return f"AI 调用失败: {str(e)}" + + def _build_messages(self, system_prompt, user_input): + """构建消息列表""" + messages = [] + + # 添加系统提示词 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加历史对话记录 + if self.shared_conversation_history is not None: + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + + # 添加当前用户输入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成 AI 响应""" + try: + # 如果有工具结果,将其作为上下文返回给AI处理 + if tool_results: + tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + messages = self._build_messages(system_prompt, tool_result_text) + else: + messages = self._build_messages(system_prompt, user_input) + + # 调用 API + return self._call_openai_api(messages) + + except Exception as e: + return f"AI 调用失败: {str(e)}" + + def _process_thinking_content(self, response): + """处理思考内容,将思考部分以灰色文本显示,只返回回复部分""" + import re + + if not self.thinking_start_marker or not self.thinking_end_marker: + # 如果没有设置标记,直接返回 + return response + + # 使用正则表达式查找思考内容 + pattern = f"{re.escape(self.thinking_start_marker)}(.*?){re.escape(self.thinking_end_marker)}" + matches = re.findall(pattern, response, re.DOTALL) + + if matches and self.show_thinking: + for match in matches: + # 用灰色文本显示思考内容 + thinking_text = f"{Fore.LIGHTBLACK_EX}[思考: {match}]{Style.RESET_ALL}" + # 替换思考标记之间的内容,保留思考内容但以灰色显示 + response = re.sub(pattern, thinking_text, response, 1) + elif matches and not self.show_thinking: + # 如果不显示思考内容,直接删除 + for match in matches: + response = re.sub(pattern, "", response, 1) + + return response + + +# 全局 SDK 实例 +_sdk_instance = _AIEngineSDK() + + +def engine_Online(mode): + """ + 设置引擎模式 + + Args: + mode: "on" - 云模式(在线 API) + "off" - 本地模式(如 Ollama) + """ + _sdk_instance.is_online = (mode.lower() == "on") + + +def ai_name(name): + """设置引擎名称""" + _sdk_instance.name = name + + +def ai_api_url(url): + """设置 API 地址""" + _sdk_instance.base_url = url + + +def ai_api_key(key): + """设置 API 密钥""" + _sdk_instance.api_key = key + + +def ai_model_id(model): + """设置模型名称""" + _sdk_instance.model = model + + +def ai_thinking_engine(enable=True, start_marker="", end_marker="", show_thinking=True): + """ + 设置深度思考引擎配置 + + Args: + enable: 是否启用深度思考引擎模式 + start_marker: 思考开始标记,默认为 "" + end_marker: 思考结束标记,默认为 "" + show_thinking: 是否显示思考过程,默认为 True + """ + _sdk_instance.is_thinking_engine = enable + _sdk_instance.thinking_start_marker = start_marker + _sdk_instance.thinking_end_marker = end_marker + _sdk_instance.show_thinking = show_thinking + + +def set_cli(cli): + """设置 CLI 引用""" + _sdk_instance.cli = cli + + +def set_shared_history(history): + """设置共享对话历史""" + _sdk_instance.shared_conversation_history = history + + +def generate_response(user_input, tool_results=None, system_prompt=None): + """生成 AI 响应""" + return _sdk_instance.generate_response(user_input, tool_results, system_prompt) + + +def get_sdk_instance(): + """获取 SDK 实例(用于高级用法)""" + return _sdk_instance + + +# 导出 +__all__ = [ + 'engine_Online', + 'ai_name', + 'ai_api_url', + 'ai_api_key', + 'ai_model_id', + 'ai_thinking_engine', + 'set_cli', + 'set_shared_history', + 'generate_response', + 'get_sdk_instance' +] \ No newline at end of file diff --git a/ai_engines/iflow_api_engine.py b/ai_engines/iflow_api_engine.py new file mode 100644 index 0000000..8f68d35 --- /dev/null +++ b/ai_engines/iflow_api_engine.py @@ -0,0 +1,405 @@ +import os +import sys +import json +import requests +import threading +import time +from colorama import Fore, Style +import logging + +# 设置日志 +logger = logging.getLogger(__name__) + +# 添加项目根目录到sys.path,以便正确导入 +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) # 获取项目根目录 +sys.path.insert(0, project_root) + + +class IflowAPIAI: + """基于Qwen3-Coder-Plus模型的AI引擎""" + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = """ +iflow API AI引擎插件帮助信息 +========================== + +引擎名称: iflow-api +描述: 基于Qwen3-Coder-Plus模型的AI引擎,支持复杂推理和代码生成 + +可用命令: + 无特定命令,直接与AI对话即可 + +使用说明: +1. 此引擎使用Qwen3-Coder-Plus云API +2. 使用/engine.switch iflow-api切换到此引擎 +3. 直接输入自然语言与AI对话 +4. 特别适合处理代码生成、技术问题等复杂任务 +""" + return help_text + + def __init__(self): + self.name = "iflow-api" + self.cli = None # 引用CLI实例以访问插件 + + # 直接从 config.json 读取配置(一次性读取) + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + config_file = os.path.join(project_root, "config.json") + + # 默认值 + self.api_key = "" + self.url = "" + self.model = "" + self.max_history = 10 + + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("iflow_api", {}) + self.api_key = engine_config.get("api_key", "") + self.url = engine_config.get("base_url", "") + self.model = engine_config.get("model", "") + self.max_history = config.get("system", {}).get("max_history", 10) + except Exception as e: + # 如果读取失败,使用默认值 + logger.warning(f"警告: 读取配置文件失败: {e}") + print(f"{Fore.YELLOW}警告: 读取配置文件失败: {e}{Style.RESET_ALL}") + + # 验证API密钥 + if not self.api_key: + logger.warning("警告: iflow-api引擎的API密钥未设置") + print(f"{Fore.YELLOW}警告: iflow-api引擎的API密钥未设置{Style.RESET_ALL}") + + self.headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # 初始化对话历史 + self.conversation_history = [] + + # 设置思考标记 + self.thinking_start_marker = "" + self.thinking_end_marker = "" + + # 共享对话历史引用(由CLI设置) + self.shared_conversation_history = None + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI响应 - 使用 qwen3-coder-plus""" + try: + # 如果有工具结果,将其作为上下文返回给AI处理 + if tool_results: + tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + # 构建消息列表 + + messages = self._build_messages_with_history(system_prompt, tool_result_text) + + + + # 构建请求体 + + body = { + + "model": self.model or "qwen3-coder-plus", + + "messages": messages, + + "stream": False # 使用非流式响应以便于处理 + + } + + + + # 发送请求到qwen3-coder-plus API + + response = requests.post(url=self.url, json=body, headers=self.headers, timeout=None) + + + + if response.status_code == 200: + + response_data = response.json() + + # 检查是否是API错误响应(例如认证失败) + if isinstance(response_data, dict) and "status" in response_data: + # 检查是否是认证错误 + if response_data.get("status") in ["434", "401", "403"]: + error_msg = response_data.get("msg", "API认证失败") + return f"qwen3-coder-plus API错误: {error_msg}" + elif "body" in response_data and response_data["body"] is None: + error_msg = response_data.get("msg", "API请求失败") + return f"qwen3-coder-plus API错误: {error_msg}" + + if "choices" in response_data and len(response_data["choices"]) > 0: + + # 改进错误处理以适应不同的API响应格式 + choice = response_data["choices"][0] + if "message" in choice and "content" in choice["message"]: + ai_response = choice["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + # 返回处理后的AI回复 + return processed_response + else: + # 尝试其他可能的字段名 + if "text" in choice: + return choice["text"] + elif "delta" in choice and "content" in choice["delta"]: + return choice["delta"]["content"] + else: + return f" qwen3-coder-plus返回格式错误: 缺少预期的响应字段 - {response_data}" + + else: + + return f" qwen3-coder-plus返回格式错误: {response_data}" + + else: + + return f" qwen3-coder-plus调用失败: {response.status_code} - {response.text}" + + + + # 构建消息列表 + + messages = self._build_messages_with_history(system_prompt, user_input) + + + + # 构建请求体 + + body = { + + "model": self.model or "qwen3-coder-plus", + + "messages": messages, + + "stream": False # 使用非流式响应以便于处理 + + } + + + + # 发送请求到qwen3-coder-plus + + response = requests.post(url=self.url, json=body, headers=self.headers, timeout=None) + + + + if response.status_code == 200: + + response_data = response.json() + + # 检查是否是API错误响应(例如认证失败) + if isinstance(response_data, dict) and "status" in response_data: + # 检查是否是认证错误 + if response_data.get("status") in ["434", "401", "403"]: + error_msg = response_data.get("msg", "API认证失败") + return f"qwen3-coder-plus API错误: {error_msg}" + elif "body" in response_data and response_data["body"] is None: + error_msg = response_data.get("msg", "API请求失败") + return f"qwen3-coder-plus API错误: {error_msg}" + + if "choices" in response_data and len(response_data["choices"]) > 0: + + # 改进错误处理以适应不同的API响应格式 + choice = response_data["choices"][0] + if "message" in choice and "content" in choice["message"]: + ai_response = choice["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + # 返回处理后的AI回复 + return processed_response + else: + # 尝试其他可能的字段名 + if "text" in choice: + return choice["text"] + elif "delta" in choice and "content" in choice["delta"]: + return choice["delta"]["content"] + else: + return f"qwen3-coder-plus返回格式错误: 缺少预期的响应字段 - {response_data}" + + else: + + return f"qwen3-coder-plus返回格式错误: {response_data}" + + else: + + return f"qwen3-coder-plus调用失败: {response.status_code} - {response.text}" + + + + except Exception as e: + + # 如果API调用失败,返回错误信息 + + return f"qwen3-coder-plus调用失败: {str(e)}" + + def _build_messages_with_history(self, system_prompt, user_input): + """构建包含历史记录的消息列表""" + messages = [] + + # 添加系统提示词 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加历史对话记录(使用共享历史) + if self.shared_conversation_history is not None: + # 确保历史记录格式正确 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正确,跳过该条记录 + continue + + # 添加当前用户输入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + + + def _call_api_with_history(self, content, system_prompt=None): + """使用对话历史调用API""" + # 构建消息列表 + + messages = [] + + # 添加系统提示词(如果提供) + + if system_prompt: + + messages.append({ + + "role": "system", + + "content": system_prompt + + }) + + # 添加历史对话记录(使用共享历史) + + if self.shared_conversation_history is not None: + + # 确保历史记录格式正确 + + for msg in self.shared_conversation_history[-self.max_history:]: + + if isinstance(msg, dict) and "role" in msg and "content" in msg: + + messages.append(msg) + + else: + + # 如果格式不正确,跳过该条记录 + + continue + + # 添加当前内容 + + messages.append({ + + "role": "tool", + + "content": content + + }) + + + + # 构建请求体 + body = { + "model": self.model or "qwen3-coder-plus", + "messages": messages, + "stream": False # 使用非流式响应以便于处理 + } + + + # 发送请求到qwen3-coder-plus + + response = requests.post(url=self.url, json=body, headers=self.headers, timeout=None) + + + + if response.status_code == 200: + + response_data = response.json() + + # 检查是否是API错误响应(例如认证失败) + if isinstance(response_data, dict) and "status" in response_data: + # 检查是否是认证错误 + if response_data.get("status") in ["434", "401", "403"]: + error_msg = response_data.get("msg", "API认证失败") + return f"qwen3-coder-plus API错误: {error_msg}" + elif "body" in response_data and response_data["body"] is None: + error_msg = response_data.get("msg", "API请求失败") + return f"qwen3-coder-plus API错误: {error_msg}" + + if "choices" in response_data and len(response_data["choices"]) > 0: + + # 改进错误处理以适应不同的API响应格式 + choice = response_data["choices"][0] + if "message" in choice and "content" in choice["message"]: + ai_response = choice["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + return processed_response + else: + # 尝试其他可能的字段名 + if "text" in choice: + return choice["text"] + elif "delta" in choice and "content" in choice["delta"]: + return choice["delta"]["content"] + else: + return f"qwen3-coder-plus返回格式错误: 缺少预期的响应字段 - {response_data}" + + else: + + return f"qwen3-coder-plus返回格式错误: {response_data}" + + else: + + return f"qwen3-coder-plus调用失败: {response.status_code} - {response.text}" + + def handle_command(self, command): + """处理引擎特定命令""" + # qwen3-coder-plus引擎目前不支持特定命令 + print(f"{Fore.RED}qwen3-coder-plus引擎不支持此命令{Style.RESET_ALL}") + return False + + def _process_thinking_content(self, response): + """处理思考内容,将思考部分以灰色文本显示,只返回回复部分""" + from colorama import Fore, Style + import re + + # 这里可以设置思考标记,由用户自己定义 + thinking_start_marker = "" + thinking_end_marker = "" + + if self.thinking_start_marker and self.thinking_end_marker: + # 使用正则表达式查找思考内容 + pattern = f"{re.escape(self.thinking_start_marker)}(.*?){re.escape(self.thinking_end_marker)}" + matches = re.findall(pattern, response, re.DOTALL) + + if matches: + for match in matches: + # 用灰色文本显示思考内容 + thinking_text = f"{Fore.LIGHTBLACK_EX}[思考: {match}]{Style.RESET_ALL}" + # 替换思考标记之间的内容,保留思考内容但以灰色显示 + response = re.sub(pattern, thinking_text, response, 1) + + return response \ No newline at end of file diff --git a/ai_engines/maas_http_engine.py b/ai_engines/maas_http_engine.py new file mode 100644 index 0000000..5ae5446 --- /dev/null +++ b/ai_engines/maas_http_engine.py @@ -0,0 +1,205 @@ +import os +import json +import requests +from colorama import Fore, Style +from openai import OpenAI + +# 获取项目根目录 +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) + + +class Maas_http_engineAI: + """基于HTTP协议的MaaS(Model as a Service)AI引擎""" + requires_api_key = True # MaaS HTTP引擎需要API密钥 + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = """ +MaaS HTTP AI引擎插件帮助信息 +========================== + +引擎名称: maas_http +描述: 基于HTTP协议的MaaS(Model as a Service)AI引擎,支持多种大模型推理服务 + +可用命令: + 无特定命令,直接与AI对话即可 + +使用说明: +1. 此引擎使用HTTP协议调用MaaS API +2. 使用/engine.switch maas_http切换到此引擎 +3. 直接输入自然语言与AI对话 +4. 支持普通对话、流式对话、JSON模式等多种调用方式 +""" + return help_text + + def __init__(self): + self.name = "maas_http" + self.cli = None # 引用CLI实例以訪問插件 + + # 直接从 config.json 读取配置 + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("maas_http", {}) + self.api_key = engine_config.get("api_key", "") + self.base_url = engine_config.get("base_url", "") + self.model_id = engine_config.get("model", "") + except Exception as e: + # 如果读取失败,使用空值 + self.api_key = "" + self.base_url = "" + self.model_id = "" + # 初始化客户端 + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url + ) + + # 初始化对话历史 + self.conversation_history = [] + # 设置最大历史记录数 + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + self.max_history = config.get("system", {}).get("max_history", 10) + except: + self.max_history = 10 + + # 共享對話歷史引用(由CLI設置) + self.shared_conversation_history = None + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI響應 - 使用MaaS HTTP API""" + try: + # 如果有工具結果,將其作為上下文返回給AI處理 + if tool_results: + tool_result_text = f"工具執行結果: {tool_results.get('result', '無結果')}" + + # 構建包含歷史記錄的消息列表 + messages = self._build_messages_with_history(system_prompt, tool_result_text) + + # 調用API + response = self._call_api(messages) + + if response: + return response + else: + return "MaaS HTTP API調用失敗" + + # 構建包含歷史記錄的消息列表 + messages = self._build_messages_with_history(system_prompt, user_input) + + # 調用API + response = self._call_api(messages) + + if response: + return response + else: + return "MaaS HTTP API調用失敗" + + except Exception as e: + # 如果API調用失敗,返回錯誤信息 + return f"MaaS HTTP API調用失敗: {str(e)}" + + def _call_api(self, messages, use_stream=False, extra_body=None): + """調用API的核心方法""" + if extra_body is None: + extra_body = {} + + try: + response = self.client.chat.completions.create( + model=self.model_id or self.default_model, + messages=messages, + stream=use_stream, + temperature=0.7, + max_tokens=4096, + extra_headers={"lora_id": "0"}, + stream_options={"include_usage": True}, + extra_body=extra_body + ) + + if use_stream: + # 處理流式響應 + full_response = "" + for chunk in response: + if hasattr(chunk.choices[0].delta, 'content') and chunk.choices[0].delta.content: + content = chunk.choices[0].delta.content + full_response += content + return full_response + else: + # 處理非流式響應 + return response.choices[0].message.content + + except Exception as e: + print(f"MaaS HTTP API請求出錯: {e}") + return None + + def _build_messages_with_history(self, system_prompt, user_input): + """構建包含歷史記錄的消息列表""" + messages = [] + + # 添加系統提示詞 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加歷史對話記錄(使用共享歷史) + if self.shared_conversation_history is not None: + # 確保歷史記錄格式正確 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正確,跳過該條記錄 + continue + + # 添加當前用戶輸入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + def _call_api_with_history(self, content, system_prompt=None): + """使用對話歷史調用API""" + # 構建消息列表 + messages = [] + # 添加系統提示詞(如果提供) + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + # 添加歷史對話記錄(使用共享歷史) + if self.shared_conversation_history is not None: + # 確保歷史記錄格式正確 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正確,跳過該條記錄 + continue + # 添加當前內容 + messages.append({ + "role": "tool", + "content": content + }) + + # 調用API + response = self._call_api(messages) + + if response: + return response + else: + return "MaaS HTTP API調用失敗" + + def handle_command(self, command): + """處理引擎特定命令""" + # MaaS HTTP引擎目前不支持特定命令 + print(f"{Fore.RED}MaaS HTTP引擎不支持此命令{Style.RESET_ALL}") + return False \ No newline at end of file diff --git a/ai_engines/mimo_engine.py b/ai_engines/mimo_engine.py new file mode 100644 index 0000000..f8a23a2 --- /dev/null +++ b/ai_engines/mimo_engine.py @@ -0,0 +1,355 @@ +import os +import sys +import json +import requests +from colorama import Fore, Style +from openai import OpenAI +import logging +from datetime import datetime + +# 设置日志 +logger = logging.getLogger(__name__) + +# 添加项目根目录到sys.path +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) + + +class MimoAI: + """小米MiMoAI引擎 - 使用MiMo-v2-flash模型 - OpenAI格式""" + requires_api_key = True # MiMo引擎需要API密钥 + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = """ +MiMo AI引擎插件帮助信息 +========================== + +引擎名称: MiMo +描述: 基于MiMo模型的AI引擎,由小米公司开发 + +可用命令: + 无特定命令,直接与AI对话即可 + +使用说明: +1. 此引擎使用小米MiMo云API +2. 使用/engine.switch mimo切换到此引擎 +3. 直接输入自然语言与AI对话 +4. 支持思考模式和多轮对话 +""" + return help_text + + def __init__(self): + self.name = "mimo" + self.cli = None + + # 直接从 config.json 读取配置 + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("mimo", {}) + self.api_key = engine_config.get("api_key", "") + base_url_raw = engine_config.get("base_url", "") + self.base_url = base_url_raw.rstrip('/') + '/chat/completions' if base_url_raw else "" + self.model = engine_config.get("model", "") + except Exception as e: + # 如果读取失败,使用空值 + self.api_key = "" + self.base_url = "" + self.model = "" + + # 初始化对话历史 + self.conversation_history = [] + # 设置最大历史记录数 + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + self.max_history = config.get("system", {}).get("max_history", 10) + except: + self.max_history = 10 + + # 初始化 headers(用于 API 请求) + self.headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # 初始化 OpenAI 客户端 + try: + if self.api_key and self.base_url: + self.client = OpenAI( + api_key=self.api_key, + base_url=self.base_url.replace('/chat/completions', ''), + timeout=None + ) + else: + self.client = None + except Exception as e: + logger.warning(f"初始化 MiMo OpenAI 客户端失败: {e}") + self.client = None + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI响应 - 使用MiMo-v2-flash - OpenAI格式""" + if not self.client: + return "MiMo引擎未正确初始化,无法调用API" + + try: + # 获取当前日期和星期 + now = datetime.now() + date_str = now.strftime("%Y年%m月%d日") + week_str = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"][now.weekday()] + current_datetime = f"{date_str} {week_str}" + + # 设置默认系统提示词(如果未提供) + if not system_prompt: + system_prompt = f"You are MiMo, an AI assistant developed by Xiaomi. Today is date: {current_datetime}. Your knowledge cutoff date is December 2024." + + # 如果有工具结果,将其作为上下文返回给AI处理 + if tool_results: + tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + + # 构建包含历史记录的消息列表 + messages = self._build_messages_with_history(system_prompt, tool_result_text) + + # 构建请求体,使用MiMo特定参数 + body = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "max_tokens": 2048, + "top_p": 0.95, + "frequency_penalty": 0, + "presence_penalty": 0, + # MiMo特定参数 + "extra_body": { + "thinking": {"type": "disabled"} # 禁用思考模式 + } + } + + # 直接使用requests调用MiMo API,因为需要特定的header格式 + import requests + response = requests.post( + url=self.base_url, + json=body, + headers=self.headers, + timeout=None + ) + + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + # 更新对话历史 + self._update_conversation_history(user_input if not tool_results else tool_result_text, ai_response) + + # 返回处理后的AI回复,前面加上✦ + return processed_response + else: + return "MiMo-v2-flash API返回格式错误" + elif response.status_code == 401: + return "MiMo-v2-flash API认证失败,请检查API密钥是否正确配置" + else: + return f"MiMo-v2-flash API调用失败: {response.status_code} - {response.text}" + + # 构建包含历史记录的消息列表 + messages = self._build_messages_with_history(system_prompt, user_input) + + # 构建请求体,使用MiMo特定参数 + body = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "max_tokens": 2048, + "top_p": 0.95, + "frequency_penalty": 0, + "presence_penalty": 0, + # MiMo特定参数 + "extra_body": { + "thinking": {"type": "disabled"} # 禁用思考模式 + } + } + + # 直接使用requests调用MiMo API,因为需要特定的header格式 + import requests + response = requests.post( + url=self.base_url, + json=body, + headers=self.headers, + timeout=None + ) + + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + # 更新对话历史 + self._update_conversation_history(user_input, ai_response) + + # 返回处理后的AI回复 + return processed_response + else: + return "MiMo-v2-flash API返回格式错误" + elif response.status_code == 401: + return "MiMo-v2-flash API认证失败,请检查API密钥是否正确配置" + else: + return f"MiMo-v2-flash API调用失败: {response.status_code} - {response.text}" + + except Exception as e: + # 如果API调用失败,返回错误信息 + return f"MiMo-v2-flash API调用失败: {str(e)}" + + def _build_messages_with_history(self, system_prompt, user_input): + """构建包含历史记录的消息列表""" + messages = [] + + # 添加系统提示词 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加历史对话记录(使用共享历史) + if self.shared_conversation_history is not None: + # 确保历史记录格式正确 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正确,跳过该条记录 + continue + + # 添加当前用户输入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + def _update_conversation_history(self, user_input, ai_response): + """更新对话历史""" + # 添加用户输入到历史记录 + self.conversation_history.append({ + "role": "user", + "content": user_input + }) + # 添加AI回复到历史记录 + self.conversation_history.append({ + "role": "assistant", + "content": ai_response + }) + # 限制历史记录数量 + if len(self.conversation_history) > self.max_history * 2: + self.conversation_history = self.conversation_history[-self.max_history * 2:] + + def _call_api_with_history(self, content, system_prompt=None): + """使用对话历史调用API""" + # 获取当前日期和星期 + now = datetime.now() + date_str = now.strftime("%Y年%m月%d日") + week_str = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"][now.weekday()] + current_datetime = f"{date_str} {week_str}" + + # 设置默认系统提示词(如果未提供) + if not system_prompt: + system_prompt = f"You are MiMo, an AI assistant developed by Xiaomi. Today is date: {current_datetime}. Your knowledge cutoff date is December 2024." + + # 构建消息列表 + messages = [] + + # 添加系统提示词(如果提供) + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加历史对话记录(使用共享历史) + if self.shared_conversation_history is not None: + # 确保历史记录格式正确 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正确,跳过该条记录 + continue + + # 添加当前内容 + messages.append({ + "role": "user", + "content": content + }) + + # 构建请求体 + body = { + "model": self.model, + "messages": messages, + "max_completion_tokens": 1024, + "temperature": 0.3, + "top_p": 0.95, + "stream": False, + "frequency_penalty": 0, + "presence_penalty": 0, + "extra_body": { + "thinking": {"type": "disabled"} + } + } + + # 发送请求到MiMo-v2-flash API + response = requests.post(url=self.base_url, json=body, headers=self.headers, timeout=None) + + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + return processed_response + else: + return "MiMo-v2-flash API返回格式错误" + else: + return f"MiMo-v2-flash API调用失败: {response.status_code} - {response.text}" + + def handle_command(self, command): + """处理引擎特定命令""" + # MiMo引擎目前不支持特定命令 + print(f"{Fore.RED}MiMo引擎不支持此命令{Style.RESET_ALL}") + return False + + def _process_thinking_content(self, response): + """处理思考内容,将思考部分以灰色文本显示,只返回回复部分""" + from colorama import Fore, Style + import re + + # 这里可以设置思考标记,由用户自己定义 + thinking_start_marker = "" + thinking_end_marker = "" + + # 如果响应中包含思考标记,则提取思考内容和回复内容 + if thinking_start_marker in response and thinking_end_marker in response: + # 提取思考内容 + thinking_match = re.search(f'{thinking_start_marker}(.*?){thinking_end_marker}', response, re.DOTALL) + if thinking_match: + thinking_content = thinking_match.group(1).strip() + # 打印思考内容(灰色) + print(f"{Fore.LIGHTBLACK_EX}思考: {thinking_content}{Style.RESET_ALL}") + + # 提取回复内容 + response_content = re.split(f'{thinking_end_marker}', response, maxsplit=1)[-1].strip() + return response_content + + # 如果没有思考标记,直接返回响应 + return response diff --git a/ai_engines/ollama_engine.py b/ai_engines/ollama_engine.py new file mode 100644 index 0000000..9015376 --- /dev/null +++ b/ai_engines/ollama_engine.py @@ -0,0 +1,401 @@ +import os +import sys +import json +import requests +import threading +import time +from colorama import Fore, Style + +# 延迟导入 ollama,避免程序启动时卡住 +# 如果导入失败,ollama_client 将为 None +try: + import ollama + ollama_client = ollama +except Exception as e: + print(f"{Fore.YELLOW}警告: 无法导入 ollama 库: {e}{Style.RESET_ALL}") + print(f"{Fore.YELLOW}请确保已安装 ollama 包 (pip install ollama){Style.RESET_ALL}") + ollama_client = None + +# 添加项目根目录到sys.path +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) + + +class OllamaAI: + """基于Ollama的本地AI引擎""" + requires_api_key = False # Ollama是本地引擎,不需要API密钥 + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = """ +Ollama AI引擎插件帮助信息 +======================== + +引擎名称: Ollama +描述: 基于Ollama的本地AI引擎,支持多种大型语言模型 + +可用命令: + /engine.ollama models - 列出本地可用的模型 + /engine.ollama remote-models - 显示可下载的模型列表 + /engine.ollama search <关键词> - 搜索可下载的模型 + /engine.ollama set <模型名> - 设置当前使用的模型 + /engine.ollama pull <模型名> - 从Ollama库拉取模型 + +注意: 本引擎强制使用流式输出,AI回复会实时显示。 + +使用说明: +1. 确保Ollama服务已在本地运行 +2. 使用/engine.switch ollama切换到此引擎 +3. 使用上述命令管理模型和与AI交互 + +思考模式: + - 支持深度思考模型(如 QwQ、DeepSeek-R1) + - 思考过程会自动以灰色文本显示 +""" + return help_text + + def __init__(self): + self.name = "ollama" + self.cli = None # 引用CLI实例以访问插件 + # 初始化对话历史 + self.conversation_history = [] + + # 直接从 config.json 读取配置 + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("ollama", {}) + self.api_key = engine_config.get("api_key", "") + self.base_url = engine_config.get("base_url", "") + self.model = engine_config.get("model", "") + self.max_history = config.get("system", {}).get("max_history", 10) + except Exception as e: + # 如果读取失败,使用空值 + self.api_key = "" + self.base_url = "" + self.model = "" + self.max_history = 10 + + # 共享对话历史引用(由CLI设置) + self.shared_conversation_history = None + + # 直接启用思考标记,由主程序自动处理深度思考模型的响应 + self.thinking_start_marker = "" + self.thinking_end_marker = "" + + # 检测Ollama服务是否运行 + self.is_service_running = self.check_ollama_service() + + if self.is_service_running: + print(f"{Fore.GREEN}Ollama服务检测成功,引擎已启用{Style.RESET_ALL}") + else: + print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}") + + def check_ollama_service(self): + """检测Ollama服务是否运行""" + # 检查 ollama 库是否可用 + if ollama_client is None: + return False + try: + # 尝试调用Ollama API获取版本信息 + response = ollama_client.list() + # 如果没有异常,说明服务运行正常 + return True + except Exception as e: + # 如果出现异常,说明服务未运行 + return False + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI响应 - 使用Ollama API""" + try: + # 检查Ollama服务是否运行 + if not self.is_service_running: + return "错误:Ollama服务未运行,请启动Ollama服务后再使用此引擎." + + # 如果有工具结果,将其作为上下文返回给AI处理 + if tool_results: + tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + # 构建包含历史记录的消息列表 + messages = self._build_messages_with_history(system_prompt, tool_result_text) + + # 使用Ollama Python库调用API + response = ollama_client.chat( + model=self.model, + messages=messages, + stream=False + ) + + # 返回AI的回复 + return response["message"]["content"] + + # 构建包含历史记录的消息列表 + messages = self._build_messages_with_history(system_prompt, user_input) + + # 使用Ollama Python库调用API + response = ollama_client.chat( + model=self.model, + messages=messages, + stream=False + ) + + # 返回AI的回复 + content = response["message"]["content"] + # 清理无效的 UTF-8 代理字符 + content = content.encode('utf-8', 'replace').decode('utf-8') + return content + + except Exception as e: + # 如果API调用失败,返回错误信息 + return f"Ollama API调用失败: {str(e)}" + + def _build_messages_with_history(self, system_prompt, user_input): + """构建包含历史记录的消息列表""" + messages = [] + + # 添加系统提示词 + if system_prompt: + # 清理代理字符 + clean_prompt = system_prompt.encode('utf-8', 'replace').decode('utf-8') + messages.append({ + "role": "system", + "content": clean_prompt + }) + +# 添加历史对话记录(使用共享历史) + if self.shared_conversation_history is not None: + # 确保历史记录格式正确 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + # 清理内容中的代理字符 + clean_content = msg["content"].encode('utf-8', 'replace').decode('utf-8') + messages.append({"role": msg["role"], "content": clean_content}) + else: + # 如果格式不正确,跳过该条记录 + continue + + # 清理用户输入 + if user_input: + clean_input = user_input.encode('utf-8', 'replace').decode('utf-8') + messages.append({ + "role": "user", + "content": clean_input + }) + + return messages + + + + def _call_api_with_history(self, content, system_prompt=None): + """使用对话历史调用API""" + if not self.is_service_running: + return "错误:Ollama服务未运行,请启动Ollama服务后再使用此引擎." + + # 构建消息列表 + messages = [] + # 添加系统提示词(如果提供) + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + # 添加历史对话记录(使用共享历史) + if self.shared_conversation_history is not None: + # 确保历史记录格式正确 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正确,跳过该条记录 + continue + # 添加当前内容 + messages.append({ + "role": "tool", + "content": content + }) + + # 使用Ollama Python库调用API + response = ollama_client.chat( + model=self.model, + messages=messages, + stream=False + ) + + return response["message"]["content"] + + def set_model(self, model_name): + """设置要使用的模型""" + if not self.is_service_running: + print(f"{Fore.RED}错误:Ollama服务未运行,无法切换模型{Style.RESET_ALL}") + return + + self.model = model_name + print(f"{Fore.GREEN}已切换Ollama模型为: {model_name}{Style.RESET_ALL}") + + def list_models(self): + """列出本地可用的模型""" + if not self.is_service_running: + return ["错误:Ollama服务未运行"] + + try: + response = ollama_client.list() + models = [model["name"] for model in response["models"]] + return models + except Exception as e: + return [f"获取模型列表失败: {str(e)}"] + + def list_remote_models(self, limit=10): + """获取可下载的模型列表(仅展示前N个)""" + if not self.is_service_running: + return ["错误:Ollama服务未运行"] + + try: + # 使用Ollama Python库获取可下载的模型列表 + # 注意:Ollama库本身没有直接获取可下载模型列表的API + # 这里返回一些常见的模型作为示例 + # 实际应用中可能需要从Ollama官网或其他来源获取此信息 + common_models = [ + "llama3.1:latest", + "llama3:latest", + "mistral:latest", + "gemma2:latest", + "phi3:latest", + "qwen2:latest", + "command-r:latest", + "dolphin-mistral:latest", + "nous-hermes2:latest", + "llava:latest", + "codellama:latest", + "mxbai-embed-large:latest", + "all-minilm:latest", + "stable-diffusion:latest", + "moondream:latest" + ] + return common_models[:limit] + except Exception as e: + return [f"获取远程模型列表失败: {str(e)}"] + + def search_models(self, query, limit=10): + """模糊搜索可下载的模型""" + if not self.is_service_running: + return ["错误:Ollama服务未运行"] + + try: + # 使用Ollama Python库获取可下载的模型列表 + # 注意:Ollama库本身没有直接获取可下载模型列表的API + all_models = [ + "llama3.1:latest", "llama3:latest", "llama2:latest", + "mistral:latest", "mistral-openorca:latest", + "gemma2:latest", "gemma:latest", + "phi3:latest", "phi2:latest", + "qwen2:latest", "qwen:latest", + "command-r:latest", "command-r-plus:latest", + "dolphin-mistral:latest", "dolphin-llama3:latest", + "nous-hermes2:latest", "nous-hermes:latest", + "llava:latest", "bakllava:latest", + "codellama:latest", "codeup:latest", + "mxbai-embed-large:latest", "nomic-embed-text:latest", + "all-minilm:latest", "bge-m3:latest", + "stable-diffusion:latest", "moondream:latest", + "llama3-groq:latest", "eva-qwen2:latest" + ] + + # 进行模糊搜索(简单匹配,包含查询词的模型) + matched_models = [model for model in all_models if query.lower() in model.lower()] + + # 限制返回数量 + return matched_models[:limit] + except Exception as e: + return [f"搜索模型失败: {str(e)}"] + + def pull_model(self, model_name): + """从Ollama库拉取模型""" + if not self.is_service_running: + print(f"{Fore.RED}错误:Ollama服务未运行,无法拉取模型{Style.RESET_ALL}") + return False + + try: + print(f"{Fore.YELLOW}正在拉取模型 {model_name}...{Style.RESET_ALL}") + + # 使用Ollama Python库拉取模型 + response = ollama_client.pull(model_name, stream=True) + + # 逐块处理响应 + for part in response: + if 'status' in part: + status = part['status'] + print(f"{Fore.YELLOW}{status}{Style.RESET_ALL}") + # 检查是否完成 + if 'completed' in part and 'total' in part: + if part['completed'] == part['total']: + print(f"{Fore.GREEN}模型 {model_name} 拉取完成!{Style.RESET_ALL}") + + print(f"{Fore.GREEN}模型 {model_name} 拉取成功!{Style.RESET_ALL}") + return True + except Exception as e: + print(f"{Fore.RED}拉取模型 {model_name} 失败: {str(e)}{Style.RESET_ALL}") + return False + + def handle_command(self, command): + """处理引擎特定命令""" + if not self.is_service_running: + print(f"{Fore.RED}Ollama服务未运行,无法执行命令{Style.RESET_ALL}") + return False + + if command == "models": + # 显示本地可用模型 + models = self.list_models() + print(f"{Fore.GREEN}本地可用的Ollama模型:{Style.RESET_ALL}") + for model in models: + status = " (当前使用)" if model == self.model else "" + print(f"{Fore.GREEN} - {model}{status}{Style.RESET_ALL}") + return True + + elif command == "remote-models": + # 显示可下载的模型(前10个) + models = self.list_remote_models() + print(f"{Fore.GREEN}可下载的Ollama模型(前10个):{Style.RESET_ALL}") + for i, model in enumerate(models, 1): + print(f"{Fore.GREEN} {i}. {model}{Style.RESET_ALL}") + return True + + elif command.startswith("search "): + # 搜索模型 + query = command[7:].strip() # 移除 'search ' + if query: + models = self.search_models(query) + if models: + print(f"{Fore.GREEN}搜索 '{query}' 的结果(前10个):{Style.RESET_ALL}") + for i, model in enumerate(models, 1): + print(f"{Fore.GREEN} {i}. {model}{Style.RESET_ALL}") + else: + print(f"{Fore.YELLOW}未找到与 '{query}' 匹配的模型{Style.RESET_ALL}") + return True + else: + print(f"{Fore.RED}请提供搜索关键词.用法: /engine.search <关键词>{Style.RESET_ALL}") + return False + + elif command.startswith("set "): + # 设置模型 + model_name = command[4:].strip() # 移除 'set ' + if model_name: + self.set_model(model_name) + return True + else: + print(f"{Fore.RED}请提供模型名称.用法: /engine.set <模型名>{Style.RESET_ALL}") + return False + + elif command.startswith("pull "): + # 拉取模型 + model_name = command[5:].strip() # 移除 'pull ' + if model_name: + success = self.pull_model(model_name) + return success + else: + print(f"{Fore.RED}请提供模型名称.用法: /engine.pull <模型名>{Style.RESET_ALL}") + return False + + else: + print(f"{Fore.RED}未知的Ollama命令.可用命令: models, remote-models, search, set, pull{Style.RESET_ALL}") + return False \ No newline at end of file diff --git a/ai_engines/qwq_engine.py b/ai_engines/qwq_engine.py new file mode 100644 index 0000000..eee52eb --- /dev/null +++ b/ai_engines/qwq_engine.py @@ -0,0 +1,291 @@ +import os +import json +import requests +import threading +import time +from colorama import Fore, Style + + +class QwqAI: + """基于QwQ-32B模型的AI引擎""" + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = """ +QwQ-32B AI引擎插件帮助信息 +========================== + +引擎名称: QwQ +描述: 基于QwQ-32B模型的AI引擎,支持复杂推理和数学计算 + +可用命令: + 无特定命令,直接与AI对话即可 + +使用说明: +1. 此引擎使用QwQ-32B云API +2. 使用/engine.switch qwq切换到此引擎 +3. 直接输入自然语言与AI对话 +4. 特别适合处理数学、逻辑推理等复杂任务 +""" + return help_text + + def __init__(self): + self.name = "qwq" + self.cli = None # 引用CLI实例以访问插件 + + # 直接从 config.json 读取配置 + import json + import os + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("qwq", {}) + self.api_key = engine_config.get("api_key", "") + self.url = engine_config.get("base_url", "") + self.model = engine_config.get("model", "") + except Exception as e: + # 如果读取失败,使用空值 + print(f"{Fore.YELLOW}警告: 读取配置文件失败: {e}{Style.RESET_ALL}") + self.api_key = "" + self.url = "" + self.model = "" + + self.headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + # 初始化对话历史 + self.conversation_history = [] + # 设置最大历史记录数 + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + self.max_history = config.get("system", {}).get("max_history", 10) + except: + self.max_history = 10 + + # 设置思考标记 + self.thinking_start_marker = "" + self.thinking_end_marker = "" + + # 共享对话历史引用(由CLI设置) + self.shared_conversation_history = None + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI响应 - 使用QwQ API""" + try: + # 如果有工具结果,将其作为上下文返回给AI处理 + if tool_results: + tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + # 构建消息列表 + messages = self._build_messages_with_history(system_prompt, tool_result_text) + + # 构建请求体 + body = { + "model": self.model, + "messages": messages, + "stream": False # 使用非流式响应以便于处理 + } + + # 发送请求到QwQ API + response = requests.post(url=self.url, json=body, headers=self.headers, timeout=None) + + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + # 返回处理后的AI回复 + return processed_response + else: + return "QwQ API返回格式错误" + else: + return f"QwQ API调用失败: {response.status_code} - {response.text}" + + # 构建消息列表 + messages = self._build_messages_with_history(system_prompt, user_input) + + # 构建请求体 + body = { + "model": self.model, + "messages": messages, + "stream": False # 使用非流式响应以便于处理 + } + + # 发送请求到QwQ API + response = requests.post(url=self.url, json=body, headers=self.headers, timeout=None) + + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + # 返回处理后的AI回复 + return processed_response + else: + return "QwQ API返回格式错误" + else: + return f"QwQ API调用失败: {response.status_code} - {response.text}" + + except Exception as e: + # 如果API调用失败,返回错误信息 + return f"QwQ API调用失败: {str(e)}" + + def _build_messages_with_history(self, system_prompt, user_input): + """构建包含历史记录的消息列表""" + messages = [] + + # 添加系统提示词 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加历史对话记录(使用共享历史) + if self.shared_conversation_history is not None: + # 确保历史记录格式正确 + for msg in self.shared_conversation_history[-self.max_history:]: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + messages.append(msg) + else: + # 如果格式不正确,跳过该条记录 + continue + + # 添加当前用户输入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + + + def _call_api_with_history(self, content, system_prompt=None): + """使用对话历史调用API""" + # 构建消息列表 + + messages = [] + + # 添加系统提示词(如果提供) + + if system_prompt: + + messages.append({ + + "role": "system", + + "content": system_prompt + + }) + + # 添加历史对话记录(使用共享历史) + + if self.shared_conversation_history is not None: + + # 确保历史记录格式正确 + + for msg in self.shared_conversation_history[-self.max_history:]: + + if isinstance(msg, dict) and "role" in msg and "content" in msg: + + messages.append(msg) + + else: + + # 如果格式不正确,跳过该条记录 + + continue + + # 添加当前内容 + + messages.append({ + + "role": "tool", + + "content": content + + }) + + + + # 构建请求体 + body = { + "model": self.model, + "messages": messages, + "stream": False # 使用非流式响应以便于处理 + } + + # 发送请求到QwQ API + response = requests.post(url=self.url, json=body, headers=self.headers, timeout=None) + + if response.status_code == 200: + response_data = response.json() + if "choices" in response_data and len(response_data["choices"]) > 0: + ai_response = response_data["choices"][0]["message"]["content"] + + # 处理思考标记 + processed_response = self._process_thinking_content(ai_response) + + return processed_response + else: + return "QwQ API返回格式错误" + else: + return f"QwQ API调用失败: {response.status_code} - {response.text}" + + def handle_command(self, command): + """处理引擎特定命令""" + # QwQ引擎目前不支持特定命令 + print(f"{Fore.RED}QwQ引擎不支持此命令{Style.RESET_ALL}") + return False + + def _process_thinking_content(self, response): + """处理思考内容,将思考部分以灰色文本显示,只返回回复部分""" + from colorama import Fore, Style + import re + + # 这里可以设置思考标记,由用户自己定义 + thinking_start_marker = "" # 用户可以设置开始标记 + thinking_end_marker = "" # 用户可以设置结束标记 + + # 如果用户设置了思考标记,则处理思考内容 + if thinking_start_marker and thinking_end_marker and thinking_start_marker in response and thinking_end_marker in response: + # 分离思考内容和回复内容 + parts = response.split(thinking_start_marker) + if len(parts) > 1: + # 获取包含结束标记的部分 + remaining = parts[1] + thinking_content = "" + reply_content = "" + + # 检查结束标记 + 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() + else: + # 如果没有找到结束标记,将剩余部分都视为思考内容 + thinking_content = remaining.strip() + reply_content = "" + + # 显示思考内容为灰色文本 + if thinking_content: + print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking_content}]{Style.RESET_ALL}") + + # 返回回复内容 + if reply_content: + return reply_content + else: + return "" # 如果没有回复内容,返回空字符串 + else: + # 如果没有设置思考标记或没有找到标记,直接返回原始响应 + return response \ No newline at end of file diff --git a/ai_engines/xfyun_spark_engine.py b/ai_engines/xfyun_spark_engine.py new file mode 100644 index 0000000..7f69da5 --- /dev/null +++ b/ai_engines/xfyun_spark_engine.py @@ -0,0 +1,275 @@ +import os +import sys +import json +import hashlib +import hmac +import base64 +import websocket +import time +import threading +from datetime import datetime +from time import mktime +from urllib.parse import urlparse, urlencode +from wsgiref.handlers import format_date_time +from colorama import Fore, Style + +# 获取项目根目录 +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) + + +class XfyunSparkAI: + """基于讯飞星火深度推理X2模型的AI引擎(WebSocket接口)""" + requires_api_key = True # 讯飞引擎需要API密钥 + + # X2深度推理版本固定配置 + URL = "wss://spark-api.xf-yun.com/x2" + DOMAIN = "spark-x" + MAX_TOKENS = 131072 # X2输出上限128K + + def __init__(self): + self.name = "xfyun-spark" + self.cli = None + + # 直接从 config.json 读取配置 + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + engine_config = config.get("api", {}).get("engines", {}).get("xfyun_spark", {}) + self.app_id = engine_config.get("app_id", "") + self.api_key = engine_config.get("api_key", "") + self.api_secret = engine_config.get("api_secret", "") + self.max_history = config.get("system", {}).get("max_history", 10) + except Exception as e: + self.app_id = "" + self.api_key = "" + self.api_secret = "" + self.max_history = 10 + + # 初始化对话历史 + self.conversation_history = [] + self.shared_conversation_history = None + self.chat_id = f"chat_{self.app_id}_{int(time.time())}" # 固定会话ID,确保上下文连续性 + + # 验证API密钥 + if not self.app_id or not self.api_key or not self.api_secret: + print(f"{Fore.YELLOW}警告: xfyun-spark引擎的API配置未完整设置(需要app_id、api_key、api_secret){Style.RESET_ALL}") + + def get_help_info(self): + """获取引擎帮助信息""" + help_text = f""" +讯飞星火深度推理X2引擎帮助信息 +================================ + +引擎名称: xfyun-spark +描述: 基于讯飞星火深度推理X2模型的AI引擎,支持WebSocket流式输出 + +版本: X2深度推理 (输入64K, 输出128K) + +配置要求: + 在config.json中配置: + {{ + "api": {{ + "engines": {{ + "xfyun_spark": {{ + "app_id": "你的应用ID", + "api_key": "你的API Key", + "api_secret": "你的API Secret" + }} + }} + }} + }} + +使用说明: +1. 使用/engine.switch xfyun-spark切换到此引擎 +2. 直接输入自然语言与AI对话 +3. 本引擎强制使用流式输出,AI回复会实时显示 +4. 支持深度思考模式,思考过程会自动显示 + +注意: 本引擎使用WebSocket协议,需要app_id、api_key、api_secret三个参数 +""" + return help_text + + def _generate_url(self): + """生成鉴权URL(使用官方示例的方式)""" + # 生成RFC1123格式的时间戳 + now = datetime.now() + date = format_date_time(mktime(now.timetuple())) + + # 解析URL获取host和path + parsed_url = urlparse(self.URL) + host = parsed_url.netloc + path = parsed_url.path + + # 拼接签名字符串 + signature_origin = "host: " + host + "\n" + signature_origin += "date: " + date + "\n" + signature_origin += "GET " + path + " HTTP/1.1" + + # 进行hmac-sha256进行加密 + signature_sha = hmac.new( + self.api_secret.encode('utf-8'), + signature_origin.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + + signature_sha_base64 = base64.b64encode(signature_sha).decode(encoding='utf-8') + + # 拼接authorization_origin + authorization_origin = f'api_key="{self.api_key}", algorithm="hmac-sha256", headers="host date request-line", signature="{signature_sha_base64}"' + + authorization = base64.b64encode(authorization_origin.encode('utf-8')).decode(encoding='utf-8') + + # 将请求的鉴权参数组合为字典 + v = { + "authorization": authorization, + "date": date, + "host": host + } + + # 拼接鉴权参数,生成url + url = self.URL + '?' + urlencode(v) + + return url + + def _build_messages_with_history(self, system_prompt, user_input): + """构建包含历史记录的消息列表(传递完整历史对话)""" + messages = [] + + # 添加系统提示词 + if system_prompt: + messages.append({ + "role": "system", + "content": system_prompt + }) + + # 添加完整的历史对话(不限制数量,让API自己处理) + if self.shared_conversation_history: + for msg in self.shared_conversation_history: + if isinstance(msg, dict) and "role" in msg and "content" in msg: + role = msg["role"] + content = msg["content"] + # 转换角色名称 + if role == "user": + messages.append({"role": "user", "content": content}) + elif role == "assistant": + messages.append({"role": "assistant", "content": content}) + + # 添加当前用户输入 + messages.append({ + "role": "user", + "content": user_input + }) + + return messages + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + """生成AI响应(非流式)""" + try: + # 处理工具结果 + if tool_results: + tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}" + messages = self._build_messages_with_history(system_prompt, tool_result_text) + else: + messages = self._build_messages_with_history(system_prompt, user_input) + + # 构建请求数据 + request_data = { + "header": { + "app_id": self.app_id, + "uid": str(time.time()).replace('.', '')[:32] + }, + "parameter": { + "chat": { + "domain": self.DOMAIN, + "temperature": 0, + "max_tokens": 4096, + "presence_penalty": 1, + "frequency_penalty": 0.02, + "top_k": 5, + "chat_id": self.chat_id + } + }, + "payload": { + "message": { + "text": messages + } + } + } + + # 生成鉴权URL + auth_url = self._generate_url() + + # 收集完整响应 + full_response = "" + thinking_content = "" + ws = None + result_queue = [] + done_event = threading.Event() + + def on_message(ws, message): + nonlocal full_response, thinking_content + try: + data = json.loads(message) + code = data.get("header", {}).get("code", -1) + + if code != 0: + result_queue.append(("error", data.get('header', {}).get('message', '未知错误'))) + done_event.set() + return + + payload = data.get("payload", {}) + choices = payload.get("choices", {}) + + # 处理文本响应 + text_list = choices.get("text", []) + if text_list: + reasoning = text_list[0].get("reasoning_content", "") + if reasoning: + thinking_content += reasoning + content = text_list[0].get("content", "") + if content: + full_response += content + + if choices.get("status") == 2: + done_event.set() + + except Exception as e: + result_queue.append(("error", str(e))) + done_event.set() + + def on_error(ws, error): + result_queue.append(("error", str(error))) + done_event.set() + + def on_close(ws, close_status_code, close_msg): + pass + + def on_open(ws): + ws.send(json.dumps(request_data)) + + # 创建WebSocket连接 + ws = websocket.WebSocketApp( + auth_url, + on_open=on_open, + on_message=on_message, + on_error=on_error, + on_close=on_close + ) + + # 在后台线程运行WebSocket + ws_thread = threading.Thread(target=ws.run_forever) + ws_thread.daemon = True + ws_thread.start() + + # 等待完成 + done_event.wait(timeout=60) + + # 返回完整响应 + return full_response + + except Exception as e: + print(f"\n{Fore.RED}讯飞星火API调用失败: {str(e)}{Style.RESET_ALL}") + return f"讯飞星火API调用失败: {str(e)}" \ No newline at end of file diff --git a/clawli_server.py b/clawli_server.py new file mode 100644 index 0000000..7fd79de --- /dev/null +++ b/clawli_server.py @@ -0,0 +1,259 @@ +""" +Clawli WebSocket Server - 独立进程 WebSocket 服务器 +用于 PC-手机端通信 +""" + +import multiprocessing +from multiprocessing import Process, Queue +from typing import Optional, List +import threading +import time +import json +import base64 +import os +import asyncio + + +class ClawliWebSocketServer: + """Clawli WebSocket 服务器管理类""" + + def __init__(self): + self.process: Optional[Process] = None + self.input_queue: Optional[Queue] = None + self.output_queue: Optional[Queue] = None + self.running = False + self.host = "0.0.0.0" + self.port = 9079 + self.password = "" + + def start(self, host: str = "0.0.0.0", port: int = 9079, password: str = "") -> bool: + """启动 WebSocket 服务器进程""" + if self.running: + print("[Clawli] 服务器已在运行") + return True + + self.host = host + self.port = int(port) + self.password = password + + self.input_queue = Queue() + self.output_queue = Queue() + + self.process = Process( + target=_ws_server_main, + args=(self.host, self.port, self.password, self.input_queue, self.output_queue) + ) + self.process.daemon = True + self.process.start() + self.running = True + + print(f"[Clawli] WebSocket 服务器已启动: ws://{host}:{self.port}") + return True + + def stop(self): + """停止服务器""" + if not self.running: + return + + self.running = False + if self.process: + self.process.terminate() + self.process.join(timeout=2) + if self.process.is_alive(): + self.process.kill() + self.process = None + + self.input_queue = None + self.output_queue = None + print("[Clawli] WebSocket 服务器已停止") + + def send_message(self, content: str, msg_type: str = "text", **kwargs) -> tuple: + """发送消息到手机""" + if not self.running or not self.input_queue: + print(f"[Clawli] 发送失败: 服务器未运行") + return False, "服务器未运行" + + try: + msg = { + "type": msg_type, + "content": content, + "timestamp": time.time(), + **kwargs + } + self.input_queue.put(msg) + print(f"[Clawli] 消息已放入队列: type={msg_type}, len={len(content)}") + return True, "消息已发送" + except Exception as e: + print(f"[Clawli] 发送异常: {e}") + return False, f"发送失败: {e}" + + def send_image(self, image_path: str = None, image_data: str = None, + filename: str = "", caption: str = "") -> tuple: + """发送图片到手机""" + if not self.running or not self.input_queue: + return False, "服务器未运行" + + try: + if image_path and os.path.exists(image_path): + with open(image_path, 'rb') as f: + image_bytes = f.read() + image_data = base64.b64encode(image_bytes).decode('utf-8') + filename = filename or os.path.basename(image_path) + + if not image_data: + return False, "无图片数据" + + msg = { + "type": "image", + "image": image_data, + "filename": filename, + "caption": caption, + "timestamp": time.time() + } + self.input_queue.put(msg) + print(f"[Clawli] 图片已放入队列: {filename}") + return True, "图片已发送" + + except Exception as e: + return False, f"发送失败: {e}" + + def send_tool_status(self, tool_name: str, args: str, status: str, result: str = ""): + """发送工具状态""" + return self.send_message( + content=result, + msg_type="tool_status", + tool=tool_name, + args=args, + status=status, + result=result + ) + + def get_messages(self) -> List[dict]: + """获取从手机收到的消息""" + messages = [] + if self.output_queue: + while True: + try: + msg = self.output_queue.get(timeout=0.01) + messages.append(msg) + except: + break + return messages + + def is_running(self) -> bool: + return self.running and self.process is not None and self.process.is_alive() + + +def _ws_server_main(host: str, port: int, password: str, + input_queue: Queue, output_queue: Queue): + """WebSocket 服务器主进程""" + import websockets + from websockets.server import serve + + port = int(port) + running = [True] # 用列表包装以便在闭包中修改 + + async def handler(websocket): + """WebSocket 连接处理器""" + # 认证 + try: + auth_msg = await asyncio.wait_for(websocket.recv(), timeout=5.0) + auth_data = json.loads(auth_msg) + + if auth_data.get("type") != "auth" or auth_data.get("password") != password: + await websocket.send(json.dumps({"type": "auth_failed", "message": "密码错误"})) + return + + await websocket.send(json.dumps({"type": "auth_success"})) + print(f"[Clawli WS] 客户端认证成功") + + except Exception as e: + print(f"[Clawli WS] 认证失败: {e}") + return + + print(f"[Clawli WS] 开始处理消息循环") + + try: + # 接收任务 + async def receiver(): + async for message in websocket: + try: + data = json.loads(message) + output_queue.put(data) + print(f"[Clawli WS] 收到: {data.get('type', 'unknown')}") + except Exception as e: + print(f"[Clawli WS] 解析失败: {e}") + + # 发送任务 + async def sender(): + while running[0]: + try: + # 从队列获取消息(非阻塞) + msg = await asyncio.get_event_loop().run_in_executor( + None, lambda: input_queue.get(timeout=0.2) + ) + await websocket.send(json.dumps(msg, ensure_ascii=False)) + print(f"[Clawli WS] 发送: {msg.get('type', 'unknown')}") + except: + pass # 超时,继续循环 + + # 并行运行两个任务 + await asyncio.gather(receiver(), sender()) + + except websockets.exceptions.ConnectionClosed: + print("[Clawli WS] 客户端断开") + except Exception as e: + print(f"[Clawli WS] 连接错误: {e}") + + async def main(): + print(f"[Clawli WS] 服务器启动于 ws://{host}:{port}") + async with serve(handler, host, port): + await asyncio.Future() + + try: + asyncio.run(main()) + except KeyboardInterrupt: + running[0] = False + print("[Clawli WS] 服务器关闭") + + +# 全局实例 +_server_instance = ClawliWebSocketServer() + + +def start_server(host: str = "0.0.0.0", port: int = 9079, password: str = "") -> bool: + return _server_instance.start(host, port, password) + + +def stop_server(): + _server_instance.stop() + + +def send_message(content: str, msg_type: str = "text", **kwargs) -> tuple: + return _server_instance.send_message(content, msg_type, **kwargs) + + +def send_image(image_path: str = None, image_data: str = None, + filename: str = "", caption: str = "") -> tuple: + return _server_instance.send_image(image_path, image_data, filename, caption) + + +def send_tool_status(tool_name: str, args: str, status: str, result: str = ""): + return _server_instance.send_tool_status(tool_name, args, status, result) + + +def get_messages() -> List[dict]: + return _server_instance.get_messages() + + +def is_running() -> bool: + return _server_instance.is_running() + + +if __name__ == "__main__": + start_server("0.0.0.0", 9079, "test") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + stop_server() \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..deaea8f --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,42 @@ +"""核心配置管理模块""" +import os +import json +import logging + +logger = logging.getLogger(__name__) + +# 常量 +CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json") +MAX_FILE_SIZE = 52428800 # 50MB +DEFAULT_MAX_HISTORY = 999999 + +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: + logger.error(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) diff --git a/core/conversation.py b/core/conversation.py new file mode 100644 index 0000000..b244f1f --- /dev/null +++ b/core/conversation.py @@ -0,0 +1,315 @@ +""" +对话处理模块 - 核心对话循环和工具调用 +""" +import json +import re +import time +import threading +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from colorama import Fore, Style + +logger = logging.getLogger(__name__) + + +class ConversationManager: + """对话管理器 - 处理用户输入、AI 响应、工具调用循环""" + + def __init__(self, cli): + self.cli = cli + self.history = [] + self.max_history = 999999 + + def process(self, user_input: str): + """处理完整的对话循环""" + self.history.append({"role": "user", "content": user_input}) + + tool_prompts = self._get_tool_prompts() + loop_count = 0 + current_input = user_input + max_loops = 20 + + while loop_count < max_loops: + response = self._generate_with_animation(current_input, tool_prompts) + processed = self._process_thinking(response) + processed = self._process_code_blocks(processed) + + try: + text_content, json_data = self._parse_response(processed) + + if json_data: + if text_content: + self._add_and_display(text_content) + + if isinstance(json_data, dict): + result = self._handle_dict(json_data, text_content, processed) + if result == "break": + break + elif result == "continue": + loop_count += 1 + continue + elif isinstance(json_data, list): + result = self._handle_list(json_data, text_content, processed) + if result == "break": + break + elif result == "continue": + loop_count += 1 + continue + else: + display = text_content if text_content else processed + self._add_and_display(display) + break + + except json.JSONDecodeError: + text_content, json_data = self._parse_response(processed) + if json_data: + # Re-process with parsed data + pass + else: + self._add_and_display(processed) + break + + if loop_count >= max_loops: + self.cli.display.output(f"AI 已达到最大循环次数 ({max_loops}),已停止") + + def _handle_dict(self, data, text_content, processed): + """处理 dict 类型的 JSON 数据""" + if data.get('action') == 'use_tool': + self.cli._execute_single_tool(data) + return "break" + + if data.get('action') == 'continue': + content = data.get('content', '') + if content: + self._add_and_display(content) + return "continue" + + if data.get('continue') or data.get('need_continue') or data.get('think_more'): + msg = data.get('message', '') + if msg: + self.history.append({"role": "assistant", "content": msg}) + self.cli.display.response(msg, is_continue=True) + return "continue" + + msg = data.get('message', '') + if msg: + self._add_and_display(msg) + return "break" + + if text_content: + self._add_and_display(text_content) + else: + self._add_and_display(processed) + return "break" + + def _handle_list(self, data_list, text_content, processed): + """处理 list 类型的 JSON 数据""" + tool_calls = [item for item in data_list + if isinstance(item, dict) and item.get('action') == 'use_tool'] + + if tool_calls: + self.cli._execute_concurrent_tools(tool_calls) + return "break" + + continue_items = [item for item in data_list + if isinstance(item, dict) and + (item.get('continue') or item.get('need_continue') or item.get('think_more'))] + + if continue_items: + msg = continue_items[0].get('message', '') + if msg: + self.history.append({"role": "assistant", "content": msg}) + self.cli.display.response(msg, is_continue=True) + return "continue" + + if text_content: + self._add_and_display(text_content) + else: + self._add_and_display(processed) + return "break" + + def _add_and_display(self, content): + """添加到历史并显示""" + self.history.append({"role": "assistant", "content": content}) + self.cli.display.response(content) + + def _generate_with_animation(self, user_input, tool_prompts=None): + """生成 AI 响应(带动画)""" + system_prompt = self._build_system_prompt(tool_prompts) + + if not self.cli.engine_manager.current_engine: + return "错误: 当前没有可用的AI引擎" + + # TUI 模式:直接生成 + if self.cli.display.tui_callback: + response = self.cli.engine_manager.generate_response(user_input, system_prompt) + if response: + self.history.append({"role": "assistant", "content": response}) + return response + + # CLI 模式:带动画 + stop_event = threading.Event() + esc_event = threading.Event() + + # 加载等待句子 + sentences = self._load_love_sentences() + + anim_thread = threading.Thread( + target=self.cli.display.thinking_animation, + args=(sentences, stop_event, esc_event), daemon=True) + anim_thread.start() + + try: + response = self.cli.engine_manager.generate_response(user_input, system_prompt) + if response: + self.cli.display.typeprint(response, Fore.CYAN) + self.history.append({"role": "assistant", "content": response}) + return response + finally: + stop_event.set() + anim_thread.join() + + def _build_system_prompt(self, tool_prompts=None): + """构建系统提示词""" + base = ("你叫小狸,是一个强大的智能编程助手。你具备精准的代码编辑能力、" + "完整的 Git 工作流支持、以及跨文件代码搜索和理解能力。" + "你可以帮助用户编写、修改、调试代码,管理版本控制,理解复杂代码库。") + + tool_section = "" + if tool_prompts: + tool_section = f"\n\n{tool_prompts}" + + call_format = """ +【工具调用格式】 +当需要执行操作时,在回复中包含 JSON 指令: + +1. 纯 JSON: {"action": "use_tool", "tool": "工具名", "args": "参数"} +2. 文本+JSON: 好的,我来修改这个文件。{"action": "use_tool", "tool": "code_editor", "args": "edit file.py old_code <<<>>> new_code"} + +【核心工具】 +- code_editor: 精准代码编辑(edit/search/diff/insert/create/write) +- code_search: 代码搜索与理解(find/regex/symbols/imports/structure) +- git_tools: Git 操作(status/diff/log/add/commit/branch) +- cmd_executor: 执行 shell 命令 +- file_manager: 文件管理 + +【编码工作流】 +1. 先用 code_search 了解代码结构 +2. 用 code_editor.read_range 查看相关代码 +3. 用 code_editor.edit 精准修改 +4. 用 git_tools 提交变更 + +【注意事项】 +- code_editor.edit 的格式: old_text <<<>>> new_text(用 <<<>>> 分隔) +- args 必须是字符串 +- 可以一次返回多个 JSON 指令顺序执行 +- 使用 continue 补充回答: {"action": "continue", "content": "补充内容"}""" + + return base + tool_section + call_format + + def _process_thinking(self, response): + """处理深度思考标记""" + # 格式 + if '' in response: + parts = response.split('', 1) + if len(parts) >= 2: + thinking = parts[0].strip() + reply = parts[1].strip() + if thinking: + print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking[:200]}...]{Style.RESET_ALL}" if len(thinking) > 200 else f"{Fore.LIGHTBLACK_EX}[思考: {thinking}]{Style.RESET_ALL}") + return reply if reply else "" + + # ... 格式 + engine = self.cli.engine_manager.current_engine + if engine: + start = getattr(engine, 'thinking_start_marker', None) + end = getattr(engine, 'thinking_end_marker', None) + if start and end and start in response and end in response: + parts = response.split(start, 1) + if len(parts) > 1 and end in parts[1]: + thinking, reply = parts[1].split(end, 1) + if thinking.strip(): + print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking.strip()[:200]}]{Style.RESET_ALL}") + return reply.strip() + + return response + + def _process_code_blocks(self, text): + """处理代码块标记""" + pattern = r'(""".*?""")' + def replace(m): + inner = m.group(1)[3:-3] + return f'{Fore.RED}{inner}{Style.RESET_ALL}' + return re.sub(pattern, replace, text, flags=re.DOTALL) + + def _parse_response(self, response): + """解析混合响应(文本+JSON)""" + response = response.strip() + + # 直接 JSON + try: + parsed = json.loads(response) + if isinstance(parsed, (dict, list)): + return "", parsed + except json.JSONDecodeError: + pass + + # 代码块中的 JSON + pattern = r"```(?:json)?\s*({.*?})\s*```" + matches = re.findall(pattern, response, re.DOTALL) + if matches: + try: + data = json.loads(matches[-1]) + if isinstance(data, (dict, list)): + text = re.sub(pattern, "", response, flags=re.DOTALL).strip() + return text, data + except json.JSONDecodeError: + pass + + # 内联 JSON + markers = ['{"action": "use_tool"', '{"action": "continue"', + '{"action":"use_tool"', '{"action":"continue"'] + for marker in markers: + idx = response.find(marker) + if idx != -1: + brace_count = 0 + for i in range(idx, len(response)): + if response[i] == '{': + brace_count += 1 + elif response[i] == '}': + brace_count -= 1 + if brace_count == 0: + try: + data = json.loads(response[idx:i+1]) + if isinstance(data, dict) and data.get('action') in ['use_tool', 'continue']: + return response[:idx].strip(), data + except json.JSONDecodeError: + pass + break + + return response, None + + def _get_tool_prompts(self): + """获取工具提示词""" + if not self.cli.tool_manager or not self.cli.tool_manager.tools: + return "当前没有可用的工具" + + lines = ["可用工具列表:\n"] + for tool in self.cli.tool_manager.tools: + name = tool.get('name', '') + desc = tool.get('description', '') + lines.append(f"- {name}: {desc}") + + return '\n'.join(lines) + + def _load_love_sentences(self): + """加载等待动画句子""" + import os + path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "love.txt") + if os.path.exists(path): + try: + with open(path, 'r', encoding='utf-8') as f: + return [l.strip() for l in f.readlines() if l.strip()] + except: + pass + return ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."] diff --git a/core/display.py b/core/display.py new file mode 100644 index 0000000..5387b30 --- /dev/null +++ b/core/display.py @@ -0,0 +1,131 @@ +""" +显示与输出模块 - 统一 CLI/TUI 输出 +""" +import sys +import os +import time +import random +import shutil +import threading +from colorama import Fore, Style +import logging + +logger = logging.getLogger(__name__) + + +class Display: + """统一显示管理器""" + + def __init__(self, user_id="", tui_callback=None): + self.user_id = user_id + self.tui_callback = tui_callback + + def output(self, message): + """统一输出方法""" + if self.tui_callback: + self.tui_callback(message) + else: + print(message) + + def response(self, content, is_continue=False): + """显示 AI 响应""" + user_id_display = f"[用户ID: {self.user_id}]" + lines = content.split('\n') + + for i, line in enumerate(lines): + if i == 0: + prefix = f"{Fore.YELLOW}✦{Style.RESET_ALL} " if is_continue else "✦ " + line = prefix + line + if i == len(lines) - 1: + print(f"{line} {user_id_display}") + else: + print(line) + print() + + def tool_result(self, tool_name, tool_args, full_result, is_error=False): + """显示工具执行结果""" + if full_result is None: + full_result = "无结果" + + self.output("") + user_id_display = f"[用户ID: {self.user_id}]" + + 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_lines(full_result, max_lines=2) + color = Fore.RED if is_error else Fore.GREEN + for line in display_result.split('\n'): + self.output(f"{color} {line}{Style.RESET_ALL}") + + def error(self, message): + """显示错误""" + self.output(f"{Fore.RED}{message}{Style.RESET_ALL}") + + def success(self, message): + """显示成功""" + self.output(f"{Fore.GREEN}{message}{Style.RESET_ALL}") + + def info(self, message): + """显示信息""" + self.output(f"{Fore.CYAN}{message}{Style.RESET_ALL}") + + def warning(self, message): + """显示警告""" + self.output(f"{Fore.YELLOW}{message}{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 thinking_animation(self, sentences, stop_event, esc_event): + """等待动画""" + if not sentences: + sentences = ["AI正在思考中...", "请稍等片刻...", "正在处理您的请求..."] + + last_change = time.time() + current_sentence = random.choice(sentences) + chars = "|/-\\" + idx = 0 + + while not stop_event.is_set(): + now = time.time() + if now - last_change >= 5: + current_sentence = random.choice(sentences) + last_change = now + + char = chars[idx % len(chars)] + display = current_sentence[:47] + "..." if len(current_sentence) > 50 else current_sentence + print(f"\r{Fore.MAGENTA}{display} {char}{Style.RESET_ALL}", end="", flush=True) + idx += 1 + time.sleep(0.1) + + print("\r" + " " * 60 + "\r", end="", flush=True) + + def _limit_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 show_banner(self, engine_name, engines, user_id): + """显示启动横幅""" + self.success(f"小狸 Pro-CLI 已启动!") + self.success(f"用户ID: {user_id}") + print(f"{Fore.GREEN}输入 '/help' 查看帮助信息{Style.RESET_ALL}") + print(f"{Fore.GREEN}输入 '/quit' 退出程序{Style.RESET_ALL}") + print(f"{Fore.GREEN}当前引擎: {engine_name}{Style.RESET_ALL}") + if len(engines) > 1: + print(f"{Fore.GREEN}已加载引擎: {', '.join(engines)}{Style.RESET_ALL}") + print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}") diff --git a/core/engines.py b/core/engines.py new file mode 100644 index 0000000..cd81873 --- /dev/null +++ b/core/engines.py @@ -0,0 +1,115 @@ +""" +AI 引擎管理模块 - 动态加载和切换 AI 引擎 +""" +import os +import importlib.util +import logging +from colorama import Fore, Style + +logger = logging.getLogger(__name__) + + +class EngineManager: + """AI 引擎管理器""" + + def __init__(self): + self.engines = {} + self.current_engine = None + + def load_engines(self, engines_dir: str) -> dict: + """动态加载所有 AI 引擎""" + print(f"{Fore.GREEN}正在加载AI引擎插件...{Style.RESET_ALL}") + + if not os.path.exists(engines_dir): + print(f"{Fore.YELLOW}引擎目录不存在: {engines_dir}{Style.RESET_ALL}") + return self.engines + + for filename in os.listdir(engines_dir): + if not filename.endswith('.py') or filename == '__init__.py': + continue + + engine_name = filename[:-3] + engine_path = os.path.join(engines_dir, filename) + + 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_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}引擎插件 {filename} 中未找到合适的引擎类{Style.RESET_ALL}") + + except Exception as e: + logger.error(f"加载引擎失败 {filename}: {e}") + print(f"{Fore.RED}加载引擎失败 {filename}: {e}{Style.RESET_ALL}") + + print(f"{Fore.GREEN}总共加载了 {len(self.engines)} 个AI引擎{Style.RESET_ALL}") + return self.engines + + def _find_engine_class(self, module, engine_name): + """查找引擎类""" + possible_names = [ + engine_name.capitalize() + "AI", + engine_name.replace('_', '').replace('-', '').capitalize() + "AI", + ''.join(w.capitalize() for w in engine_name.replace('-', ' ').replace('_', ' ').split()) + "AI", + ''.join(w.capitalize() for w 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 (isinstance(attr, type) and + attr_name.endswith('AI') and + hasattr(attr, 'generate_response')): + return attr_name + return None + + def set_default(self, name: str) -> bool: + """设置默认引擎""" + if name in self.engines: + self.current_engine = self.engines[name] + return True + return False + + def switch(self, name: str) -> bool: + """切换引擎""" + if name not in self.engines: + return False + + old_engine = self.current_engine + self.current_engine = self.engines[name] + + if hasattr(self.current_engine, 'cli'): + self.current_engine.cli = getattr(old_engine, 'cli', None) + + return True + + def get_current_name(self) -> str: + """获取当前引擎名称""" + if self.current_engine: + return getattr(self.current_engine, 'name', '未设置') + return '未设置' + + def list_engines(self) -> list: + """列出所有引擎""" + return list(self.engines.keys()) + + def generate_response(self, user_input: str, system_prompt: str = None) -> str: + """使用当前引擎生成响应""" + if not self.current_engine: + return "错误: 没有可用的AI引擎" + return self.current_engine.generate_response(user_input, system_prompt=system_prompt) diff --git a/core/sandbox.py b/core/sandbox.py new file mode 100644 index 0000000..1cea068 --- /dev/null +++ b/core/sandbox.py @@ -0,0 +1,210 @@ +""" +安全代码沙箱 - 在隔离环境中执行用户/插件代码 +""" +import io +import ast +import time +import re +import threading +import traceback +from contextlib import redirect_stdout, redirect_stderr +from datetime import datetime +from colorama import Fore, Style +import logging + +logger = logging.getLogger(__name__) + + +class CodeSandbox: + """安全代码执行沙箱""" + + # 危险模块黑名单 + DANGEROUS_MODULES = { + 'os', 'sys', 'subprocess', 'shutil', 'socket', 'pickle', + 'marshal', 'ctypes', 'multiprocessing', 'threading', + 'signal', 'resource', 'posix', 'nt', 'builtins', + 'importlib', 'code', 'codeop', 'commands', 'popen2', + } + + # 危险函数/属性黑名单 + DANGEROUS_FUNCTIONS = { + 'exec', 'eval', 'compile', 'execfile', 'open', + 'input', 'raw_input', 'globals', 'locals', 'vars', + 'dir', 'getattr', 'setattr', 'delattr', 'hasattr', + '__import__', '__builtins__', '__class__', '__bases__', + '__subclasses__', '__mro__', '__dict__', '__getattribute__', + } + + def __init__(self, timeout=30, whitelist=None): + self.enabled = True + self.timeout = timeout + self.whitelist = whitelist or [ + 'json', 'math', 'datetime', 'random', 'collections', + 'itertools', 'functools', 'operator', 'typing', 're' + ] + self.history = [] + self.max_history = 100 + + def is_safe(self, code: str) -> bool: + """检查代码是否安全(AST 分析 + 字符串快速检查)""" + # 快速字符串检查 + dangerous_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', + ] + for pattern in dangerous_patterns: + if re.search(pattern, code, re.IGNORECASE): + return False + + # AST 分析 + try: + tree = ast.parse(code) + except SyntaxError: + return False + + class SafetyVisitor(ast.NodeVisitor): + def __init__(self): + self.is_safe = True + + def _check_name(self, name): + if name in CodeSandbox.DANGEROUS_FUNCTIONS: + self.is_safe = False + + def visit_Import(self, node): + for alias in node.names: + if alias.name.split('.')[0] in CodeSandbox.DANGEROUS_MODULES: + self.is_safe = False + self.generic_visit(node) + + def visit_ImportFrom(self, node): + if node.module: + if node.module.split('.')[0] in CodeSandbox.DANGEROUS_MODULES: + self.is_safe = False + self.generic_visit(node) + + def visit_Call(self, node): + if isinstance(node.func, ast.Name): + self._check_name(node.func.id) + elif isinstance(node.func, ast.Attribute): + self._check_name(node.func.attr) + self.generic_visit(node) + + def visit_Attribute(self, node): + self._check_name(node.attr) + self.generic_visit(node) + + def visit_Name(self, node): + self._check_name(node.id) + self.generic_visit(node) + + visitor = SafetyVisitor() + visitor.visit(tree) + return visitor.is_safe + + def execute(self, code: str, variables: dict = None) -> dict: + """ + 在安全沙箱中执行代码 + + Returns: + { + 'success': bool, + 'result': str, + 'stdout': str, + 'stderr': str, + 'execution_time': float, + } + """ + if not self.enabled: + return {'success': False, 'result': '代码执行功能已禁用', + 'stdout': '', 'stderr': '', 'execution_time': 0} + + if not self.is_safe(code): + return {'success': False, 'result': '代码包含潜在危险操作,已阻止执行', + 'stdout': '', 'stderr': '', 'execution_time': 0} + + # 准备变量 + exec_vars = {} + if variables: + exec_vars.update(variables) + + # 添加安全的内置函数 + safe_builtins = { + 'print': print, 'len': len, 'str': str, 'int': int, + 'float': float, 'list': list, 'dict': dict, 'tuple': tuple, + 'set': set, 'range': range, 'enumerate': enumerate, + 'zip': zip, 'sorted': sorted, 'sum': sum, 'max': max, + 'min': min, 'abs': abs, 'round': round, 'bool': bool, + 'type': type, 'isinstance': isinstance, + } + exec_vars.update(safe_builtins) + + # 添加白名单模块 + for module_name in self.whitelist: + try: + exec_vars[module_name] = __import__(module_name) + except ImportError: + pass + + # 捕获输出 + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + start_time = time.time() + result = {'success': True, 'error': None, 'traceback': None} + + def _exec(): + try: + exec(code, exec_vars) + except Exception as e: + result['success'] = False + result['error'] = str(e) + result['traceback'] = traceback.format_exc() + + exec_thread = threading.Thread(target=_exec, daemon=True) + exec_thread.start() + exec_thread.join(timeout=self.timeout) + + exec_time = time.time() - start_time + + if exec_thread.is_alive(): + return { + 'success': False, + 'result': f'代码执行超时(超过 {self.timeout} 秒)', + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue(), + 'execution_time': self.timeout, + } + + if result['success']: + exec_result = exec_vars.get('result', None) + result_str = str(exec_result) if exec_result is not None else '代码执行成功(无返回值)' + return { + 'success': True, + 'result': result_str, + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue(), + 'execution_time': exec_time, + } + else: + return { + 'success': False, + 'result': f"代码执行错误: {result['error']}", + 'stdout': stdout_capture.getvalue(), + 'stderr': stderr_capture.getvalue() + '\n' + (result['traceback'] or ''), + 'execution_time': exec_time, + } + + def add_to_history(self, record: dict): + """添加执行记录""" + self.history.append(record) + if len(self.history) > self.max_history: + self.history.pop(0) + + def get_history(self, limit=10): + """获取执行历史""" + return self.history[-limit:] + + def clear_history(self): + """清空执行历史""" + self.history = [] diff --git a/image_engine/__init__.py b/image_engine/__init__.py new file mode 100644 index 0000000..6846e41 --- /dev/null +++ b/image_engine/__init__.py @@ -0,0 +1,209 @@ +""" +图像识别引擎模块 +支持多种图像识别引擎的切换和管理 +""" + +import os +import sys +import importlib +import json +from colorama import Fore, Style + +# 添加项目根目录到sys.path +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) + + +class BaseImageEngine: + """图像识别引擎基类""" + + name = "base" + description = "基础图像识别引擎" + requires_api_key = False + + def __init__(self): + self.config = {} + + def set_config(self, config): + """设置引擎配置""" + self.config = config + + def is_available(self): + """检查引擎是否可用(子类实现)""" + return False + + def analyze(self, image_path, prompt="请描述这张图片"): + """分析图片(子类实现)""" + raise NotImplementedError("子类必须实现 analyze 方法") + + def stream_analyze(self, image_path, prompt="请描述这张图片"): + """流式分析图片(子类实现,可选)""" + # 默认实现:调用 analyze 并一次性返回 + result = self.analyze(image_path, prompt) + yield result + + def get_model_list(self): + """获取可用模型列表(子类实现,可选)""" + return [] + + def set_model(self, model_name): + """设置当前使用的模型(子类实现,可选)""" + pass + + def get_help(self): + """获取引擎帮助信息""" + return f"{self.name}: {self.description}" + + +class ImageEngineManager: + """图像识别引擎管理器""" + + def __init__(self): + self.engines = {} + self.current_engine = None + self.current_engine_name = None + self.config = self._load_config() + + # 自动加载所有引擎 + self._load_engines() + + # 设置默认引擎 + self._set_default_engine() + + def _load_config(self): + """加载配置文件""" + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {"image": {"engines": {}, "default_engine": "ollama"}} + + def _save_config(self): + """保存配置文件""" + config_file = os.path.join(project_root, "config.json") + try: + with open(config_file, 'r', encoding='utf-8') as f: + full_config = json.load(f) + except: + full_config = {"api": {"engines": {}}, "system": {}, "image": {}} + + # 确保image配置存在 + if "image" not in full_config: + full_config["image"] = {} + full_config["image"]["default_engine"] = self.current_engine_name + + try: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(full_config, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"{Fore.RED}保存配置失败: {e}{Style.RESET_ALL}") + + def _load_engines(self): + """加载所有引擎""" + engine_dir = os.path.dirname(__file__) + + for filename in os.listdir(engine_dir): + if filename.endswith('_engine.py') and filename != '__init__.py': + engine_name = filename[:-11] # 移除 _engine.py + try: + # 动态导入模块 + module_path = os.path.join(engine_dir, filename) + spec = importlib.util.spec_from_file_location(f"image_engine.{engine_name}", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 获取引擎类 + for attr_name in dir(module): + attr = getattr(module, attr_name) + if (isinstance(attr, type) and + issubclass(attr, BaseImageEngine) and + attr != BaseImageEngine): + engine_instance = attr() + # 加载引擎配置 + engine_config = self.config.get("image", {}).get("engines", {}).get(engine_name, {}) + engine_instance.set_config(engine_config) + self.engines[engine_name] = engine_instance + print(f"{Fore.GREEN}已加载图像引擎: {engine_name}{Style.RESET_ALL}") + break + except Exception as e: + print(f"{Fore.RED}加载图像引擎 {engine_name} 失败: {e}{Style.RESET_ALL}") + + def _set_default_engine(self): + """设置默认引擎""" + # 从配置读取默认引擎 + default = self.config.get("image", {}).get("default_engine", "ollama") + + if default in self.engines: + engine = self.engines[default] + if engine.is_available(): + self.current_engine = engine + self.current_engine_name = default + print(f"{Fore.GREEN}当前图像引擎: {default}{Style.RESET_ALL}") + return + + # 如果配置的默认引擎不可用,找第一个可用的 + for name, engine in self.engines.items(): + if engine.is_available(): + self.current_engine = engine + self.current_engine_name = name + print(f"{Fore.GREEN}当前图像引擎: {name}{Style.RESET_ALL}") + return + + print(f"{Fore.YELLOW}警告: 没有可用的图像引擎{Style.RESET_ALL}") + + def get_engine(self, name=None): + """获取指定引擎或当前引擎""" + if name: + return self.engines.get(name) + return self.current_engine + + def switch_engine(self, name): + """切换引擎""" + if name not in self.engines: + available = list(self.engines.keys()) + return False, f"引擎 '{name}' 不存在。可用引擎: {', '.join(available)}" + + engine = self.engines[name] + if not engine.is_available(): + return False, f"引擎 '{name}' 不可用,请检查配置" + + self.current_engine = engine + self.current_engine_name = name + self._save_config() + return True, f"已切换到图像引擎: {name}" + + def list_engines(self): + """列出所有引擎""" + result = f"{Fore.CYAN}图像识别引擎列表:{Style.RESET_ALL}\n" + for name, engine in self.engines.items(): + status = f"{Fore.GREEN}✓ 可用{Style.RESET_ALL}" if engine.is_available() else f"{Fore.RED}✗ 不可用{Style.RESET_ALL}" + current = f" {Fore.YELLOW}[当前]{Style.RESET_ALL}" if name == self.current_engine_name else "" + result += f" - {name}: {engine.description} {status}{current}\n" + return result + + def analyze(self, image_path, prompt="请描述这张图片", stream=True): + """使用当前引擎分析图片""" + if not self.current_engine: + raise Exception("没有可用的图像引擎") + + if stream and hasattr(self.current_engine, 'stream_analyze'): + return self.current_engine.stream_analyze(image_path, prompt) + else: + return self.current_engine.analyze(image_path, prompt) + + def get_current_engine_name(self): + """获取当前引擎名称""" + return self.current_engine_name + + +# 全局引擎管理器实例 +_engine_manager = None + +def get_engine_manager(): + """获取全局引擎管理器实例""" + global _engine_manager + if _engine_manager is None: + _engine_manager = ImageEngineManager() + return _engine_manager diff --git a/image_engine/ollama_engine.py b/image_engine/ollama_engine.py new file mode 100644 index 0000000..5e27d03 --- /dev/null +++ b/image_engine/ollama_engine.py @@ -0,0 +1,204 @@ +""" +Ollama 视觉引擎 +使用本地 Ollama 服务进行图像识别 +""" + +import os +from colorama import Fore, Style +from . import BaseImageEngine + +try: + import ollama + OLLAMA_AVAILABLE = True +except ImportError: + OLLAMA_AVAILABLE = False + + +class OllamaImageEngine(BaseImageEngine): + """Ollama 视觉引擎""" + + name = "ollama" + description = "Ollama本地视觉模型(支持多种视觉模型)" + requires_api_key = False + + # 预设提示词 + PROMPTS = { + "analyze": """请详细分析这张图片: +1. 图像类型:照片/截图/插画/图表/文档等 +2. 主要内容:描述核心元素和场景 +3. 文字内容:如有文字,请完整转录 +4. 视觉细节:颜色、构图、风格等 +5. 总结:一句话概括 +请用中文简洁回答。""", + + "ocr": "请识别图片中的所有文字内容,按原文格式输出。如果没有文字,请描述图片内容。", + + "quick": "请用中文简要描述这张图片的内容。", + + "describe": "请详细描述这张图片的内容,包括所有可见的元素、颜色、文字等。" + } + + # 推荐的视觉模型列表 + VISION_MODELS = [ + 'qwen3-vl:4b', 'qwen3-vl:latest', + 'llava:7b', 'llava:13b', 'llava:34b', + 'llava-llama3:latest', + 'minicpm-v:latest', + 'deepseek-ocr:latest', + 'moondream:latest', + 'bakllava:latest', + ] + + def __init__(self): + super().__init__() + self.model = "qwen3-vl:4b" + self.installed_models = [] + self._refresh_models() + + def set_config(self, config): + """设置引擎配置""" + super().set_config(config) + if 'model' in config: + self.model = config['model'] + + def _refresh_models(self): + """刷新已安装模型列表""" + if not OLLAMA_AVAILABLE: + self.installed_models = [] + return + + try: + result = ollama.list() + self.installed_models = [m['model'] for m in result.get('models', [])] + except: + self.installed_models = [] + + def is_available(self): + """检查引擎是否可用""" + if not OLLAMA_AVAILABLE: + return False + try: + ollama.list() + return True + except: + return False + + def _find_model(self, model_name): + """查找模型(处理版本标签)""" + for m in self.installed_models: + if m == model_name or m.startswith(model_name + ':') or model_name in m: + return m + return None + + def get_model_list(self): + """获取可用模型列表""" + self._refresh_models() + + result = f"{Fore.CYAN}已安装的模型:{Style.RESET_ALL}\n" + for m in self.installed_models: + marker = f"{Fore.GREEN}*{Style.RESET_ALL}" if any(vm.split(':')[0] in m for vm in ['vl', 'llava', 'vision', 'ocr', 'minicpm', 'moondream', 'qwen3-vl']) else " " + result += f" {marker} {m}\n" + + result += f"\n{Fore.CYAN}推荐的视觉模型:{Style.RESET_ALL}\n" + for vm in self.VISION_MODELS: + status = f"{Fore.GREEN}[已安装]{Style.RESET_ALL}" if self._find_model(vm) else f"{Fore.YELLOW}[未安装]{Style.RESET_ALL}" + result += f" - {vm} {status}\n" + + return result + + def set_model(self, model_name): + """设置当前使用的模型""" + actual = self._find_model(model_name) + if actual: + self.model = actual + return True, f"已切换到模型: {actual}" + + # 如果没找到,直接使用用户指定的名称(可能还没安装) + self.model = model_name + return True, f"已设置模型: {model_name} (未安装? 运行 ollama pull {model_name})" + + def analyze(self, image_path, prompt="请描述这张图片"): + """分析图片(非流式)""" + if not OLLAMA_AVAILABLE: + raise Exception("ollama 库未安装,请运行: pip install ollama") + + if not os.path.exists(image_path): + raise Exception(f"文件不存在: {image_path}") + + # 使用预设提示词或自定义提示词 + actual_prompt = self.PROMPTS.get(prompt, prompt) + + try: + response = ollama.chat( + model=self.model, + messages=[{ + 'role': 'user', + 'content': actual_prompt, + 'images': [image_path] + }] + ) + return response.get('message', {}).get('content', '') + except Exception as e: + raise Exception(f"Ollama识别失败: {str(e)}") + + def stream_analyze(self, image_path, prompt="请描述这张图片"): + """流式分析图片""" + if not OLLAMA_AVAILABLE: + raise Exception("ollama 库未安装,请运行: pip install ollama") + + if not os.path.exists(image_path): + raise Exception(f"文件不存在: {image_path}") + + # 使用预设提示词或自定义提示词 + actual_prompt = self.PROMPTS.get(prompt, prompt) + + # 检查模型是否已安装 + actual_model = self._find_model(self.model) + if not actual_model: + raise Exception(f"模型 {self.model} 未安装。请运行: ollama pull {self.model}") + + try: + response = ollama.chat( + model=actual_model, + messages=[{ + 'role': 'user', + 'content': actual_prompt, + 'images': [image_path] + }], + stream=False + ) + + content = response.get('message', {}).get('content', '') + if content: + yield content + else: + raise Exception("模型返回空内容") + + except Exception as e: + error_msg = str(e) + if "connection" in error_msg.lower(): + raise Exception("无法连接到 Ollama 服务,请确保服务正在运行 (ollama serve)") + raise Exception(f"Ollama识别失败: {error_msg}") + + def get_help(self): + """获取引擎帮助信息""" + return f""" +Ollama 视觉引擎帮助 +================== +当前模型: {self.model} + +使用说明: +1. 确保 Ollama 服务正在运行 +2. 安装视觉模型: ollama pull qwen3-vl:4b +3. 使用 /image model <模型名> 切换模型 + +支持的操作: +- analyze 详细分析 +- ocr 文字识别 +- quick 快速描述 +- describe 完整描述 + +模型管理: +- /image models 列出模型 +- /image model <名> 切换模型 +""" diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..edcdc19 --- /dev/null +++ b/launcher.py @@ -0,0 +1,1814 @@ +import subprocess +import sys +import os +import re +import json +import tempfile +import urllib.request +import zipfile +from pathlib import Path +import io +from datetime import datetime +import platform + +# 设置控制台编码为UTF-8 +if sys.stdout.encoding != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +if sys.stderr.encoding != 'utf-8': + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + +class KawaiiPythonLauncher: + """可爱版Python启动器~""" + + def clear_screen(self): + """清屏函数""" + os.system('cls' if platform.system() == 'Windows' else 'clear') + + def get_hidden_input(self): + """获取隐藏输入的API密钥(Windows和跨平台兼容)""" + import sys + try: + import msvcrt # Windows only + # Windows implementation + print("💡 输入API密钥 (输入后按回车确认,字符将被隐藏): ", end='', flush=True) + password = "" + + while True: + char = msvcrt.getch() # 获取单个字符(不显示) + if char in [b'\r', b'\n']: # Enter键 + print() # 换行 + break + elif char == b'\x08': # Backspace键 + if len(password) > 0: + password = password[:-1] + print('\b \b', end='', flush=True) # 删除一个星号 + else: + password += char.decode('utf-8') + print('*', end='', flush=True) # 显示星号 + + return password + except ImportError: + # Fallback for non-Windows systems + import getpass + return getpass.getpass("💡 请输入API密钥 (输入时将隐藏): ") + + def __init__(self): + # 获取项目目录 + if getattr(sys, 'frozen', False): + self.project_dir = Path(os.path.dirname(sys.executable)) + else: + self.project_dir = Path(__file__).parent + + # 嵌入式Python目录 + self.embedded_python_dir = self.project_dir / "embedded_python" + self.embedded_python_exe = self.embedded_python_dir / "python.exe" + self.embedded_pip_exe = self.embedded_python_dir / "Scripts" / "pip.exe" + + # 主程序文件 + self.main_program = self.project_dir / "ai_cli.py" + self.ai_engines_dir = self.project_dir / "ai_engines" + self.plugins_dir = self.project_dir / "plugins" + + # 下载源配置 + self.download_sources = { + "1": { + "name": "Python官方源 🌟", + "url": "https://www.python.org/ftp/python/3.12.0/python-3.12.0-embed-amd64.zip", + "description": "官方源,最稳定可靠啦~" + }, + "2": { + "name": "清华镜像源 🚀", + "url": "https://mirrors.tuna.tsinghua.edu.cn/python/3.12.0/python-3.12.0-embed-amd64.zip", + "description": "国内小伙伴推荐,飞一般的速度!" + }, + "3": { + "name": "中科大镜像源 📚", + "url": "https://mirrors.ustc.edu.cn/python/3.12.0/python-3.12.0-embed-amd64.zip", + "description": "学霸源,稳定又快速~" + }, + "4": { + "name": "阿里云镜像源 ☁️", + "url": "https://mirrors.aliyun.com/python/3.12.0/python-3.12.0-embed-amd64.zip", + "description": "云上小可爱,下载超顺畅" + } + } + + # 状态变量 + self.use_system_python = False + self.use_embedded_python = False + self.current_python_exe = None + self.current_pip_exe = None + + # 错误收集 + self.errors = [] + + # 已知的第三方库 + self.known_third_party_packages = { + # 基础库 + 'colorama', 'openai', 'requests', 'ollama', 'pygame', + # 图像处理 + 'PIL', 'Pillow', 'cv2', 'opencv-python', 'numpy', + # 终端显示 + 'ascii_magic', + # 其他可能的库 + 'tkinter', 'matplotlib', 'pandas', 'scipy', 'scikit-learn' + } + + # 插件管理 + self.enabled_plugins = [] + + def discover_plugins(self): + """发现可用的插件""" + plugins = [] + if self.plugins_dir.exists(): + for py_file in self.plugins_dir.glob("*.py"): + # 跳过__init__.py等特殊文件 + if not py_file.name.startswith('__'): + plugin_name = py_file.stem + plugins.append(plugin_name) + return sorted(plugins) + + def show_plugin_selection(self): + """显示插件选择菜单""" + available_plugins = self.discover_plugins() + + if not available_plugins: + print("🎈 没有发现任何插件呢~") + return [] + + print("\n" + "🎀" * 25) + print("✨ 插件选择菜单 ✨") + print("🎀" * 25) + print(f"🎯 发现了 {len(available_plugins)} 个插件:") + + for i, plugin in enumerate(available_plugins, 1): + print(f" {i}. {plugin}") + + print("\n💫 请选择要启用的插件:") + print(" 🎯 输入 'all' 启用所有插件") + print(" 🎯 输入插件编号(多个用逗号分隔,如: 1,3,5") + print(" 🎯 输入 'none' 不启用任何插件") + print("-" * 40) + + return available_plugins + + def get_plugin_selection(self, available_plugins): + """获取用户的插件选择""" + if not available_plugins: + return [] + + while True: + try: + choice = input("🎯 你的选择: ").strip().lower() + + if choice == 'all': + print("🎊 启用所有插件!") + return available_plugins + + elif choice == 'none' or choice == '0': + print("🐾 不启用任何插件") + return [] + + elif choice: + # 处理数字选择 + selected_indices = [] + for part in choice.split(','): + part = part.strip() + if part.isdigit(): + idx = int(part) + if 1 <= idx <= len(available_plugins): + selected_indices.append(idx - 1) + + if selected_indices: + selected_plugins = [available_plugins[i] for i in selected_indices] + print(f"🎯 已选择插件: {', '.join(selected_plugins)}") + return selected_plugins + else: + print("🤔 唔...没有识别到有效的选择呢,请重新输入~") + + else: + print("💫 请输入你的选择哦~") + + except KeyboardInterrupt: + print("\n🐾 你按了Ctrl+C,小狸明白啦~使用默认设置") + return [] + except Exception as e: + print(f"💫 选择时出了点小问题: {e}") + return [] + + def ask_plugin_enable(self): + """询问用户是否启用插件""" + available_plugins = self.discover_plugins() + + if not available_plugins: + print("🎈 没有发现任何插件呢,直接启动主程序~") + return [] + + print(f"\n🎁 小狸发现了 {len(available_plugins)} 个插件!") + + while True: + try: + enable_all = input("🎯 是否启用所有插件?(y/N): ").strip().lower() + + if enable_all == 'y': + print("🎊 启用所有插件!") + return available_plugins + elif enable_all == 'n' or enable_all == '': + # 显示选择菜单 + plugins = self.show_plugin_selection() + return self.get_plugin_selection(plugins) + else: + print("🤔 请输入 y 或 n 哦~") + + except KeyboardInterrupt: + print("\n🐾 你按了Ctrl+C,小狸明白啦~使用默认设置") + return [] + except Exception as e: + print(f"💫 选择时出了点小问题: {e}") + return [] + + def create_plugin_config(self, enabled_plugins): + """创建插件配置文件""" + config_content = "# 小狸插件配置 - 由启动器自动生成\n" + + if not enabled_plugins: + # 用户选择不启用任何插件 + config_content += "# 不启用任何插件\n" + config_content += "# 启用的插件: 无\n\n" + else: + config_content += f"# 启用的插件: {', '.join(enabled_plugins)}\n\n" + for plugin in enabled_plugins: + config_content += f"ENABLE_{plugin.upper()} = True\n" + + config_file = self.project_dir / "plugins_config.py" + try: + with open(config_file, 'w', encoding='utf-8') as f: + f.write(config_content) + print(f"📝 插件配置已保存到: {config_file}") + return config_file + except Exception as e: + print(f"💫 保存插件配置时出错了: {e}") + return None + + def show_download_source_menu(self): + """显示可爱的下载源选择菜单""" + print("\n" + "✨" * 30) + print("🎀 请选择Python下载源 🎀") + print("✨" * 30) + + print("小狸为你准备了多个下载源,选一个最适合你的吧~") + print() + + for key, source in self.download_sources.items(): + print(f"🍭 {key}. {source['name']}") + print(f" 💬 {source['description']}") + print(f" 🔗 {source['url']}") + print() + + print("🌠 5. 手动输入下载地址(高级模式)") + print("🐾 0. 取消下载(人家会伤心的 😢)") + print("-" * 60) + + def get_user_download_choice(self): + """获取用户下载源选择(可爱版)""" + while True: + try: + choice = input("🎯 请选择下载源 (1-5, 0取消): ").strip() + + if choice == "0": + print("😿 呜...你取消了下载,小狸好难过...") + return None + + elif choice == "5": + print("🧚‍♀️ 进入高级模式~请输入完整的下载地址:") + custom_url = input("🔗 下载地址: ").strip() + if custom_url and custom_url.startswith(('http://', 'https://')): + print("✅ 自定义地址设置成功!") + return custom_url + else: + print("❌ 哎呀,这个地址不太对呢,请重新输入~") + continue + + elif choice in self.download_sources: + source = self.download_sources[choice] + print(f"🎉 太棒了!选择了: {source['name']}") + return source['url'] + + else: + print("🤔 唔...这个选择小狸看不懂呢,请输入 1-5 的数字哦~") + + except KeyboardInterrupt: + print("\n🐾 你按了Ctrl+C,小狸明白啦~") + return None + except Exception as e: + print(f"💫 选择时出了点小问题: {e}") + return None + + def test_download_source(self, url): + """测试下载源是否可用(可爱版)""" + print(f"🔍 正在测试下载源: {url}") + try: + # 创建测试请求 + req = urllib.request.Request(url, method='HEAD') + response = urllib.request.urlopen(req, timeout=10) + + if response.status == 200: + file_size = response.headers.get('Content-Length') + if file_size: + size_mb = int(file_size) / (1024 * 1024) + print(f"🎊 太棒了!源可用,文件大小: {size_mb:.1f} MB") + else: + print("🎊 耶~源可用!") + return True + else: + print(f"💔 哎呀,源不可用,状态码: {response.status}") + return False + + except Exception as e: + print(f"🌧️ 源测试失败: {e}") + return False + + def log_error(self, action, error): + """记录错误信息(可爱版)""" + error_msg = f"行为: {action}\n错误: {error}\n{'-'*50}" + self.errors.append(error_msg) + print(f"💔 {action}时出错了: {error}") + + def save_errors_to_file(self): + """保存错误信息到文件(可爱版)""" + if self.errors: + bug_file = self.project_dir / "bug.txt" + try: + with open(bug_file, 'w', encoding='utf-8') as f: + f.write("🐛 小狸 Pro-CLI 启动错误报告 🐛\n") + f.write("🌈" * 20 + "\n") + f.write("\n".join(self.errors)) + print(f"📝 已经把错误信息悄悄保存在: {bug_file}") + return True + except Exception as e: + print(f"😅 保存错误文件时也出错了: {e}") + return False + + def check_system_python(self): + """检测系统Python环境(可爱版)""" + print("🔍 正在寻找系统里的Python...") + try: + # 尝试python命令 + result = subprocess.run( + ["python", "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + if result.returncode == 0 and "Python" in result.stdout: + print(f"🎯 找到系统Python啦: {result.stdout.strip()}") + self.use_system_python = True + self.current_python_exe = "python" + return True + except Exception as e: + self.log_error("寻找系统Python", str(e)) + + try: + # 尝试python3命令 + result = subprocess.run( + ["python3", "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + if result.returncode == 0 and "Python" in result.stdout: + print(f"🎯 找到系统Python3啦: {result.stdout.strip()}") + self.use_system_python = True + self.current_python_exe = "python3" + return True + except Exception as e: + self.log_error("寻找系统Python3", str(e)) + + print("😢 没有找到系统Python呢...") + return False + + def check_embedded_python(self): + """检测嵌入式Python(可爱版)""" + print("🔍 看看有没有自带的Python...") + if self.embedded_python_exe.exists(): + try: + result = subprocess.run( + [str(self.embedded_python_exe), "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + if result.returncode == 0: + print(f"🎁 找到嵌入式Python啦: {result.stdout.strip()}") + self.use_embedded_python = True + self.current_python_exe = str(self.embedded_python_exe) + self.current_pip_exe = str(self.embedded_pip_exe) + return True + except Exception as e: + self.log_error("检查嵌入式Python", str(e)) + + print("📦 没有找到现成的嵌入式Python呢...") + return False + + def download_file(self, url, local_filename): + """下载文件(可爱版)""" + print(f"🌐 正在下载: {os.path.basename(local_filename)}...") + try: + def progress_hook(count, block_size, total_size): + if total_size > 0: + percent = min(100, int(count * block_size * 100 / total_size)) + # 可爱的进度条 + bars = "🟩" * (percent // 5) + "⬜" * (20 - percent // 5) + print(f"\r📥 下载中... {bars} {percent}%", end='', flush=True) + + urllib.request.urlretrieve(url, local_filename, progress_hook) + print("\n🎊 下载完成!太棒了!") + return True + except Exception as e: + print(f"\n💔 下载失败了: {e}") + return False + + def install_embedded_python(self): + """安装嵌入式Python(可爱版)""" + print("🛠️ 准备安装Python环境...") + + # 显示下载源菜单 + self.show_download_source_menu() + + # 获取用户选择 + python_url = self.get_user_download_choice() + if not python_url: + return False + + # 测试下载源 + print("\n🧪 测试下载源...") + if not self.test_download_source(python_url): + print("💔 下载源不可用,可能是网络问题呢~") + retry = input("🔄 要再试一次吗?(y/N): ").strip().lower() + if retry == 'y': + print("🦄 小狸陪你再来一次!") + return self.install_embedded_python() + else: + return False + + # 创建目录 + self.embedded_python_dir.mkdir(exist_ok=True) + + # 下载嵌入式Python + zip_path = self.project_dir / "python_embedded.zip" + + print(f"\n🚀 开始下载Python...") + if not self.download_file(python_url, zip_path): + self.log_error("下载嵌入式Python", "网络连接失败") + + # 提供重试选项 + retry = input("🔄 下载失败,要再试一次吗?(y/N): ").strip().lower() + if retry == 'y': + print("💪 不放弃!我们再试一次!") + return self.install_embedded_python() + else: + return False + + # 解压 + try: + print("📦 解压Python中...") + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(self.embedded_python_dir) + print("🎀 解压完成!") + except Exception as e: + self.log_error("解压Python", str(e)) + return False + finally: + if zip_path.exists(): + zip_path.unlink() + + # 配置Python环境 + try: + pth_file = self.embedded_python_dir / "python._pth" + if pth_file.exists(): + content = pth_file.read_text(encoding='utf-8') + # 确保import site被启用并添加当前目录 + content = content.replace('#import site', 'import site') + if 'import site' not in content: + content += '\nimport site' + # 添加当前目录到路径 + content += f'\n.\n{str(self.embedded_python_dir / "Lib")}\n{str(self.embedded_python_dir / "DLLs")}' + pth_file.write_text(content, encoding='utf-8') + print("⚙️ Python环境配置完成!") + except Exception as e: + self.log_error("配置Python环境", str(e)) + + # 验证Python是否能正常运行 + try: + result = subprocess.run( + [str(self.embedded_python_exe), "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + if result.returncode == 0: + print(f"✅ Python版本: {result.stdout.strip()}") + else: + print(f"⚠️ Python验证失败: {result.stderr.strip() if result.stderr else '未知错误'}") + except Exception as e: + print(f"⚠️ Python验证异常: {str(e)}") + + # 安装pip + print("\n🔧 安装pip工具...") + if not self.install_pip_for_embedded(): + print("⚠️ pip安装失败,但Python环境已安装,部分功能可能受限") + # 即使pip安装失败也继续,因为Python本身可能仍然可用 + else: + # 验证pip安装 + try: + pip_result = subprocess.run( + [str(self.embedded_python_exe), "-m", "pip", "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + if pip_result.returncode == 0: + print(f"✅ pip版本: {pip_result.stdout.strip()}") + else: + print(f"⚠️ pip验证失败: {pip_result.stderr.strip() if pip_result.stderr else '未知错误'}") + except Exception as e: + print(f"⚠️ pip验证异常: {str(e)}") + + # 验证安装 + return self.check_embedded_python() + + def install_pip_for_embedded(self): + """为嵌入式Python安装pip(可爱版)""" + try: + # 首先尝试使用ensurepip(Python内置的pip安装工具) + print("🔍 尝试使用内置ensurepip安装pip...") + result = subprocess.run( + [str(self.embedded_python_exe), "-m", "ensurepip", "--upgrade"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + + if result.returncode == 0: + print("🎯 pip通过ensurepip安装成功!现在可以安装各种库啦~") + + # 验证pip安装 + pip_result = subprocess.run( + [str(self.embedded_python_exe), "-m", "pip", "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + + if pip_result.returncode == 0: + print(f"✅ pip验证成功: {pip_result.stdout.strip()}") + # 更新pip路径 + pip_exe_path = self.embedded_python_dir / "Scripts" / "pip.exe" + if pip_exe_path.exists(): + self.current_pip_exe = str(pip_exe_path) + return True + else: + print("⚠️ pip安装完成但验证失败") + return False + else: + print("⚠️ ensurepip安装失败,尝试使用get-pip.py...") + + # 如果ensurepip失败,尝试下载get-pip.py + get_pip_urls = [ + "https://bootstrap.pypa.io/get-pip.py", # 官方源 + "https://gitee.com/mirrors/get-pip/raw/main/public/get-pip.py", # 国内镜像 + ] + + get_pip_path = self.embedded_python_dir / "get-pip.py" + downloaded = False + + # 尝试多个源下载get-pip.py + for url in get_pip_urls: + print(f"📥 正在从 {url} 下载 get-pip.py...") + if self.download_file(url, get_pip_path): + downloaded = True + break + else: + print(f"❌ 从 {url} 下载失败,尝试下一个源...") + + if not downloaded: + print("💔 所有pip下载源都不可用") + return False + + # 运行get-pip.py + result = subprocess.run( + [str(self.embedded_python_exe), str(get_pip_path)], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + + # 清理 + if get_pip_path.exists(): + get_pip_path.unlink() + + if result.returncode == 0: + print("🎯 pip安装成功!现在可以安装各种库啦~") + + # 验证pip安装 + pip_result = subprocess.run( + [str(self.embedded_python_exe), "-m", "pip", "--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + + if pip_result.returncode == 0: + print(f"✅ pip验证成功: {pip_result.stdout.strip()}") + # 更新pip路径 + pip_exe_path = self.embedded_python_dir / "Scripts" / "pip.exe" + if pip_exe_path.exists(): + self.current_pip_exe = str(pip_exe_path) + return True + else: + print("⚠️ pip安装完成但验证失败") + return False + else: + error_msg = result.stderr[-200:] if result.stderr else '未知错误' + print(f"❌ pip安装失败: {error_msg}") + self.log_error("安装pip", error_msg) + return False + except Exception as e: + print(f"💥 pip安装过程中出现异常: {str(e)}") + self.log_error("安装pip", str(e)) + return False + + def scan_imports_in_file(self, file_path): + """扫描Python文件中的import语句""" + imports = set() + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # import module + import_pattern = r'^\s*import\s+([a-zA-Z_][a-zA-Z0-9_.]*)' + matches = re.findall(import_pattern, content, re.MULTILINE) + imports.update(matches) + + # from module import ... + from_pattern = r'^\s*from\s+([a-zA-Z_][a-zA-Z0-9_.]*)\s+import' + matches = re.findall(from_pattern, content, re.MULTILINE) + imports.update(matches) + + except Exception as e: + self.log_error(f"扫描文件 {file_path}", str(e)) + + return imports + + def get_required_packages(self): + """获取所有需要的包(可爱版)- 扫描整个项目目录""" + print("🔍 正在扫描需要的依赖库...") + all_imports = set() + + # 扫描整个项目目录中的所有 Python 文件(递归) + print(f"📂 扫描目录: {self.project_dir}") + + # 使用 glob 递归查找所有 .py 文件 + py_files = list(self.project_dir.rglob("*.py")) + + # 排除一些不需要扫描的文件/目录 + exclude_patterns = [ + "__pycache__", + ".git", + "venv", + ".venv", + "env", + ".env", + "build", + "dist", + "*.egg-info", + ] + + scanned_files = [] + skipped_files = [] + + for py_file in py_files: + # 检查是否在排除目录中 + should_skip = False + for pattern in exclude_patterns: + if pattern in str(py_file): + should_skip = True + break + + if should_skip: + skipped_files.append(py_file) + continue + + # 扫描文件 + file_imports = self.scan_imports_in_file(py_file) + if file_imports: + all_imports.update(file_imports) + scanned_files.append(py_file) + + print(f"📄 扫描了 {len(scanned_files)} 个 Python 文件") + if skipped_files: + print(f"⏭️ 跳过了 {len(skipped_files)} 个文件(在排除目录中)") + + # 显示扫描到的所有导入 + if all_imports: + print(f"📦 发现的导入模块: {len(all_imports)} 个") + # 显示前10个导入 + import_list = sorted(list(all_imports)) + if import_list: + sample = import_list[:10] + print(f" 示例: {', '.join(sample)}") + if len(import_list) > 10: + print(f" ... 还有 {len(import_list) - 10} 个") + + # 筛选第三方库 + required = set() + unknown_imports = set() + + for imp in all_imports: + module_name = imp.split('.')[0] + if module_name in self.known_third_party_packages: + required.add(module_name) + else: + # 收集未知的导入,可能需要添加到已知列表中 + unknown_imports.add(module_name) + + # 显示未知的导入(可能是新添加的第三方库) + if unknown_imports: + print(f"❓ 发现 {len(unknown_imports)} 个未分类的导入:") + unknown_list = sorted(list(unknown_imports))[:15] + for imp in unknown_list: + print(f" - {imp}") + if len(unknown_imports) > 15: + print(f" ... 还有 {len(unknown_imports) - 15} 个") + print(f"💡 提示: 这些可能是 Python 标准库或未识别的第三方库") + + print(f"📋 需要安装的第三方库: {', '.join(required)}") + + # 保存到 requirements.txt + if required: + requirements_file = self.project_dir / "requirements.txt" + try: + with open(requirements_file, 'w', encoding='utf-8') as f: + f.write("# 小狸 Pro-CLI 依赖库\n") + f.write(f"# 自动生成于: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write(f"# 扫描了 {len(scanned_files)} 个 Python 文件\n\n") + for package in sorted(required): + f.write(f"{package}\n") + print(f"✅ 依赖列表已保存到: {requirements_file}") + except Exception as e: + print(f"💫 保存 requirements.txt 失败: {e}") + + return required + + def scan_and_save_engines(self): + """扫描引擎并保存配置到 config.json""" + print("🔍 正在扫描 AI 引擎...") + engines_config = {} + + if self.ai_engines_dir.exists(): + engine_files = list(self.ai_engines_dir.glob("*_engine.py")) + if engine_files: + print(f"🎯 发现了 {len(engine_files)} 个引擎文件:") + for engine_file in engine_files: + engine_name = engine_file.stem.replace("_engine", "") + engines_config[engine_name] = { + "api_key": "", + "base_url": "", + "model": "" + } + print(f" - {engine_name}") + else: + print("🎈 没有发现任何引擎文件") + else: + print("🎈 ai_engines 目录不存在") + + # 读取或创建配置文件 + config_file = self.project_dir / "config.json" + if config_file.exists(): + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + except Exception as e: + print(f"💫 读取配置文件失败: {e}") + config = { + "api": {"engines": {}}, + "system": { + "web_server_port": 8080, + "max_file_size": 52428800, + "max_history": 10, + "max_video_duration": 10000, + "video_frame_delay": 0.04, + "love_file_path": "love.txt" + }, + "plugins": {"enabled": []} + } + else: + config = { + "api": {"engines": {}}, + "system": { + "web_server_port": 8080, + "max_file_size": 52428800, + "max_history": 10, + "max_video_duration": 10000, + "video_frame_delay": 0.04, + "love_file_path": "love.txt" + }, + "plugins": {"enabled": []} + } + + # 合并引擎配置,保留已设置的值 + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + + for engine_name, engine_config in engines_config.items(): + if engine_name in config["api"]["engines"]: + # 保留已设置的值 + existing_config = config["api"]["engines"][engine_name] + engines_config[engine_name]["api_key"] = existing_config.get("api_key", "") + engines_config[engine_name]["base_url"] = existing_config.get("base_url", "") + engines_config[engine_name]["model"] = existing_config.get("model", "") + + config["api"]["engines"] = engines_config + + # 保存配置 + try: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print(f"✅ 引擎配置已保存到: {config_file}") + return True + except Exception as e: + print(f"💫 保存配置文件失败: {e}") + return False + + def check_package_installed(self, package_name): + """检查包是否已安装(可爱版)""" + try: + result = subprocess.run( + [self.current_python_exe, "-c", f"import {package_name}"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + return result.returncode == 0 + except Exception as e: + self.log_error(f"检查包 {package_name}", str(e)) + return False + + def install_package(self, package_name): + """安装单个包(可爱版)""" + print(f"📦 正在安装 {package_name}...") + + # 确定pip命令 + if self.use_system_python: + pip_cmd = [self.current_python_exe, "-m", "pip"] + else: + # 优先使用已知的pip路径,否则使用python -m pip + pip_cmd = [self.current_pip_exe] if self.current_pip_exe and os.path.exists(self.current_pip_exe) else [self.current_python_exe, "-m", "pip"] + + # 检查pip是否可用 + pip_check = subprocess.run( + pip_cmd + ["--version"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + + if pip_check.returncode != 0: + print("⚠️ pip不可用,尝试修复pip...") + # 尝试修复pip + fix_result = subprocess.run( + [self.current_python_exe, "-m", "ensurepip", "--upgrade"], + capture_output=True, text=True, check=False, encoding='utf-8' + ) + if fix_result.returncode == 0: + print("✅ pip修复成功!") + # 更新pip路径 + pip_exe_path = self.embedded_python_dir / "Scripts" / "pip.exe" + if pip_exe_path.exists(): + self.current_pip_exe = str(pip_exe_path) + pip_cmd = [self.current_pip_exe] if self.current_pip_exe and os.path.exists(self.current_pip_exe) else [self.current_python_exe, "-m", "pip"] + else: + print("❌ pip修复失败,使用备用方案") + # 仍然使用python -m pip + pip_cmd = [self.current_python_exe, "-m", "pip"] + + # 尝试安装,如果失败则使用国内镜像源重试 + install_commands = [ + pip_cmd + ["install", package_name], + pip_cmd + ["install", package_name, "-i", "https://pypi.tuna.tsinghua.edu.cn/simple/"], + pip_cmd + ["install", package_name, "-i", "https://mirrors.aliyun.com/pypi/simple/"], + ] + + for i, cmd in enumerate(install_commands): + try: + print(f"🔍 尝试安装方式 {i+1}/3...") + result = subprocess.run( + cmd, + capture_output=True, text=True, check=False, encoding='utf-8' + ) + + if result.returncode == 0: + print(f"🎉 {package_name} 安装成功!") + return True + else: + error_msg = result.stderr[-200:] if result.stderr else '未知错误' + print(f"⚠️ 方式 {i+1} 安装失败: {error_msg}") + if i == len(install_commands) - 1: # 最后一次尝试 + self.log_error(f"安装包 {package_name}", error_msg) + return False + except Exception as e: + print(f"⚠️ 方式 {i+1} 安装异常: {str(e)}") + if i == len(install_commands) - 1: # 最后一次尝试 + self.log_error(f"安装包 {package_name}", str(e)) + return False + + def check_and_install_packages(self, required_packages): + """检查并安装需要的包(可爱版)""" + if not required_packages: + print("🌈 所有依赖库都已经准备好啦!") + return True + + # 检查缺失的包 + missing_packages = [] + for package in required_packages: + if self.check_package_installed(package): + print(f"✅ {package} 已经安装好啦~") + else: + print(f"❌ {package} 还没有安装呢") + missing_packages.append(package) + + if not missing_packages: + print("🎊 太棒了!所有依赖库都准备就绪!") + return True + + # 安装缺失的包 + print(f"🔧 需要安装 {len(missing_packages)} 个库: {', '.join(missing_packages)}") + success_count = 0 + + for package in missing_packages: + if self.install_package(package): + success_count += 1 + print() # 空行分隔 + + return success_count == len(missing_packages) + + def run_main_program(self): + """运行主程序(可爱版)""" + self.clear_screen() + print("🚀 启动主程序...") + + # 设置环境变量,传递启用的插件信息 + env = os.environ.copy() + if self.enabled_plugins is not None: + # 即使是空列表也要传递,表示不启用任何插件 + env["XIAOLI_ENABLED_PLUGINS"] = ",".join(self.enabled_plugins) + if self.enabled_plugins: + print(f"🎯 启用的插件: {', '.join(self.enabled_plugins)}") + else: + print("🎯 不启用任何插件") + + try: + result = subprocess.run( + [self.current_python_exe, str(self.main_program)], + env=env + ) + return result.returncode == 0 + except Exception as e: + self.log_error("启动主程序", str(e)) + return False + + def run(self): + """运行启动器(可爱版)""" + print("🎀" * 25) + print("✨ 欢迎使用小狸 Pro-CLI 智能启动器 ✨") + print("🐾 让小狸帮你准备好一切吧~") + print("🎀" * 25) + + # 步骤1: 检测Python环境 + print("\n[步骤1] 🔍 检测Python环境") + if self.check_system_python(): + print("👉 使用系统Python") + else: + print("👉 系统Python不可用,看看有没有自带的...") + if self.check_embedded_python(): + print("👉 使用嵌入式Python") + else: + print("👉 需要安装Python环境...") + if self.install_embedded_python(): + print("👉 使用新安装的嵌入式Python") + else: + print("💔 无法设置Python环境") + self.show_final_result() + return + + # 步骤2: 检测并安装依赖 + print("\n[步骤2] 📚 检测依赖库") + required_packages = self.get_required_packages() + all_packages_installed = self.check_and_install_packages(required_packages) + + # 步骤3: 插件选择 + print("\n[步骤3] 🎁 插件配置") + self.enabled_plugins = self.ask_plugin_enable() + + # 创建插件配置文件 + if self.enabled_plugins: + self.create_plugin_config(self.enabled_plugins) + + # 步骤4: 显示最终结果 + self.show_final_result(all_packages_installed) + + # 步骤5: 运行主程序 + if all_packages_installed or (not all_packages_installed and len(self.errors) < len(required_packages)): + print("\n[步骤4] 🎮 运行主程序") + self.run_main_program() + else: + print("\n由于严重错误,跳过运行主程序") + + def show_final_result(self, all_packages_installed=True): + """显示最终检测结果(超可爱版)""" + print("\n" + "⭐" * 30) + print("🎯 检测结果报告") + print("⭐" * 30) + + error_count = len(self.errors) + + if error_count == 0 and all_packages_installed: + print("🎊 太棒了!所有配置都成功啦!") + print(f"✅ Python环境: 已准备就绪") + print(f"✅ 依赖库: 全部安装完成") + print(f"✅ 插件: {len(self.enabled_plugins) if self.enabled_plugins else 0} 个已启用") + else: + print("💫 部分配置信息:") + print(f"✅ Python环境: {'已准备就绪' if (self.use_system_python or self.use_embedded_python) else '未准备就绪'}") + print(f"✅ 依赖库: {'全部安装' if all_packages_installed else '部分缺失'}") + print(f"⚠️ 错误数量: {error_count}") + + print("⭐" * 30) + + def get_available_ai_engines(self): + """自动检测可用的AI引擎""" + engines_dir = self.project_dir / "ai_engines" + available_engines = {} + + if engines_dir.exists(): + import re + for py_file in engines_dir.glob("*_engine.py"): + # 提取引擎名称(从文件名中移除_engine.py后缀) + engine_name = py_file.stem.replace('_engine', '') + # 使用更准确的引擎名称映射 + display_name = engine_name.replace('_', ' ').replace('-', ' ').title() + available_engines[engine_name] = f"{display_name}" + + return available_engines + + def ensure_api_config_has_all_engines(self): + """确保config.json包含所有检测到的AI引擎的默认配置""" + config_file = self.project_dir / "config.json" + + # 加载config.json现有配置 + if config_file.exists(): + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + config = {"api": {"engines": {}}} + else: + config = {"api": {"engines": {}}} + + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + + # 检测AI引擎 + ai_engines_dir = self.project_dir / "ai_engines" + if not ai_engines_dir.exists(): + return + + config_updated = False + + for py_file in ai_engines_dir.glob("*_engine.py"): + engine_name = py_file.stem.replace("_engine", "") + + if engine_name not in config["api"]["engines"]: + # 为新引擎添加默认配置,根据引擎类型设置不同的默认值 + default_config = { + "base_url": "https://api.example.com/v1/chat/completions", + "model": engine_name, + "api_key": "" # 为需要API密钥的引擎也设置空api_key,但用户需要手动配置 + } + + # 通过动态导入引擎模块来确定是否需要API密钥和默认配置 + engine_requires_api = True # 默认需要API密钥 + + # 尝试动态加载引擎以获取其配置需求 + import importlib.util + import os + + engines_dir = os.path.join(self.project_dir, "ai_engines") + engine_file = os.path.join(engines_dir, f"{engine_name}_engine.py") + + if os.path.exists(engine_file): + try: + spec = importlib.util.spec_from_file_location(f"{engine_name}_engine", engine_file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 查找引擎类并检查其属性 + possible_class_names = [ + f"{engine_name.replace('-', '_').replace(' ', '').title()}AI", + f"{engine_name.replace('-', '_').replace('http', '').replace(' ', '').title()}AI", + f"{engine_name.replace('_', '').replace('-', '').title()}AI" + ] + + engine_class = None + for class_name in possible_class_names: + if hasattr(module, class_name): + engine_class = getattr(module, class_name) + break + + if engine_class: + # 检查是否需要API密钥 + if hasattr(engine_class, 'requires_api_key'): + engine_requires_api = engine_class.requires_api_key + elif 'requires_api_key' in engine_class.__dict__: + engine_requires_api = engine_class.__dict__['requires_api_key'] + + # 检查默认配置 + if hasattr(engine_class, 'default_base_url'): + default_config["base_url"] = engine_class.default_base_url + if hasattr(engine_class, 'default_model'): + default_config["model"] = engine_class.default_model + + except Exception: + pass # 如果导入失败,使用默认值 + + # 根据引擎是否需要API密钥设置默认API密钥 + default_config["api_key"] = "" if not engine_requires_api else "" + + # 为常见本地引擎设置默认URL和模型 + if 'ollama' in engine_name.lower(): + default_config.setdefault("base_url", "http://localhost:11434/v1") + default_config.setdefault("model", "llama2") + elif 'lm_studio' in engine_name.lower(): + default_config.setdefault("base_url", "http://localhost:1234/v1") + default_config.setdefault("model", "local-model") + elif 'openrouter' in engine_name.lower(): + default_config.setdefault("base_url", "https://openrouter.ai/api/v1/chat/completions") + default_config.setdefault("model", "openai/gpt-3.5-turbo") + elif 'http' in engine_name.lower(): + default_config.setdefault("base_url", "https://api.example.com/v1/chat/completions") + default_config.setdefault("model", engine_name) + + config["api"]["engines"][engine_name] = default_config + print(f"📝 为引擎 {engine_name} 添加了默认API配置到config.json") + config_updated = True + + # 保存更新后的config.json + if config_updated: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print("✅ config.json 已更新") + + + + def _check_engine_requires_api_key(self, engine_name): + """ + 检测引擎是否需要API密钥 + 通过动态导入引擎模块并检查其requires_api_key属性 + """ + import importlib.util + import os + + # 构建引擎文件路径 + engines_dir = os.path.join(os.path.dirname(self.project_dir), "ai_engines") + if not os.path.exists(engines_dir): + engines_dir = self.project_dir / "ai_engines" + + engine_file = os.path.join(engines_dir, f"{engine_name}_engine.py") + + if not os.path.exists(engine_file): + # 如果没找到对应文件,使用启发式判断 + # 含有特定关键词的引擎通常不需要API密钥 + if any(keyword in engine_name.lower() for keyword in ["ollama", "lm_studio", "local", "custom"]): + return False + else: + # 默认情况下,假设需要API密钥 + return True + + try: + # 动态导入引擎模块 + spec = importlib.util.spec_from_file_location(f"{engine_name}_engine", engine_file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 查找引擎类 + possible_class_names = [ + f"{engine_name.replace('-', '_').replace(' ', '').title()}AI", + f"{engine_name.replace('-', '_').replace('http', '').replace(' ', '').title()}AI", + f"{engine_name.replace('_', '').replace('-', '').title()}AI" + ] + + engine_class = None + for class_name in possible_class_names: + if hasattr(module, class_name): + engine_class = getattr(module, class_name) + break + + if engine_class: + # 检查类是否有requires_api_key属性 + if hasattr(engine_class, 'requires_api_key'): + return engine_class.requires_api_key + elif hasattr(engine_class, '__annotations__') or hasattr(engine_class, '__dict__'): + # 检查类属性 + if 'requires_api_key' in engine_class.__dict__: + return engine_class.__dict__['requires_api_key'] + + # 如果没找到明确声明,使用启发式判断 + if any(keyword in engine_name.lower() for keyword in ["ollama", "lm_studio", "local", "custom"]): + return False + else: + # 默认需要API密钥 + return True + except Exception: + # 如果导入失败,使用启发式判断 + if any(keyword in engine_name.lower() for keyword in ["ollama", "lm_studio", "local", "custom"]): + return False + else: + return True + + def configure_api_keys(self): + """配置API密钥功能 - 自动检测可用的AI引擎""" + self.clear_screen() + print("\n" + "🌟" * 20) + print("🎯 API密钥配置向导") + print("🌟" * 20) + print("请输入您要使用的AI服务的API密钥") + print("注意:API密钥是敏感信息,请确保在安全的环境中配置\n") + + # 自动检测可用的AI引擎 + available_engines = self.get_available_ai_engines() + + if not available_engines: + print("⚠️ 未检测到任何AI引擎文件") + print(" 请确保AI引擎文件遵循命名规范: {engine_name}_engine.py") + return + + print(f"🔍 检测到 {len(available_engines)} 个AI引擎:") + for engine_key, engine_name in available_engines.items(): + print(f" - {engine_key}: {engine_name}") + + # 尝试导入配置管理器 + config_file = "config.json" + config = {} + + if os.path.exists(config_file): + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + print("⚠️ 配置文件不存在或格式错误,将创建新的配置") + config = {} + + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + + for engine_key, engine_display_name in available_engines.items(): + print(f"\n--- {engine_display_name} ({engine_key}) ---") + + # 动态检测引擎是否需要API密钥 + requires_api_key = self._check_engine_requires_api_key(engine_key) + + current_config = config["api"]["engines"].get(engine_key, {}) + current_key = current_config.get("api_key", "") + current_url = current_config.get("base_url", "") + current_model = current_config.get("model", "") + + # 显示当前配置 + if current_url or current_model or current_key: + print(f" 当前配置:") + if current_key: + print(f" API密钥: {'*' * min(len(current_key), 8)}{current_key[-4:] if len(current_key) > 4 else current_key}") + if current_url: + print(f" 基础URL: {current_url}") + if current_model: + print(f" 模型名称: {current_model}") + + # 根据引擎是否需要API密钥进行不同的配置流程 + if not requires_api_key: + print(f"💡 {engine_display_name} 为本地引擎或无需API密钥的引擎") + + # 询问是否配置URL和模型 + config_url = input(f"是否要配置{engine_display_name}的基础URL? [Y/n]: ").lower() + if config_url != 'n': + # 根据引擎类型提供合适的默认URL + if 'ollama' in engine_key.lower(): + default_url = "http://localhost:11434/v1" + elif 'lm_studio' in engine_key.lower(): + default_url = "http://localhost:1234/v1" + else: + default_url = "http://localhost:11434/v1" + + base_url = input(f"请输入{engine_display_name}的基础URL (默认: {default_url}): ").strip() + if not base_url: + base_url = default_url + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + if engine_key not in config["api"]["engines"]: + config["api"]["engines"][engine_key] = {} + config["api"]["engines"][engine_key]["base_url"] = base_url + print(f"✅ {engine_display_name} 基础URL已配置") + + config_model = input(f"是否要配置{engine_display_name}的模型名称? [Y/n]: ").lower() + if config_model != 'n': + # 根据引擎类型提供合适的默认模型 + if 'ollama' in engine_key.lower(): + default_model = "llama2" + elif 'lm_studio' in engine_key.lower(): + default_model = "local-model" + else: + default_model = "default-model" + + model_name = input(f"请输入{engine_display_name}的模型名称 (默认: {default_model}): ").strip() + if not model_name: + model_name = default_model + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + if engine_key not in config["api"]["engines"]: + config["api"]["engines"][engine_key] = {} + config["api"]["engines"][engine_key]["model"] = model_name + print(f"✅ {engine_display_name} 模型名称已配置") + else: + # 需要API密钥的引擎 + if current_key: + print(f"当前API密钥: {'*' * min(len(current_key), 8)}{current_key[-4:] if len(current_key) > 4 else current_key}") + + # 询问是否配置API密钥 + action = input(f"是否要配置{engine_display_name}的API密钥? [Y/n]: ").lower() + if action != 'n': + print(f"请输入{engine_display_name}的API密钥 (输入后按回车确认,字符将被隐藏): ", end='', flush=True) + # 使用隐藏输入函数,避免直接使用getpass + api_key = self.get_hidden_input() + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + if engine_key not in config["api"]["engines"]: + config["api"]["engines"][engine_key] = {} + config["api"]["engines"][engine_key]["api_key"] = api_key + print(f"✅ {engine_display_name} API密钥已配置") + + # 询问是否配置URL(云端引擎也可能需要自定义URL) + config_url = input(f"是否要配置{engine_display_name}的基础URL? [Y/n]: ").lower() + if config_url != 'n': + # 根据引擎类型提供合适的默认URL + if 'mimo' in engine_key.lower(): + default_url = "https://api.xiaomimimo.com/v1/chat/completions" + elif 'qwen' in engine_key.lower(): + default_url = "https://apis.iflow.cn/v1/chat/completions" + elif 'spark' in engine_key.lower(): + default_url = "https://spark-api-open.xf-yun.com/v1/chat/completions" + elif 'glm' in engine_key.lower(): + default_url = "https://www.dmxapi.cn/v1" + elif 'maas' in engine_key.lower(): + default_url = "http://maas-api.cn-huabei-1.xf-yun.com/v1" + else: + # 默认API基础URL + default_url = "https://api.example.com/v1/chat/completions" + + base_url = input(f"请输入{engine_display_name}的基础URL (默认: {default_url}): ").strip() + if not base_url: + base_url = default_url + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + if engine_key not in config["api"]["engines"]: + config["api"]["engines"][engine_key] = {} + config["api"]["engines"][engine_key]["base_url"] = base_url + print(f"✅ {engine_display_name} 基础URL已配置") + + # 询问是否配置模型名称 + config_model = input(f"是否要配置{engine_display_name}的模型名称? [Y/n]: ").lower() + if config_model != 'n': + # 根据引擎类型提供合适的默认模型 + if 'mimo' in engine_key.lower(): + default_model = "mimo-v2-flash" + elif 'qwen' in engine_key.lower(): + default_model = "qwen3-coder-plus" + elif 'spark' in engine_key.lower(): + default_model = "lite" + elif 'glm' in engine_key.lower(): + default_model = "GLM-4.5-Flash" + elif 'maas' in engine_key.lower(): + default_model = "xopdeepseekv32" + else: + default_model = engine_key # 使用引擎名称作为默认模型名 + + model_name = input(f"请输入{engine_display_name}的模型名称 (默认: {default_model}): ").strip() + if not model_name: + model_name = default_model + if "api" not in config: + config["api"] = {} + if "engines" not in config["api"]: + config["api"]["engines"] = {} + if engine_key not in config["api"]["engines"]: + config["api"]["engines"][engine_key] = {} + config["api"]["engines"][engine_key]["model"] = model_name + print(f"✅ {engine_display_name} 模型名称已配置") + + if action == 'n' and config_url == 'n' and config_model == 'n': + print(f"跳过{engine_display_name}的配置") + + # 保存配置到两个文件 + try: + # 保存到 config.json(用于主程序读取) + with open("config.json", 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print("✅ 配置已保存到 config.json 文件") + except Exception as e: + print(f"❌ 保存配置失败: {e}") + + def configure_system_settings(self): + """配置系统设置""" + self.clear_screen() + print("\n" + "⚙️ " * 15) + print("🎯 系统设置配置") + print("⚙️ " * 15) + + config_file = "config.json" + config = {} + + if os.path.exists(config_file): + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + config = {} + + if "system" not in config: + config["system"] = {} + + print("\n当前系统设置:") + system_defaults = { + "web_server_port": 8080, + "max_file_size": 52428800, + "max_history": 10, + "max_video_duration": 10000, + "video_frame_delay": 0.04, + "love_file_path": "love.txt" + } + + for key, default_value in system_defaults.items(): + current_value = config["system"].get(key, default_value) + print(f" {key}: {current_value}") + + change = input("\n是否要修改系统设置? [y/N]: ").lower() + if change == 'y': + for key, default_value in system_defaults.items(): + current_value = config["system"].get(key, default_value) + new_value_str = input(f"请输入 {key} 的新值 (默认: {current_value}): ").strip() + + if new_value_str == "": + new_value = current_value + else: + # 尝试转换为适当的数据类型 + if isinstance(default_value, int): + try: + new_value = int(new_value_str) + except ValueError: + print(f"⚠️ 输入无效,使用默认值 {default_value}") + new_value = default_value + elif isinstance(default_value, float): + try: + new_value = float(new_value_str) + except ValueError: + print(f"⚠️ 输入无效,使用默认值 {default_value}") + new_value = default_value + else: + new_value = new_value_str + + config["system"][key] = new_value + + # 保存配置 + try: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print("\n✅ 系统设置已保存") + except Exception as e: + print(f"\n❌ 保存设置失败: {e}") + + def show_current_config(self): + """显示当前配置""" + self.clear_screen() + print("\n" + "📋" * 15) + print("🎯 当前配置状态") + print("📋" * 15) + + config_file = "config.json" + if os.path.exists(config_file): + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + print("\n--- API配置 ---") + if "api" in config and "engines" in config["api"]: + for engine_name, engine_config in config["api"]["engines"].items(): + # 检查引擎是否需要API密钥 + requires_api_key = True + engine_file = self.ai_engines_dir / f"{engine_name}_engine.py" + if engine_file.exists(): + try: + with open(engine_file, 'r', encoding='utf-8') as f: + content = f.read() + if 'requires_api_key = False' in content: + requires_api_key = False + except: + pass + + if not requires_api_key: + # 不需要API密钥的引擎(如Ollama) + print(f" {engine_name}: 本地引擎,无需API密钥") + else: + api_key = engine_config.get("api_key", "") + if api_key: + masked_key = f"{'*' * max(0, len(api_key) - 4)}{api_key[-4:] if len(api_key) > 4 else api_key}" + print(f" {engine_name}: API密钥已配置 ({masked_key})") + else: + print(f" {engine_name}: 未配置API密钥") + + print("\n--- 系统配置 ---") + if "system" in config: + for key, value in config["system"].items(): + print(f" {key}: {value}") + + print("\n--- 插件配置 ---") + if "plugins" in config: + enabled_plugins = config["plugins"].get("enabled", []) + if enabled_plugins: + print(f" 启用的插件: {', '.join(enabled_plugins)}") + else: + print(" 启用的插件: 无") + else: + print(" 启用的插件: 未配置") + except (json.JSONDecodeError, FileNotFoundError): + print("❌ 无法读取配置文件") + else: + print("📝 配置文件不存在") + + # 询问用户是否继续 + print("\n" + "-" * 40) + print("请选择下一步操作:") + print(" 1. 🚀 启动主程序") + print(" 2. 🎛️ 进入配置向导") + print(" 3. 🌈 退出") + + next_choice = input("\n请选择 (1-3): ").strip() + + if next_choice == "1": + print("\n🚀 启动主程序...") + self._run_main_process() + elif next_choice == "2": + print("\n🎛️ 进入配置向导...") + self.run_config_wizard() + else: + print("\n🌈 退出程序") + return + + def run_config_wizard(self): + """运行配置向导""" + import json # 在方法内导入,避免与其他导入冲突 + + while True: + self.clear_screen() + print("\n" + "🎊" * 25) + print("🎯 小狸 Pro-CLI 配置向导") + print("🎊" * 25) + print("请选择要进行的操作:") + print(" 1. 🎯 配置API密钥") + print(" 2. ⚙️ 配置系统设置") + print(" 3. 🧩 配置插件") + print(" 4. 📋 查看当前配置") + print(" 5. 🏃‍♂️ 返回并启动主程序") + print("-" * 30) + + choice = input("请输入选项 (1-5): ").strip() + + if choice == "1": + self.configure_api_keys() + elif choice == "2": + self.configure_system_settings() + elif choice == "3": + self.configure_plugins_interactive() + elif choice == "4": + self.show_current_config() + elif choice == "5": + print("🚀 返回启动主程序...") + break + else: + print("🤔 无效选项,请重新选择") + + def configure_plugins_interactive(self): + """交互式插件配置""" + self.clear_screen() + print("\n" + "🧩" * 20) + print("🎯 插件配置") + print("🧩" * 20) + + # 发现可用插件 + available_plugins = self.discover_plugins() + + if not available_plugins: + print("🎁 没有发现任何插件") + input("\n按回车键返回...") + return + + print(f"\n🎯 发现了 {len(available_plugins)} 个插件:") + for i, plugin in enumerate(available_plugins, 1): + print(f" {i}. {plugin}") + + print("\n💫 请选择要启用的插件:") + print(" 🎯 输入 'all' 启用所有插件") + print(" 🎯 输入插件编号(多个用逗号分隔,如: 1,3,5)") + print(" 🎯 输入 'none' 不启用任何插件") + print(" 🎯 输入 'back' 返回上级菜单") + print("-" * 40) + + while True: + try: + choice = input("🎯 你的选择: ").strip().lower() + + if choice == 'back': + return + elif choice == 'all': + selected_plugins = available_plugins + print("🎊 启用所有插件!") + elif choice == 'none' or choice == '0': + selected_plugins = [] + print("🐾 不启用任何插件") + elif choice: + # 处理数字选择 + selected_indices = [] + for part in choice.split(','): + part = part.strip() + if part.isdigit(): + idx = int(part) + if 1 <= idx <= len(available_plugins): + selected_indices.append(idx - 1) + + if selected_indices: + selected_plugins = [available_plugins[i] for i in selected_indices] + print(f"🎯 已选择插件: {', '.join(selected_plugins)}") + else: + print("🤔 唔...没有识别到有效的选择呢,请重新输入~") + continue + else: + print("💫 请输入你的选择哦~") + continue + + # 创建插件配置 + self.create_plugin_config(selected_plugins) + print(f"\n✅ 已成功配置 {len(selected_plugins)} 个插件") + input("\n按回车键返回...") + return + + except KeyboardInterrupt: + print("\n🐾 你按了Ctrl+C,返回上级菜单") + return + except Exception as e: + print(f"💫 配置插件时出了点小问题: {e}") + return + + def run(self): + """运行启动器(可爱版)- 修改版本,添加配置选项""" + self.clear_screen() + print("🎀" * 25) + print("✨ 欢迎使用小狸 Pro-CLI 智能启动器 ✨") + print("🐾 让小狸帮你准备好一切吧~") + print("🎀" * 25) + + # 自动检测并确保API配置包含所有引擎 + print("🔍 检测可用的AI引擎...") + self.ensure_api_config_has_all_engines() + + # 询问用户想要进行的操作 + print("\n🎯 请选择操作模式:") + print(" 1. 🚀 直接启动(检测并安装环境后启动主程序)") + print(" 2. 🎛️ 配置向导(先配置API密钥等设置)") + print(" 3. 📋 查看当前配置状态") + print("-" * 40) + + mode_choice = input("请选择模式 (1-3): ").strip() + + if mode_choice == "2": + # 进入配置向导 + self.run_config_wizard() + # 配置完成后询问是否启动主程序 + start_main = input("\n是否现在启动主程序? [Y/n]: ").lower() + if start_main != 'n': + self._run_main_process() # 用私有方法执行实际启动过程 + return + elif mode_choice == "3": + # 查看配置并返回 + self.show_current_config() + return + elif mode_choice != "1": + print("⚠️ 无效选择,默认执行直接启动...") + + # 原有的直接启动流程 + self._run_main_process() + + def _run_main_process(self): + """私有方法:执行主启动流程""" + # 步骤1: 检测Python环境 + print("\n[步骤1] 🔍 检测Python环境") + if self.check_system_python(): + print("👉 使用系统Python") + else: + print("👉 系统Python不可用,看看有没有自带的...") + if self.check_embedded_python(): + print("👉 使用嵌入式Python") + else: + print("👉 需要安装Python环境...") + if self.install_embedded_python(): + print("👉 使用新安装的嵌入式Python") + else: + print("💔 无法设置Python环境") + self.show_final_result() + return + + # 步骤2: 扫描并保存引擎配置 + print("\n[步骤2] 🤖 扫描AI引擎") + self.scan_and_save_engines() + + # 步骤3: 检测并安装依赖 + print("\n[步骤3] 📚 检测依赖库") + required_packages = self.get_required_packages() + all_packages_installed = self.check_and_install_packages(required_packages) + + # 步骤4: 插件选择 + print("\n[步骤4] 🎁 插件配置") + self.enabled_plugins = self.ask_plugin_enable() + + # 创建插件配置文件 + if self.enabled_plugins: + self.create_plugin_config(self.enabled_plugins) + + # 步骤5: 显示最终结果 + self.show_final_result(all_packages_installed) + + # 步骤6: 运行主程序 + if all_packages_installed or (not all_packages_installed and len(self.errors) < len(required_packages)): + print("\n[步骤5] 🎮 运行主程序") + self.run_main_program() + else: + print("\n由于严重错误,跳过运行主程序") + + print("⭐" * 30) + + def show_final_result(self, all_packages_installed=True): + """显示最终检测结果(超可爱版)""" + print("\n" + "⭐" * 30) + print("🎯 检测结果报告") + print("⭐" * 30) + + error_count = len(self.errors) + + if error_count == 0 and all_packages_installed: + print("🎊 太棒了!所有配置都成功啦!") + print(f"✅ Python环境: 已准备就绪") + print(f"✅ 依赖库: 全部安装完成") + print(f"✅ 插件: {len(self.enabled_plugins) if self.enabled_plugins else 0} 个已启用") + elif error_count > 0 and error_count < len(self.known_third_party_packages): + print("🤔 咦?好像有一些小问题...") + print("💫 不过没关系,我们还是可以继续的!") + print(f"✅ Python环境: {'已准备就绪' if (self.use_system_python or self.use_embedded_python) else '未准备就绪'}") + print(f"⚠️ 错误数量: {error_count}") + if self.enabled_plugins: + print(f"✅ 插件: 启用了 {len(self.enabled_plugins)} 个") + print("📝 下面是遇到的问题:") + for error in self.errors: + print(f" 🐛 {error}") + + if self.save_errors_to_file(): + print("💾 小狸已经把错误信息记在小本本上啦~") + else: + print("😭 呜呜...遇到了好多问题...") + print("💔 可能无法正常运行了") + print(f"✅ Python环境: {'已准备就绪' if (self.use_system_python or self.use_embedded_python) else '未准备就绪'}") + print(f"⚠️ 错误数量: {error_count}") + print("🔍 详细问题如下:") + for error in self.errors: + print(f" ❌ {error}") + + if self.save_errors_to_file(): + print("📁 错误信息已经保存在 bug.txt 文件里") + + print("⭐" * 30) + + +def main(): + """主函数(可爱版)""" + try: + launcher = KawaiiPythonLauncher() + launcher.run() + except KeyboardInterrupt: + print("\n\n🐾 你按了Ctrl+C,小狸明白啦~再见!") + except Exception as e: + print(f"\n\n💫 程序运行出了点小问题: {e}") + # 保存未捕获的异常 + launcher = KawaiiPythonLauncher() + launcher.log_error("程序运行", str(e)) + launcher.save_errors_to_file() + finally: + print("\n🌈 小狸感谢你的使用,下次再见啦~") + + +if __name__ == "__main__": + main() diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..a6a9bf4 --- /dev/null +++ b/logger.py @@ -0,0 +1,47 @@ +""" +增强日志记录模块 +提供结构化日志记录功能 +""" +import logging +import os +from datetime import datetime + +def setup_logger(): + """设置日志记录器""" + # 创建logs目录 + log_dir = os.path.join(os.path.dirname(__file__), 'logs') + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # 创建日志文件名 + log_file = os.path.join(log_dir, f"app_{datetime.now().strftime('%Y%m%d')}.log") + + # 配置日志记录器 + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_file, encoding='utf-8'), + logging.StreamHandler() # 同时输出到控制台 + ] + ) + + return logging.getLogger(__name__) + +# 全局日志记录器 +logger = setup_logger() + +def log_error(operation, error, user_id=None): + """记录错误日志""" + user_info = f" [用户ID: {user_id}]" if user_id else "" + logger.error(f"{operation} 失生错误{user_info}: {str(error)}") + +def log_info(operation, message, user_id=None): + """记录信息日志""" + user_info = f" [用户ID: {user_id}]" if user_id else "" + logger.info(f"{operation}{user_info}: {message}") + +def log_warning(operation, message, user_id=None): + """记录警告日志""" + user_info = f" [用户ID: {user_id}]" if user_id else "" + logger.warning(f"{operation}{user_info}: {message}") \ No newline at end of file diff --git a/platform_utils.py b/platform_utils.py new file mode 100644 index 0000000..a019876 --- /dev/null +++ b/platform_utils.py @@ -0,0 +1,124 @@ +""" +跨平台工具模块 +提供跨平台的兼容性支持 +""" +import os +import sys + +def get_platform(): + """获取当前平台类型""" + if os.name == 'nt': + return 'windows' + elif sys.platform == 'darwin': + return 'macos' + else: + return 'linux' + +def get_hidden_input(prompt="请输入密码: "): + """跨平台的隐藏输入""" + platform = get_platform() + + if platform == 'windows': + try: + import msvcrt + print(f"{prompt} ", end='', flush=True) + password = "" + + while True: + char = msvcrt.getch() + if char in [b'\r', b'\n']: + print() + break + elif char == b'\x08': + if len(password) > 0: + password = password[:-1] + print('\b \b', end='', flush=True) + else: + password += char.decode('utf-8') + print('*', end='', flush=True) + + return password + except ImportError: + import getpass + return getpass.getpass(prompt) + else: + import getpass + return getpass.getpass(prompt) + +def check_key_input(): + """检查是否有键盘输入(跨平台)""" + platform = get_platform() + + if platform == 'windows': + try: + import msvcrt + return msvcrt.kbhit() > 0 + except ImportError: + return False + else: + # Linux/Mac 的实现较为复杂,暂时返回 False + return False + +def get_key(): + """获取按键(跨平台)""" + platform = get_platform() + + if platform == 'windows': + try: + import msvcrt + if msvcrt.kbhit(): + return msvcrt.getch() + return None + except ImportError: + return None + else: + # Linux/Mac 的实现 + import select + import tty + import termios + + if select.select([sys.stdin], [], [], 0)[0]: + return sys.stdin.read(1) + return None + +def supports_esc_detection(): + """检查是否支持 ESC 键检测""" + platform = get_platform() + return platform == 'windows' # 目前只有 Windows 完全支持 ESC 检测 + +def get_user_directory(): + """获取用户目录(跨平台)""" + platform = get_platform() + + if platform == 'windows': + return os.path.expanduser("~") + elif platform == 'macos': + return os.path.expanduser("~") + else: # Linux + return os.path.expanduser("~") + +def get_desktop_directory(): + """获取桌面目录(跨平台)""" + platform = get_platform() + + if platform == 'windows': + return os.path.join(os.path.expanduser("~"), "Desktop") + elif platform == 'macos': + return os.path.join(os.path.expanduser("~"), "Desktop") + else: # Linux + desktop = os.path.join(os.path.expanduser("~"), "Desktop") + if os.path.exists(desktop): + return desktop + else: + return os.path.expanduser("~") + +def get_config_directory(): + """获取应用配置目录(跨平台)""" + platform = get_platform() + + if platform == 'windows': + return os.path.join(os.path.getenv('APPDATA', ''), 'Xiaoli Pro-CLI 3') + elif platform == 'macos': + return os.path.join(os.path.expanduser("~"), 'Library', 'Application Support', 'Xiaoli Pro-CLI 3') + else: # Linux + return os.path.join(os.path.expanduser('~'), '.config', 'xiaoli-pro-cli-3') \ No newline at end of file diff --git a/plugins/ai_search.py b/plugins/ai_search.py new file mode 100644 index 0000000..e0a0a9c --- /dev/null +++ b/plugins/ai_search.py @@ -0,0 +1,174 @@ +import subprocess +import json +from urllib.parse import quote +import requests + + +class Liugin: + """AI搜索插件 - 使用jina.ai进行智能搜索""" + + def __init__(self): + self.usage = """AI搜索工具使用方法:/r +ai_search <操作> <参数>/r +例如:/r +- ai_search url https://www.example.com - 搜索网址内容/r +- ai_search search Python教程 - 普通搜索/r +- ai_search web 什么是人工智能 - 搜索网络内容/r +/r +详细说明:/r +- url操作: ai_search url <网址> - 提取并搜索指定网址的内容/r +- search操作: ai_search search <搜索词> - 使用必应搜索指定内容/r +- web操作: ai_search web <搜索词> - 使用必应搜索指定内容(与search相同)/r +/r +使用工具的JSON格式示例:/r +{"action": "use_tool", "tool": "ai_search", "args": "url https://www.example.com"} - 搜索网址内容/r +{"action": "use_tool", "tool": "ai_search", "args": "search Python教程"} - 普通搜索/r +{"action": "use_tool", "tool": "ai_search", "args": "web 什么是人工智能"} - 搜索网络内容/r +""" + self.cli = None # CLI实例引用 + self.timeout = 30 # 请求超时时间(秒) + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 注册插件命令 + self.cli.register_liugin_command('ai_search', self.command_handler) + self.cli.register_liugin_command('search', self.search_command_handler) + self.cli.register_liugin_command('web', self.web_command_handler) + + def command_handler(self, args): + """处理 /ai_search 命令""" + # 解析参数 + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return "请提供操作类型: url, search, web" + + operation = parts[0].lower() + remaining_args = parts[1] if len(parts) > 1 else "" + + # 调用实际的AI搜索功能 + return self.handle(f"{operation} {remaining_args}") + + def search_command_handler(self, args): + """处理 /search 命令""" + return self.handle(f"search {args}") + + def web_command_handler(self, args): + """处理 /web 命令""" + return self.handle(f"search {args}") + + def get_tool_info(self): + return { + "name": "ai_search", + "description": "AI搜索工具,使用jina.ai服务进行智能搜索,支持网址内容提取和网络搜索。当用户询问天气、新闻、汇率、股票、航班、实时信息、网络内容搜索等问题时,应该直接使用此工具。使用格式:ai_search search <搜索词> 或 ai_search url <网址>", + "keywords": ["搜索", "search", "网址", "url", "web", "AI搜索", "网络搜索", "内容提取", "天气", "新闻", "实时信息"], + "usage": """ai_search 工具使用说明: +JSON格式示例: +{\"action\": \"use_tool\", \"tool\": \"ai_search\", \"args\": \"url https://www.example.com\"} - 搜索网址内容 +{\"action\": \"use_tool\", \"tool\": \"ai_search\", \"args\": \"search Python教程\"} - 普通搜索 +{\"action\": \"use_tool\", \"tool\": \"ai_search\", \"args\": \"web 什么是人工智能\"} - 搜索网络内容""" + } + + def handle(self, args): + """处理AI搜索请求""" + try: + # 解析参数 + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return "错误:参数不足。请提供操作类型。\n可用操作: url, search, web" + + operation = parts[0].lower() + + if operation == "url": + # 搜索网址内容 + if len(parts) < 2: + return "错误:请提供URL。格式: url <网址>" + + url = parts[1].strip() + return self._search_url(url) + + elif operation in ["search", "web"]: + # 普通搜索 + if len(parts) < 2: + return "错误:请提供搜索词。格式: search <搜索词>" + + query = parts[1].strip() + return self._search_web(query) + + else: + return f"错误:不支持的操作 '{operation}'。支持的操作有: url, search, web。" + + except Exception as e: + return f"AI搜索操作错误: {str(e)}" + + def _search_url(self, url): + """搜索网址内容(使用jina.ai提取)""" + try: + # 构建jina.ai的URL - 直接使用URL + jina_url = f"https://r.jina.ai/{url}" + + result = self._execute_curl(jina_url) + + if result.startswith("错误:"): + return result + + return f"📄 网址内容 (来自 {url}):\n\n{result}" + + except Exception as e: + return f"网址搜索错误: {str(e)}" + + def _search_web(self, query): + """普通搜索(使用jina.ai + 必应搜索)""" + try: + # 对搜索词进行URL编码,确保中文等特殊字符正确编码 + # 使用safe=''确保所有非字母数字字符都被编码 + encoded_query = quote(query, safe='') + + # 构建必应搜索URL + bing_url = f"https://cn.bing.com/search?q={encoded_query}" + + # 构建jina.ai的URL - 直接使用必应URL,不需要二次编码 + jina_url = f"https://r.jina.ai/{bing_url}" + + result = self._execute_curl(jina_url) + + if result.startswith("错误:"): + return result + + return f"🔍 搜索结果 (搜索词: {query}):\n\n{result}" + + except Exception as e: + return f"网络搜索错误: {str(e)}" + + def _execute_curl(self, url): + """使用 requests 库获取内容(替代 curl 命令)""" + try: + # 使用 requests 库发送请求 + response = requests.get( + url, + timeout=self.timeout, + headers={'User-Agent': 'Mozilla/5.0 (compatible; AI-Search-Bot)'} + ) + + # 检查状态码 + if response.status_code != 200: + return f"错误:HTTP请求失败,状态码: {response.status_code}" + + # 获取内容 + content = response.text.strip() + + if not content: + return "错误:未获取到任何内容" + + # 限制输出长度以避免过长输出 + if len(content) > 5000: + content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)" + + return content + + except requests.Timeout: + return f"错误:请求超时 (>{self.timeout}秒)" + except requests.ConnectionError: + return "错误:网络连接失败,请检查网络" + except Exception as e: + return f"错误:请求时发生错误: {str(e)}" \ No newline at end of file diff --git a/plugins/audio_player.py b/plugins/audio_player.py new file mode 100644 index 0000000..9bcc65b --- /dev/null +++ b/plugins/audio_player.py @@ -0,0 +1,150 @@ +import os +import threading +from colorama import Fore, Style + + +class Liugin: + """音频播放插件""" + + def __init__(self): + self.usage = """音频播放工具使用方法: +audio_player play - 播放音频文件 +例如: +- audio_player play - 播放Ciallo~(∠・ω- )⌒☆-.mp3音频文件 + +使用工具的JSON格式示例: +{"action": "use_tool", "tool": "audio_player", "args": "play"} - 播放音频文件 + +直接命令: +- /ciallo - 播放音频文件 +""" + self.cli = None # CLI实例引用 + self.audio_file = None # 音频文件路径将在set_cli中设置 + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 获取主程序目录路径,通过CLI实例 + import os + # 使用CLI实例中的项目目录信息 + self.audio_file = os.path.join(os.path.dirname(cli.chat_history_dir), "Ciallo~(∠・ω- )⌒☆-.mp3") + # 注册插件命令 + self.cli.register_liugin_command('ciallo', self.play_command_handler) + + def get_tool_info(self): + return { + "name": "audio_player", + "description": "音频播放工具,播放指定的音频文件", + "keywords": ["音频", "播放", "audio", "play", "音乐", "声音"], + "usage": """audio_player 工具使用说明: + JSON格式示例: + {"action": "use_tool", "tool": "audio_player", "args": "play"} - 播放音频文件 + 直接命令:/ciallo - 播放音频文件""" + } + def handle(self, args): + """处理音频播放请求""" + try: + # 解析参数 + parts = args.strip().split() + if len(parts) < 1: + return "错误:请提供操作类型。支持的操作:play" + + operation = parts[0].lower() + + if operation == "play": + return self._play_audio() + else: + return f"错误:不支持的操作 '{operation}'。支持的操作:play" + + except Exception as e: + return f"音频播放错误: {str(e)}" + + def play_command_handler(self, args): + """处理 /ciallo 命令""" + # 在新线程中播放音频,避免阻塞主程序 + thread = threading.Thread(target=self._play_audio_internal) + thread.daemon = True + thread.start() + return f"正在播放音频文件: {self.audio_file}" + + def _play_audio(self): + """播放音频文件""" + try: + # 检查音频文件是否存在 + if not os.path.exists(self.audio_file): + return f"错误:音频文件 '{self.audio_file}' 不存在。" + + # 使用多种方式尝试播放音频 + success = self._try_play_audio() + + if success: + return f"音频文件 '{self.audio_file}' 播放成功!" + else: + return f"音频播放失败。请确保已安装相应的音频播放库。" + + except Exception as e: + return f"播放音频时发生错误: {str(e)}" + + def _play_audio_internal(self): + """在新线程中播放音频""" + try: + # 检查音频文件是否存在 + if not os.path.exists(self.audio_file): + print(f"{Fore.RED}错误:音频文件 '{self.audio_file}' 不存在。{Style.RESET_ALL}") + return + + # 使用多种方式尝试播放音频 + self._try_play_audio() + + except Exception as e: + print(f"{Fore.RED}播放音频时发生错误: {str(e)}{Style.RESET_ALL}") + + def _try_play_audio(self): + """尝试多种方式播放音频""" + # 首先尝试使用pygame(如果已安装) + try: + import pygame + pygame.mixer.init() + pygame.mixer.music.load(self.audio_file) + pygame.mixer.music.play() + + # 等待音频播放完成 + while pygame.mixer.music.get_busy(): + pygame.time.Clock().tick(10) + + pygame.mixer.quit() + return True + except ImportError: + pass # pygame未安装,尝试其他方式 + except Exception: + pass # 其他pygame相关错误,尝试其他方式 + + # 尝试使用playsound库(如果已安装) + try: + from playsound import playsound + playsound(self.audio_file) + return True + except ImportError: + pass # playsound未安装,尝试其他方式 + except Exception: + pass # 其他playsound相关错误,尝试其他方式 + + # 尝试使用winsound(仅Windows) + try: + import winsound + # 对于MP3文件,先尝试使用高级API + import subprocess + subprocess.run(["start", "", self.audio_file], shell=True) + return True + except Exception: + pass # winsound相关错误,尝试其他方式 + + # 尝试使用os.startfile(仅Windows) + try: + os.startfile(self.audio_file) # 这会使用默认程序打开文件 + return True + except Exception: + pass # os.startfile相关错误 + + # 如果所有方式都失败,返回False + return False \ No newline at end of file diff --git a/plugins/cmd_executor.py b/plugins/cmd_executor.py new file mode 100644 index 0000000..f31ddfc --- /dev/null +++ b/plugins/cmd_executor.py @@ -0,0 +1,188 @@ +import subprocess +import os +import sys +import time +from colorama import Fore, Style + + +class Liugin: + """CMD命令执行插件 - 仅限Windows环境""" + + def __init__(self): + self.usage = """CMD命令执行工具使用方法: +cmd_executor <操作> <参数> +例如: +- cmd_executor run dir - 执行dir命令查看当前目录内容 +- cmd_executor run "ipconfig /all" - 执行ipconfig命令查看网络配置 +- cmd_executor run "tasklist | findstr python" - 查找Python相关进程 + +详细说明: +- run操作: cmd_executor run <命令> - 执行指定的CMD命令 + +使用工具的JSON格式示例: +{"action": "use_tool", "tool": "cmd_executor", "args": "run dir"} - 执行dir命令 +{"action": "use_tool", "tool": "cmd_executor", "args": "run ipconfig /all"} - 执行ipconfig命令 + +重要说明: +- 此工具仅在Windows环境下可用 +- 为安全起见,某些系统命令可能被限制执行 +- 命令执行结果会返回给AI进行分析""" + self.cli = None # CLI实例引用 + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 注册插件命令 + self.cli.register_liugin_command('cmd', self.command_handler) + + def command_handler(self, args): + """处理 /cmd 命令""" + # 解析参数 + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return "请提供操作类型: run <命令>" + + operation = parts[0].lower() + remaining_args = parts[1] if len(parts) > 1 else "" + + # 调用实际的CMD执行功能 + return self.handle(f"{operation} {remaining_args}") + + def get_tool_info(self): + return { + "name": "cmd_executor", + "description": "CMD命令执行工具,用于执行Windows命令行指令", + "keywords": ["命令", "执行", "CMD", "cmd", "executor", "Windows", "指令", "操作"], + "usage": """cmd_executor 工具使用说明: + JSON格式示例: + {"action": "use_tool", "tool": "cmd_executor", "args": "run dir"} - 执行dir命令 + {"action": "use_tool", "tool": "cmd_executor", "args": "run ipconfig /all"} - 执行ipconfig命令 + {"action": "use_tool", "tool": "cmd_executor", "args": "run tasklist"} - 列出所有进程 + 注意:仅限Windows环境使用""" + } + def handle(self, args): + """处理CMD命令执行请求""" + try: + # 解析参数 + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return "错误:参数不足。请提供操作类型。\n可用操作: run" + + operation = parts[0].lower() + + if operation == "run": + # 检查是否为Windows系统 + if os.name != 'nt': + return "错误:CMD命令执行工具仅支持Windows系统。" + + if len(parts) < 2: + return "错误:请提供要执行的命令。格式: run <命令>" + + command = parts[1] + return self._execute_command(command) + + else: + return f"错误:不支持的操作 '{operation}'。支持的操作有: run。" + + except Exception as e: + return f"CMD命令执行错误: {str(e)}" + + def _execute_command(self, command): + """执行CMD命令""" + try: + # 解析命令和可能的超时参数 + # 允许格式如 "command --timeout 10" 或 "command -t 10" + timeout = 30 # 默认超时时间 + import re + + # 检查是否在命令中指定超时 + timeout_pattern = r'(?:--timeout|-t)\s+(\d+)' + timeout_match = re.search(timeout_pattern, command, re.IGNORECASE) + + if timeout_match: + timeout = int(timeout_match.group(1)) + # 移除超时参数,只保留实际命令 + command = re.sub(timeout_pattern, '', command).strip() + + # 检查是否有CLI实例可用,请求用户确认 + if self.cli is not None: + # 检查是否是 Clawli 远程模式 + if not getattr(self.cli, 'is_clawli_mode', False): + # 非远程模式,请求用户确认 + try: + from ai_cli import ask_user_confirmation + # 生成唯一的请求ID + import uuid + request_id = str(uuid.uuid4()) + + # 存储请求信息 + self.cli.user_input_queue[request_id] = { + 'type': 'cmd_confirm', + 'command': command, + 'timeout': timeout, + 'status': 'pending' + } + + # 发送确认请求给用户 + confirmed = ask_user_confirmation( + f"即将执行CMD命令: {command}\n超时时间: {timeout}秒\n是否确认执行?", + request_id + ) + + if not confirmed: + return f"命令执行已取消: {command}" + except Exception as e: + # 如果无法请求确认,直接执行 + pass + + # 执行命令 + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=timeout, # 使用自定义超时时间 + encoding='utf-8', # 指定编码 + errors='replace' # 处理编码错误 + ) + + # 获取输出 + stdout = result.stdout.strip() + stderr = result.stderr.strip() + return_code = result.returncode + + output = "" + if stdout: + output += f"命令输出:\n{stdout}\n" + if stderr: + output += f"错误信息:\n{stderr}\n" + if return_code != 0: + output += f"命令退出码: {return_code}\n" + + if not output: + output = f"命令执行完成,无输出。(超时设置: {timeout}秒)" + + # 限制输出长度为5000字符 + if len(output) > 5000: + output = output[:5000] + f"\n... (输出已截断,超时设置: {timeout}秒)" + + return output + except subprocess.TimeoutExpired: + return f"错误:命令执行超时(超过{timeout}秒),已取消执行。" + except ValueError: + return f"错误:超时值无效,已使用默认30秒超时。命令: {command}" + except Exception as e: + return f"命令执行失败: {str(e)}" + + +# 测试函数 +def test_plugin(): + """测试插件功能""" + plugin = Plugin() + print("CMD执行器插件测试:") + print("工具信息:", plugin.get_tool_info()) + print("使用说明:", plugin.usage) + + +if __name__ == "__main__": + test_plugin() \ No newline at end of file diff --git a/plugins/code_editor.py b/plugins/code_editor.py new file mode 100644 index 0000000..65b8cf9 --- /dev/null +++ b/plugins/code_editor.py @@ -0,0 +1,542 @@ +""" +代码编辑器插件 - 小狸的精准代码编辑能力 +对标 Claude Code 的多文件编辑、上下文感知修改 +""" +import os +import re +import difflib +from typing import Optional, Tuple, List, Dict + + +class Plugin: + """精准代码编辑器 - 支持搜索替换、多文件编辑、diff 预览""" + + def __init__(self): + self.usage = """代码编辑器 - 精准修改代码文件 + +操作类型: + edit <文件路径> - 精准替换代码片段 + multi <文件路径> - 批量修改(JSON数组) + diff <文件路径1> <文件路径2> - 比较两个文件差异 + diff_text - 比较两段文本差异 + insert <文件路径> <行号> <内容> - 在指定行插入内容 + delete_lines <文件路径> <起始行> <结束行> - 删除指定行范围 + read_range <文件路径> <起始行> <结束行> - 读取指定行范围 + search <目录> <正则表达式> [文件模式] - 在代码中搜索 + ast_info <文件路径> - 获取 Python 文件的 AST 摘要 + create <文件路径> <内容> - 创建新文件 + write <文件路径> <内容> - 写入/覆盖文件内容 + append <文件路径> <内容> - 追加内容到文件 +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "code_editor", + "description": "精准代码编辑器 - 支持搜索替换、多文件批量编辑、代码搜索、diff对比。用于修改代码文件的核心工具。", + "keywords": ["编辑", "修改", "代码", "文件", "edit", "replace", "search", "diff", "code"], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "code_editor", + "description": "精准代码编辑器", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["edit", "multi", "diff", "diff_text", "insert", + "delete_lines", "read_range", "search", "ast_info", + "create", "write", "append"], + "description": "操作类型" + }, + "path": {"type": "string", "description": "文件路径"}, + "old_text": {"type": "string", "description": "要替换的原文"}, + "new_text": {"type": "string", "description": "替换后的新文本"}, + "content": {"type": "string", "description": "文件内容"}, + "line_number": {"type": "integer", "description": "行号"}, + "start_line": {"type": "integer", "description": "起始行"}, + "end_line": {"type": "integer", "description": "结束行"}, + "pattern": {"type": "string", "description": "搜索正则表达式"}, + "file_pattern": {"type": "string", "description": "文件匹配模式"}, + "directory": {"type": "string", "description": "搜索目录"}, + "operations": {"type": "array", "description": "批量操作列表"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments): + op = arguments.get("operation", "") + path = arguments.get("path", "") + return f"{op} {path}" + + def handle(self, args: str) -> str: + try: + parts = args.strip().split(maxsplit=2) + if not parts: + return "错误:请提供操作类型" + + operation = parts[0].lower() + + handlers = { + "edit": self._op_edit, + "multi": self._op_multi_edit, + "diff": self._op_diff, + "diff_text": self._op_diff_text, + "insert": self._op_insert, + "delete_lines": self._op_delete_lines, + "read_range": self._op_read_range, + "search": self._op_search, + "ast_info": self._op_ast_info, + "create": self._op_create, + "write": self._op_write, + "append": self._op_append, + } + + handler = handlers.get(operation) + if not handler: + return f"错误:不支持的操作 '{operation}'" + + return handler(parts[1] if len(parts) > 1 else "", + parts[2] if len(parts) > 2 else "") + + except Exception as e: + return f"代码编辑器错误: {str(e)}" + + def _normalize_path(self, path: str) -> str: + """规范化路径""" + path = path.strip().strip('"').strip("'") + path = os.path.expanduser(path) + path = os.path.normpath(path) + return path + + def _op_edit(self, path: str, rest: str) -> str: + """精准替换代码片段 - 核心编辑能力""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + # 从 rest 中解析 old_text 和 new_text + # 格式: old_text <<>> new_text + separator = " <<<>>> " + if separator not in rest: + return "错误:格式应为: edit <文件> <<<>>> " + + old_text, new_text = rest.split(separator, 1) + + if not old_text.strip(): + return "错误:old_text 不能为空" + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 精准匹配(去除首尾空白但保留内部格式) + old_text = old_text.strip() + new_text = new_text.strip() + + if old_text not in content: + # 尝试模糊匹配 + lines = content.split('\n') + old_lines = old_text.split('\n') + match_info = self._fuzzy_match(lines, old_lines) + if match_info: + return f"错误:未找到精确匹配。最接近的位置在第 {match_info[0]+1}-{match_info[1]} 行,但内容不完全一致。请检查缩进和空白字符。" + return f"错误:在文件中未找到要替换的内容。请确保文本完全匹配(包括缩进)。" + + count = content.count(old_text) + if count > 1: + return f"警告:找到 {count} 处匹配,请提供更多上下文以确保唯一匹配。" + + new_content = content.replace(old_text, new_text, 1) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(new_content) + + # 生成 diff 预览 + diff = self._make_diff(old_text, new_text, file_path) + return f"✅ 已编辑: {file_path}\n\n{diff}" + + def _op_multi_edit(self, path: str, rest: str) -> str: + """批量编辑 - 一次修改多处""" + import json + + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + try: + operations = json.loads(rest) + except json.JSONDecodeError: + return "错误:operations 必须是有效的 JSON 数组。格式: [{\"old\": \"...\", \"new\": \"...\"}]" + + if not isinstance(operations, list): + return "错误:operations 必须是数组" + + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + results = [] + for i, op in enumerate(operations): + old_text = op.get("old", "").strip() + new_text = op.get("new", "").strip() + + if not old_text: + results.append(f" ❌ 操作 {i+1}: old 为空") + continue + + if old_text not in content: + results.append(f" ❌ 操作 {i+1}: 未找到匹配") + continue + + content = content.replace(old_text, new_text, 1) + results.append(f" ✅ 操作 {i+1}: 已替换") + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + return f"✅ 批量编辑完成: {file_path}\n" + "\n".join(results) + + def _op_diff(self, path: str, rest: str) -> str: + """比较两个文件差异""" + if not path: + return "错误:请提供第一个文件路径" + + file1 = self._normalize_path(path) + file2 = self._normalize_path(rest.split()[0] if rest.strip() else "") + + if not os.path.exists(file1): + return f"错误:文件不存在: {file1}" + if not os.path.exists(file2): + return f"错误:文件不存在: {file2}" + + with open(file1, 'r', encoding='utf-8') as f: + lines1 = f.readlines() + with open(file2, 'r', encoding='utf-8') as f: + lines2 = f.readlines() + + diff = difflib.unified_diff(lines1, lines2, + fromfile=file1, tofile=file2, lineterm='') + diff_text = '\n'.join(diff) + + if not diff_text: + return "两个文件完全相同" + + return f"文件差异:\n{diff_text}" + + def _op_diff_text(self, path: str, rest: str) -> str: + """比较两段文本差异""" + separator = " <<<>>> " + if separator not in rest: + return "错误:格式: diff_text <<<>>> " + + old_text, new_text = rest.split(separator, 1) + diff = self._make_diff(old_text.strip(), new_text.strip(), "修改") + return diff + + def _op_insert(self, path: str, rest: str) -> str: + """在指定行插入内容""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + parts = rest.split(maxsplit=1) + if len(parts) < 2: + return "错误:格式: insert <文件> <行号> <内容>" + + try: + line_num = int(parts[0]) + except ValueError: + return "错误:行号必须是整数" + + content_to_insert = parts[1].strip() + + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + if line_num < 1 or line_num > len(lines) + 1: + return f"错误:行号超出范围 (1-{len(lines)+1})" + + insert_lines = [l + '\n' for l in content_to_insert.split('\n')] + lines[line_num-1:line_num-1] = insert_lines + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return f"✅ 已在第 {line_num} 行插入 {len(insert_lines)} 行内容" + + def _op_delete_lines(self, path: str, rest: str) -> str: + """删除指定行范围""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + parts = rest.split() + if len(parts) < 2: + return "错误:格式: delete_lines <文件> <起始行> <结束行>" + + try: + start = int(parts[0]) + end = int(parts[1]) + except ValueError: + return "错误:行号必须是整数" + + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + if start < 1 or end > len(lines) or start > end: + return f"错误:行号范围无效 (1-{len(lines)})" + + deleted = lines[start-1:end] + lines[start-1:end] = [] + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return f"✅ 已删除第 {start}-{end} 行 ({len(deleted)} 行)" + + def _op_read_range(self, path: str, rest: str) -> str: + """读取指定行范围""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + parts = rest.split() + start = int(parts[0]) if len(parts) > 0 else 1 + end = int(parts[1]) if len(parts) > 1 else None + + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total = len(lines) + if end is None: + end = total + start = max(1, min(start, total)) + end = max(start, min(end, total)) + + selected = lines[start-1:end] + numbered = [f"{start+i:4d} | {line.rstrip()}" for i, line in enumerate(selected)] + + return f"📄 {file_path} (第 {start}-{end} 行,共 {total} 行)\n{'─'*60}\n" + '\n'.join(numbered) + + def _op_search(self, path: str, rest: str) -> str: + """在代码中搜索(类似 grep)""" + if not path: + return "错误:请提供搜索目录" + + search_dir = self._normalize_path(path) + + if not os.path.isdir(search_dir): + return f"错误:目录不存在: {search_dir}" + + parts = rest.split(maxsplit=1) + if not parts: + return "错误:请提供搜索正则表达式" + + pattern = parts[0] + file_pattern = parts[1] if len(parts) > 1 else "*.py" + + try: + regex = re.compile(pattern) + except re.error as e: + return f"错误:无效的正则表达式: {e}" + + # 构建文件匹配模式 + if file_pattern.startswith("*."): + ext = file_pattern[1:] # .py + file_filter = lambda f: f.endswith(ext) + elif file_pattern == "*": + file_filter = lambda f: True + else: + file_filter = lambda f: file_pattern in f + + results = [] + max_results = 50 + + for root, dirs, files in os.walk(search_dir): + # 跳过隐藏目录和常见不需要搜索的目录 + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in + {'node_modules', '__pycache__', '.git', 'venv', '.venv', 'env'}] + + for filename in files: + if not file_filter(filename): + continue + + filepath = os.path.join(root, filename) + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + if regex.search(line): + rel_path = os.path.relpath(filepath, search_dir) + results.append(f" {rel_path}:{line_num}: {line.rstrip()}") + if len(results) >= max_results: + break + except (UnicodeDecodeError, PermissionError): + continue + + if len(results) >= max_results: + break + if len(results) >= max_results: + break + + if not results: + return f"未找到匹配 '{pattern}' 的结果" + + header = f"🔍 搜索结果 ({len(results)}{'+' if len(results) >= max_results else ''} 匹配):\n" + return header + '\n'.join(results) + + def _op_ast_info(self, path: str, rest: str) -> str: + """获取 Python 文件的 AST 摘要""" + import ast + + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + with open(file_path, 'r', encoding='utf-8') as f: + source = f.read() + + try: + tree = ast.parse(source) + except SyntaxError as e: + return f"错误:语法错误 - {e}" + + info = [f"📄 {file_path} AST 摘要:"] + + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + methods = [n.name for n in ast.iter_child_nodes(node) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))] + info.append(f" class {node.name} (行 {node.lineno})") + for m in methods: + info.append(f" └─ {m}()") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + info.append(f" def {node.name}() (行 {node.lineno})") + elif isinstance(node, ast.Import): + names = [a.name for a in node.names] + info.append(f" import {', '.join(names)}") + elif isinstance(node, ast.ImportFrom): + names = [a.name for a in node.names] + info.append(f" from {node.module} import {', '.join(names)}") + + if len(info) == 1: + return f"📄 {file_path}: 文件为空或无顶层定义" + + return '\n'.join(info) + + def _op_create(self, path: str, rest: str) -> str: + """创建新文件""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if os.path.exists(file_path): + return f"错误:文件已存在: {file_path}" + + # 确保目录存在 + dir_path = os.path.dirname(file_path) + if dir_path and not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + + content = rest if rest else "" + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + return f"✅ 已创建文件: {file_path} ({len(content)} 字符)" + + def _op_write(self, path: str, rest: str) -> str: + """写入/覆盖文件内容""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + # 确保目录存在 + dir_path = os.path.dirname(file_path) + if dir_path and not os.path.exists(dir_path): + os.makedirs(dir_path, exist_ok=True) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(rest) + + return f"✅ 已写入文件: {file_path} ({len(rest)} 字符)" + + def _op_append(self, path: str, rest: str) -> str: + """追加内容到文件""" + if not path: + return "错误:请提供文件路径" + + file_path = self._normalize_path(path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + with open(file_path, 'a', encoding='utf-8') as f: + f.write(rest) + + return f"✅ 已追加内容到: {file_path} ({len(rest)} 字符)" + + def _make_diff(self, old_text: str, new_text: str, label: str) -> str: + """生成 diff 预览""" + old_lines = old_text.splitlines(keepends=True) + new_lines = new_text.splitlines(keepends=True) + + diff = difflib.unified_diff(old_lines, new_lines, + fromfile=f"原始", tofile=f"修改后", lineterm='') + diff_text = '\n'.join(diff) + + if not diff_text: + return "内容无变化" + + return f"📝 变更预览:\n{diff_text}" + + def _fuzzy_match(self, lines: list, old_lines: list) -> Optional[Tuple[int, int]]: + """模糊匹配,尝试找到最接近的位置""" + if not old_lines: + return None + + best_ratio = 0 + best_pos = None + + for i in range(len(lines) - len(old_lines) + 1): + candidate = lines[i:i+len(old_lines)] + ratio = difflib.SequenceMatcher(None, + ''.join(candidate), ''.join(old_lines)).ratio() + if ratio > best_ratio: + best_ratio = ratio + best_pos = (i, i + len(old_lines) - 1) + + if best_ratio > 0.6: + return best_pos + return None diff --git a/plugins/code_search.py b/plugins/code_search.py new file mode 100644 index 0000000..6b4920b --- /dev/null +++ b/plugins/code_search.py @@ -0,0 +1,472 @@ +""" +代码搜索与理解插件 - 代码库级别的智能搜索 +对标 Claude Code 的代码理解能力 +""" +import os +import re +import ast +from typing import List, Dict, Optional, Tuple + + +class Plugin: + """代码搜索与理解工具 - 跨文件搜索、依赖分析、代码结构理解""" + + def __init__(self): + self.usage = """代码搜索与理解工具 + +操作类型: + find <目录> <关键词> [文件模式] - 搜索代码中的关键词 + regex <目录> <正则表达式> [文件模式] - 正则表达式搜索 + symbols <目录> [文件模式] - 提取所有符号(类、函数、变量) + imports <文件路径> - 分析文件的导入依赖 + callers <目录> <函数名> - 查找调用指定函数的位置 + todo <目录> [文件模式] - 搜索 TODO/FIXME/HACK 注释 + structure <目录> - 显示项目目录结构 + context <文件路径> [行号] [范围] - 获取某行的上下文 + stats <目录> - 代码统计(行数、文件数等) +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "code_search", + "description": "代码搜索与理解工具 - 跨文件搜索、符号提取、依赖分析、项目结构查看", + "keywords": ["搜索", "查找", "grep", "search", "find", "代码", "符号", "结构"], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "code_search", + "description": "代码搜索与理解工具", + "inputSchema": { + "type": "object", + "properties": { + "operation": {"type": "string"}, + "directory": {"type": "string"}, + "pattern": {"type": "string"}, + "file_pattern": {"type": "string"}, + "file_path": {"type": "string"}, + "line_number": {"type": "integer"}, + "range": {"type": "integer"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments): + op = arguments.get("operation", "") + directory = arguments.get("directory", "") + pattern = arguments.get("pattern", "") + return f"{op} {directory} {pattern}".strip() + + def handle(self, args: str) -> str: + try: + parts = args.strip().split(maxsplit=2) + if not parts: + return "错误:请提供操作类型" + + operation = parts[0].lower() + arg1 = parts[1] if len(parts) > 1 else "" + arg2 = parts[2] if len(parts) > 2 else "" + + handlers = { + "find": self._op_find, + "regex": self._op_regex, + "symbols": self._op_symbols, + "imports": self._op_imports, + "callers": self._op_callers, + "todo": self._op_todo, + "structure": self._op_structure, + "context": self._op_context, + "stats": self._op_stats, + } + + handler = handlers.get(operation) + if not handler: + return f"错误:不支持的操作 '{operation}'" + + return handler(arg1, arg2) + + except Exception as e: + return f"代码搜索错误: {str(e)}" + + def _normalize_path(self, path: str) -> str: + path = path.strip().strip('"').strip("'") + path = os.path.expanduser(path) + return os.path.normpath(path) + + def _walk_files(self, directory: str, file_pattern: str = "*.py", + max_files: int = 200) -> List[str]: + """遍历目录中的文件""" + if file_pattern.startswith("*."): + ext = file_pattern[1:] + file_filter = lambda f: f.endswith(ext) + elif file_pattern == "*": + file_filter = lambda f: True + else: + file_filter = lambda f: file_pattern in f + + files = [] + skip_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', + 'env', '.env', '.tox', '.mypy_cache', '.pytest_cache'} + + for root, dirs, filenames in os.walk(directory): + dirs[:] = [d for d in dirs if not d.startswith('.') and d not in skip_dirs] + for f in filenames: + if file_filter(f): + files.append(os.path.join(root, f)) + if len(files) >= max_files: + return files + return files + + def _op_find(self, directory: str, rest: str) -> str: + """关键词搜索""" + if not directory: + return "错误:请提供搜索目录" + + search_dir = self._normalize_path(directory) + parts = rest.split(maxsplit=1) + keyword = parts[0] if parts else "" + file_pattern = parts[1] if len(parts) > 1 else "*.py" + + if not keyword: + return "错误:请提供搜索关键词" + + if not os.path.isdir(search_dir): + return f"错误:目录不存在: {search_dir}" + + files = self._walk_files(search_dir, file_pattern) + results = [] + max_results = 80 + + for filepath in files: + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + if keyword.lower() in line.lower(): + rel_path = os.path.relpath(filepath, search_dir) + results.append(f" {rel_path}:{line_num}: {line.rstrip()}") + if len(results) >= max_results: + break + except (UnicodeDecodeError, PermissionError): + continue + if len(results) >= max_results: + break + + if not results: + return f"未找到 '{keyword}' 的匹配" + + return f"🔍 搜索 '{keyword}' ({len(results)}{'+' if len(results) >= max_results else ''} 匹配):\n" + '\n'.join(results) + + def _op_regex(self, directory: str, rest: str) -> str: + """正则表达式搜索""" + if not directory: + return "错误:请提供搜索目录" + + search_dir = self._normalize_path(directory) + parts = rest.split(maxsplit=1) + pattern = parts[0] if parts else "" + file_pattern = parts[1] if len(parts) > 1 else "*.py" + + if not pattern: + return "错误:请提供正则表达式" + + try: + regex = re.compile(pattern) + except re.error as e: + return f"错误:无效的正则表达式: {e}" + + files = self._walk_files(search_dir, file_pattern) + results = [] + max_results = 50 + + for filepath in files: + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + if regex.search(line): + rel_path = os.path.relpath(filepath, search_dir) + results.append(f" {rel_path}:{line_num}: {line.rstrip()}") + if len(results) >= max_results: + break + except (UnicodeDecodeError, PermissionError): + continue + if len(results) >= max_results: + break + + if not results: + return f"未找到匹配 '{pattern}' 的结果" + + return f"🔍 正则搜索 ({len(results)}{'+' if len(results) >= max_results else ''} 匹配):\n" + '\n'.join(results) + + def _op_symbols(self, directory: str, rest: str) -> str: + """提取目录中所有 Python 符号""" + if not directory: + return "错误:请提供目录路径" + + search_dir = self._normalize_path(directory) + file_pattern = rest.strip() or "*.py" + + files = self._walk_files(search_dir, file_pattern) + all_symbols = [] + + for filepath in files: + if not filepath.endswith('.py'): + continue + try: + with open(filepath, 'r', encoding='utf-8') as f: + source = f.read() + tree = ast.parse(source) + rel_path = os.path.relpath(filepath, search_dir) + + symbols = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.ClassDef): + methods = [n.name for n in ast.iter_child_nodes(node) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))] + symbols.append(f" class {node.name} (行 {node.lineno})") + for m in methods[:10]: # 限制每个类最多显示10个方法 + symbols.append(f" └─ {m}()") + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + symbols.append(f" def {node.name}() (行 {node.lineno})") + + if symbols: + all_symbols.append(f"\n📄 {rel_path}:") + all_symbols.extend(symbols) + + except (SyntaxError, UnicodeDecodeError): + continue + + if not all_symbols: + return "未找到 Python 符号" + + return f"📋 代码符号:\n" + '\n'.join(all_symbols) + + def _op_imports(self, file_path: str, rest: str) -> str: + """分析文件的导入依赖""" + if not file_path: + return "错误:请提供文件路径" + + path = self._normalize_path(file_path) + + if not os.path.exists(path): + return f"错误:文件不存在: {path}" + + with open(path, 'r', encoding='utf-8') as f: + source = f.read() + + try: + tree = ast.parse(source) + except SyntaxError as e: + return f"错误:语法错误 - {e}" + + imports = [] + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imports.append(f" import {alias.name} (行 {node.lineno})") + elif isinstance(node, ast.ImportFrom): + names = [a.name for a in node.names] + imports.append(f" from {node.module} import {', '.join(names)} (行 {node.lineno})") + + if not imports: + return f"📄 {file_path}: 没有导入语句" + + return f"📦 {file_path} 导入依赖:\n" + '\n'.join(imports) + + def _op_callers(self, directory: str, rest: str) -> str: + """查找调用指定函数的位置""" + if not directory: + return "错误:请提供搜索目录" + + search_dir = self._normalize_path(directory) + parts = rest.split(maxsplit=1) + func_name = parts[0] if parts else "" + file_pattern = parts[1] if len(parts) > 1 else "*.py" + + if not func_name: + return "错误:请提供函数名" + + # 搜索函数调用模式 + patterns = [ + re.compile(rf'\b{re.escape(func_name)}\s*\('), + re.compile(rf'\.{re.escape(func_name)}\s*\('), + ] + + files = self._walk_files(search_dir, file_pattern) + results = [] + + for filepath in files: + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + for pat in patterns: + if pat.search(line): + rel_path = os.path.relpath(filepath, search_dir) + results.append(f" {rel_path}:{line_num}: {line.rstrip()}") + break + except (UnicodeDecodeError, PermissionError): + continue + + if not results: + return f"未找到调用 '{func_name}' 的位置" + + return f"🔎 '{func_name}' 的调用位置 ({len(results)}):\n" + '\n'.join(results) + + def _op_todo(self, directory: str, rest: str) -> str: + """搜索 TODO/FIXME/HACK 注释""" + if not directory: + return "错误:请提供目录路径" + + search_dir = self._normalize_path(directory) + file_pattern = rest.strip() or "*.py" + + pattern = re.compile(r'#\s*(TODO|FIXME|HACK|XXX|BUG|NOTE)\b[:\s]*(.*)', re.IGNORECASE) + files = self._walk_files(search_dir, file_pattern) + results = [] + + for filepath in files: + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + match = pattern.search(line) + if match: + rel_path = os.path.relpath(filepath, search_dir) + tag = match.group(1).upper() + desc = match.group(2).strip() + results.append(f" {rel_path}:{line_num} [{tag}] {desc}") + except (UnicodeDecodeError, PermissionError): + continue + + if not results: + return "没有找到 TODO/FIXME 注释" + + return f"📝 TODO/FIXME ({len(results)}):\n" + '\n'.join(results) + + def _op_structure(self, directory: str, rest: str) -> str: + """显示项目目录结构""" + if not directory: + return "错误:请提供目录路径" + + search_dir = self._normalize_path(directory) + if not os.path.isdir(search_dir): + return f"错误:目录不存在: {search_dir}" + + max_depth = int(rest) if rest.strip().isdigit() else 3 + lines = [f"📁 {os.path.basename(search_dir)}/"] + skip_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', + 'env', '.tox', '.mypy_cache'} + + def _tree(path, prefix="", depth=0): + if depth >= max_depth: + return + try: + entries = sorted(os.listdir(path)) + except PermissionError: + return + + dirs = [e for e in entries if os.path.isdir(os.path.join(path, e)) + and e not in skip_dirs and not e.startswith('.')] + files = [e for e in entries if os.path.isfile(os.path.join(path, e))] + + # 限制每个目录显示的文件数 + if len(files) > 20: + files = files[:20] + [f"... ({len(files)-20} more)"] + + for i, d in enumerate(dirs): + is_last_dir = (i == len(dirs) - 1) and not files + connector = "└── " if is_last_dir else "├── " + lines.append(f"{prefix}{connector}{d}/") + extension = " " if is_last_dir else "│ " + _tree(os.path.join(path, d), prefix + extension, depth + 1) + + for i, f in enumerate(files): + is_last = i == len(files) - 1 + connector = "└── " if is_last else "├── " + # 文件大小 + fpath = os.path.join(path, f) + if os.path.isfile(fpath): + size = os.path.getsize(fpath) + if size > 1024 * 1024: + size_str = f" ({size // (1024*1024)}MB)" + elif size > 1024: + size_str = f" ({size // 1024}KB)" + else: + size_str = f" ({size}B)" + else: + size_str = "" + lines.append(f"{prefix}{connector}{f}{size_str}") + + _tree(search_dir) + return '\n'.join(lines) + + def _op_context(self, file_path: str, rest: str) -> str: + """获取某行的上下文""" + if not file_path: + return "错误:请提供文件路径" + + path = self._normalize_path(file_path) + + if not os.path.exists(path): + return f"错误:文件不存在: {path}" + + parts = rest.split() + line_num = int(parts[0]) if parts else 1 + context_range = int(parts[1]) if len(parts) > 1 else 5 + + with open(path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total = len(lines) + start = max(1, line_num - context_range) + end = min(total, line_num + context_range) + + result = [f"📄 {path} 第 {start}-{end} 行 (目标: 第 {line_num} 行):"] + result.append("─" * 60) + + for i in range(start - 1, end): + marker = ">>>" if i + 1 == line_num else " " + result.append(f"{marker} {i+1:4d} | {lines[i].rstrip()}") + + return '\n'.join(result) + + def _op_stats(self, directory: str, rest: str) -> str: + """代码统计""" + if not directory: + return "错误:请提供目录路径" + + search_dir = self._normalize_path(directory) + files = self._walk_files(search_dir, "*.py") + + total_lines = 0 + total_blank = 0 + total_comment = 0 + total_code = 0 + file_count = len(files) + + for filepath in files: + try: + with open(filepath, 'r', encoding='utf-8') as f: + for line in f: + total_lines += 1 + stripped = line.strip() + if not stripped: + total_blank += 1 + elif stripped.startswith('#'): + total_comment += 1 + else: + total_code += 1 + except (UnicodeDecodeError, PermissionError): + continue + + return f"""📊 代码统计 ({search_dir}): + 文件数: {file_count} + 总行数: {total_lines} + 代码行: {total_code} + 注释行: {total_comment} + 空行: {total_blank} + 注释率: {total_comment/max(total_code,1)*100:.1f}%""" diff --git a/plugins/file_manager.py b/plugins/file_manager.py new file mode 100644 index 0000000..44e447f --- /dev/null +++ b/plugins/file_manager.py @@ -0,0 +1,793 @@ + +""" +文件管理器插件 - 小狸自有协议版本 +支持文件和目录的创建、读取、写入、删除、移动、复制、搜索等操作 +""" + +import os +import json +import shutil +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, Any, List + + +class Liugin: + """文件管理器插件""" + + def __init__(self): + self.usage = """文件管理器使用方法: +file_manager <操作> <参数> + +操作类型: +- list <路径> [-p "模式"] - 列出目录内容(只列当前目录) +- read <文件路径> [-n 行数] - 读取文件内容 +- write <文件路径> -c "内容" - 写入文件 +- append <文件路径> -c "内容" - 追加内容到文件 +- copy <源路径> <目标路径> - 复制文件或目录 +- move <源路径> <目标路径> - 移动或重命名 +- delete <路径> [-r] - 删除文件或目录 +- search <目录> [-p "模式"] [-c "内容"] - 搜索文件(递归) +- info <路径> - 获取文件信息 +- mkdir <路径> - 创建目录 +- modify <文件路径> <行号> "新内容" - 修改指定行 +- insert <文件路径> <行号> "内容" - 在指定行插入内容 +- delete-line <文件路径> <行号> - 删除指定行 +- replace <文件路径> "旧文本" "新文本" [--start 行号] [--end 行号] - 替换文本 + +示例: +- file_manager list . - 列出当前目录 +- file_manager list . -p "*.py" - 列出所有Python文件 +- file_manager read config.json - 读取文件 +- file_manager write test.txt -c "Hello World" - 写入文件 +- file_manager copy file1.txt file2.txt - 复制文件 +- file_manager move old.txt new.txt - 重命名文件 +- file_manager delete temp -r - 递归删除目录 +- file_manager search . -c "TODO" - 搜索包含TODO的文件 + +JSON调用格式: +{"action": "use_tool", "tool": "file_manager", "args": "list ."} +{"action": "use_tool", "tool": "file_manager", "args": "read config.json -n 50"} +{"action": "use_tool", "tool": "file_manager", "args": "write test.txt -c Hello World"} +""" + self.cli = None + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + + def get_tool_info(self): + return { + "name": "file_manager", + "description": "文件和目录管理工具,支持创建、读取、写入、删除、移动、复制、搜索文件等操作", + "keywords": ["文件", "目录", "管理", "file", "manager", "读写", "复制", "删除", "搜索", "mkdir"], + "usage": self.usage + } + + def get_mcp_definition(self) -> Dict[str, Any]: + """返回符合 MCP 标准的工具定义""" + return { + "name": "file_manager", + "description": "文件和目录管理工具,支持创建、读取、写入、删除、移动、复制、搜索文件等操作。操作类型: list(列出目录), read(读取文件), write(写入文件), append(追加内容), copy(复制), move(移动/重命名), delete(删除), search(搜索), info(文件信息), mkdir(创建目录), modify(修改行), insert(插入行), delete-line(删除行), replace(替换文本)", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "read", "write", "append", "copy", "move", "delete", "search", "info", "mkdir", "modify", "insert", "delete-line", "replace"], + "description": "操作类型" + }, + "path": { + "type": "string", + "description": "文件或目录路径" + }, + "content": { + "type": "string", + "description": "文件内容(用于 write/append 操作)" + }, + "destination": { + "type": "string", + "description": "目标路径(用于 copy/move 操作)" + }, + "pattern": { + "type": "string", + "description": "文件匹配模式(用于 list/search 操作)" + }, + "line_number": { + "type": "integer", + "description": "行号(用于 modify/insert/delete-line 操作)" + }, + "old_text": { + "type": "string", + "description": "旧文本(用于 replace 操作)" + }, + "new_text": { + "type": "string", + "description": "新文本(用于 replace/modify 操作)" + }, + "recursive": { + "type": "boolean", + "description": "是否递归(用于 delete/search 操作)", + "default": False + } + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments: Dict[str, Any]) -> str: + """将 MCP 参数转换为命令行格式""" + op = arguments.get("operation", "") + parts = [op] + + if "path" in arguments: + parts.append(arguments["path"]) + + if "destination" in arguments: + parts.append(arguments["destination"]) + + if "content" in arguments: + parts.extend(["-c", f'"{arguments["content"]}"']) + + if "pattern" in arguments: + parts.extend(["-p", f'"{arguments["pattern"]}"']) + + if "line_number" in arguments: + parts.append(str(arguments["line_number"])) + + if "old_text" in arguments: + parts.append(f'"{arguments["old_text"]}"') + + if "new_text" in arguments: + parts.append(f'"{arguments["new_text"]}"') + + if arguments.get("recursive"): + parts.append("-r") + + return " ".join(parts) + + def handle(self, args: str) -> str: + """处理文件管理请求""" + try: + # 解析参数 + parts = self._parse_args(args) + if not parts: + return "错误:请提供操作类型。\n可用操作: list, read, write, append, copy, move, delete, search, info, mkdir, modify, insert, delete-line, replace" + + operation = parts[0].lower() + + handlers = { + "list": self._op_list, + "read": self._op_read, + "write": self._op_write, + "append": self._op_append, + "copy": self._op_copy, + "move": self._op_move, + "delete": self._op_delete, + "search": self._op_search, + "info": self._op_info, + "mkdir": self._op_mkdir, + "modify": self._op_modify_line, + "insert": self._op_insert_line, + "delete-line": self._op_delete_line, + "replace": self._op_replace_text, + } + + handler = handlers.get(operation) + if not handler: + return f"错误:不支持的操作 '{operation}'。\n支持的操作: {', '.join(handlers.keys())}" + + return handler(parts[1:]) + + except Exception as e: + return f"文件操作错误: {str(e)}" + + def _parse_args(self, args: str) -> List[str]: + """解析命令行参数,支持引号""" + parts = [] + current = "" + in_quotes = False + quote_char = None + + for char in args: + if char in ('"', "'") and not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char and in_quotes: + in_quotes = False + quote_char = None + elif char == ' ' and not in_quotes: + if current: + parts.append(current) + current = "" + else: + current += char + + if current: + parts.append(current) + + return parts + + def _op_list(self, args: List[str]) -> str: + """列出目录内容 - 默认只列出当前目录,不递归""" + path = "." + pattern = None + + i = 0 + while i < len(args): + if args[i] == "-p" and i + 1 < len(args): + pattern = args[i + 1] + i += 1 + elif not args[i].startswith("-"): + path = args[i] + i += 1 + + path = os.path.abspath(path) + + if not os.path.exists(path): + return f"错误:路径不存在: {path}" + + if not os.path.isdir(path): + return f"错误:不是目录: {path}" + + items = [] + + try: + for item in os.listdir(path): + full_path = os.path.join(path, item) + if pattern and not Path(item).match(pattern): + continue + if os.path.isdir(full_path): + items.append(('dir', item, '')) + else: + size = os.path.getsize(full_path) + if size < 1024: + size_str = f"{size}B" + elif size < 1024 * 1024: + size_str = f"{size/1024:.1f}KB" + else: + size_str = f"{size/1024/1024:.1f}MB" + items.append(('file', item, size_str)) + + # 排序:文件夹优先 + items.sort(key=lambda x: (0 if x[0] == 'dir' else 1, x[1].lower())) + + result = [f"📁 {path}"] + result.append("─" * 50) + + dir_count = sum(1 for x in items if x[0] == 'dir') + file_count = len(items) - dir_count + + for item_type, name, size in items[:100]: + if item_type == 'dir': + result.append(f" 📁 {name}/") + else: + result.append(f" 📄 {name} ({size})") + + if len(items) > 100: + result.append(f" ... 还有 {len(items) - 100} 项") + + result.append("─" * 50) + result.append(f"共 {dir_count} 个文件夹, {file_count} 个文件") + + return '\n'.join(result) + + except Exception as e: + return f"列出目录失败: {e}" + + def _op_read(self, args: List[str]) -> str: + """读取文件内容""" + if not args: + return "错误:请提供文件路径" + + file_path = args[0] + lines = None + + i = 1 + while i < len(args): + if args[i] == "-n" and i + 1 < len(args): + try: + lines = int(args[i + 1]) + except ValueError: + return "错误:行数必须是整数" + i += 1 + i += 1 + + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + if not os.path.isfile(file_path): + return f"错误:不是文件: {file_path}" + + file_size = os.path.getsize(file_path) + if file_size > 10 * 1024 * 1024: # 10MB + return f"警告:文件过大 ({file_size / 1024 / 1024:.1f}MB),请使用 -n 参数限制行数" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + if lines: + content_lines = [] + line_count = 0 + for i, line in enumerate(f): + line_count = i + 1 + if i < lines: + content_lines.append(f"{i+1:4d} | {line.rstrip()}") + content = '\n'.join(content_lines) + total_lines = line_count + else: + all_lines = f.readlines() + content = '\n'.join(f"{i+1:4d} | {line.rstrip()}" for i, line in enumerate(all_lines)) + total_lines = len(all_lines) + + result = [f"文件: {file_path}"] + result.append(f"大小: {file_size} 字节") + result.append(f"行数: {total_lines}") + if lines: + result.append(f"显示: 前 {min(lines, total_lines)} 行") + result.append(f"\n{'─' * 50}") + result.append(content) + + return '\n'.join(result) + + except UnicodeDecodeError: + return f"错误:无法解码文件(可能是二进制文件或编码不正确)" + except Exception as e: + return f"读取文件失败: {e}" + + def _op_write(self, args: List[str]) -> str: + """写入文件""" + if len(args) < 2: + return "错误:请提供文件路径和内容" + + file_path = args[0] + content = None + + i = 1 + while i < len(args): + if args[i] == "-c" and i + 1 < len(args): + content = args[i + 1] + i += 1 + i += 1 + + if content is None: + return "错误:请使用 -c 参数提供内容" + + file_path = os.path.abspath(file_path) + + try: + # 创建父目录 + parent = os.path.dirname(file_path) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + + return f"✅ 文件已写入: {file_path}\n大小: {len(content.encode('utf-8'))} 字节" + + except Exception as e: + return f"写入文件失败: {e}" + + def _op_append(self, args: List[str]) -> str: + """追加内容到文件""" + if len(args) < 2: + return "错误:请提供文件路径和内容" + + file_path = args[0] + content = None + + i = 1 + while i < len(args): + if args[i] == "-c" and i + 1 < len(args): + content = args[i + 1] + i += 1 + i += 1 + + if content is None: + return "错误:请使用 -c 参数提供内容" + + file_path = os.path.abspath(file_path) + + try: + with open(file_path, 'a', encoding='utf-8') as f: + f.write(content) + + return f"✅ 内容已追加: {file_path}" + + except Exception as e: + return f"追加内容失败: {e}" + + def _op_copy(self, args: List[str]) -> str: + """复制文件或目录""" + if len(args) < 2: + return "错误:请提供源路径和目标路径" + + source = os.path.abspath(args[0]) + destination = os.path.abspath(args[1]) + + if not os.path.exists(source): + return f"错误:源路径不存在: {source}" + + try: + parent = os.path.dirname(destination) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + if os.path.isfile(source): + shutil.copy2(source, destination) + item_type = "文件" + else: + shutil.copytree(source, destination, dirs_exist_ok=True) + item_type = "目录" + + return f"✅ {item_type}已复制:\n 源: {source}\n 目标: {destination}" + + except Exception as e: + return f"复制失败: {e}" + + def _op_move(self, args: List[str]) -> str: + """移动或重命名文件或目录""" + if len(args) < 2: + return "错误:请提供源路径和目标路径" + + source = os.path.abspath(args[0]) + destination = os.path.abspath(args[1]) + + if not os.path.exists(source): + return f"错误:源路径不存在: {source}" + + try: + parent = os.path.dirname(destination) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + shutil.move(source, destination) + + return f"✅ 已移动/重命名:\n 源: {source}\n 目标: {destination}" + + except Exception as e: + return f"移动失败: {e}" + + def _op_delete(self, args: List[str]) -> str: + """删除文件或目录""" + if not args: + return "错误:请提供路径" + + path = os.path.abspath(args[0]) + recursive = "-r" in args + + if not os.path.exists(path): + return f"错误:路径不存在: {path}" + + try: + if os.path.isfile(path): + os.remove(path) + item_type = "文件" + elif os.path.isdir(path): + if recursive: + shutil.rmtree(path) + item_type = "目录" + else: + # 检查是否为空 + if os.listdir(path): + return f"错误:目录不为空。使用 -r 参数递归删除。" + os.rmdir(path) + item_type = "空目录" + + return f"✅ 已删除 {item_type}: {path}" + + except Exception as e: + return f"删除失败: {e}" + + def _op_search(self, args: List[str]) -> str: + """搜索文件""" + if not args: + return "错误:请提供搜索目录" + + directory = args[0] + pattern = None + content_search = None + + i = 1 + while i < len(args): + if args[i] == "-p" and i + 1 < len(args): + pattern = args[i + 1] + i += 1 + elif args[i] == "-c" and i + 1 < len(args): + content_search = args[i + 1] + i += 1 + i += 1 + + directory = os.path.abspath(directory) + + if not os.path.exists(directory): + return f"错误:目录不存在: {directory}" + + results = [] + + try: + for root, dirs, files in os.walk(directory): + for filename in files: + file_path = os.path.join(root, filename) + rel_path = os.path.relpath(file_path, directory) + + # 模式匹配 + if pattern and not Path(filename).match(pattern): + continue + + # 内容搜索 + if content_search: + try: + with open(file_path, 'r', encoding='utf-8') as f: + file_content = f.read() + if content_search not in file_content: + continue + # 找到匹配行 + for i, line in enumerate(file_content.split('\n'), 1): + if content_search in line: + results.append(f"{rel_path}:{i}: {line.strip()[:80]}") + if len(results) >= 50: + break + except (UnicodeDecodeError, IOError): + continue + else: + results.append(rel_path) + + if len(results) >= 100: + break + + if len(results) >= 100: + break + + if not results: + return "未找到匹配的文件" + + result_text = [f"搜索结果 (共 {len(results)} 个):"] + result_text.append(f"目录: {directory}") + if pattern: + result_text.append(f"模式: {pattern}") + if content_search: + result_text.append(f"内容: {content_search}") + result_text.append("") + result_text.extend(results[:100]) + + if len(results) > 100: + result_text.append(f"... 还有 {len(results) - 100} 个结果") + + return '\n'.join(result_text) + + except Exception as e: + return f"搜索失败: {e}" + + def _op_info(self, args: List[str]) -> str: + """获取文件信息""" + if not args: + return "错误:请提供路径" + + path = os.path.abspath(args[0]) + + if not os.path.exists(path): + return f"错误:路径不存在: {path}" + + try: + stat_info = os.stat(path) + + result = [f"路径: {path}"] + result.append(f"名称: {os.path.basename(path)}") + result.append(f"类型: {'目录' if os.path.isdir(path) else '文件'}") + result.append(f"大小: {stat_info.st_size:,} 字节 ({stat_info.st_size / 1024:.1f} KB)") + result.append(f"创建时间: {datetime.fromtimestamp(stat_info.st_ctime).strftime('%Y-%m-%d %H:%M:%S')}") + result.append(f"修改时间: {datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S')}") + result.append(f"访问时间: {datetime.fromtimestamp(stat_info.st_atime).strftime('%Y-%m-%d %H:%M:%S')}") + + if os.path.isfile(path): + ext = os.path.splitext(path)[1] + result.append(f"扩展名: {ext if ext else '无'}") + + # 尝试检测编码 + try: + with open(path, 'r', encoding='utf-8') as f: + f.read(1024) + result.append("编码: UTF-8 (文本文件)") + except UnicodeDecodeError: + result.append("编码: 二进制文件") + + return '\n'.join(result) + + except Exception as e: + return f"获取信息失败: {e}" + + def _op_mkdir(self, args: List[str]) -> str: + """创建目录""" + if not args: + return "错误:请提供目录路径" + + path = os.path.abspath(args[0]) + + if os.path.exists(path): + return f"错误:路径已存在: {path}" + + try: + os.makedirs(path, exist_ok=True) + return f"✅ 目录已创建: {path}" + except Exception as e: + return f"创建目录失败: {e}" + + def _op_modify_line(self, args: List[str]) -> str: + """修改指定行""" + if len(args) < 3: + return "错误:请提供文件路径、行号和新内容" + + file_path = os.path.abspath(args[0]) + try: + line_number = int(args[1]) + except ValueError: + return "错误:行号必须是整数" + + new_content = args[2] + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + if line_number < 1: + return "错误:行号必须 >= 1" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + if line_number > len(lines): + return f"错误:文件只有 {len(lines)} 行,无法修改第 {line_number} 行" + + old_content = lines[line_number - 1].rstrip('\n\r') + lines[line_number - 1] = new_content + '\n' + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return f"✅ 第 {line_number} 行已修改:\n 旧: {old_content}\n 新: {new_content}" + + except Exception as e: + return f"修改行失败: {e}" + + def _op_insert_line(self, args: List[str]) -> str: + """插入行""" + if len(args) < 3: + return "错误:请提供文件路径、行号和内容" + + file_path = os.path.abspath(args[0]) + try: + line_number = int(args[1]) + except ValueError: + return "错误:行号必须是整数" + + content = args[2] + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + if line_number < 1: + return "错误:行号必须 >= 1" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + insert_pos = min(line_number - 1, len(lines)) + lines.insert(insert_pos, content + '\n') + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return f"✅ 已在第 {line_number} 行插入内容: {content}" + + except Exception as e: + return f"插入行失败: {e}" + + def _op_delete_line(self, args: List[str]) -> str: + """删除指定行""" + if len(args) < 2: + return "错误:请提供文件路径和行号" + + file_path = os.path.abspath(args[0]) + try: + line_number = int(args[1]) + except ValueError: + return "错误:行号必须是整数" + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + if line_number < 1: + return "错误:行号必须 >= 1" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + if line_number > len(lines): + return f"错误:文件只有 {len(lines)} 行,无法删除第 {line_number} 行" + + deleted = lines[line_number - 1].rstrip('\n\r') + del lines[line_number - 1] + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return f"✅ 已删除第 {line_number} 行: {deleted}" + + except Exception as e: + return f"删除行失败: {e}" + + def _op_replace_text(self, args: List[str]) -> str: + """替换文本""" + if len(args) < 3: + return "错误:请提供文件路径、旧文本和新文本" + + file_path = os.path.abspath(args[0]) + old_text = args[1] + new_text = args[2] + + start_line = None + end_line = None + + i = 3 + while i < len(args): + if args[i] == "--start" and i + 1 < len(args): + start_line = int(args[i + 1]) + i += 1 + elif args[i] == "--end" and i + 1 < len(args): + end_line = int(args[i + 1]) + i += 1 + i += 1 + + if not os.path.exists(file_path): + return f"错误:文件不存在: {file_path}" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total_lines = len(lines) + replacements = [] + + start = max(0, (start_line or 1) - 1) + end = min(total_lines, end_line or total_lines) + + for i in range(start, end): + if old_text in lines[i]: + count = lines[i].count(old_text) + lines[i] = lines[i].replace(old_text, new_text) + replacements.append(f"第 {i+1} 行: 替换了 {count} 处") + + if not replacements: + return f"未找到匹配文本: {old_text}" + + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + result = [f"✅ 文本替换完成"] + result.append(f"文件: {file_path}") + result.append(f"'{old_text}' -> '{new_text}'") + if start_line or end_line: + result.append(f"范围: 第 {start+1} 行到第 {end} 行") + result.append(f"修改了 {len(replacements)} 行:") + result.extend(replacements[:20]) + if len(replacements) > 20: + result.append(f"... 还有 {len(replacements) - 20} 行") + + return '\n'.join(result) + + except Exception as e: + return f"文本替换失败: {e}" + + +# 测试 +if __name__ == "__main__": + plugin = Plugin() + print("文件管理器插件测试:") + print(plugin.get_tool_info()) + print("\n测试 list 操作:") + print(plugin.handle("list . -p *.py")) diff --git a/plugins/frontend_tester.py b/plugins/frontend_tester.py new file mode 100644 index 0000000..565b2c0 --- /dev/null +++ b/plugins/frontend_tester.py @@ -0,0 +1,452 @@ +""" +前端测试验证插件 - 类似 iFlow CLI 的 frontend-tester +支持HTML/CSS/JS自动化测试和UI验证 +""" + +import os +import subprocess +from colorama import Fore, Style + + +class Liugin: + """前端测试验证插件 - 自动化测试Web界面""" + + def __init__(self): + self.usage = """前端测试工具使用方法: + +JSON格式示例: +{"action": "use_tool", "tool": "frontend_tester", "args": "validate index.html"} - 验证HTML文件 +{"action": "use_tool", "tool": "frontend_tester", "args": "test style.css"} - 测试CSS文件 +{"action": "use_tool", "tool": "frontend_tester", "args": "check app.js"} - 检查JS文件 +{"action": "use_tool", "tool": "frontend_tester", "args": "preview index.html"} - 在浏览器中预览 +{"action": "use_tool", "tool": "frontend_tester", "args": "analyze project"} - 分析整个项目 + +功能说明: +- validate - 验证HTML文件的语法和结构 +- test - 测试CSS文件的样式和兼容性 +- check - 检查JS文件的语法和潜在问题 +- preview - 在默认浏览器中打开预览 +- analyze <项目路径> - 分析整个前端项目的结构和问题 + +支持的检查项: +✓ HTML语法验证 +✓ CSS语法和兼容性检查 +✓ JavaScript语法检查 +✓ 响应式设计检查 +✓ 可访问性检查 +✓ 性能优化建议""" + self.cli = None + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 注册插件命令 + self.cli.register_liugin_command('ftest', self.command_handler) + self.cli.register_liugin_command('frontend', self.command_handler) + + def command_handler(self, args): + """处理 /ftest 或 /frontend 命令""" + return self.handle(args) + + def get_tool_info(self): + return { + "name": "frontend_tester", + "description": "前端测试验证工具,支持HTML/CSS/JS自动化测试和UI验证,包括语法检查、响应式设计、可访问性检查等", + "keywords": ["前端", "测试", "HTML", "CSS", "JavaScript", "UI", "验证", "preview", "validate", "frontend", "test"], + "usage": self.usage + } + + def handle(self, args): + """处理前端测试请求""" + try: + parts = args.strip().split(maxsplit=1) + if not parts: + return "错误:请提供操作类型和参数。使用 'frontend_tester help' 查看帮助" + + operation = parts[0].lower() + + if operation == "help": + return self.usage + + elif operation == "validate": + # 验证HTML文件 + if len(parts) < 2: + return "错误:请提供HTML文件路径。格式: validate " + file_path = parts[1] + return self._validate_html(file_path) + + elif operation == "test": + # 测试CSS文件 + if len(parts) < 2: + return "错误:请提供CSS文件路径。格式: test " + file_path = parts[1] + return self._test_css(file_path) + + elif operation == "check": + # 检查JS文件 + if len(parts) < 2: + return "错误:请提供JS文件路径。格式: check " + file_path = parts[1] + return self._check_js(file_path) + + elif operation == "preview": + # 在浏览器中预览 + if len(parts) < 2: + return "错误:请提供HTML文件路径。格式: preview " + file_path = parts[1] + return self._preview_in_browser(file_path) + + elif operation == "analyze": + # 分析整个项目 + if len(parts) < 2: + return "错误:请提供项目路径。格式: analyze <项目路径>" + project_path = parts[1] + return self._analyze_project(project_path) + + else: + return f"错误:不支持的操作 '{operation}'。支持的操作有: validate, test, check, preview, analyze, help" + + except Exception as e: + return f"前端测试操作错误: {str(e)}" + + def _validate_html(self, file_path): + """验证HTML文件""" + if not os.path.exists(file_path): + return f"❌ 文件不存在: {file_path}" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + result = f"\n🧪 HTML验证结果: {file_path}\n" + result += "=" * 60 + "\n\n" + + # 基本检查 + issues = [] + + # 检查DOCTYPE + if not content.strip().startswith('" + }) + + # 检查lang属性 + if '' not in content: + issues.append({ + "level": "error", + "message": "缺少标签" + }) + + # 统计元素 + div_count = content.count('<div') + img_count = content.count('<img') + img_alt_count = content.count('alt=') + link_count = content.count('<link') + script_count = content.count('<script') + + result += f"📊 文件统计:\n" + result += f" - div元素: {div_count}\n" + result += f" - img元素: {img_count}\n" + result += f" - img alt属性: {img_alt_count}\n" + result += f" - link标签: {link_count}\n" + result += f" - script标签: {script_count}\n\n" + + # 检查img alt属性 + if img_count > 0 and img_alt_count < img_count: + issues.append({ + "level": "warning", + "message": f"有 {img_count - img_alt_count} 个img元素缺少alt属性" + }) + + # 显示问题 + if issues: + result += f"⚠️ 发现 {len(issues)} 个问题:\n\n" + for i, issue in enumerate(issues, 1): + level = issue['level'] + if level == 'error': + icon = f"{Fore.RED}❌{Style.RESET_ALL}" + elif level == 'warning': + icon = f"{Fore.YELLOW}⚠️{Style.RESET_ALL}" + else: + icon = f"{Fore.GREEN}ℹ️{Style.RESET_ALL}" + result += f"{icon} {i}. {issue['message']}\n" + else: + result += f"{Fore.GREEN}✅ 未发现问题{Style.RESET_ALL}\n" + + result += "\n" + "=" * 60 + "\n" + + return result + + except UnicodeDecodeError: + return "❌ 文件编码问题,无法读取" + except Exception as e: + return f"❌ 验证失败: {str(e)}" + + def _test_css(self, file_path): + """测试CSS文件""" + if not os.path.exists(file_path): + return f"❌ 文件不存在: {file_path}" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + result = f"\n🧪 CSS测试结果: {file_path}\n" + result += "=" * 60 + "\n\n" + + issues = [] + + # 检查常见的CSS问题 + if content.count('{') != content.count('}'): + issues.append({ + "level": "error", + "message": "大括号不匹配" + }) + + # 检查颜色使用 + if '#000000' in content or '#ffffff' in content: + issues.append({ + "level": "info", + "message": "使用了纯黑或纯白,建议使用更柔和的颜色" + }) + + # 检查单位使用 + if content.count('px') > 10 and 'rem' not in content and 'em' not in content: + issues.append({ + "level": "warning", + "message": "大量使用px单位,建议考虑使用rem或em以支持更好的缩放" + }) + + # 统计 + rule_count = content.count('{') + selector_count = len([line for line in content.split('\n') if '{' in line]) + + result += f"📊 文件统计:\n" + result += f" - CSS规则: {rule_count}\n" + result += f" - 选择器: {selector_count}\n\n" + + # 显示问题 + if issues: + result += f"⚠️ 发现 {len(issues)} 个问题:\n\n" + for i, issue in enumerate(issues, 1): + level = issue['level'] + if level == 'error': + icon = f"{Fore.RED}❌{Style.RESET_ALL}" + elif level == 'warning': + icon = f"{Fore.YELLOW}⚠️{Style.RESET_ALL}" + else: + icon = f"{Fore.GREEN}ℹ️{Style.RESET_ALL}" + result += f"{icon} {i}. {issue['message']}\n" + else: + result += f"{Fore.GREEN}✅ 未发现问题{Style.RESET_ALL}\n" + + result += "\n" + "=" * 60 + "\n" + + return result + + except UnicodeDecodeError: + return "❌ 文件编码问题,无法读取" + except Exception as e: + return f"❌ 测试失败: {str(e)}" + + def _check_js(self, file_path): + """检查JS文件""" + if not os.path.exists(file_path): + return f"❌ 文件不存在: {file_path}" + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + result = f"\n🧪 JavaScript检查结果: {file_path}\n" + result += "=" * 60 + "\n\n" + + issues = [] + + # 检查常见问题 + if 'var ' in content: + issues.append({ + "level": "warning", + "message": "使用了var声明,建议使用let或const" + }) + + if '==' in content and '!==' not in content: + issues.append({ + "level": "warning", + "message": "使用了==比较,建议使用===严格相等" + }) + + if content.count('console.log') > 5: + issues.append({ + "level": "info", + "message": f"有{content.count('console.log')}个console.log,生产环境建议移除" + }) + + if 'eval(' in content: + issues.append({ + "level": "error", + "message": "使用了eval(),存在安全风险" + }) + + # 统计 + line_count = len(content.split('\n')) + function_count = content.count('function ') + + result += f"📊 文件统计:\n" + result += f" - 代码行数: {line_count}\n" + result += f" - 函数数量: {function_count}\n\n" + + # 显示问题 + if issues: + result += f"⚠️ 发现 {len(issues)} 个问题:\n\n" + for i, issue in enumerate(issues, 1): + level = issue['level'] + if level == 'error': + icon = f"{Fore.RED}❌{Style.RESET_ALL}" + elif level == 'warning': + icon = f"{Fore.YELLOW}⚠️{Style.RESET_ALL}" + else: + icon = f"{Fore.GREEN}ℹ️{Style.RESET_ALL}" + result += f"{icon} {i}. {issue['message']}\n" + else: + result += f"{Fore.GREEN}✅ 未发现问题{Style.RESET_ALL}\n" + + result += "\n" + "=" * 60 + "\n" + + return result + + except UnicodeDecodeError: + return "❌ 文件编码问题,无法读取" + except Exception as e: + return f"❌ 检查失败: {str(e)}" + + def _preview_in_browser(self, file_path): + """在浏览器中预览""" + if not os.path.exists(file_path): + return f"❌ 文件不存在: {file_path}" + + try: + # Windows系统 + os.startfile(file_path) + return f"✅ 已在默认浏览器中打开: {file_path}" + except Exception as e: + return f"❌ 无法打开浏览器: {str(e)}" + + def _analyze_project(self, project_path): + """分析整个前端项目""" + if not os.path.exists(project_path): + return f"❌ 项目路径不存在: {project_path}" + + result = f"\n🧪 前端项目分析: {project_path}\n" + result += "=" * 60 + "\n\n" + + try: + # 统计文件 + html_files = [] + css_files = [] + js_files = [] + + for root, dirs, files in os.walk(project_path): + for file in files: + if file.endswith('.html'): + html_files.append(os.path.join(root, file)) + elif file.endswith('.css'): + css_files.append(os.path.join(root, file)) + elif file.endswith('.js'): + js_files.append(os.path.join(root, file)) + + result += f"📊 项目文件统计:\n" + result += f" - HTML文件: {len(html_files)}\n" + result += f" - CSS文件: {len(css_files)}\n" + result += f" - JavaScript文件: {len(js_files)}\n\n" + + # 建议检查 + result += f"💡 建议检查:\n" + + if html_files: + result += f" - HTML文件:\n" + for html_file in html_files[:3]: # 只显示前3个 + result += f" • {os.path.basename(html_file)}\n" + if len(html_files) > 3: + result += f" • ... 还有 {len(html_files) - 3} 个文件\n" + + if css_files: + result += f" - CSS文件:\n" + for css_file in css_files[:3]: + result += f" • {os.path.basename(css_file)}\n" + if len(css_files) > 3: + result += f" • ... 还有 {len(css_files) - 3} 个文件\n" + + if js_files: + result += f" - JavaScript文件:\n" + for js_file in js_files[:3]: + result += f" • {os.path.basename(js_file)}\n" + if len(js_files) > 3: + result += f" • ... 还有 {len(js_files) - 3} 个文件\n" + + result += f"\n{Fore.GREEN}✅ 分析完成{Style.RESET_ALL}\n" + result += "\n" + "=" * 60 + "\n" + + return result + + except Exception as e: + return f"❌ 分析失败: {str(e)}" + + +# 测试函数 +def test_plugin(): + """测试插件功能""" + plugin = Plugin() + print("前端测试验证插件测试:") + + print("\n1. 创建测试HTML文件:") + test_html = """<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>测试页面 + + +

测试

+ +""" + with open('test.html', 'w', encoding='utf-8') as f: + f.write(test_html) + + print("\n2. 验证HTML:") + print(plugin.handle("validate test.html")) + + print("\n3. 在浏览器中预览:") + print(plugin.handle("preview test.html")) + + print("\n4. 分析项目:") + print(plugin.handle("analyze .")) + + # 清理测试文件 + if os.path.exists('test.html'): + os.remove('test.html') + + +if __name__ == "__main__": + test_plugin() \ No newline at end of file diff --git a/plugins/git_tools.py b/plugins/git_tools.py new file mode 100644 index 0000000..ce75c95 --- /dev/null +++ b/plugins/git_tools.py @@ -0,0 +1,346 @@ +""" +Git 集成插件 - 版本控制能力 +对标 Claude Code 的 Git 工作流支持 +""" +import os +import subprocess +from typing import Optional, Tuple + + +class Plugin: + """Git 集成工具 - 提供完整的 Git 操作支持""" + + def __init__(self): + self.usage = """Git 集成工具 + +操作类型: + status - 查看仓库状态 + diff [文件路径] - 查看未暂存的变更 + diff --cached - 查看已暂存的变更 + log [数量] - 查看提交历史 + log --oneline [数量] - 查看简洁提交历史 + add <文件路径> - 暂存文件 + add . - 暂存所有变更 + commit <提交信息> - 提交变更 + branch - 查看分支列表 + branch <名称> - 创建新分支 + checkout <分支名> - 切换分支 + checkout -b <分支名> - 创建并切换分支 + stash - 暂存当前变更 + stash pop - 恢复暂存的变更 + show - 查看指定提交 + blame <文件路径> - 查看文件逐行修改记录 + remote - 查看远程仓库 + init - 初始化仓库 + clone [目录] - 克隆仓库 +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "git_tools", + "description": "Git 版本控制工具 - 查看状态、提交、分支管理、查看历史等", + "keywords": ["git", "版本控制", "提交", "分支", "commit", "branch", "status"], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "git_tools", + "description": "Git 版本控制工具", + "inputSchema": { + "type": "object", + "properties": { + "operation": {"type": "string", "description": "Git 操作"}, + "args": {"type": "string", "description": "操作参数"}, + "cwd": {"type": "string", "description": "工作目录"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments): + op = arguments.get("operation", "") + args = arguments.get("args", "") + return f"{op} {args}".strip() + + def _run_git(self, args: list, cwd: Optional[str] = None) -> Tuple[bool, str]: + """执行 git 命令""" + try: + result = subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + cwd=cwd, + timeout=30 + ) + output = result.stdout + if result.stderr: + output += result.stderr + return result.returncode == 0, output.strip() + except FileNotFoundError: + return False, "错误:git 未安装。请先安装 Git。" + except subprocess.TimeoutExpired: + return False, "错误:Git 命令执行超时" + except Exception as e: + return False, f"错误:{str(e)}" + + def handle(self, args: str) -> str: + try: + parts = args.strip().split(maxsplit=1) + if not parts: + return self._git_status("") + + operation = parts[0].lower() + rest = parts[1] if len(parts) > 1 else "" + + # 获取工作目录 + cwd = None + if self.cli and hasattr(self.cli, 'current_dir'): + cwd = self.cli.current_dir + + handlers = { + "status": self._git_status, + "diff": self._git_diff, + "log": self._git_log, + "add": self._git_add, + "commit": self._git_commit, + "branch": self._git_branch, + "checkout": self._git_checkout, + "stash": self._git_stash, + "show": self._git_show, + "blame": self._git_blame, + "remote": self._git_remote, + "init": self._git_init, + "clone": self._git_clone, + } + + handler = handlers.get(operation) + if not handler: + # 尝试直接执行 git 命令 + return self._git_raw(args, cwd) + + return handler(rest, cwd) + + except Exception as e: + return f"Git 工具错误: {str(e)}" + + def _git_status(self, args: str, cwd=None) -> str: + ok, output = self._run_git(["status", "--porcelain"], cwd) + if not ok: + return f"Git 错误: {output}" + + if not output: + return "✅ 工作区干净,没有未提交的变更" + + # 解析状态 + lines = output.split('\n') + staged = [] + unstaged = [] + untracked = [] + + for line in lines: + if len(line) < 4: + continue + status = line[:2] + filename = line[3:] + + if status[0] in ('M', 'A', 'D', 'R', 'C'): + staged.append(f" ✅ {status[0]} {filename}") + if status[1] in ('M', 'D'): + unstaged.append(f" ⚠️ {status[1]} {filename}") + if status == '??': + untracked.append(f" 🆕 {filename}") + + result = ["📊 Git 状态:"] + if staged: + result.append(f"\n已暂存 ({len(staged)}):") + result.extend(staged) + if unstaged: + result.append(f"\n未暂存 ({len(unstaged)}):") + result.extend(unstaged) + if untracked: + result.append(f"\n未跟踪 ({len(untracked)}):") + result.extend(untracked) + + # 获取分支信息 + ok2, branch = self._run_git(["branch", "--show-current"], cwd) + if ok2 and branch: + result.insert(1, f"🌿 分支: {branch}") + + return '\n'.join(result) + + def _git_diff(self, args: str, cwd=None) -> str: + if args.strip() == "--cached": + ok, output = self._run_git(["diff", "--cached"], cwd) + elif args.strip(): + ok, output = self._run_git(["diff", args.strip()], cwd) + else: + ok, output = self._run_git(["diff"], cwd) + + if not ok: + return f"Git 错误: {output}" + if not output: + return "没有变更" + + # 限制输出长度 + if len(output) > 5000: + output = output[:5000] + f"\n... (输出截断,共 {len(output)} 字符)" + + return f"📝 变更:\n{output}" + + def _git_log(self, args: str, cwd=None) -> str: + parts = args.split() + oneline = "--oneline" in parts + count = 10 + + for p in parts: + if p.isdigit(): + count = int(p) + + if oneline: + ok, output = self._run_git( + ["log", "--oneline", f"-{count}", "--graph", "--decorate"], cwd) + else: + ok, output = self._run_git( + ["log", f"-{count}", "--format=%h %an %ad %s", "--date=short"], cwd) + + if not ok: + return f"Git 错误: {output}" + if not output: + return "暂无提交历史" + + return f"📜 提交历史:\n{output}" + + def _git_add(self, args: str, cwd=None) -> str: + target = args.strip() + if not target: + return "错误:请提供文件路径(使用 . 暂存所有)" + + ok, output = self._run_git(["add", target], cwd) + if not ok: + return f"Git add 失败: {output}" + + return f"✅ 已暂存: {target}" + + def _git_commit(self, args: str, cwd=None) -> str: + message = args.strip() + if not message: + return "错误:请提供提交信息" + + ok, output = self._run_git(["commit", "-m", message], cwd) + if not ok: + return f"Git commit 失败: {output}" + + return f"✅ 提交成功:\n{output}" + + def _git_branch(self, args: str, cwd=None) -> str: + if not args.strip(): + ok, output = self._run_git(["branch", "-a"], cwd) + if not ok: + return f"Git 错误: {output}" + return f"🌿 分支列表:\n{output}" + else: + ok, output = self._run_git(["branch", args.strip()], cwd) + if not ok: + return f"创建分支失败: {output}" + return f"✅ 已创建分支: {args.strip()}" + + def _git_checkout(self, args: str, cwd=None) -> str: + if args.startswith("-b "): + branch = args[3:].strip() + ok, output = self._run_git(["checkout", "-b", branch], cwd) + else: + branch = args.strip() + ok, output = self._run_git(["checkout", branch], cwd) + + if not ok: + return f"切换分支失败: {output}" + return f"✅ 已切换到分支: {branch}" + + def _git_stash(self, args: str, cwd=None) -> str: + if args.strip() == "pop": + ok, output = self._run_git(["stash", "pop"], cwd) + else: + ok, output = self._run_git(["stash"], cwd) + + if not ok: + return f"Git stash 失败: {output}" + return f"✅ {output}" + + def _git_show(self, args: str, cwd=None) -> str: + commit = args.strip() or "HEAD" + ok, output = self._run_git(["show", "--stat", commit], cwd) + if not ok: + return f"Git show 失败: {output}" + + if len(output) > 3000: + output = output[:3000] + "\n... (输出截断)" + return output + + def _git_blame(self, args: str, cwd=None) -> str: + path = args.strip() + if not path: + return "错误:请提供文件路径" + + ok, output = self._run_git(["blame", "--line-porcelain", path], cwd) + if not ok: + return f"Git blame 失败: {output}" + + # 简化输出 + lines = output.split('\n') + simplified = [] + for line in lines: + if line.startswith('author '): + author = line[7:] + elif line.startswith('\t'): + simplified.append(f" {author}: {line[1:]}") + + if len(simplified) > 100: + simplified = simplified[:100] + simplified.append(f" ... (共 {len(simplified)} 行)") + + return f"📝 {path} 修改记录:\n" + '\n'.join(simplified) + + def _git_remote(self, args: str, cwd=None) -> str: + ok, output = self._run_git(["remote", "-v"], cwd) + if not ok: + return f"Git 错误: {output}" + if not output: + return "没有配置远程仓库" + return f"🌐 远程仓库:\n{output}" + + def _git_init(self, args: str, cwd=None) -> str: + path = args.strip() or "." + ok, output = self._run_git(["init", path], cwd) + if not ok: + return f"Git init 失败: {output}" + return f"✅ {output}" + + def _git_clone(self, args: str, cwd=None) -> str: + parts = args.split() + if not parts: + return "错误:请提供仓库 URL" + + url = parts[0] + target = parts[1] if len(parts) > 1 else "" + + cmd = ["clone", url] + if target: + cmd.append(target) + + ok, output = self._run_git(cmd, cwd) + if not ok: + return f"Git clone 失败: {output}" + return f"✅ 克隆成功:\n{output}" + + def _git_raw(self, args: str, cwd=None) -> str: + """直接执行 git 命令""" + parts = args.split() + ok, output = self._run_git(parts, cwd) + if not ok: + return f"Git 错误: {output}" + return output diff --git a/plugins/napcat_qq.py b/plugins/napcat_qq.py new file mode 100644 index 0000000..acbbe77 --- /dev/null +++ b/plugins/napcat_qq.py @@ -0,0 +1,1216 @@ +""" +NapCat QQ 机器人插件 - OneBot 11 协议 +通过 NapCat 框架接入 QQ,支持消息收发、群管理等操作 + +集成功能: +- 自动下载安装 NapCat 到插件目录 +- 启动/停止服务 +- 消息收发 +- 群管理 +""" + +import os +import json +import subprocess +import requests +import threading +import time +import base64 +from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import Optional, Dict, Any, List + +# NapCat 版本 +NAPCAT_VERSION = "v4.17.52" +NAPCAT_DOWNLOAD_URL = f"https://github.com/NapNeko/NapCatQQ/releases/download/{NAPCAT_VERSION}/NapCat.Shell.Windows.OneKey.zip" + + +class Liugin: + """NapCat QQ 机器人插件""" + + def __init__(self): + # 获取插件目录 + self.plugin_dir = os.path.dirname(os.path.abspath(__file__)) + self.napcat_dir = os.path.join(self.plugin_dir, "napcat") + + # NapCat 连接配置 + self.host = "127.0.0.1" + self.port = 3000 + self.token = "" + self.timeout = 10 + self._process = None + + # 消息监听配置 + self.listen_port = 3100 # 消息监听端口 + self.listen_server = None + self.listen_thread = None + self.login_user_id = None # 当前登录的QQ号 + self._monitor_running = False # 监控线程运行标志 + + # 自动加载配置 + self._load_config() + + # 启动后台监控线程 + self._start_monitor_thread() + + self.usage = """NapCat QQ机器人使用方法: +napcat_qq <操作> <参数> + +【重要】发送群消息流程: +1. 先用 group_list 获取群列表和群号 +2. 再用 send_group <群号> <消息> 发送 + +【自动回复】监听@消息: +1. start_listen [端口] - 启动消息监听(默认3100端口) +2. 在 WebUI 配置 HTTP 上报地址: http://127.0.0.1:3100 +3. 群内有人@机器人时,消息自动转发给AI + +安装管理: +- install - 下载安装 NapCat +- start - 启动 NapCat +- stop - 停止 NapCat +- status - 查看运行状态 + +信息获取: +- group_list - 获取群列表(包含群号) +- friend_list - 获取好友列表 +- login_info - 获取登录号信息 +- group_notice <群号> - 获取群公告 +- get_msg <消息ID> - 获取消息详情 +- get_chat_history <群号> - 获取群聊天记录 + +消息操作: +- send_group <群号> <消息> - 发送群消息 +- send_private <消息> - 发送私聊消息 +- send_image <群号/QQ号> <图片路径> [true/false] - 发送图片 +- recall <消息ID> - 撤回消息 +- send_forward <群号> <消息> - 发送合并转发消息 +- get_forward_msg <消息ID> - 获取合并转发消息内容 + +群管理: +- ban <群号> [时长] - 禁言成员 +- kick <群号> - 踢出群成员 +- set_admin <群号> - 设置群管理员 +- unset_admin <群号> - 取消群管理员 +- set_title <群号> <头衔> - 设置群专属头衔 +- whole_ban <群号> [true/false] - 全群禁言/解除 +- leave <群号> - 退出群聊 + +请求处理: +- set_friend [accept/reject] - 处理好友请求 +- set_group [accept/reject] - 处理群邀请 + +系统功能: +- version - 获取 OneBot 版本信息 +- restart - 重启 OneBot 实现 + +示例: +- napcat_qq start_listen # 启动监听 +- napcat_qq group_list # 获取群号 +- napcat_qq send_group 987654321 大家好 # 发群消息 +- napcat_qq set_admin 987654321 123456 # 设置管理员 +- napcat_qq whole_ban 987654321 true # 开启全群禁言 +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + # 后台监控线程会自动检测 NapCat 并启动监听 + + def _load_config(self): + """自动加载 NapCat OneBot11 配置""" + try: + # 查找 onebot11 配置文件 + config_dir = os.path.join(self.napcat_dir, "NapCat.44498.Shell", "versions") + if not os.path.exists(config_dir): + return + + # 查找版本目录 + for item in os.listdir(config_dir): + version_dir = os.path.join(config_dir, item) + if os.path.isdir(version_dir): + napcat_config = os.path.join(version_dir, "resources", "app", "napcat", "config") + if os.path.exists(napcat_config): + # 查找 onebot11_*.json + for f in os.listdir(napcat_config): + if f.startswith("onebot11_") and f.endswith(".json"): + config_path = os.path.join(napcat_config, f) + with open(config_path, 'r', encoding='utf-8') as fp: + config = json.load(fp) + + network = config.get("network", {}) + http_servers = network.get("httpServers", []) + for server in http_servers: + if server.get("enable"): + self.host = server.get("host", "127.0.0.1") + self.port = server.get("port", 3000) + self.token = server.get("token", "") + print(f"[NapCat] 已加载配置: {self.host}:{self.port}, token={self.token[:8]}...") + return + except Exception as e: + print(f"[NapCat] 加载配置失败: {e}") + + def _start_monitor_thread(self): + """启动后台监控线程,定期检查 NapCat 状态并启动监听""" + def monitor_loop(): + while self._monitor_running: + try: + # 检查 NapCat API 是否可用 + result = self._call_api("get_status") + if result.get("success"): + # 获取登录信息 + if not self.login_user_id: + login_result = self._call_api("get_login_info") + if login_result.get("success"): + self.login_user_id = str(login_result["data"].get("user_id", "")) + print(f"[NapCat] 已登录账号: {self.login_user_id}") + + # 如果监听服务未启动,启动它 + if not self.listen_server: + self.start_listener(self.listen_port) + print(f"[NapCat] 监听服务已启动,端口: {self.listen_port}") + except Exception as e: + # 静默处理监控循环中的错误,避免刷屏 + pass + + # 每5秒检查一次 + time.sleep(5) + + self._monitor_running = True + self._monitor_thread = threading.Thread(target=monitor_loop, daemon=True) + self._monitor_thread.start() + print(f"[NapCat] 后台监控线程已启动") + + def get_tool_info(self): + return { + "name": "napcat_qq", + "description": "NapCat QQ机器人插件,通过OneBot协议接入QQ,支持自动安装、消息收发、群管理等操作", + "keywords": ["qq", "机器人", "bot", "napcat", "onebot", "消息", "群管理"], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "napcat_qq", + "description": "NapCat QQ机器人插件,通过OneBot协议接入QQ,支持消息收发、群管理、图片发送、@消息自动转发AI等功能", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["install", "start", "stop", "status", "webui", + "send_private", "send_group", "send_image", "recall", + "login_info", "friend_list", "group_list", + "group_members", "group_notice", "get_msg", "get_chat_history", + "ban", "unban", "kick", "set", "start_listen", "stop_listen"], + "description": "操作类型" + }, + "user_id": {"type": "string", "description": "QQ号"}, + "group_id": {"type": "string", "description": "群号"}, + "message": {"type": "string", "description": "消息内容"}, + "message_id": {"type": "string", "description": "消息ID"}, + "image_path": {"type": "string", "description": "图片文件路径"}, + "is_group": {"type": "boolean", "description": "是否群聊(发送图片时)"}, + "duration": {"type": "integer", "description": "时长(秒)"}, + "port": {"type": "integer", "description": "监听端口"}, + "host": {"type": "string", "description": "服务地址"}, + "token": {"type": "string", "description": "访问令牌"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments: Dict[str, Any]) -> str: + op = arguments.get("operation", "") + parts = [op] + if op == "set": + parts.extend([arguments.get("host", ""), str(arguments.get("port", 3000)), arguments.get("token", "")]) + elif op == "send_private": + parts.extend([arguments.get("user_id", ""), arguments.get("message", "")]) + elif op == "send_group": + parts.extend([arguments.get("group_id", ""), arguments.get("message", "")]) + elif op == "send_image": + parts.extend([arguments.get("group_id", "") or arguments.get("user_id", ""), + arguments.get("image_path", ""), + "true" if arguments.get("is_group") else "false"]) + elif op in ("ban", "unban", "kick"): + parts.extend([arguments.get("group_id", ""), arguments.get("user_id", "")]) + if op == "ban" and "duration" in arguments: + parts.append(str(arguments["duration"])) + elif op == "group_members": + parts.append(arguments.get("group_id", "")) + elif op == "group_notice": + parts.append(arguments.get("group_id", "")) + elif op == "get_msg": + parts.append(arguments.get("message_id", "")) + elif op == "get_chat_history": + parts.append(arguments.get("group_id", "")) + elif op == "start_listen": + if "port" in arguments: + parts.append(str(arguments["port"])) + return " ".join(filter(None, parts)) + + def handle(self, args: str) -> str: + try: + parts = self._parse_args(args) + if not parts: + return "错误:请提供操作类型。\n可用操作: install, start, stop, status, webui, send_private, send_group, ..." + + operation = parts[0].lower() + + handlers = { + "install": self._op_install, + "start": self._op_start, + "stop": self._op_stop, + "status": self._op_status, + "webui": self._op_webui, + "set": self._op_set, + "test": self._op_status, + "send_private": self._op_send_private, + "send_group": self._op_send_group, + "send_image": self._op_send_image, + "recall": self._op_recall, + "login_info": self._op_login_info, + "friend_list": self._op_friend_list, + "group_list": self._op_group_list, + "group_info": self._op_group_info, + "group_members": self._op_group_members, + "group_notice": self._op_group_notice, + "get_msg": self._op_get_msg, + "get_chat_history": self._op_get_chat_history, + "user_info": self._op_user_info, + "ban": self._op_ban, + "unban": self._op_unban, + "kick": self._op_kick, + "set_card": self._op_set_card, + "set_group_name": self._op_set_group_name, + "like": self._op_like, + "poke": self._op_poke, + # 新增群管理功能 + "set_admin": self._op_set_admin, + "unset_admin": self._op_unset_admin, + "set_group_special_title": self._op_set_special_title, + "set_whole_ban": self._op_set_whole_ban, + "leave_group": self._op_leave_group, + # 新增请求处理功能 + "set_friend_add_request": self._op_set_friend_add_request, + "set_group_add_request": self._op_set_group_add_request, + # 新增消息功能 + "send_forward": self._op_send_forward, + "get_forward_msg": self._op_get_forward_msg, + # 新增系统功能 + "version": self._op_version, + "restart": self._op_restart, + # 监听 + "start_listen": self._op_start_listen, + "stop_listen": self._op_stop_listen, + } + + if operation in handlers: + return handlers[operation](parts[1:]) + else: + return f"错误:未知操作 '{operation}'\n可用操作: " + ", ".join(handlers.keys()) + + except Exception as e: + return f"处理请求失败: {e}" + + def _parse_args(self, args: str) -> List[str]: + parts = [] + current = "" + in_quotes = False + quote_char = None + for char in args: + if char in ('"', "'") and not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char and in_quotes: + in_quotes = False + quote_char = None + elif char == ' ' and not in_quotes: + if current: + parts.append(current) + current = "" + else: + current += char + if current: + parts.append(current) + return parts + + # ==================== 安装管理 ==================== + + def _op_install(self, args: List[str]) -> str: + """下载并安装 NapCat 到插件目录""" + zip_path = os.path.join(self.plugin_dir, "NapCat.Shell.Windows.OneKey.zip") + + # 检查是否已安装 - 更新路径检测 + installer_exe = os.path.join(self.napcat_dir, "NapCatInstaller.exe") + boot_exe = os.path.join(self.napcat_dir, "NapCat.44498.Shell", "NapCatWinBootMain.exe") + boot_exe_alt = os.path.join(self.napcat_dir, "bootmain", "NapCatWinBootMain.exe") + + if os.path.exists(installer_exe) or os.path.exists(boot_exe) or os.path.exists(boot_exe_alt): + return f"✅ NapCat 已安装于: {self.napcat_dir}\n运行 'napcat_qq start' 启动服务" + + try: + # 创建目录 + os.makedirs(self.napcat_dir, exist_ok=True) + + # 下载 + print(f"正在下载 NapCat {NAPCAT_VERSION}...") + print(f"下载地址: {NAPCAT_DOWNLOAD_URL}") + print(f"保存到: {zip_path}") + + import urllib.request + urllib.request.urlretrieve(NAPCAT_DOWNLOAD_URL, zip_path) + print("下载完成") + + # 解压 + print(f"正在解压到: {self.napcat_dir}") + import zipfile + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(self.napcat_dir) + + # 清理zip + os.remove(zip_path) + + return f"✅ NapCat 安装完成!\n安装目录: {self.napcat_dir}\n\n下一步:\n1. 运行 'napcat_qq start' 启动服务\n2. 在弹出的窗口中登录 QQ\n3. 访问 WebUI (http://127.0.0.1:6099) 配置 OneBot" + + except Exception as e: + return f"❌ 安装失败: {e}\n请手动下载: {NAPCAT_DOWNLOAD_URL}\n解压到: {self.napcat_dir}" + + def _op_start(self, args: List[str]) -> str: + """启动 NapCat""" + # 查找启动程序 - 优先查找 NapCat.44498.Shell 目录 + boot_exe = os.path.join(self.napcat_dir, "NapCat.44498.Shell", "NapCatWinBootMain.exe") + boot_exe_alt = os.path.join(self.napcat_dir, "bootmain", "NapCatWinBootMain.exe") + installer_exe = os.path.join(self.napcat_dir, "NapCatInstaller.exe") + + if os.path.exists(boot_exe): + exe_path = boot_exe + work_dir = os.path.dirname(boot_exe) + elif os.path.exists(boot_exe_alt): + exe_path = boot_exe_alt + work_dir = os.path.dirname(boot_exe_alt) + elif os.path.exists(installer_exe): + exe_path = installer_exe + work_dir = os.path.dirname(installer_exe) + else: + return f"❌ 未找到 NapCat,请先运行 'napcat_qq install' 安装\n预期路径: {self.napcat_dir}" + + try: + # 后台启动 + self._process = subprocess.Popen( + [exe_path], + cwd=work_dir, + creationflags=subprocess.CREATE_NEW_CONSOLE if os.name == 'nt' else 0 + ) + + # 等待几秒后检查是否成功启动并尝试启动监听 + time.sleep(3) + self._try_start_listener() + + return f"✅ NapCat 已启动!\n- PID: {self._process.pid}\n- 安装目录: {self.napcat_dir}\n- WebUI: http://127.0.0.1:6099\n- HTTP API: http://127.0.0.1:3000\n\n请在弹出的窗口中登录 QQ" + except Exception as e: + return f"❌ 启动失败: {e}" + + def _try_start_listener(self): + """尝试启动监听服务(后台线程中重试)""" + def _retry_start(): + for i in range(10): # 尝试 10 次 + try: + time.sleep(2) # 等待 2 秒 + result = self._call_api("get_status") + if result.get("success"): + # 获取登录信息 + login_result = self._call_api("get_login_info") + if login_result.get("success"): + self.login_user_id = str(login_result["data"].get("user_id", "")) + + # 启动监听 + if not self.listen_server: + if self.start_listener(self.listen_port): + print(f"[NapCat] 监听服务启动成功,端口: {self.listen_port}") + return + except Exception as e: + print(f"[NapCat] 尝试启动监听 ({i+1}/10): {e}") + print("[NapCat] 监听服务启动失败,请手动运行 napcat_qq start_listen") + + # 在后台线程中启动 + thread = threading.Thread(target=_retry_start, daemon=True) + thread.start() + + def _op_stop(self, args: List[str]) -> str: + """停止 NapCat""" + try: + if os.name == 'nt': + subprocess.run(["taskkill", "/F", "/IM", "NapCatWinBootMain.exe"], + capture_output=True, check=False) + subprocess.run(["taskkill", "/F", "/IM", "QQ.exe"], + capture_output=True, check=False) + return "✅ NapCat 已停止" + except Exception as e: + return f"❌ 停止失败: {e}" + + def _op_status(self, args: List[str]) -> str: + """查看运行状态""" + # 检查安装状态 - 更新路径检测 + installer_exe = os.path.join(self.napcat_dir, "NapCatInstaller.exe") + boot_exe = os.path.join(self.napcat_dir, "NapCat.44498.Shell", "NapCatWinBootMain.exe") + boot_exe_alt = os.path.join(self.napcat_dir, "bootmain", "NapCatWinBootMain.exe") + installed = os.path.exists(installer_exe) or os.path.exists(boot_exe) or os.path.exists(boot_exe_alt) + + # 检查进程 + try: + result = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq NapCatWinBootMain.exe"], + capture_output=True, text=True, check=False + ) + running = "NapCatWinBootMain.exe" in result.stdout + except: + running = False + + # 测试 API 连接 + try: + resp = requests.get(f"http://{self.host}:{self.port}/get_status", timeout=3) + api_ok = resp.status_code == 200 + data = resp.json().get("data", {}) + except: + api_ok = False + data = {} + + lines = ["📊 NapCat 状态"] + lines.append(f"- 安装: {'✅ 已安装' if installed else '❌ 未安装'}") + if installed: + lines.append(f"- 目录: {self.napcat_dir}") + lines.append(f"- 进程: {'✅ 运行中' if running else '❌ 未运行'}") + lines.append(f"- API: {'✅ 可用' if api_ok else '❌ 不可用'} ({self.host}:{self.port})") + + if api_ok and data: + lines.append(f"- 在线: {'是' if data.get('online') else '否'}") + lines.append(f"- 状态: {data.get('status', '未知')}") + + return "\n".join(lines) + + def _op_webui(self, args: List[str]) -> str: + """打开 WebUI""" + import webbrowser + webbrowser.open("http://127.0.0.1:6099") + return "✅ 已打开 WebUI: http://127.0.0.1:6099\n密码请在 NapCat 控制台中查看" + + # ==================== 消息监听 ==================== + + def start_listener(self, port: int = 3100): + """启动消息监听服务""" + self.listen_port = port + + # 获取当前登录的QQ号 + result = self._call_api("get_login_info") + if result["success"]: + self.login_user_id = str(result["data"].get("user_id", "")) + + # 创建HTTP请求处理器 + plugin = self + class EventHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass # 禁用默认日志 + + def do_POST(self): + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode('utf-8') if content_length > 0 else "" + + # 打印原始请求便于调试 + print(f"[NapCat] 收到请求: length={content_length}, body={body[:200] if body else 'empty'}") + + # 始终返回 200,避免 NapCat 重试 + self.send_response(200) + self.end_headers() + + # 空请求体直接返回 + if not body.strip(): + self.wfile.write(b'{"status":"empty"}') + return + + try: + event = json.loads(body) + print(f"[NapCat] 事件类型: {event.get('post_type', 'unknown')}") + plugin._handle_event(event) + self.wfile.write(b'{"status":"ok"}') + except json.JSONDecodeError as e: + print(f"[NapCat] JSON解析错误: {e}") + self.wfile.write(b'{"status":"json_error"}') + except Exception as e: + print(f"[NapCat] 处理事件错误: {e}") + self.wfile.write(f'{{"status":"error"}}'.encode()) + + try: + self.listen_server = HTTPServer(('0.0.0.0', port), EventHandler) + self.listen_thread = threading.Thread(target=self.listen_server.serve_forever, daemon=True) + self.listen_thread.start() + print(f"[NapCat] 消息监听服务已启动,端口: {port}") + return True + except Exception as e: + print(f"[NapCat] 启动监听服务失败: {e}") + return False + + def stop_listener(self): + """停止消息监听服务""" + if self.listen_server: + self.listen_server.shutdown() + self.listen_server = None + print("[NapCat] 消息监听服务已停止") + + def _handle_event(self, event: Dict): + """处理上报事件""" + try: + post_type = event.get("post_type", "") + + # 只处理消息事件 + if post_type != "message": + return + + message_type = event.get("message_type", "") + + # 只处理群消息 + if message_type != "group": + return + + group_id = str(event.get("group_id", "")) + user_id = str(event.get("user_id", "")) + sender = event.get("sender", {}) + nickname = sender.get("nickname", sender.get("card", "未知")) + message = event.get("message", []) + + print(f"[NapCat] 群消息: 群={group_id}, 用户={nickname}({user_id})") + + # 检查是否@了机器人 + is_at_me = False + text_content = "" + + for msg_part in message: + if isinstance(msg_part, dict): + msg_type = msg_part.get("type", "") + msg_data = msg_part.get("data", {}) + + # 检查@消息 + if msg_type == "at": + at_qq = str(msg_data.get("qq", "")) + print(f"[NapCat] @消息: at_qq={at_qq}, login_user_id={self.login_user_id}") + if at_qq == self.login_user_id: + is_at_me = True + + # 提取文本内容(忽略文件) + elif msg_type == "text": + text_content += msg_data.get("text", "") + + # 忽略文件、图片等 + elif msg_type in ("file", "image", "video", "audio", "record"): + continue + + print(f"[NapCat] is_at_me={is_at_me}, text={text_content[:50] if text_content else 'empty'}") + + # 如果@了机器人,转发给AI + if is_at_me and text_content.strip(): + self._forward_to_ai(group_id, user_id, nickname, text_content.strip()) + except Exception as e: + print(f"[NapCat] _handle_event 错误: {e}") + + def _forward_to_ai(self, group_id: str, user_id: str, nickname: str, content: str): + """转发消息给AI""" + try: + print(f"[NapCat] 转发消息给AI: 群={group_id}, 内容={content[:30]}...") + + # 获取群名 + group_name = "未知群" + try: + result = self._call_api("get_group_info", params={"group_id": int(group_id)}) + if result["success"]: + group_name = result["data"].get("group_name", "未知群") + except: + pass + + # 构建转发消息 + forward_msg = f"[QQ群消息转发]\n群名: {group_name}\n群号: {group_id}\n发送者: {nickname}\nQQ号: {user_id}\n内容: {content}" + + print(f"[NapCat] 转发内容:\n{forward_msg}") + + # 直接调用 CLI 的接口发送给 AI + reply = None + if self.cli and hasattr(self.cli, 'handle_plugin_message'): + try: + reply = self.cli.handle_plugin_message(forward_msg, group_id=group_id, user_id=user_id) + print(f"[NapCat] AI回复: {reply[:50] if reply else 'None'}...") + except Exception as e: + print(f"[NapCat] AI处理失败: {e}") + reply = None + else: + print(f"[NapCat] CLI 不可用: cli={self.cli is not None}, has_handle={hasattr(self.cli, 'handle_plugin_message') if self.cli else False}") + + # 如果AI返回了回复,发送到群里 + if reply: + send_result = self._call_api("send_group_msg", data={ + "group_id": int(group_id), + "message": reply + }) + if send_result["success"]: + print(f"[NapCat] 已发送回复到群 {group_id}") + else: + print(f"[NapCat] 发送回复失败: {send_result.get('error')}") + except Exception as e: + print(f"[NapCat] _forward_to_ai 错误: {e}") + + # ==================== API 调用 ==================== + + def _get_base_url(self) -> str: + return f"http://{self.host}:{self.port}" + + def _call_api(self, endpoint: str, params: Dict = None, data: Dict = None) -> Dict: + url = f"{self._get_base_url()}/{endpoint}" + headers = {"Content-Type": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + + try: + if data: + response = requests.post(url, json=data, headers=headers, timeout=self.timeout) + else: + response = requests.get(url, params=params, headers=headers, timeout=self.timeout) + + result = response.json() + if result.get("status") == "failed": + return {"success": False, "error": result.get("wording", "未知错误")} + return {"success": True, "data": result.get("data", result)} + + except requests.exceptions.ConnectionError: + return {"success": False, "error": f"无法连接到 NapCat ({self.host}:{self.port}),请确认服务已启动"} + except Exception as e: + return {"success": False, "error": str(e)} + + def _op_set(self, args: List[str]) -> str: + if len(args) < 2: + return f"当前配置:\n- 地址: {self.host}:{self.port}\n- Token: {self.token or '无'}" + self.host = args[0] + self.port = int(args[1]) if len(args) > 1 else 3000 + self.token = args[2] if len(args) > 2 else "" + return f"已设置: {self.host}:{self.port}" + + def _op_send_private(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供 QQ号 和 消息内容" + result = self._call_api("send_private_msg", data={ + "user_id": int(args[0]), + "message": " ".join(args[1:]) + }) + if result["success"]: + return f"✅ 私聊已发送到 {args[0]}" + return f"❌ 发送失败: {result['error']}" + + def _op_send_group(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供 群号 和 消息内容" + result = self._call_api("send_group_msg", data={ + "group_id": int(args[0]), + "message": " ".join(args[1:]) + }) + if result["success"]: + return f"✅ 群消息已发送到 {args[0]}" + return f"❌ 发送失败: {result['error']}" + + def _op_recall(self, args: List[str]) -> str: + if not args: + return "错误:请提供消息ID" + result = self._call_api("delete_msg", data={"message_id": int(args[0])}) + return f"✅ 消息已撤回" if result["success"] else f"❌ 撤回失败: {result['error']}" + + def _op_login_info(self, args: List[str]) -> str: + result = self._call_api("get_login_info") + if result["success"]: + d = result["data"] + return f"📱 QQ号: {d.get('user_id')}\n昵称: {d.get('nickname')}" + return f"❌ 获取失败: {result['error']}" + + def _op_friend_list(self, args: List[str]) -> str: + result = self._call_api("get_friend_list") + if result["success"]: + friends = result["data"] + lines = [f"📋 好友列表 (共 {len(friends)} 人)"] + for f in friends[:15]: + lines.append(f" - {f.get('nickname')} ({f.get('user_id')})") + if len(friends) > 15: + lines.append(f" ... 还有 {len(friends)-15} 人") + return "\n".join(lines) + return f"❌ 获取失败: {result['error']}" + + def _op_group_list(self, args: List[str]) -> str: + result = self._call_api("get_group_list") + if not result["success"]: + return f"❌ 获取失败: {result['error']}" + + groups = result.get("data", []) + if not isinstance(groups, list): + groups = [] + + if not groups: + return "📭 没有加入任何群" + + lines = [f"📋 群列表 (共 {len(groups)} 个)", "格式: 群号 | 群名"] + for g in groups[:20]: + gid = g.get("group_id", "") + gname = g.get("group_name", "未知") + lines.append(f" {gid} | {gname}") + if len(groups) > 20: + lines.append(f" ... 还有 {len(groups)-20} 个群") + lines.append("\n使用 send_group <群号> <消息> 发送群消息") + return "\n".join(lines) + + def _op_group_info(self, args: List[str]) -> str: + if not args: + return "错误:请提供群号" + result = self._call_api("get_group_info", params={"group_id": int(args[0])}) + if result["success"]: + d = result["data"] + return f"📊 群信息\n- 群号: {d.get('group_id')}\n- 群名: {d.get('group_name')}\n- 成员: {d.get('member_count')}" + return f"❌ 获取失败: {result['error']}" + + def _op_group_members(self, args: List[str]) -> str: + if not args: + return "错误:请提供群号" + result = self._call_api("get_group_member_list", params={"group_id": int(args[0])}) + if result["success"]: + members = result["data"] + lines = [f"📋 群成员 (共 {len(members)} 人)"] + for m in members[:15]: + role = {"owner": "群主", "admin": "管理"}.get(m.get("role"), "") + lines.append(f" - [{role}] {m.get('nickname')} ({m.get('user_id')})") + return "\n".join(lines) + return f"❌ 获取失败: {result['error']}" + + def _op_user_info(self, args: List[str]) -> str: + if not args: + return "错误:请提供QQ号" + result = self._call_api("get_stranger_info", params={"user_id": int(args[0])}) + if result["success"]: + d = result["data"] + return f"👤 QQ号: {d.get('user_id')}\n昵称: {d.get('nickname')}" + return f"❌ 获取失败: {result['error']}" + + def _op_ban(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供 群号 和 QQ号" + result = self._call_api("set_group_ban", data={ + "group_id": int(args[0]), + "user_id": int(args[1]), + "duration": int(args[2]) if len(args) > 2 else 1800 + }) + return f"✅ 已禁言" if result["success"] else f"❌ 禁言失败: {result['error']}" + + def _op_unban(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供 群号 和 QQ号" + result = self._call_api("set_group_ban", data={ + "group_id": int(args[0]), + "user_id": int(args[1]), + "duration": 0 + }) + return f"✅ 已解除禁言" if result["success"] else f"❌ 解除失败: {result['error']}" + + def _op_kick(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供 群号 和 QQ号" + result = self._call_api("set_group_kick", data={ + "group_id": int(args[0]), + "user_id": int(args[1]) + }) + return f"✅ 已踢出" if result["success"] else f"❌ 踢出失败: {result['error']}" + + def _op_set_card(self, args: List[str]) -> str: + if len(args) < 3: + return "错误:请提供 群号、QQ号 和 名片" + result = self._call_api("set_group_card", data={ + "group_id": int(args[0]), + "user_id": int(args[1]), + "card": " ".join(args[2:]) + }) + return f"✅ 已设置名片" if result["success"] else f"❌ 设置失败: {result['error']}" + + def _op_set_group_name(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供 群号 和 名称" + result = self._call_api("set_group_name", data={ + "group_id": int(args[0]), + "group_name": " ".join(args[1:]) + }) + return f"✅ 已设置群名" if result["success"] else f"❌ 设置失败: {result['error']}" + + def _op_like(self, args: List[str]) -> str: + if not args: + return "错误:请提供QQ号" + result = self._call_api("send_like", data={ + "user_id": int(args[0]), + "times": int(args[1]) if len(args) > 1 else 1 + }) + return f"✅ 已点赞" if result["success"] else f"❌ 点赞失败: {result['error']}" + + def _op_poke(self, args: List[str]) -> str: + if not args: + return "错误:请提供QQ号或群号" + is_group = "-g" in args + target = args[0] + if is_group: + result = self._call_api("group_poke", data={"group_id": int(target)}) + else: + result = self._call_api("friend_poke", data={"user_id": int(target)}) + return f"✅ 已戳一戳" if result["success"] else f"❌ 失败: {result['error']}" + + # ==================== 新增功能 ==================== + + def _op_group_notice(self, args: List[str]) -> str: + """获取群公告""" + if not args: + return "错误:请提供群号" + result = self._call_api("_get_group_notice", params={"group_id": int(args[0])}) + if not result["success"]: + return f"❌ 获取失败: {result['error']}" + + notices = result.get("data", []) + if not isinstance(notices, list): + notices = [notices] if notices else [] + + if not notices: + return "📭 该群暂无公告" + + lines = [f"📢 群公告 (共 {len(notices)} 条)"] + for i, n in enumerate(notices[:5], 1): + content = n.get("notice_text", n.get("content", "")) + sender = n.get("sender_id", "未知") + lines.append(f"\n--- 公告 {i} ---") + lines.append(f"发布者: {sender}") + lines.append(f"内容: {content[:200]}{'...' if len(content) > 200 else ''}") + + return "\n".join(lines) + + def _op_get_msg(self, args: List[str]) -> str: + """获取消息详情""" + if not args: + return "错误:请提供消息ID" + result = self._call_api("get_msg", params={"message_id": int(args[0])}) + if not result["success"]: + return f"❌ 获取失败: {result['error']}" + + msg = result.get("data", {}) + lines = ["📨 消息详情"] + lines.append(f"- 消息ID: {msg.get('message_id')}") + lines.append(f"- 发送者: {msg.get('sender', {}).get('nickname', '未知')} ({msg.get('sender', {}).get('user_id', '')})") + lines.append(f"- 时间: {msg.get('time', '')}") + + # 解析消息内容 + content = msg.get("message", "") + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + text_parts.append(part.get("data", {}).get("text", "")) + elif part.get("type") == "image": + text_parts.append("[图片]") + elif part.get("type") == "at": + text_parts.append(f"@{part.get('data', {}).get('qq', '')}") + elif isinstance(part, str): + text_parts.append(part) + content = "".join(text_parts) + + lines.append(f"- 内容: {content}") + return "\n".join(lines) + + def _op_get_chat_history(self, args: List[str]) -> str: + """获取群聊天记录""" + if not args: + return "错误:请提供群号" + + group_id = int(args[0]) + + # 先获取群成员列表来获取消息序号 + members_result = self._call_api("get_group_member_list", params={"group_id": group_id}) + if not members_result["success"]: + return f"❌ 获取群成员失败: {members_result['error']}" + + # 尝试获取聊天记录 + result = self._call_api("get_group_msg_history", params={"group_id": group_id}) + if not result["success"]: + return f"❌ 获取聊天记录失败: {result['error']}\n提示: 此功能可能需要特殊权限" + + messages = result.get("data", {}).get("messages", []) + if not messages: + return "📭 没有获取到聊天记录" + + lines = [f"💬 群聊天记录 (最近 {len(messages)} 条)"] + for msg in messages[-15:]: + sender = msg.get("sender", {}) + nickname = sender.get("nickname", sender.get("card", "未知")) + + # 解析消息内容 + content = msg.get("message", "") + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + text_parts.append(part.get("data", {}).get("text", "")) + elif part.get("type") == "image": + text_parts.append("[图片]") + elif part.get("type") == "at": + text_parts.append(f"@{part.get('data', {}).get('qq', '')}") + elif isinstance(part, str): + text_parts.append(part) + content = "".join(text_parts) + + lines.append(f"[{nickname}]: {content[:100]}") + + return "\n".join(lines) + + def _op_send_image(self, args: List[str]) -> str: + """发送图片""" + if len(args) < 2: + return "错误:请提供 目标ID 和 图片路径\n用法: send_image <群号/QQ号> <图片路径> [true/false是否群聊]" + + target_id = args[0] + image_path = args[1] + is_group = len(args) > 2 and args[2].lower() in ("true", "1", "yes", "群") + + # 检查图片文件 + if not os.path.exists(image_path): + # 尝试相对路径 + full_path = os.path.abspath(image_path) + if not os.path.exists(full_path): + return f"❌ 图片文件不存在: {image_path}" + image_path = full_path + + # 使用 file:// 协议或 base64 + try: + import base64 + with open(image_path, "rb") as f: + img_data = base64.b64encode(f.read()).decode("utf-8") + + # 获取图片格式 + ext = os.path.splitext(image_path)[1].lower().replace(".", "") + if ext == "jpg": + ext = "jpeg" + + image_url = f"base64://{img_data}" + + except Exception as e: + return f"❌ 读取图片失败: {e}" + + # 构建消息 + message = [ + { + "type": "image", + "data": {"file": image_url} + } + ] + + if is_group: + result = self._call_api("send_group_msg", data={ + "group_id": int(target_id), + "message": message + }) + target_type = "群" + else: + result = self._call_api("send_private_msg", data={ + "user_id": int(target_id), + "message": message + }) + target_type = "用户" + + if result["success"]: + return f"✅ 图片已发送到{target_type} {target_id}" + return f"❌ 发送失败: {result['error']}" + + def _op_start_listen(self, args: List[str]) -> str: + """启动消息监听""" + port = int(args[0]) if args else 3100 + + # 首先需要在 NapCat 配置中添加 HTTP 上报 + # 检查是否已配置上报 + result = self._call_api("get_status") + if not result["success"]: + return f"❌ NapCat 服务未连接,请先启动" + + if self.listen_server: + return f"⚠️ 监听服务已在运行,端口: {self.listen_port}" + + # 启动监听 + if self.start_listener(port): + # 提示用户需要在 WebUI 配置 HTTP 上报 + return f"""✅ 消息监听服务已启动! +- 监听端口: {port} +- 登录账号: {self.login_user_id} + +【重要】请在 NapCat WebUI 中配置 HTTP 上报: +1. 打开 http://127.0.0.1:6099 +2. 进入「网络配置」->「HTTP 上报」 +3. 添加上报地址: http://127.0.0.1:{port} +4. 保存配置 + +当群内有人 @{self.login_user_id} 时,消息会自动转发给AI处理""" + return f"❌ 启动监听失败" + + def _op_stop_listen(self, args: List[str]) -> str: + """停止消息监听""" + self.stop_listener() + return "✅ 消息监听服务已停止" + + # ==================== 群管理扩展功能 ==================== + + def _op_set_admin(self, args: List[str]) -> str: + """设置群管理员""" + if len(args) < 2: + return "错误:请提供群号和QQ号\n用法: set_admin <群号> " + result = self._call_api("set_group_admin", data={ + "group_id": int(args[0]), + "user_id": int(args[1]), + "enable": True + }) + return f"✅ 已设置为管理员" if result["success"] else f"❌ 失败: {result['error']}" + + def _op_unset_admin(self, args: List[str]) -> str: + """取消群管理员""" + if len(args) < 2: + return "错误:请提供群号和QQ号\n用法: unset_admin <群号> " + result = self._call_api("set_group_admin", data={ + "group_id": int(args[0]), + "user_id": int(args[1]), + "enable": False + }) + return f"✅ 已取消管理员" if result["success"] else f"❌ 失败: {result['error']}" + + def _op_set_special_title(self, args: List[str]) -> str: + """设置群头衔""" + if len(args) < 3: + return "错误:请提供群号、QQ号和头衔\n用法: set_group_special_title <群号> <头衔>" + result = self._call_api("set_group_special_title", data={ + "group_id": int(args[0]), + "user_id": int(args[1]), + "special_title": " ".join(args[2:]) + }) + return f"✅ 已设置头衔" if result["success"] else f"❌ 失败: {result['error']}" + + def _op_set_whole_ban(self, args: List[str]) -> str: + """设置全员禁言""" + if len(args) < 2: + return "错误:请提供群号和开关(on/off)\n用法: set_whole_ban <群号> " + enable = args[1].lower() in ("on", "true", "1", "yes") + result = self._call_api("set_group_whole_ban", data={ + "group_id": int(args[0]), + "enable": enable + }) + status = "开启" if enable else "关闭" + return f"✅ 已{status}全员禁言" if result["success"] else f"❌ 失败: {result['error']}" + + def _op_leave_group(self, args: List[str]) -> str: + """退出群""" + if not args: + return "错误:请提供群号\n用法: leave_group <群号>" + result = self._call_api("set_group_leave", data={ + "group_id": int(args[0]) + }) + return f"✅ 已退出群" if result["success"] else f"❌ 失败: {result['error']}" + + # ==================== 请求处理功能 ==================== + + def _op_set_friend_add_request(self, args: List[str]) -> str: + """处理好友请求""" + if len(args) < 2: + return "错误:请提供请求标识和操作\n用法: set_friend_add_request [备注]" + flag = args[0] + approve = args[1].lower() == "accept" + remark = args[2] if len(args) > 2 else "" + result = self._call_api("set_friend_add_request", data={ + "flag": flag, + "approve": approve, + "remark": remark + }) + action = "已同意" if approve else "已拒绝" + return f"✅ {action}好友请求" if result["success"] else f"❌ 失败: {result['error']}" + + def _op_set_group_add_request(self, args: List[str]) -> str: + """处理加群请求""" + if len(args) < 2: + return "错误:请提供请求标识和操作\n用法: set_group_add_request [理由]" + flag = args[0] + approve = args[1].lower() == "accept" + reason = " ".join(args[2:]) if len(args) > 2 else "" + result = self._call_api("set_group_add_request", data={ + "flag": flag, + "approve": approve, + "reason": reason + }) + action = "已同意" if approve else "已拒绝" + return f"✅ {action}加群请求" if result["success"] else f"❌ 失败: {result['error']}" + + # ==================== 消息扩展功能 ==================== + + def _op_send_forward(self, args: List[str]) -> str: + """发送合并转发消息""" + if len(args) < 2: + return "错误:请提供群号和消息内容\n用法: send_forward <群号> <消息内容>" + group_id = int(args[0]) + content = " ".join(args[1:]) + # 构建转发消息 + messages = [{ + "type": "node", + "data": { + "name": "消息", + "uin": self.login_user_id or "0", + "content": content + } + }] + result = self._call_api("send_group_forward_msg", data={ + "group_id": group_id, + "messages": messages + }) + return f"✅ 合并转发消息已发送" if result["success"] else f"❌ 失败: {result['error']}" + + def _op_get_forward_msg(self, args: List[str]) -> str: + """获取合并转发消息内容""" + if not args: + return "错误:请提供消息ID\n用法: get_forward_msg <消息ID>" + result = self._call_api("get_forward_msg", params={"message_id": args[0]}) + if not result["success"]: + return f"❌ 获取失败: {result['error']}" + messages = result.get("data", []) + if isinstance(messages, list): + lines = ["📜 合并转发消息内容:"] + for msg in messages: + sender = msg.get("sender", {}).get("nickname", "未知") + content = msg.get("content", "") + lines.append(f"[{sender}] {content}") + return "\n".join(lines) + return f"消息内容: {messages}" + + # ==================== 系统功能 ==================== + + def _op_version(self, args: List[str]) -> str: + """获取版本信息""" + result = self._call_api("get_version_info") + if not result["success"]: + return f"❌ 获取失败: {result['error']}" + data = result.get("data", {}) + lines = ["📋 版本信息:"] + if isinstance(data, dict): + for key, value in data.items(): + lines.append(f"- {key}: {value}") + else: + lines.append(str(data)) + return "\n".join(lines) + + def _op_restart(self, args: List[str]) -> str: + """重启 NapCat""" + delay = int(args[0]) if args else 0 + result = self._call_api("set_restart", data={"delay": delay}) + if result["success"]: + return f"✅ NapCat 将在 {delay} 毫秒后重启" + return f"❌ 重启失败: {result.get('error', '未知错误')}" + + +if __name__ == "__main__": + plugin = Plugin() + print("NapCat QQ机器人插件") + print(f"插件目录: {plugin.plugin_dir}") \ No newline at end of file diff --git a/plugins/network_tools.py b/plugins/network_tools.py new file mode 100644 index 0000000..122552d --- /dev/null +++ b/plugins/network_tools.py @@ -0,0 +1,280 @@ +import requests +import time +import socket +from urllib.parse import urlparse +import json +from colorama import Fore, Style + + +class Liugin: + """网络工具插件""" + + def __init__(self): + self.usage = """网络工具使用方法:/r +network_tools <操作> <参数>/r +例如:/r +- network_tools ping www.baidu.com - 测试网站连通性和延迟/r +- network_tools get https://www.example.com - 获取网页内容/r +- network_tools status https://www.example.com - 检查网站状态/r +- network_tools headers https://www.example.com - 获取网站响应头/r +- network_tools ip www.example.com - 获取网站IP地址/r +/r +详细说明:/r +- ping操作: network_tools ping <域名或IP> - 测试连通性和延迟/r +- get操作: network_tools get - 获取网页内容/r +- status操作: network_tools status - 检查网站HTTP状态码/r +- headers操作: network_tools headers - 获取网站响应头信息/r +- ip操作: network_tools ip <域名> - 获取域名对应的IP地址/r +/r +使用工具的JSON格式示例:/r +{"action": "use_tool", "tool": "network_tools", "args": "ping www.baidu.com"} - 测试百度连通性/r +{"action": "use_tool", "tool": "network_tools", "args": "get https://www.example.com"} - 获取网页内容/r +{"action": "use_tool", "tool": "network_tools", "args": "status https://www.example.com"} - 检查网站状态/r +{"action": "use_tool", "tool": "network_tools", "args": "headers https://www.example.com"} - 获取响应头/r +{"action": "use_tool", "tool": "network_tools", "args": "ip www.example.com"} - 获取IP地址/r +""" + self.cli = None # CLI实例引用 + self.timeout = 10 # 请求超时时间(秒) + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 注册插件命令 + self.cli.register_liugin_command('net', self.command_handler) + self.cli.register_liugin_command('ping', self.ping_command_handler) + self.cli.register_liugin_command('website', self.website_command_handler) + + def command_handler(self, args): + """处理 /net 命令""" + # 解析参数 + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return "请提供操作类型: ping, get, status, headers, ip 等" + + operation = parts[0].lower() + remaining_args = parts[1] if len(parts) > 1 else "" + + # 调用实际的网络工具功能 + return self.handle(f"{operation} {remaining_args}") + + def ping_command_handler(self, args): + """处理 /ping 命令""" + return self.handle(f"ping {args}") + + def website_command_handler(self, args): + """处理 /website 命令""" + return self.handle(f"status {args}") + + def get_tool_info(self): + return { + "name": "network_tools", + "description": "网络工具,用于测试网站连通性、获取网页内容、检查网站状态、获取响应头、解析IP地址等", + "keywords": ["网络", "ping", "网站", "状态", "网页", "IP", "headers", "get", "网络工具", "连通性", "延迟"], + "usage": """network_tools 工具使用说明: +JSON格式示例: +{"action": "use_tool", "tool": "network_tools", "args": "ping www.baidu.com"} - 测试百度连通性 +{"action": "use_tool", "tool": "network_tools", "args": "get https://www.example.com"} - 获取网页内容 +{"action": "use_tool", "tool": "network_tools", "args": "status https://www.example.com"} - 检查网站状态 +{"action": "use_tool", "tool": "network_tools", "args": "headers https://www.example.com"} - 获取网站响应头 +{"action": "use_tool", "tool": "network_tools", "args": "ip www.example.com"} - 获取网站IP地址""" + } + + def handle(self, args): + """处理网络工具请求""" + try: + # 解析参数 + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return "错误:参数不足。请提供操作类型。\n可用操作: ping, get, status, headers, ip" + + operation = parts[0].lower() + + if operation == "ping": + # 测试网站连通性和延迟 + if len(parts) < 2: + return "错误:请提供要测试的域名或IP地址。格式: ping <域名或IP>" + + target = parts[1].strip() + return self._ping(target) + + elif operation == "get": + # 获取网页内容 + if len(parts) < 2: + return "错误:请提供URL。格式: get " + + url = parts[1].strip() + return self._get_content(url) + + elif operation == "status": + # 检查网站状态码 + if len(parts) < 2: + return "错误:请提供URL。格式: status " + + url = parts[1].strip() + return self._check_status(url) + + elif operation == "headers": + # 获取网站响应头 + if len(parts) < 2: + return "错误:请提供URL。格式: headers " + + url = parts[1].strip() + return self._get_headers(url) + + elif operation == "ip": + # 获取域名IP地址 + if len(parts) < 2: + return "错误:请提供域名。格式: ip <域名>" + + domain = parts[1].strip() + return self._get_ip(domain) + + else: + return f"错误:不支持的操作 '{operation}'。支持的操作有: ping, get, status, headers, ip。" + + except Exception as e: + return f"网络工具操作错误: {str(e)}" + + def _ping(self, target): + """测试网站连通性和延迟""" + try: + # 确保URL格式正确 + if not target.startswith(('http://', 'https://')): + test_url = 'http://' + target + else: + test_url = target + + # 添加协议如果缺失 + if not test_url.startswith(('http://', 'https://')): + test_url = 'http://' + test_url + + start_time = time.time() + response = requests.get(test_url, timeout=self.timeout) + end_time = time.time() + + response_time = (end_time - start_time) * 1000 # 转换为毫秒 + + result = f".ping 测试结果:\n" + result += f"目标: {target}\n" + result += f"HTTP状态码: {response.status_code}\n" + result += f"响应时间: {response_time:.2f} ms\n" + result += f"连接状态: {'成功' if response.status_code < 400 else '失败'}\n" + + if response.status_code >= 400: + result += f"错误信息: {response.reason if hasattr(response, 'reason') else '请求失败'}\n" + + return result + + except requests.exceptions.Timeout: + return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败" + except requests.exceptions.ConnectionError: + return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败" + except requests.exceptions.RequestException as e: + return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败" + except Exception as e: + return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败" + + def _get_content(self, url): + """获取网页内容""" + try: + # 确保URL格式正确 + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + response = requests.get(url, timeout=self.timeout) + + if response.status_code == 200: + content = response.text + # 限制内容长度以避免过长输出 + if len(content) > 2000: + content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)" + + result = f"网页内容 (来自 {url}):\n" + result += content + return result + else: + return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}" + + except requests.exceptions.Timeout: + return f"获取网页内容超时 (>{self.timeout}秒)" + except requests.exceptions.ConnectionError: + return f"无法连接到 {url},请检查URL是否正确" + except requests.exceptions.RequestException as e: + return f"请求失败: {str(e)}" + except Exception as e: + return f"获取网页内容时发生错误: {str(e)}" + + def _check_status(self, url): + """检查网站状态码""" + try: + # 确保URL格式正确 + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + response = requests.get(url, timeout=self.timeout) + + result = f"网站状态 (来自 {url}):\n" + result += f"HTTP状态码: {response.status_code}\n" + result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n" + result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n" + result += f"内容长度: {len(response.content)} 字节\n" + result += f"服务器: {response.headers.get('Server', '未知')}\n" + result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n" + + return result + + except requests.exceptions.Timeout: + return f"检查网站状态超时 (>{self.timeout}秒)" + except requests.exceptions.ConnectionError: + return f"无法连接到 {url},请检查URL是否正确" + except requests.exceptions.RequestException as e: + return f"请求失败: {str(e)}" + except Exception as e: + return f"检查网站状态时发生错误: {str(e)}" + + def _get_headers(self, url): + """获取网站响应头""" + try: + # 确保URL格式正确 + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + response = requests.get(url, timeout=self.timeout) + + headers = dict(response.headers) + + result = f"响应头 (来自 {url}):\n" + for key, value in headers.items(): + result += f"{key}: {value}\n" + + return result + + except requests.exceptions.Timeout: + return f"获取响应头超时 (>{self.timeout}秒)" + except requests.exceptions.ConnectionError: + return f"无法连接到 {url},请检查URL是否正确" + except requests.exceptions.RequestException as e: + return f"请求失败: {str(e)}" + except Exception as e: + return f"获取响应头时发生错误: {str(e)}" + + def _get_ip(self, domain): + """获取域名对应的IP地址""" + try: + # 如果域名包含协议,去掉协议部分 + if domain.startswith(('http://', 'https://')): + parsed = urlparse(domain) + domain = parsed.netloc + + ip_address = socket.gethostbyname(domain) + + result = f"域名解析结果:\n" + result += f"域名: {domain}\n" + result += f"IP地址: {ip_address}\n" + + return result + + except socket.gaierror: + return f"无法解析域名: {domain},请检查域名是否正确" + except Exception as e: + return f"域名解析失败: {str(e)}" diff --git a/plugins/ollama_image.py b/plugins/ollama_image.py new file mode 100644 index 0000000..8108d38 --- /dev/null +++ b/plugins/ollama_image.py @@ -0,0 +1,198 @@ +""" +Ollama图像识别插件 +支持多种图像识别引擎的切换 +""" + +import os +import sys +from colorama import Fore, Style + +# 添加项目根目录到sys.path +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) + +from image_engine import get_engine_manager + + +class Liugin: + """图像识别插件 - 支持多引擎切换""" + + def __init__(self): + self.usage = """图像识别插件使用方法: +/image <操作> <参数> + +操作命令: +- analyze <图片路径> - 详细分析图片 +- ocr <图片路径> - 快速OCR文字识别 +- quick <图片路径> - 快速图片描述 +- describe <图片路径> - 完整描述图片 +- model [模型名] - 查看/切换模型 +- models - 列出可用的视觉模型 +- engine [引擎名] - 查看/切换识别引擎 +- engines - 列出所有识别引擎 + +示例: +- image analyze C:\\photo.png +- image ocr C:\\screenshot.png +- image model llava:13b +- image engine openai + +JSON调用示例: +{"action": "use_tool", "tool": "image", "args": "analyze c:\\\\test.png"} +""" + self.cli = None + self.engine_manager = None + + # 支持的图片格式 + self.supported_formats = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'] + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 注册插件命令 + self.cli.register_liugin_command('image', self.command_handler) + self.cli.register_liugin_command('img', self.command_handler) + + def command_handler(self, args): + """处理命令""" + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return self._get_help() + + operation = parts[0].lower() + remaining_args = parts[1] if len(parts) > 1 else "" + return self.handle(f"{operation} {remaining_args}") + + def get_tool_info(self): + return { + "name": "image", + "description": "图像识别工具,支持多引擎切换,使用本地或云端模型进行图像分析", + "keywords": ["图像", "图片", "image", "analyze", "分析", "视觉", "识别", "OCR"], + "usage": """image 工具使用说明: +- analyze <图片路径> - 详细分析 +- ocr <图片路径> - OCR文字识别 +- quick <图片路径> - 快速描述 +- engine <引擎名> - 切换引擎 +- engines - 列出所有引擎 +支持格式:PNG, JPG, JPEG, GIF, WEBP, BMP""" + } + + def handle(self, args): + """处理请求""" + # 延迟初始化引擎管理器 + if self.engine_manager is None: + self.engine_manager = get_engine_manager() + + try: + parts = args.strip().split(maxsplit=1) + if len(parts) < 1: + return self._get_help() + + operation = parts[0].lower() + image_path = parts[1].strip() if len(parts) > 1 else "" + + # 引擎管理命令 + if operation == "engines": + return self.engine_manager.list_engines() + + elif operation == "engine": + if image_path: + success, msg = self.engine_manager.switch_engine(image_path) + return msg + return f"当前引擎: {self.engine_manager.get_current_engine_name()}" + + elif operation == "models": + engine = self.engine_manager.get_engine() + if engine and hasattr(engine, 'get_model_list'): + return engine.get_model_list() + return "当前引擎不支持模型列表" + + elif operation == "model": + engine = self.engine_manager.get_engine() + if not image_path: + if engine and hasattr(engine, 'model'): + return f"当前模型: {engine.model}" + return "当前引擎不支持模型切换" + if engine and hasattr(engine, 'set_model'): + success, msg = engine.set_model(image_path) + return msg + return "当前引擎不支持模型切换" + + # 图像分析命令 + elif operation in ["analyze", "ocr", "quick", "describe"]: + if not image_path: + return f"错误:请提供图片路径。用法: {operation} <图片路径>" + return self._analyze_image(image_path, operation) + + else: + # 兼容旧用法,直接当作图片路径处理 + return self._analyze_image(operation, "analyze") + + except Exception as e: + return f"错误: {str(e)}" + + def _get_help(self): + """获取帮助信息""" + current = self.engine_manager.get_current_engine_name() if self.engine_manager else "未初始化" + return f"""图像识别插件命令: +- analyze <图片路径> 详细分析图片 +- ocr <图片路径> OCR文字识别 +- quick <图片路径> 快速描述 +- describe <图片路径> 完整描述 +- model [模型名] 查看/切换模型 +- models 列出视觉模型 +- engine [引擎名] 查看/切换引擎 +- engines 列出所有引擎 + +当前引擎: {current}""" + + def _analyze_image(self, image_path, mode="analyze"): + """使用引擎分析图片""" + # 检查文件 + if not os.path.exists(image_path): + return f"文件不存在: {image_path}" + + ext = os.path.splitext(image_path)[1].lower().lstrip('.') + if ext not in self.supported_formats: + return f"不支持的图片格式: {ext}。支持: {', '.join(self.supported_formats)}" + + # 检查引擎 + engine = self.engine_manager.get_engine() + if not engine: + return "没有可用的图像引擎" + + file_size = os.path.getsize(image_path) + engine_name = self.engine_manager.get_current_engine_name() + + # 获取模型名(如果支持) + model_info = "" + if hasattr(engine, 'model'): + model_info = f" | 模型: {engine.model}" + + # 文件大小警告 + size_warning = "" + if file_size > 1024 * 1024: # 大于1MB + size_warning = f" {Fore.YELLOW}(较大图片,处理可能需要较长时间){Style.RESET_ALL}" + + print(f"{Fore.CYAN}📷 图像识别: {os.path.basename(image_path)}{Style.RESET_ALL}{size_warning}") + print(f"{Fore.YELLOW}📦 大小: {file_size/1024:.1f}KB | 引擎: {engine_name}{model_info}{Style.RESET_ALL}") + print(f"{Fore.YELLOW}⏳ 正在识别,请耐心等待...{Style.RESET_ALL}") + + try: + # 使用引擎分析 + full_response = "" + for chunk in self.engine_manager.analyze(image_path, mode, stream=True): + if chunk: + print(chunk, end='', flush=True) + full_response += chunk + + print() # 换行 + print(f"{Fore.GREEN}✓ 识别完成{Style.RESET_ALL}") + + return full_response if full_response else "识别结果为空" + + except Exception as e: + error_msg = str(e) + print(f"{Fore.RED}✗ {error_msg}{Style.RESET_ALL}") + return f"识别失败: {error_msg}" \ No newline at end of file diff --git a/plugins/scheduler.py b/plugins/scheduler.py new file mode 100644 index 0000000..37c38b5 --- /dev/null +++ b/plugins/scheduler.py @@ -0,0 +1 @@ +"hello" diff --git a/plugins/send_image.py b/plugins/send_image.py new file mode 100644 index 0000000..fb045eb --- /dev/null +++ b/plugins/send_image.py @@ -0,0 +1,144 @@ +""" +发送图片插件 - Clawli 模式下向手机端发送图片 +支持独立进程服务器模式 +""" +import os +import json +from typing import Dict, Any + + +def get_clawli_server(): + """获取 Clawli 服务器实例(优先使用独立进程服务器)""" + try: + import clawli_server + if clawli_server.is_running(): + return clawli_server + except ImportError: + pass + + # 回退到旧的 WebSocket 服务器 + try: + from websocket_server import websocket_server + return websocket_server + except ImportError: + return None + + +class Liugin: + """发送图片插件 - 在 Clawli 模式下发送图片到手机端""" + + def __init__(self): + self.name = "send_image" + self.cli = None + + def set_cli(self, cli_instance): + """设置 CLI 引用""" + self.cli = cli_instance + + def get_tool_info(self): + """返回工具信息""" + return { + "name": "send_image", + "description": "在 Clawli 模式下发送图片到手机端,支持 JPG、PNG、GIF、BMP、WEBP 格式", + "keywords": ["图片", "发送", "手机", "clawli", "image", "send"], + "usage": "send_image {\"image_path\": \"图片路径\", \"caption\": \"说明文字\"}" + } + + def handle(self, args): + """处理工具调用 + + Args: + args: 可以是 JSON 字符串或字典 + """ + # 解析参数 + if isinstance(args, str): + try: + params = json.loads(args) + except json.JSONDecodeError: + params = {"image_path": args.strip()} + elif isinstance(args, dict): + params = args + else: + return "错误: 参数格式无效" + + image_path = params.get("image_path", "") + caption = params.get("caption", "") + + if not image_path: + return "错误: 请提供图片路径" + + # 检查文件是否存在 + if not os.path.isfile(image_path): + return f"错误: 图片文件不存在: {image_path}" + + # 检查是否是图片文件 + ext = image_path.lower().split('.')[-1] if '.' in image_path else '' + supported_formats = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'] + if ext not in supported_formats: + return f"错误: 不支持的图片格式: {ext},支持: {', '.join(supported_formats)}" + + # 获取服务器 + server = get_clawli_server() + if not server: + return "错误: Clawli 服务器未初始化" + + # 尝试使用独立进程服务器 + try: + import clawli_server + if clawli_server.is_running(): + success, msg = clawli_server.send_image(image_path, caption) + if success: + file_size = os.path.getsize(image_path) + size_str = f"{file_size/1024:.1f}KB" if file_size < 1024*1024 else f"{file_size/1024/1024:.2f}MB" + return f"图片已发送到手机端: {os.path.basename(image_path)} ({size_str})" + else: + return f"错误: {msg}" + except ImportError: + pass + + # 回退到旧的 WebSocket 服务器 + if hasattr(server, 'running') and not server.running: + return "错误: Clawli 模式未启动,请先使用 /remote start 启动远程服务" + + if hasattr(server, 'clients') and not server.clients: + return "错误: 没有连接的手机端,请确保手机已连接" + + if hasattr(server, 'send_image_to_clients'): + success = server.send_image_to_clients(image_path, caption) + if success: + file_size = os.path.getsize(image_path) + size_str = f"{file_size/1024:.1f}KB" if file_size < 1024*1024 else f"{file_size/1024/1024:.2f}MB" + return f"图片已发送到手机端: {os.path.basename(image_path)} ({size_str})" + + return "错误: 发送图片失败" + + +# 工具定义 +TOOLS = [ + { + "type": "function", + "function": { + "name": "send_image", + "description": "在 Clawli 模式下发送图片到手机端。支持 JPG、PNG、GIF、BMP、WEBP 格式。", + "parameters": { + "type": "object", + "properties": { + "image_path": { + "type": "string", + "description": "图片文件的绝对路径" + }, + "caption": { + "type": "string", + "description": "图片说明文字(可选)" + } + }, + "required": ["image_path"] + } + } + } +] + + +def get_tools(): + """返回工具定义列表""" + return TOOLS \ No newline at end of file diff --git a/plugins/speech_recognition.py b/plugins/speech_recognition.py new file mode 100644 index 0000000..2454c6b --- /dev/null +++ b/plugins/speech_recognition.py @@ -0,0 +1,549 @@ +""" +语音识别插件 - 弹窗录音界面 +AI 触发后弹出独立录音窗口 +""" + +import os +import json +import subprocess +import sys +import time +from typing import Optional, Dict, Any, List + +# 结果保存路径 +RESULT_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "speech_temp", "last_result.txt") +TEMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "speech_temp") + + +class Liugin: + """语音识别插件""" + + def __init__(self): + self.plugin_dir = os.path.dirname(os.path.abspath(__file__)) + self.temp_dir = TEMP_DIR + os.makedirs(self.temp_dir, exist_ok=True) + + self.usage = """语音识别插件使用方法: +speech_recognition <操作> [参数] + +录音识别: +- record - 弹出录音窗口,录音并识别 + +文件识别: +- file <音频文件路径> - 识别音频文件内容 + +其他: +- status - 查看状态 +- result - 获取上次识别结果 + +支持的音频格式: wav, mp3, m4a, flac, ogg +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "speech_recognition", + "description": "语音识别插件,弹出录音窗口进行语音识别,将语音转换为文字", + "keywords": ["语音", "识别", "录音", "转文字", "speech", "voice", "recognition"], + "usage": self.usage + } + + def get_mcp_definition(self): + return { + "name": "speech_recognition", + "description": "语音识别插件,弹出录音窗口进行语音识别", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["record", "file", "status", "result"], + "description": "操作类型" + }, + "file_path": {"type": "string", "description": "音频文件路径"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments: Dict[str, Any]) -> str: + op = arguments.get("operation", "") + parts = [op] + if op == "file": + parts.append(arguments.get("file_path", "")) + return " ".join(filter(None, parts)) + + def handle(self, args: str) -> str: + try: + parts = self._parse_args(args) + if not parts: + return "错误:请提供操作类型。\n" + self.usage + + operation = parts[0].lower() + + handlers = { + "record": self._op_record, + "file": self._op_file, + "status": self._op_status, + "result": self._op_result, + } + + if operation in handlers: + return handlers[operation](parts[1:]) + else: + return f"错误:未知操作 '{operation}'\n可用操作: " + ", ".join(handlers.keys()) + + except Exception as e: + return f"处理请求失败: {e}" + + def _parse_args(self, args: str) -> List[str]: + parts = [] + current = "" + in_quotes = False + quote_char = None + for char in args: + if char in ('"', "'") and not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char and in_quotes: + in_quotes = False + quote_char = None + elif char == ' ' and not in_quotes: + if current: + parts.append(current) + current = "" + else: + current += char + if current: + parts.append(current) + return parts + + # ==================== 弹窗录音 ==================== + + def _op_record(self, args: List[str]) -> str: + """弹出录音窗口""" + + # 创建录音窗口脚本 + recorder_script = os.path.join(self.temp_dir, "recorder_gui.py") + + gui_code = f'''import tkinter as tk +from tkinter import messagebox +import os +import sys +import threading +import time + +RESULT_FILE = r"{RESULT_FILE}" +TEMP_DIR = r"{TEMP_DIR}" + +class VoiceRecorder: + def __init__(self): + self.root = tk.Tk() + self.root.title("语音识别录音") + self.root.geometry("450x350") + self.root.resizable(False, False) + + # 居中显示 + self.root.update_idletasks() + x = (self.root.winfo_screenwidth() - 450) // 2 + y = (self.root.winfo_screenheight() - 350) // 2 + self.root.geometry(f"450x350+{{x}}+{{y}}") + + # 录音状态 + self.is_recording = False + self.record_file = None + self.frames = [] + self.start_time = None + self.p = None + self.stream = None + + # 创建界面 + self._create_ui() + + # 检查 pyaudio + self.has_pyaudio = self._check_pyaudio() + if not self.has_pyaudio: + self.status_label.config(text="请安装 pyaudio: pip install pyaudio", fg="red") + + def _check_pyaudio(self): + try: + import pyaudio + return True + except: + return False + + def _create_ui(self): + # 标题 + title_label = tk.Label(self.root, text="🎙️ 语音识别录音", font=("Arial", 18, "bold")) + title_label.pack(pady=15) + + # 状态标签 + self.status_label = tk.Label(self.root, text="点击开始录音", font=("Arial", 11)) + self.status_label.pack(pady=5) + + # 时间标签 + self.time_label = tk.Label(self.root, text="00:00", font=("Arial", 28, "bold"), fg="#2196F3") + self.time_label.pack(pady=10) + + # 按钮框架 + btn_frame = tk.Frame(self.root) + btn_frame.pack(pady=15) + + # 开始/停止按钮 + self.record_btn = tk.Button( + btn_frame, text="🎤 开始录音", font=("Arial", 12), + width=12, height=2, command=self.toggle_record, + bg="#4CAF50", fg="white", cursor="hand2" + ) + self.record_btn.pack(side=tk.LEFT, padx=10) + + # 识别按钮 + self.recognize_btn = tk.Button( + btn_frame, text="📝 识别", font=("Arial", 12), + width=12, height=2, command=self.recognize, + bg="#2196F3", fg="white", cursor="hand2", state=tk.DISABLED + ) + self.recognize_btn.pack(side=tk.LEFT, padx=10) + + # 结果显示 + result_frame = tk.LabelFrame(self.root, text="识别结果", font=("Arial", 10)) + result_frame.pack(pady=10, padx=15, fill=tk.BOTH, expand=True) + + self.result_text = tk.Text(result_frame, height=5, width=50, font=("Arial", 10)) + self.result_text.pack(pady=5, padx=5, fill=tk.BOTH, expand=True) + + def toggle_record(self): + if not self.has_pyaudio: + messagebox.showerror("错误", "请先安装 pyaudio:\npip install pyaudio") + return + + if self.is_recording: + self.stop_record() + else: + self.start_record() + + def start_record(self): + import pyaudio + + self.is_recording = True + self.frames = [] + self.start_time = time.time() + self.record_btn.config(text="⏹️ 停止录音", bg="#f44336") + self.status_label.config(text="🔴 正在录音...", fg="red") + self.recognize_btn.config(state=tk.DISABLED) + + CHUNK = 1024 + FORMAT = pyaudio.paInt16 + CHANNELS = 1 + RATE = 16000 + + self.p = pyaudio.PyAudio() + self.stream = self.p.open( + format=FORMAT, channels=CHANNELS, rate=RATE, + input=True, frames_per_buffer=CHUNK + ) + + # 更新时间 + self._update_time() + + # 录音线程 + def record_loop(): + while self.is_recording: + try: + data = self.stream.read(CHUNK, exception_on_overflow=False) + self.frames.append(data) + except: + break + + self.record_thread = threading.Thread(target=record_loop, daemon=True) + self.record_thread.start() + + def stop_record(self): + self.is_recording = False + self.record_btn.config(text="🎤 开始录音", bg="#4CAF50") + self.status_label.config(text="录音完成,可以识别", fg="green") + self.recognize_btn.config(state=tk.NORMAL) + + # 停止录音 + if self.stream: + self.stream.stop_stream() + self.stream.close() + if self.p: + self.p.terminate() + + # 保存文件 + import wave + import pyaudio + self.record_file = os.path.join(TEMP_DIR, f"record_{{int(time.time())}}.wav") + wf = wave.open(self.record_file, "wb") + wf.setnchannels(1) + wf.setsampwidth(pyaudio.get_sample_size(pyaudio.paInt16)) + wf.setframerate(16000) + wf.writeframes(b"".join(self.frames)) + wf.close() + + self.result_text.delete(1.0, tk.END) + self.result_text.insert(tk.END, "✅ 录音已保存\n点击 [识别] 按钮进行语音识别") + + def _update_time(self): + if self.is_recording: + elapsed = int(time.time() - self.start_time) + mins = elapsed // 60 + secs = elapsed % 60 + self.time_label.config(text=f"{{mins:02d}}:{{secs:02d}}") + self.root.after(100, self._update_time) + + def recognize(self): + if not self.record_file or not os.path.exists(self.record_file): + messagebox.showerror("错误", "没有录音文件,请先录音") + return + + self.status_label.config(text="正在识别...", fg="blue") + self.result_text.delete(1.0, tk.END) + self.result_text.insert(tk.END, "🔍 识别中,请稍候...") + self.root.update() + + # 尝试识别 + result = self._do_recognize() + + self.result_text.delete(1.0, tk.END) + if result: + self.result_text.insert(tk.END, result) + self.status_label.config(text="识别完成", fg="green") + # 保存结果 + with open(RESULT_FILE, "w", encoding="utf-8") as f: + f.write(result) + else: + self.result_text.insert(tk.END, "❌ 识别失败\n\n请安装识别引擎:\n• pip install openai-whisper\n• pip install SpeechRecognition") + self.status_label.config(text="识别失败", fg="red") + + def _do_recognize(self): + # 尝试 Whisper + try: + import whisper + self.status_label.config(text="使用 Whisper 识别...", fg="blue") + self.root.update() + model = whisper.load_model("base") + result = model.transcribe(self.record_file, language="zh") + text = result.get("text", "").strip() + if text: + return f"✅ {{text}}" + except Exception as e: + print(f"Whisper error: {{e}}") + + # 尝试 SpeechRecognition + try: + import speech_recognition as sr + self.status_label.config(text="使用 Google 识别...", fg="blue") + self.root.update() + r = sr.Recognizer() + with sr.AudioFile(self.record_file) as source: + audio = r.record(source) + text = r.recognize_google(audio, language="zh-CN") + if text: + return f"✅ {{text}}" + except Exception as e: + print(f"SpeechRecognition error: {{e}}") + + return None + + def run(self): + self.root.mainloop() + + # 清理 + if self.is_recording: + self.is_recording = False + if self.stream: + self.stream.close() + if self.p: + self.p.terminate() + +if __name__ == "__main__": + app = VoiceRecorder() + app.run() +''' + + # 写入脚本 + with open(recorder_script, "w", encoding="utf-8") as f: + f.write(gui_code) + + # 启动录音窗口 + try: + subprocess.Popen([sys.executable, recorder_script], + creationflags=subprocess.CREATE_NEW_CONSOLE) + return "🎙️ 已弹出录音窗口,请在窗口中进行录音和识别\n\n使用方法:\n1. 点击「开始录音」\n2. 对着麦克风说话\n3. 点击「停止录音」\n4. 点击「识别」获取文字结果" + except Exception as e: + return f"❌ 启动录音窗口失败: {e}" + + # ==================== 文件识别 ==================== + + def _op_file(self, args: List[str]) -> str: + """识别音频文件""" + if not args: + return "错误:请提供音频文件路径" + + file_path = args[0] + + if not os.path.exists(file_path): + return f"❌ 文件不存在: {file_path}" + + return self._recognize_file(file_path) + + def _recognize_file(self, file_path: str) -> str: + """识别音频文件内容""" + + # 尝试使用 whisper(本地) + result = self._try_whisper(file_path) + if result: + self._save_result(result) + return result + + # 尝试使用 speech_recognition 库 + result = self._try_speech_recognition(file_path) + if result: + self._save_result(result) + return result + + return "❌ 语音识别失败:未安装识别引擎\n\n安装方法:\n1. Whisper (推荐): pip install openai-whisper\n2. SpeechRecognition: pip install SpeechRecognition\n3. PyAudio (录音): pip install pyaudio" + + def _save_result(self, result: str): + """保存识别结果""" + try: + with open(RESULT_FILE, "w", encoding="utf-8") as f: + f.write(result) + except: + pass + + def _try_whisper(self, file_path: str) -> Optional[str]: + """使用 Whisper 识别""" + try: + import whisper + + print(f"[语音识别] 使用 Whisper 识别中...") + model = whisper.load_model("base") + result = model.transcribe(file_path, language="zh") + + text = result.get("text", "").strip() + if text: + return f"✅ 识别结果:\n{text}" + return None + + except ImportError: + return None + except Exception as e: + print(f"[语音识别] Whisper 错误: {e}") + return None + + def _try_speech_recognition(self, file_path: str) -> Optional[str]: + """使用 SpeechRecognition 库识别""" + wav_file = file_path + try: + import speech_recognition as sr + + print(f"[语音识别] 使用 SpeechRecognition 识别中...") + r = sr.Recognizer() + + # 转换为 wav 格式 + wav_file = self._convert_to_wav(file_path) + + with sr.AudioFile(wav_file) as source: + audio = r.record(source) + + # 尝试 Google 语音识别 + try: + text = r.recognize_google(audio, language="zh-CN") + if text: + return f"✅ 识别结果:\n{text}" + except: + pass + + return None + + except ImportError: + return None + except Exception as e: + print(f"[语音识别] SpeechRecognition 错误: {e}") + return None + finally: + # 清理转换的临时文件 + if wav_file != file_path and os.path.exists(wav_file): + try: + os.remove(wav_file) + except: + pass + + def _convert_to_wav(self, file_path: str) -> str: + """转换音频为 wav 格式""" + if file_path.lower().endswith('.wav'): + return file_path + + try: + wav_file = os.path.join(self.temp_dir, f"convert_{int(time.time())}.wav") + + # 使用 ffmpeg 转换 + subprocess.run([ + 'ffmpeg', '-y', '-i', file_path, + '-acodec', 'pcm_s16le', + '-ar', '16000', + '-ac', '1', + wav_file + ], capture_output=True, check=True) + + return wav_file + + except Exception as e: + print(f"[语音识别] 转换错误: {e}") + return file_path + + # ==================== 其他 ==================== + + def _op_status(self, args: List[str]) -> str: + """查看状态""" + lines = ["📊 语音识别插件状态"] + + # 检查可用引擎 + engines = [] + + try: + import whisper + engines.append("whisper ✅") + except: + engines.append("whisper ❌ (pip install openai-whisper)") + + try: + import speech_recognition + engines.append("speech_recognition ✅") + except: + engines.append("speech_recognition ❌ (pip install SpeechRecognition)") + + try: + import pyaudio + engines.append("pyaudio ✅ (录音支持)") + except: + engines.append("pyaudio ❌ (pip install pyaudio)") + + lines.append("\n可用引擎:") + for e in engines: + lines.append(f" - {e}") + + return "\n".join(lines) + + def _op_result(self, args: List[str]) -> str: + """获取上次识别结果""" + if os.path.exists(RESULT_FILE): + try: + with open(RESULT_FILE, "r", encoding="utf-8") as f: + return f.read() + except: + pass + return "暂无识别结果" + + +if __name__ == "__main__": + plugin = Plugin() + print("语音识别插件") + print(f"临时目录: {plugin.temp_dir}") + print("\n" + plugin.usage) diff --git a/plugins/task_manager.py b/plugins/task_manager.py new file mode 100644 index 0000000..6487cad --- /dev/null +++ b/plugins/task_manager.py @@ -0,0 +1,263 @@ +""" +任务管理插件 - 提供任务追踪和进度管理功能 +""" + +import json +import os +from datetime import datetime +from colorama import Fore, Style + + +class Liugin: + """任务管理插件 - 类似 iFlow CLI 的 todo 功能""" + + def __init__(self): + self.usage = """任务管理工具使用方法: + +JSON格式示例: +{"action": "use_tool", "tool": "task_manager", "args": "add 创建登录页面"} - 添加新任务 +{"action": "use_tool", "tool": "task_manager", "args": "list"} - 列出所有任务 +{"action": "use_tool", "tool": "task_manager", "args": "complete 1"} - 完成任务ID为1的任务 +{"action": "use_tool", "tool": "task_manager", "args": "in_progress 2"} - 将任务ID为2标记为进行中 +{"action": "use_tool", "tool": "task_manager", "args": "delete 3"} - 删除任务ID为3的任务 +{"action": "use_tool", "tool": "task_manager", "args": "clear"} - 清除所有已完成的任务 + +功能说明: +- add <任务描述> - 添加新任务(自动分配ID) +- list - 列出所有任务及其状态 +- complete <任务ID> - 标记任务为已完成 +- in_progress <任务ID> - 标记任务为进行中 +- delete <任务ID> - 删除指定任务 +- clear - 清除所有已完成的任务 + +状态说明: +- pending: 待处理 +- in_progress: 进行中 +- completed: 已完成 +- failed: 失败""" + self.cli = None + self.tasks_file = "tasks.json" + self.tasks = [] + self._load_tasks() + + def set_cli(self, cli): + """设置CLI实例引用""" + self.cli = cli + # 注册插件命令 + self.cli.register_liugin_command('task', self.command_handler) + self.cli.register_liugin_command('todo', self.command_handler) + + def command_handler(self, args): + """处理 /task 或 /todo 命令""" + return self.handle(args) + + def get_tool_info(self): + return { + "name": "task_manager", + "description": "任务管理工具,提供任务追踪和进度管理功能,支持添加、完成、删除任务", + "keywords": ["任务", "task", "todo", "管理", "进度", "追踪", "list", "complete"], + "usage": self.usage + } + + def _load_tasks(self): + """从文件加载任务""" + try: + if os.path.exists(self.tasks_file): + with open(self.tasks_file, 'r', encoding='utf-8') as f: + self.tasks = json.load(f) + except Exception as e: + self.tasks = [] + + def _save_tasks(self): + """保存任务到文件""" + try: + with open(self.tasks_file, 'w', encoding='utf-8') as f: + json.dump(self.tasks, f, ensure_ascii=False, indent=2) + except Exception as e: + return f"保存任务失败: {e}" + return None + + def handle(self, args): + """处理任务管理请求""" + try: + parts = args.strip().split() + if not parts: + return self._list_tasks() + + operation = parts[0].lower() + + if operation == "add": + # 添加任务 + if len(parts) < 2: + return "错误:请提供任务描述。格式: add <任务描述>" + task_desc = " ".join(parts[1:]) + return self._add_task(task_desc) + + elif operation == "list": + # 列出任务 + return self._list_tasks() + + elif operation == "complete": + # 完成任务 + if len(parts) < 2: + return "错误:请提供任务ID。格式: complete <任务ID>" + task_id = parts[1] + return self._update_task_status(task_id, "completed") + + elif operation == "in_progress": + # 标记为进行中 + if len(parts) < 2: + return "错误:请提供任务ID。格式: in_progress <任务ID>" + task_id = parts[1] + return self._update_task_status(task_id, "in_progress") + + elif operation == "delete": + # 删除任务 + if len(parts) < 2: + return "错误:请提供任务ID。格式: delete <任务ID>" + task_id = parts[1] + return self._delete_task(task_id) + + elif operation == "clear": + # 清除已完成任务 + return self._clear_completed() + + else: + return f"错误:不支持的操作 '{operation}'。支持的操作有: add, list, complete, in_progress, delete, clear" + + except Exception as e: + return f"任务管理操作错误: {str(e)}" + + def _add_task(self, description): + """添加新任务""" + # 使用最大ID+1的方式分配ID,避免删除后ID重复 + existing_ids = [int(t.get("id", 0)) for t in self.tasks if t.get("id", "").isdigit()] + task_id = str(max(existing_ids, default=0) + 1) + new_task = { + "id": task_id, + "task": description, + "status": "pending", + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "priority": "medium" + } + self.tasks.append(new_task) + error = self._save_tasks() + if error: + return error + return f"✅ 已添加任务 #{task_id}: {description}" + + def _list_tasks(self): + """列出所有任务""" + if not self.tasks: + return "📝 当前没有任务" + + result = f"\n📋 任务列表 (共 {len(self.tasks)} 个任务)\n" + result += "=" * 50 + "\n" + + pending_count = 0 + in_progress_count = 0 + completed_count = 0 + + for task in self.tasks: + status = task.get('status', 'pending') + task_id = task.get('id', '?') + description = task.get('task', '') + created_at = task.get('created_at', '') + + # 统计 + if status == 'pending': + pending_count += 1 + status_icon = "⏳" + status_color = Fore.YELLOW + elif status == 'in_progress': + in_progress_count += 1 + status_icon = "🔄" + status_color = Fore.CYAN + elif status == 'completed': + completed_count += 1 + status_icon = "✅" + status_color = Fore.GREEN + else: + status_icon = "❌" + status_color = Fore.RED + + result += f"{status_icon} [{task_id}] {status_color}{status}{Style.RESET_ALL} - {description}\n" + result += f" 创建时间: {created_at}\n\n" + + result += "=" * 50 + "\n" + result += f"📊 统计: {Fore.YELLOW}待处理 {pending_count}{Style.RESET_ALL} | {Fore.CYAN}进行中 {in_progress_count}{Style.RESET_ALL} | {Fore.GREEN}已完成 {completed_count}{Style.RESET_ALL}\n" + + return result + + def _update_task_status(self, task_id, new_status): + """更新任务状态""" + for task in self.tasks: + if task.get('id') == task_id: + old_status = task.get('status') + task['status'] = new_status + if new_status == 'completed': + task['completed_at'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + error = self._save_tasks() + if error: + return error + return f"✅ 任务 #{task_id} 状态已更新: {old_status} → {new_status}" + + return f"❌ 未找到任务ID: {task_id}" + + def _delete_task(self, task_id): + """删除任务""" + for i, task in enumerate(self.tasks): + if task.get('id') == task_id: + deleted_task = self.tasks.pop(i) + error = self._save_tasks() + if error: + return error + return f"🗑️ 已删除任务 #{task_id}: {deleted_task.get('task', '')}" + + return f"❌ 未找到任务ID: {task_id}" + + def _clear_completed(self): + """清除所有已完成的任务""" + completed_count = len([t for t in self.tasks if t.get('status') == 'completed']) + if completed_count == 0: + return "📝 没有已完成的任务需要清除" + + self.tasks = [t for t in self.tasks if t.get('status') != 'completed'] + error = self._save_tasks() + if error: + return error + return f"✅ 已清除 {completed_count} 个已完成的任务" + + +# 测试函数 +def test_plugin(): + """测试插件功能""" + plugin = Plugin() + print("任务管理插件测试:") + + print("\n1. 添加任务:") + print(plugin.handle("add 创建登录页面")) + print(plugin.handle("add 实现用户认证")) + print(plugin.handle("add 添加数据库连接")) + + print("\n2. 列出任务:") + print(plugin.handle("list")) + + print("\n3. 标记任务为进行中:") + print(plugin.handle("in_progress 1")) + + print("\n4. 完成任务:") + print(plugin.handle("complete 1")) + + print("\n5. 再次列出任务:") + print(plugin.handle("list")) + + print("\n6. 清除已完成任务:") + print(plugin.handle("clear")) + + print("\n7. 最终任务列表:") + print(plugin.handle("list")) + + +if __name__ == "__main__": + test_plugin() \ No newline at end of file diff --git a/plugins/tool_search.py b/plugins/tool_search.py new file mode 100644 index 0000000..12229de --- /dev/null +++ b/plugins/tool_search.py @@ -0,0 +1,107 @@ +""" +工具搜索插件 - 支持按需发现工具 +""" +import json + + +class Liugin: + """工具搜索插件 - 允许 AI 按需搜索和发现可用工具""" + + def __init__(self): + self.usage = """工具搜索使用方法: +tool_search <关键词> - 根据关键词搜索可用工具 + +例如: +- tool_search 文件 - 搜索与文件相关的工具 +- tool_search 网络 - 搜索与网络相关的工具 +- tool_search 图片 - 搜索与图片相关的工具 + +此插件帮助 AI 在需要特定功能时快速找到合适的工具, +而不是预加载所有工具到上下文中。""" + self.cli = None + + def set_cli(self, cli): + """设置 CLI 实例引用""" + self.cli = cli + + def get_tool_info(self): + return { + "name": "tool_search", + "description": "工具搜索插件,支持按需发现和搜索可用工具。当你不确定有哪些工具可用时,可以使用此工具搜索相关功能的工具。", + "keywords": ["搜索", "工具", "发现", "search", "tool", "find", "可用工具", "工具列表"], + "usage": """tool_search 工具使用说明: +JSON格式示例: +{"action": "use_tool", "tool": "tool_search", "args": "文件"} - 搜索与文件相关的工具 +{"action": "use_tool", "tool": "tool_search", "args": "网络"} - 搜索与网络相关的工具 +{"action": "use_tool", "tool": "tool_search", "args": "图片"} - 搜索与图片相关的工具 + +功能说明: +- 支持关键词匹配(工具名称、描述、关键词) +- 返回匹配度最高的前 5 个工具 +- 包含工具的详细使用说明 +- 帮助 AI 快速找到合适的工具而不需要预加载所有工具""" + } + + def handle(self, args): + """处理工具搜索请求""" + try: + # 解析参数 + query = args.strip() + + if not query: + return "错误:请提供搜索关键词。格式: tool_search <关键词>" + + # 搜索工具 + if self.cli and hasattr(self.cli, 'liugin_manager'): + matches = self.cli.liugin_manager.search_tools(query, limit=5) + + if not matches: + return f"未找到与 '{query}' 相关的工具。" + + # 格式化搜索结果 + result_lines = [f"找到 {len(matches)} 个与 '{query}' 相关的工具:\n"] + + for i, match in enumerate(matches, 1): + result_lines.append(f"{i}. {match['name']}") + result_lines.append(f" 描述: {match['description']}") + + # 显示协议类型 + protocol = match.get('protocol', 'liugin') + result_lines.append(f" 协议: {protocol}") + + # 关键词 + keywords = match.get('keywords', []) + if keywords: + result_lines.append(f" 关键词: {', '.join(keywords)}") + + # 根据协议类型显示不同的使用说明 + if protocol == 'skill': + # Skill 协议:显示完整指令 + instance = match.get('instance') + if instance: + instructions = getattr(instance, 'instructions', '') + if instructions: + # 限制长度 + if len(instructions) > 1500: + instructions = instructions[:1500] + "\n...(内容已截断)" + result_lines.append(f" 指令:\n{instructions}") + else: + usage = match.get('usage', '') + if usage: + result_lines.append(f" 用法: {usage}") + else: + # Plugin 协议:显示 usage + if match.get('usage'): + usage = match['usage'] + if len(usage) > 500: + usage = usage[:500] + "..." + result_lines.append(f" 用法: {usage}") + + result_lines.append("") + + return "\n".join(result_lines) + else: + return "错误:无法访问插件管理器。" + + except Exception as e: + return f"工具搜索失败: {str(e)}" \ No newline at end of file diff --git a/plugins_config.py b/plugins_config.py new file mode 100644 index 0000000..692577d --- /dev/null +++ b/plugins_config.py @@ -0,0 +1,16 @@ +# 小狸插件配置 +# 自动生成于: 2026-04-25 + +ENABLE_AI_SEARCH = True +ENABLE_AUDIO_PLAYER = True +ENABLE_CMD_EXECUTOR = True +ENABLE_FILE_MANAGER = True +ENABLE_CODE_EDITOR = True +ENABLE_CODE_SEARCH = True +ENABLE_GIT_TOOLS = True +ENABLE_FRONTEND_TESTER = True +ENABLE_NETWORK_TOOLS = True +ENABLE_SCHEDULER = True +ENABLE_SPEECH_RECOGNITION = True +ENABLE_TASK_MANAGER = True +ENABLE_TOOL_SEARCH = True diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19a5f2e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +# 小狸 Pro-CLI 依赖库 +# 修复后的包名(使用 pip 安装名) + +# 核心依赖 +colorama>=0.4.6 +openai>=1.0.0 +requests>=2.28.0 +ollama>=0.1.0 + +# 图像处理 +Pillow>=9.0.0 +opencv-python>=4.5.0 +numpy>=1.21.0 +ascii-magic>=2.0.0 + +# 数据处理 +pandas>=1.3.0 +scipy>=1.7.0 + +# 音频 +pygame>=2.0.0 + +# TUI 模式 +textual>=0.40.0 + +# WebSocket 远程 +websockets>=11.0 + +# 类型提示 +typing-extensions>=4.0.0 diff --git a/skills/__init__.py b/skills/__init__.py new file mode 100644 index 0000000..a993e0d --- /dev/null +++ b/skills/__init__.py @@ -0,0 +1,28 @@ +""" +Agent Skills 协议实现 +支持两种格式: +1. 标准开源格式: SKILL.md (YAML frontmatter + Markdown) +2. Python 类格式: Skill 子类 (用于需要代码执行的技能) +""" + +from .base import ( + # 基础类 + Skill, + SkillResult, + SkillParameter, + ParameterType, + SkillLoader, + # Markdown 技能 + MarkdownSkill, +) + +__all__ = [ + 'Skill', + 'SkillResult', + 'SkillParameter', + 'ParameterType', + 'SkillLoader', + 'MarkdownSkill', +] + +__version__ = '1.0.0' diff --git a/skills/base.py b/skills/base.py new file mode 100644 index 0000000..1008526 --- /dev/null +++ b/skills/base.py @@ -0,0 +1,396 @@ +""" +Agent Skills 协议基类 +支持两种格式: +1. 标准开源格式: SKILL.md (YAML frontmatter + Markdown) +2. Python 类格式: Skill 子类 (用于需要代码执行的技能) +""" + +import os +import re +import json +import threading +import importlib +import inspect +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, List, Dict, Any, Union + + +class ParameterType(Enum): + """参数类型枚举 - 映射到 JSON Schema""" + STRING = "string" + INTEGER = "integer" + NUMBER = "number" + BOOLEAN = "boolean" + ARRAY = "array" + OBJECT = "object" + + +@dataclass +class SkillParameter: + """技能参数定义""" + name: str + param_type: ParameterType + description: str + required: bool = True + default: Any = None + enum: Optional[List[Any]] = None + + def to_json_schema(self) -> Dict[str, Any]: + """转换为 JSON Schema 格式""" + schema = { + "type": self.param_type.value, + "description": self.description + } + if self.enum: + schema["enum"] = self.enum + return schema + + +@dataclass +class SkillResult: + """技能执行结果""" + success: bool + data: Any = None + message: str = "" + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + def __str__(self) -> str: + if self.success: + return self.message or str(self.data) + return f"Error: {self.error}" + + def to_dict(self) -> Dict[str, Any]: + return { + "success": self.success, + "data": self.data, + "message": self.message, + "error": self.error, + "metadata": self.metadata + } + + +class Skill(ABC): + """技能基类 - 用于 Python 实现的技能""" + + def __init__(self): + self.name: str = "" + self.description: str = "" + self.version: str = "1.0.0" + self.author: str = "" + self.tags: List[str] = [] + self._cli = None + + @property + @abstractmethod + def parameters(self) -> List[SkillParameter]: + pass + + @abstractmethod + def execute(self, **kwargs) -> SkillResult: + pass + + def set_cli(self, cli) -> None: + self._cli = cli + + def get_tool_definition(self) -> Dict[str, Any]: + """获取 OpenAI 格式的工具定义""" + properties = {} + required = [] + for param in self.parameters: + properties[param.name] = param.to_json_schema() + if param.required: + required.append(param.name) + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": properties, + "required": required + } + } + } + + def get_anthropic_tool_definition(self) -> Dict[str, Any]: + """获取 Anthropic 格式的工具定义""" + properties = {} + required = [] + for param in self.parameters: + properties[param.name] = param.to_json_schema() + if param.required: + required.append(param.name) + return { + "name": self.name, + "description": self.description, + "input_schema": { + "type": "object", + "properties": properties, + "required": required + } + } + + def run(self, **kwargs) -> SkillResult: + """执行技能(带参数验证)""" + error = self._validate_parameters(**kwargs) + if error: + return SkillResult(success=False, error=error) + return self.execute(**kwargs) + + def _validate_parameters(self, **kwargs) -> Optional[str]: + """验证参数""" + for param in self.parameters: + if param.required and param.name not in kwargs: + return f"缺少必需参数: {param.name}" + return None + + +@dataclass +class MarkdownSkill: + """Markdown 格式的技能 (标准 Agent Skills 格式)""" + name: str + description: str + version: str = "1.0.0" + author: str = "" + license: str = "MIT" + tools: List[str] = field(default_factory=list) + instructions: str = "" + file_path: str = "" + scripts_dir: str = "" + + def get_tool_definition(self) -> Dict[str, Any]: + """获取 OpenAI 格式的工具定义""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "read", "write", "delete", "copy", "move", "info", "search"], + "description": "要执行的文件操作类型" + }, + "path": { + "type": "string", + "description": "目标文件或目录路径" + }, + "content": { + "type": "string", + "description": "写入内容(write/append 操作时使用)" + }, + "destination": { + "type": "string", + "description": "目标路径(copy/move 操作时使用)" + } + }, + "required": ["operation"] + } + } + } + + def get_anthropic_tool_definition(self) -> Dict[str, Any]: + """获取 Anthropic 格式的工具定义""" + return { + "name": self.name, + "description": self.description, + "input_schema": { + "type": "object", + "properties": { + "operation": {"type": "string"}, + "path": {"type": "string"}, + "content": {"type": "string"}, + "destination": {"type": "string"} + }, + "required": ["operation"] + } + } + + +class SkillLoader: + """技能加载器 - 支持标准 SKILL.md 和 Python 类两种格式""" + + def __init__(self, skills_dir: str = None): + self.skills_dir = skills_dir + self._skills: Dict[str, Union[Skill, MarkdownSkill]] = {} + self._lock = threading.RLock() + + @property + def skills(self) -> Dict[str, Union[Skill, MarkdownSkill]]: + return self._skills.copy() + + def discover_skills(self, skills_dir: str = None) -> List[Dict[str, str]]: + """发现所有技能""" + search_dir = skills_dir or self.skills_dir + if not search_dir or not os.path.isdir(search_dir): + return [] + + discovered = [] + for item in os.listdir(search_dir): + item_path = os.path.join(search_dir, item) + + # 跳过特殊文件 + if item in ('__init__.py', 'base.py') or item.startswith('_') or item.startswith('.'): + continue + + # SKILL.md 格式 (目录) + skill_md = os.path.join(item_path, "SKILL.md") + if os.path.isdir(item_path) and os.path.isfile(skill_md): + discovered.append({"name": item, "type": "markdown", "path": skill_md}) + + # Python 单文件格式 + elif item.endswith('.py') and os.path.isfile(item_path): + discovered.append({"name": item[:-3], "type": "python", "path": item_path}) + + return discovered + + def parse_skill_md(self, file_path: str) -> Optional[MarkdownSkill]: + """解析 SKILL.md 文件""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 解析 YAML frontmatter + if not content.startswith('---'): + return None + + end_idx = content.find('---', 3) + if end_idx == -1: + return None + + frontmatter = content[3:end_idx].strip() + instructions = content[end_idx + 3:].strip() + + # 解析 YAML (简化版) + metadata = {} + current_list = None + current_key = None + + for line in frontmatter.split('\n'): + stripped = line.strip() + if not stripped: + continue + + # 列表项 + if stripped.startswith('- '): + if current_key and current_key not in metadata: + metadata[current_key] = [] + if current_key and isinstance(metadata.get(current_key), list): + metadata[current_key].append(stripped[2:]) + continue + + # 键值对 + if ':' in line: + current_key = None + colon_idx = line.index(':') + key = line[:colon_idx].strip() + value = line[colon_idx + 1:].strip() + + if value: + # 处理列表格式 [a, b, c] + if value.startswith('[') and value.endswith(']'): + value = [v.strip() for v in value[1:-1].split(',') if v.strip()] + metadata[key] = value + else: + current_key = key + current_list = [] + + skill_dir = os.path.dirname(file_path) + scripts_dir = os.path.join(skill_dir, "scripts") + + # 解析参数定义 (从 frontmatter 中的 parameters 字段) + parameters = {} + if "parameters" in metadata and isinstance(metadata["parameters"], dict): + parameters = metadata["parameters"] + elif "params" in metadata and isinstance(metadata["params"], dict): + parameters = metadata["params"] + + return MarkdownSkill( + name=metadata.get('name', os.path.basename(skill_dir)), + description=metadata.get('description', ''), + version=metadata.get('version', '1.0.0'), + author=metadata.get('author', ''), + license=metadata.get('license', 'MIT'), + tools=metadata.get('tools', []) if isinstance(metadata.get('tools'), list) else [], + instructions=instructions, + file_path=file_path, + scripts_dir=scripts_dir if os.path.isdir(scripts_dir) else "" + ) + + except Exception as e: + print(f"解析 SKILL.md 失败 ({file_path}): {e}") + return None + + def load_skill(self, skill_info: Dict[str, str]) -> Optional[Union[Skill, MarkdownSkill]]: + """加载单个技能""" + skill_type = skill_info.get('type') + skill_path = skill_info.get('path') + skill_name = skill_info.get('name') + + if skill_type == "markdown": + skill = self.parse_skill_md(skill_path) + if skill: + self._skills[skill.name] = skill + return skill + + elif skill_type == "python": + try: + # 动态导入 Python 模块 + spec = importlib.util.spec_from_file_location(skill_name, skill_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 查找 Skill 子类 + for name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Skill) and obj is not Skill: + skill = obj() + self._skills[skill.name] = skill + return skill + + except Exception as e: + print(f"加载 Python 技能 '{skill_name}' 失败: {e}") + + return None + + def load_all_skills(self, skills_dir: str = None) -> Dict[str, Union[Skill, MarkdownSkill]]: + """加载所有技能""" + discovered = self.discover_skills(skills_dir) + + for skill_info in discovered: + self.load_skill(skill_info) + + return self.skills + + def get_skill(self, name: str) -> Optional[Union[Skill, MarkdownSkill]]: + """获取已加载的技能""" + return self._skills.get(name) + + def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]: + """获取所有技能的工具定义""" + definitions = [] + for skill in self._skills.values(): + if format == "anthropic": + definitions.append(skill.get_anthropic_tool_definition()) + else: + definitions.append(skill.get_tool_definition()) + return definitions + + def get_skill(self, name: str) -> Optional[Union[Skill, MarkdownSkill]]: + """获取已加载的技能""" + return self._skills.get(name) + + def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]: + """获取所有技能的工具定义""" + definitions = [] + for skill in self._skills.values(): + if format == "anthropic": + definitions.append(skill.get_anthropic_tool_definition()) + else: + definitions.append(skill.get_tool_definition()) + return definitions \ No newline at end of file diff --git a/skills/file-manager/SKILL.md b/skills/file-manager/SKILL.md new file mode 100644 index 0000000..7210cd1 --- /dev/null +++ b/skills/file-manager/SKILL.md @@ -0,0 +1,305 @@ +--- +name: file-manager +description: File and directory management tool for creating, reading, writing, deleting, moving, copying, and searching files. Use this skill when the user needs to perform file operations, list directories, search by pattern or content, or get file metadata like size and modification time. +license: MIT +version: 1.0.0 +author: Xiaoli Team (based on openakita/openakita) +tools: + - read_file + - write_file + - bash +--- + +# File Manager Skill + +A comprehensive file and directory management toolset for AI agents. + +## Capabilities + +- **Create**: Files and directories +- **Read**: File contents with encoding support +- **Write**: Create or overwrite files +- **Append**: Add content to existing files +- **Delete**: Remove files or directories +- **Move**: Move or rename files and directories +- **Copy**: Duplicate files or directories +- **Search**: Find files by pattern or content +- **List**: Browse directory contents +- **Info**: Get file metadata (size, dates, permissions) + +## Script Usage + +This skill includes a Python script for structured JSON output: + +```bash +python scripts/file_ops.py list [--recursive] [--pattern "*.py"] +python scripts/file_ops.py read [--encoding utf-8] [--lines N] +python scripts/file_ops.py write --content "内容" [--append] +python scripts/file_ops.py copy +python scripts/file_ops.py move +python scripts/file_ops.py delete [--recursive] +python scripts/file_ops.py search --pattern "*.py" [--content "搜索词"] +python scripts/file_ops.py info +``` + +## Direct Shell Commands + +### List Directory Contents + +When asked to show files in a directory: + +```bash +# List current directory +ls -la + +# List specific directory +ls -la /path/to/directory + +# Recursive listing (Unix) +find /path -type f -name "*.py" + +# Windows +dir /s /b C:\path\*.py +``` + +### Read File + +When asked to read a file: + +```bash +# Read entire file +cat filename.txt + +# Read with line numbers +cat -n filename.txt + +# Read first N lines +head -n 50 filename.txt + +# Read last N lines +tail -n 50 filename.txt +``` + +### Write File + +When asked to create or write a file: + +```bash +# Write content to file (overwrites) +echo "content" > filename.txt + +# Write multiple lines +cat > filename.txt << 'EOF' +Line 1 +Line 2 +Line 3 +EOF + +# Windows PowerShell +Set-Content -Path filename.txt -Value "content" +``` + +### Append to File + +When asked to add content to an existing file: + +```bash +# Append to file +echo "new line" >> filename.txt + +# Append multiple lines +cat >> filename.txt << 'EOF' +Additional line 1 +Additional line 2 +EOF +``` + +### Create Directory + +When asked to create a folder: + +```bash +# Create single directory +mkdir directory_name + +# Create nested directories +mkdir -p path/to/nested/directories + +# Windows +mkdir path\to\nested\directories +``` + +### Delete File or Directory + +When asked to delete: + +```bash +# Delete a file +rm filename.txt + +# Delete empty directory +rmdir directory_name + +# Delete directory and contents +rm -rf directory_name + +# Windows +del filename.txt +rmdir /s /q directory_name +``` + +⚠️ **Warning**: Deletion is permanent. Always confirm before deleting. + +### Copy File or Directory + +When asked to copy: + +```bash +# Copy file +cp source.txt destination.txt + +# Copy directory recursively +cp -r source_dir/ destination_dir/ + +# Windows +copy source.txt destination.txt +xcopy /E /I source_dir destination_dir +``` + +### Move or Rename + +When asked to move or rename: + +```bash +# Move file +mv source.txt /new/location/source.txt + +# Rename file +mv oldname.txt newname.txt + +# Windows +move source.txt destination.txt +ren oldname.txt newname.txt +``` + +### Search Files + +When asked to find files: + +```bash +# Find by name pattern +find /path -name "*.py" +find /path -iname "*.PY" # case-insensitive + +# Find by content (grep) +grep -r "search_term" /path +grep -rl "search_term" /path # filenames only + +# Windows PowerShell +Get-ChildItem -Path C:\path -Filter *.py -Recurse +Select-String -Path C:\path\*.py -Pattern "search_term" +``` + +### Get File Information + +When asked about file details: + +```bash +# File metadata (Unix) +ls -la filename.txt +stat filename.txt + +# File size only +du -h filename.txt + +# Windows +dir filename.txt +``` + +## JSON Output Format + +All operations should return structured results: + +```json +{ + "success": true, + "operation": "list", + "path": "/home/user/project", + "data": { + "files": ["main.py", "config.json", "README.md"], + "directories": ["src", "tests", "docs"], + "total_count": 6 + }, + "timestamp": "2026-03-21T10:30:00Z" +} +``` + +## Safety Rules + +1. **Always validate paths**: Reject paths containing `..` that escape the working directory +2. **Confirm destructive operations**: Ask before delete, move, or overwrite +3. **Handle encoding**: Default to UTF-8, detect and handle other encodings +4. **Large file warning**: Alert for files > 10MB +5. **Binary file detection**: Cannot read binary files as text + +## Protected Directories + +Never allow operations on: +- System directories: `/etc`, `/root`, `C:\Windows`, `C:\Program Files` +- Hidden files starting with `.` (unless explicitly requested) +- Files with sensitive names: `.env`, `credentials`, `secrets` + +## Error Handling + +| Error | Response | +|-------|----------| +| File not found | Suggest similar filenames or check path | +| Permission denied | Explain required permissions | +| Disk full | Warn user and suggest cleanup | +| Invalid path | Show correct path format | + +## Examples + +### Example 1: List Project Files + +User: "Show me the project structure" + +```bash +find . -type f -name "*.py" | head -20 +tree -L 2 -I '__pycache__|*.pyc' +``` + +### Example 2: Search and Replace + +User: "Replace 'old_func' with 'new_func' in all Python files" + +```bash +# Find and preview +grep -r "old_func" --include="*.py" . + +# Replace (with backup) +find . -name "*.py" -exec sed -i.bak 's/old_func/new_func/g' {} \; +``` + +### Example 3: Safe Delete + +User: "Delete the temp folder" + +Before executing: +1. Check if path exists +2. List contents to show what will be deleted +3. Ask for confirmation +4. Execute if confirmed + +## Cross-Platform Notes + +| Operation | Unix | Windows | +|-----------|------|---------| +| List | `ls -la` | `dir` | +| Copy | `cp` | `copy` | +| Move | `mv` | `move` | +| Delete | `rm -rf` | `rmdir /s /q` | +| Find | `find` | `Get-ChildItem` | +| Grep | `grep` | `Select-String` | + +Always detect the OS and use appropriate commands. \ No newline at end of file diff --git a/skills/file-manager/scripts/file_ops.py b/skills/file-manager/scripts/file_ops.py new file mode 100644 index 0000000..e2acfa1 --- /dev/null +++ b/skills/file-manager/scripts/file_ops.py @@ -0,0 +1,670 @@ +#!/usr/bin/env python3 +""" +File Operations Script for file-manager skill +Provides file system operations as JSON output +""" + +import os +import sys +import json +import shutil +import argparse +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, Any, List + + +def json_response(success: bool, operation: str, data: Any = None, error: str = None) -> str: + """Generate JSON response""" + return json.dumps({ + "success": success, + "operation": operation, + "data": data, + "error": error, + "timestamp": datetime.now().isoformat() + }, ensure_ascii=False, indent=2) + + +def list_directory(path: str, recursive: bool = False, pattern: str = None) -> str: + """List directory contents""" + try: + path = os.path.abspath(path) + if not os.path.exists(path): + return json_response(False, "list", error=f"Path not found: {path}") + + if not os.path.isdir(path): + return json_response(False, "list", error=f"Not a directory: {path}") + + files = [] + directories = [] + + if recursive: + for root, dirs, filenames in os.walk(path): + for d in dirs: + rel_path = os.path.relpath(os.path.join(root, d), path) + if pattern is None or Path(d).match(pattern): + directories.append(rel_path) + for f in filenames: + rel_path = os.path.relpath(os.path.join(root, f), path) + if pattern is None or Path(f).match(pattern): + files.append(rel_path) + else: + for item in os.listdir(path): + full_path = os.path.join(path, item) + if os.path.isdir(full_path): + if pattern is None or Path(item).match(pattern): + directories.append(item) + else: + if pattern is None or Path(item).match(pattern): + files.append(item) + + return json_response(True, "list", { + "path": path, + "files": sorted(files), + "directories": sorted(directories), + "file_count": len(files), + "dir_count": len(directories), + "total_count": len(files) + len(directories) + }) + except Exception as e: + return json_response(False, "list", error=str(e)) + + +def read_file(file_path: str, encoding: str = "utf-8", lines: int = None) -> str: + """Read file content""" + try: + file_path = os.path.abspath(file_path) + if not os.path.exists(file_path): + return json_response(False, "read", error=f"File not found: {file_path}") + + if not os.path.isfile(file_path): + return json_response(False, "read", error=f"Not a file: {file_path}") + + file_size = os.path.getsize(file_path) + + with open(file_path, 'r', encoding=encoding) as f: + if lines: + content_lines = [] + for i, line in enumerate(f): + if i >= lines: + break + content_lines.append(line.rstrip('\n\r')) + content = '\n'.join(content_lines) + total_lines = sum(1 for _ in open(file_path, 'r', encoding=encoding)) + else: + content = f.read() + total_lines = content.count('\n') + 1 + + return json_response(True, "read", { + "path": file_path, + "content": content, + "size": file_size, + "lines": total_lines, + "displayed_lines": lines if lines else total_lines + }) + except UnicodeDecodeError: + return json_response(False, "read", error="Cannot decode file (binary or wrong encoding)") + except Exception as e: + return json_response(False, "read", error=str(e)) + + +def write_file(file_path: str, content: str, append: bool = False) -> str: + """Write or append to file""" + try: + file_path = os.path.abspath(file_path) + + # Create parent directory if needed + parent = os.path.dirname(file_path) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + mode = 'a' if append else 'w' + with open(file_path, mode, encoding='utf-8') as f: + f.write(content) + + return json_response(True, "write" if not append else "append", { + "path": file_path, + "size": len(content.encode('utf-8')), + "appended": append + }) + except Exception as e: + return json_response(False, "write", error=str(e)) + + +def copy_path(source: str, destination: str) -> str: + """Copy file or directory""" + try: + source = os.path.abspath(source) + destination = os.path.abspath(destination) + + if not os.path.exists(source): + return json_response(False, "copy", error=f"Source not found: {source}") + + # Create parent directory for destination + parent = os.path.dirname(destination) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + if os.path.isfile(source): + shutil.copy2(source, destination) + else: + shutil.copytree(source, destination, dirs_exist_ok=True) + + return json_response(True, "copy", { + "source": source, + "destination": destination, + "type": "file" if os.path.isfile(source) else "directory" + }) + except Exception as e: + return json_response(False, "copy", error=str(e)) + + +def move_path(source: str, destination: str) -> str: + """Move or rename file or directory""" + try: + source = os.path.abspath(source) + destination = os.path.abspath(destination) + + if not os.path.exists(source): + return json_response(False, "move", error=f"Source not found: {source}") + + # Create parent directory for destination + parent = os.path.dirname(destination) + if parent and not os.path.exists(parent): + os.makedirs(parent, exist_ok=True) + + shutil.move(source, destination) + + return json_response(True, "move", { + "source": source, + "destination": destination + }) + except Exception as e: + return json_response(False, "move", error=str(e)) + + +def delete_path(path: str, recursive: bool = False) -> str: + """Delete file or directory""" + try: + path = os.path.abspath(path) + + if not os.path.exists(path): + return json_response(False, "delete", error=f"Path not found: {path}") + + if os.path.isfile(path): + os.remove(path) + item_type = "file" + elif os.path.isdir(path): + if recursive: + shutil.rmtree(path) + else: + os.rmdir(path) # Only works for empty directories + item_type = "directory" + + return json_response(True, "delete", { + "path": path, + "type": item_type + }) + except OSError as e: + if "not empty" in str(e).lower(): + return json_response(False, "delete", error="Directory not empty. Use --recursive to delete.") + return json_response(False, "delete", error=str(e)) + except Exception as e: + return json_response(False, "delete", error=str(e)) + + +def search_files(directory: str, pattern: str = None, content: str = None) -> str: + """Search files by name pattern or content""" + try: + directory = os.path.abspath(directory) + if not os.path.exists(directory): + return json_response(False, "search", error=f"Directory not found: {directory}") + + results = [] + + for root, dirs, files in os.walk(directory): + for filename in files: + file_path = os.path.join(root, filename) + rel_path = os.path.relpath(file_path, directory) + + # Pattern match + if pattern and not Path(filename).match(pattern): + continue + + # Content search + if content: + try: + with open(file_path, 'r', encoding='utf-8') as f: + file_content = f.read() + if content not in file_content: + continue + # Find matching lines + matches = [] + for i, line in enumerate(file_content.split('\n'), 1): + if content in line: + matches.append({"line": i, "text": line.strip()[:100]}) + if matches: + results.append({ + "path": rel_path, + "matches": matches[:5] # Limit matches per file + }) + except (UnicodeDecodeError, IOError): + continue + else: + results.append({"path": rel_path}) + + return json_response(True, "search", { + "directory": directory, + "pattern": pattern, + "content_search": content, + "results": results[:100], # Limit results + "total": len(results) + }) + except Exception as e: + return json_response(False, "search", error=str(e)) + + +def get_info(path: str) -> str: + """Get file/directory information""" + try: + path = os.path.abspath(path) + if not os.path.exists(path): + return json_response(False, "info", error=f"Path not found: {path}") + + stat_info = os.stat(path) + + info = { + "path": path, + "name": os.path.basename(path), + "type": "directory" if os.path.isdir(path) else "file", + "size": stat_info.st_size, + "created": datetime.fromtimestamp(stat_info.st_ctime).isoformat(), + "modified": datetime.fromtimestamp(stat_info.st_mtime).isoformat(), + "accessed": datetime.fromtimestamp(stat_info.st_atime).isoformat(), + "permissions": oct(stat_info.st_mode)[-3:] + } + + if os.path.isfile(path): + info["extension"] = os.path.splitext(path)[1] + # Try to detect if text file + try: + with open(path, 'r', encoding='utf-8') as f: + f.read(1024) + info["encoding"] = "utf-8" + except UnicodeDecodeError: + info["encoding"] = "binary" + + return json_response(True, "info", info) + except Exception as e: + return json_response(False, "info", error=str(e)) + + +def modify_line(file_path: str, line_number: int, new_content: str) -> str: + """Modify a specific line in a file""" + try: + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + return json_response(False, "modify_line", error=f"File not found: {file_path}") + + if not os.path.isfile(file_path): + return json_response(False, "modify_line", error=f"Not a file: {file_path}") + + if line_number < 1: + return json_response(False, "modify_line", error="Line number must be >= 1") + + # Read all lines + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total_lines = len(lines) + + if line_number > total_lines: + return json_response(False, "modify_line", + error=f"File has only {total_lines} lines, cannot modify line {line_number}") + + # Get old content for reporting + old_content = lines[line_number - 1].rstrip('\n\r') + + # Modify the line (preserve newline if existed) + has_newline = lines[line_number - 1].endswith('\n') + lines[line_number - 1] = new_content + ('\n' if has_newline else '') + + # Write back + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return json_response(True, "modify_line", { + "path": file_path, + "line_number": line_number, + "old_content": old_content, + "new_content": new_content, + "total_lines": total_lines + }) + except UnicodeDecodeError: + return json_response(False, "modify_line", error="Cannot modify binary file") + except Exception as e: + return json_response(False, "modify_line", error=str(e)) + + +def insert_line(file_path: str, line_number: int, content: str) -> str: + """Insert a new line at specified position""" + try: + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + return json_response(False, "insert_line", error=f"File not found: {file_path}") + + if not os.path.isfile(file_path): + return json_response(False, "insert_line", error=f"Not a file: {file_path}") + + if line_number < 1: + return json_response(False, "insert_line", error="Line number must be >= 1") + + # Read all lines + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total_lines = len(lines) + + # Insert at position (can be after last line) + insert_pos = min(line_number - 1, total_lines) + lines.insert(insert_pos, content + '\n') + + # Write back + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return json_response(True, "insert_line", { + "path": file_path, + "line_number": line_number, + "inserted_content": content, + "total_lines": len(lines) + }) + except Exception as e: + return json_response(False, "insert_line", error=str(e)) + + +def delete_line(file_path: str, line_number: int) -> str: + """Delete a specific line from a file""" + try: + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + return json_response(False, "delete_line", error=f"File not found: {file_path}") + + if not os.path.isfile(file_path): + return json_response(False, "delete_line", error=f"Not a file: {file_path}") + + if line_number < 1: + return json_response(False, "delete_line", error="Line number must be >= 1") + + # Read all lines + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total_lines = len(lines) + + if line_number > total_lines: + return json_response(False, "delete_line", + error=f"File has only {total_lines} lines, cannot delete line {line_number}") + + # Get deleted content + deleted_content = lines[line_number - 1].rstrip('\n\r') + + # Remove the line + del lines[line_number - 1] + + # Write back + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return json_response(True, "delete_line", { + "path": file_path, + "line_number": line_number, + "deleted_content": deleted_content, + "total_lines": len(lines) + }) + except Exception as e: + return json_response(False, "delete_line", error=str(e)) + + +def modify_lines(file_path: str, modifications: Dict[int, str]) -> str: + """ + Modify multiple lines at once + + Args: + file_path: Path to the file + modifications: Dict mapping line numbers (1-based) to new content + Example: {2: "new line 2", 5: "new line 5"} + """ + try: + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + return json_response(False, "modify_lines", error=f"File not found: {file_path}") + + if not os.path.isfile(file_path): + return json_response(False, "modify_lines", error=f"Not a file: {file_path}") + + # Read all lines + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total_lines = len(lines) + results = [] + + # Sort modifications by line number (descending to avoid index shift issues) + for line_number in sorted(modifications.keys()): + if line_number < 1: + results.append({"line": line_number, "success": False, "error": "Line number must be >= 1"}) + continue + + if line_number > total_lines: + results.append({"line": line_number, "success": False, + "error": f"File has only {total_lines} lines"}) + continue + + # Get old content + old_content = lines[line_number - 1].rstrip('\n\r') + new_content = modifications[line_number] + + # Modify the line + has_newline = lines[line_number - 1].endswith('\n') + lines[line_number - 1] = new_content + ('\n' if has_newline else '') + + results.append({ + "line": line_number, + "success": True, + "old_content": old_content, + "new_content": new_content + }) + + # Write back + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return json_response(True, "modify_lines", { + "path": file_path, + "total_lines": total_lines, + "modifications_count": len(modifications), + "results": results + }) + except Exception as e: + return json_response(False, "modify_lines", error=str(e)) + + +def replace_text(file_path: str, old_text: str, new_text: str, start_line: int = None, end_line: int = None) -> str: + """ + Replace all occurrences of text in a file (optionally within a line range) + + Args: + file_path: Path to the file + old_text: Text to find and replace + new_text: Replacement text + start_line: Optional start line (1-based, inclusive) + end_line: Optional end line (1-based, inclusive) + """ + try: + file_path = os.path.abspath(file_path) + + if not os.path.exists(file_path): + return json_response(False, "replace_text", error=f"File not found: {file_path}") + + if not os.path.isfile(file_path): + return json_response(False, "replace_text", error=f"Not a file: {file_path}") + + # Read all lines + with open(file_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + total_lines = len(lines) + replacements = [] + + # Determine range + start = max(0, (start_line or 1) - 1) + end = min(total_lines, end_line or total_lines) + + for i in range(start, end): + line = lines[i] + if old_text in line: + count = line.count(old_text) + lines[i] = line.replace(old_text, new_text) + replacements.append({ + "line": i + 1, + "count": count, + "old_line": line.rstrip('\n\r'), + "new_line": lines[i].rstrip('\n\r') + }) + + # Write back + with open(file_path, 'w', encoding='utf-8') as f: + f.writelines(lines) + + return json_response(True, "replace_text", { + "path": file_path, + "old_text": old_text, + "new_text": new_text, + "line_range": [start + 1, end] if start_line or end_line else None, + "total_replacements": sum(r["count"] for r in replacements), + "lines_modified": len(replacements), + "details": replacements + }) + except Exception as e: + return json_response(False, "replace_text", error=str(e)) + + +def main(): + parser = argparse.ArgumentParser(description="File operations for file-manager skill") + subparsers = parser.add_subparsers(dest="operation", help="Operation to perform") + + # List + list_parser = subparsers.add_parser("list", help="List directory contents") + list_parser.add_argument("path", help="Directory path") + list_parser.add_argument("-r", "--recursive", action="store_true", help="Recursive listing") + list_parser.add_argument("-p", "--pattern", help="File pattern (e.g., *.py)") + + # Read + read_parser = subparsers.add_parser("read", help="Read file content") + read_parser.add_argument("path", help="File path") + read_parser.add_argument("-e", "--encoding", default="utf-8", help="File encoding") + read_parser.add_argument("-n", "--lines", type=int, help="Number of lines to read") + + # Write + write_parser = subparsers.add_parser("write", help="Write to file") + write_parser.add_argument("path", help="File path") + write_parser.add_argument("-c", "--content", required=True, help="Content to write") + write_parser.add_argument("-a", "--append", action="store_true", help="Append to file") + + # Copy + copy_parser = subparsers.add_parser("copy", help="Copy file or directory") + copy_parser.add_argument("source", help="Source path") + copy_parser.add_argument("destination", help="Destination path") + + # Move + move_parser = subparsers.add_parser("move", help="Move file or directory") + move_parser.add_argument("source", help="Source path") + move_parser.add_argument("destination", help="Destination path") + + # Delete + delete_parser = subparsers.add_parser("delete", help="Delete file or directory") + delete_parser.add_argument("path", help="Path to delete") + delete_parser.add_argument("-r", "--recursive", action="store_true", help="Recursive delete") + + # Search + search_parser = subparsers.add_parser("search", help="Search files") + search_parser.add_argument("directory", help="Directory to search") + search_parser.add_argument("-p", "--pattern", help="File pattern") + search_parser.add_argument("-c", "--content", help="Content to search for") + + # Info + info_parser = subparsers.add_parser("info", help="Get file info") + info_parser.add_argument("path", help="File or directory path") + + # Modify line + modify_parser = subparsers.add_parser("modify-line", help="Modify a specific line") + modify_parser.add_argument("path", help="File path") + modify_parser.add_argument("line", type=int, help="Line number (1-based)") + modify_parser.add_argument("content", help="New line content") + + # Insert line + insert_parser = subparsers.add_parser("insert-line", help="Insert a new line") + insert_parser.add_argument("path", help="File path") + insert_parser.add_argument("line", type=int, help="Line number to insert at (1-based)") + insert_parser.add_argument("content", help="Line content to insert") + + # Delete line + del_line_parser = subparsers.add_parser("delete-line", help="Delete a specific line") + del_line_parser.add_argument("path", help="File path") + del_line_parser.add_argument("line", type=int, help="Line number to delete (1-based)") + + # Modify multiple lines (JSON format) + multi_parser = subparsers.add_parser("modify-lines", help="Modify multiple lines at once") + multi_parser.add_argument("path", help="File path") + multi_parser.add_argument("-m", "--modifications", required=True, + help='JSON dict of line numbers to content, e.g. \'{"2": "new line 2", "5": "new line 5"}\'') + + # Replace text + replace_parser = subparsers.add_parser("replace", help="Replace text in file") + replace_parser.add_argument("path", help="File path") + replace_parser.add_argument("old", help="Text to find and replace") + replace_parser.add_argument("new", help="Replacement text") + replace_parser.add_argument("--start", type=int, help="Start line (optional)") + replace_parser.add_argument("--end", type=int, help="End line (optional)") + + args = parser.parse_args() + + if args.operation == "list": + print(list_directory(args.path, args.recursive, args.pattern)) + elif args.operation == "read": + print(read_file(args.path, args.encoding, args.lines)) + elif args.operation == "write": + print(write_file(args.path, args.content, args.append)) + elif args.operation == "copy": + print(copy_path(args.source, args.destination)) + elif args.operation == "move": + print(move_path(args.source, args.destination)) + elif args.operation == "delete": + print(delete_path(args.path, args.recursive)) + elif args.operation == "search": + print(search_files(args.directory, args.pattern, args.content)) + elif args.operation == "info": + print(get_info(args.path)) + elif args.operation == "modify-line": + print(modify_line(args.path, args.line, args.content)) + elif args.operation == "insert-line": + print(insert_line(args.path, args.line, args.content)) + elif args.operation == "delete-line": + print(delete_line(args.path, args.line)) + elif args.operation == "modify-lines": + mods = json.loads(args.modifications) + mods_int = {int(k): v for k, v in mods.items()} + print(modify_lines(args.path, mods_int)) + elif args.operation == "replace": + print(replace_text(args.path, args.old, args.new, args.start, args.end)) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/skills/file-manager/scripts/test.txt b/skills/file-manager/scripts/test.txt new file mode 100644 index 0000000..e20306b --- /dev/null +++ b/skills/file-manager/scripts/test.txt @@ -0,0 +1,3 @@ +Line 1 +MODIFIED LINE +Line 3 diff --git a/skills/file-manager/scripts/test_multi.txt b/skills/file-manager/scripts/test_multi.txt new file mode 100644 index 0000000..bdc6a99 --- /dev/null +++ b/skills/file-manager/scripts/test_multi.txt @@ -0,0 +1,5 @@ +Line 1 +REPLACED LINE 2 +Line 3 +REPLACED LINE 4 +Line 5 diff --git a/unified_tool_manager.py b/unified_tool_manager.py new file mode 100644 index 0000000..0b3499e --- /dev/null +++ b/unified_tool_manager.py @@ -0,0 +1,832 @@ +""" +统一工具管理器 - 协调 Liugin、Skill 和 MCP 三套协议 +自动判断并路由到正确的处理器 +""" + +import os +import json +import threading +from typing import Dict, List, Any, Optional, Union, Callable +from colorama import Fore, Style + + +class UnifiedToolManager: + """ + 统一工具管理器 + + 协调三种协议: + 1. Liugin 协议 (plugins/) - 小狸自有协议 + 2. Skill 协议 (skills/) - Agent Skills 开源协议 + 3. MCP 协议 - Model Context Protocol (Anthropic/OpenAI 标准) + + 路由策略: + - 优先使用 Skill 协议(如果存在同名技能) + - 回退到 Liugin 协议 + - 支持强制指定协议 + - MCP 格式调用统一入口 + """ + + def __init__(self, cli_instance=None): + self.cli = cli_instance + self._lock = threading.RLock() + + # 两个独立的加载器 + self._liugin_manager = None + self._skill_loader = None + + # 统一的工具注册表 + self._tools: Dict[str, Dict[str, Any]] = {} + + # 协议映射: tool_name -> protocol_type + self._protocol_map: Dict[str, str] = {} + + # MCP 工具定义缓存 + self._mcp_tools_cache: Optional[List[Dict]] = None + + def initialize(self, plugins_dir: str = "plugins", skills_dir: str = "skills") -> None: + """ + 初始化并加载所有工具 + + Args: + plugins_dir: Liugin 目录 + skills_dir: Skill 目录 + """ + # 加载 Liugins + self._load_plugins(plugins_dir) + + # 加载 Skills + self._load_skills(skills_dir) + + # 构建缓存 + self._build_mcp_cache() + + print(f"{Fore.GREEN}[统一工具管理器] 加载完成: {len(self._tools)} 个工具{Style.RESET_ALL}") + print(f" - Liugin 协议: {sum(1 for p in self._protocol_map.values() if p == 'liugin')}") + print(f" - Skill 协议: {sum(1 for p in self._protocol_map.values() if p == 'skill')}") + + def _load_plugins(self, plugins_dir: str) -> None: + """加载 Liugin 协议工具""" + import importlib.util + + if not os.path.exists(plugins_dir): + return + + # 获取启用的插件列表 + enabled_plugins = self._get_enabled_plugins(plugins_dir) + + for filename in os.listdir(plugins_dir): + if not filename.endswith('.py') or filename == '__init__.py': + continue + + plugin_name = filename[:-3] + + # 检查是否启用 + if enabled_plugins is not None and plugin_name.lower() not in enabled_plugins: + continue + + plugin_path = os.path.join(plugins_dir, filename) + + try: + spec = importlib.util.spec_from_file_location(plugin_name, plugin_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 兼容 Plugin 和 Liugin 两种类名 + plugin_class = None + if hasattr(module, 'Liugin'): + plugin_class = module.Liugin + elif hasattr(module, 'Plugin'): + plugin_class = module.Plugin + + if plugin_class: + plugin_instance = plugin_class() + + # 设置 CLI 引用 + if hasattr(plugin_instance, 'set_cli') and self.cli: + plugin_instance.set_cli(self.cli) + + tool_info = plugin_instance.get_tool_info() + tool_name = tool_info.get('name', plugin_name) + + # 获取 MCP 工具定义(如果插件提供) + mcp_definition = None + if hasattr(plugin_instance, 'get_mcp_definition'): + mcp_definition = plugin_instance.get_mcp_definition() + + # 注册到统一工具表 + self._tools[tool_name] = { + 'name': tool_name, + 'description': tool_info.get('description', ''), + 'keywords': tool_info.get('keywords', []), + 'usage': tool_info.get('usage', ''), + 'handler': plugin_instance.handle, + 'protocol': 'liugin', + 'instance': plugin_instance, + 'mcp_definition': mcp_definition + } + self._protocol_map[tool_name] = 'liugin' + + except Exception as e: + print(f"{Fore.RED}加载 Liugin '{plugin_name}' 失败: {e}{Style.RESET_ALL}") + + def _load_skills(self, skills_dir: str) -> None: + """加载 Skill 协议工具 (支持 Markdown 和 Python 两种格式)""" + if not os.path.exists(skills_dir): + print(f"{Fore.YELLOW}Skills 目录不存在: {skills_dir}{Style.RESET_ALL}") + return + + try: + from skills.base import SkillLoader, MarkdownSkill, Skill + + self._skill_loader = SkillLoader(skills_dir) + + # 先发现所有技能 + discovered = self._skill_loader.discover_skills(skills_dir) + print(f"{Fore.CYAN}发现 {len(discovered)} 个技能: {[s['name'] for s in discovered]}{Style.RESET_ALL}") + + # 加载所有技能 + self._skill_loader.load_all_skills(skills_dir) + + loaded_count = len(self._skill_loader.skills) + if loaded_count == 0: + print(f"{Fore.YELLOW}未加载任何 Skill 技能{Style.RESET_ALL}") + return + + for skill_name, skill in self._skill_loader.skills.items(): + # 设置 CLI 引用 (仅 Python 类技能) + if hasattr(skill, 'set_cli') and self.cli: + skill.set_cli(self.cli) + + # 获取工具定义 + tool_def = skill.get_tool_definition() + func_def = tool_def.get('function', {}) + + # 检查是否已有同名 Plugin,Skill 优先覆盖 + if skill_name in self._tools: + old_protocol = self._protocol_map[skill_name] + print(f"{Fore.YELLOW}工具 '{skill_name}' 已存在({old_protocol}),使用 Skill 协议覆盖{Style.RESET_ALL}") + + # 判断技能格式 + is_markdown = isinstance(skill, MarkdownSkill) + skill_format = "markdown" if is_markdown else "python" + + # 注册到统一工具表 + self._tools[skill_name] = { + 'name': skill_name, + 'description': func_def.get('description', ''), + 'keywords': getattr(skill, 'tags', []), + 'usage': getattr(skill, 'instructions', '')[:500] if is_markdown else getattr(skill, 'usage', ''), + 'handler': self._create_skill_handler(skill), + 'protocol': 'skill', + 'format': skill_format, + 'instance': skill, + 'tool_definition': tool_def, + 'anthropic_definition': skill.get_anthropic_tool_definition(), + 'mcp_definition': tool_def # Skill 的 OpenAI 格式就是 MCP 格式 + } + self._protocol_map[skill_name] = 'skill' + print(f"{Fore.GREEN}已加载 Skill ({skill_format}): {skill_name}{Style.RESET_ALL}") + + except ImportError as e: + print(f"{Fore.YELLOW}Skill 协议模块未找到: {e}{Style.RESET_ALL}") + except Exception as e: + print(f"{Fore.RED}加载 Skills 失败: {e}{Style.RESET_ALL}") + + def _create_skill_handler(self, skill) -> callable: + """ + 为 Skill 创建兼容 Plugin 的 handler + + 支持 Markdown 和 Python 两种技能格式 + """ + from skills.base import MarkdownSkill, Skill + + def handler(args: str) -> str: + import json + + kwargs = {} + + # 解析参数 + if args.strip().startswith('{'): + try: + kwargs = json.loads(args) + except json.JSONDecodeError: + kwargs = {'operation': args} + else: + kwargs = {'operation': args} + + # Markdown 技能: 返回指令供 AI 执行 + if isinstance(skill, MarkdownSkill): + return f"[Skill: {skill.name}]\n{skill.instructions}" + + # Python 技能: 直接执行 + if hasattr(skill, 'run'): + result = skill.run(**kwargs) + if result.success: + return result.message or str(result.data) + else: + return f"错误: {result.error}" + + return f"未知的技能类型: {type(skill)}" + + return handler + + def _get_enabled_plugins(self, plugins_dir: str) -> Optional[List[str]]: + """获取启用的插件列表""" + # 检查环境变量 + enabled_env = os.environ.get("XIAOLI_ENABLED_PLUGINS") + if enabled_env is not None: + if enabled_env == "": + return [] + return [p.lower() for p in enabled_env.split(",")] + + # 检查配置文件 + config_file = os.path.join(os.path.dirname(plugins_dir), "plugins_config.py") + if os.path.exists(config_file): + try: + with open(config_file, 'r', encoding='utf-8') as f: + content = f.read() + + if "# 不启用任何插件" in content: + return [] + + enabled = [] + for line in content.split('\n'): + if line.startswith('ENABLE_') and '= True' in line: + name = line.split('=')[0].replace('ENABLE_', '').strip().lower() + enabled.append(name) + return enabled + except Exception: + pass + + return None # 加载全部 + + def _build_mcp_cache(self) -> None: + """构建 MCP 工具定义缓存(线程安全)""" + with self._lock: + self._mcp_tools_cache = [] + for tool in self._tools.values(): + mcp_def = tool.get('mcp_definition') + if mcp_def: + # 确保符合 MCP 标准 + normalized = self._normalize_mcp_definition(mcp_def) + self._mcp_tools_cache.append(normalized) + else: + # 为没有 MCP 定义的 Plugin 生成默认定义 + self._mcp_tools_cache.append(self._generate_mcp_definition(tool)) + + def _normalize_mcp_definition(self, mcp_def: Dict[str, Any]) -> Dict[str, Any]: + """ + 标准化 MCP 工具定义 + + 支持多种输入格式并转换为标准 MCP 格式 + """ + # 如果已经是 MCP 格式(有 name 或 inputSchema) + if 'name' in mcp_def and ('inputSchema' in mcp_def or 'description' in mcp_def): + return mcp_def + + # 如果是 OpenAI Function Calling 格式,转换 + if mcp_def.get('type') == 'function' and 'function' in mcp_def: + func = mcp_def['function'] + return { + "name": func.get('name', ''), + "description": func.get('description', ''), + "inputSchema": func.get('parameters', { + "type": "object", + "properties": {} + }) + } + + # 其他情况,直接返回 + return mcp_def + + def _generate_mcp_definition(self, tool: Dict[str, Any]) -> Dict[str, Any]: + """ + 为 Plugin 生成符合 MCP 标准的工具定义 + + MCP 官方格式 (2025-03-26): + { + "name": "tool_name", + "title": "Human Readable Name", // optional + "description": "Human-readable description", + "inputSchema": { + "type": "object", + "properties": {...}, + "required": [...] + }, + "outputSchema": {...}, // optional + "annotations": {...} // optional + } + """ + return { + "name": tool['name'], + "description": tool.get('description', ''), + "inputSchema": { + "type": "object", + "properties": { + "args": { + "type": "string", + "description": f"命令参数。用法:\\n{tool.get('usage', '')[:1000]}" + } + }, + "required": ["args"] + } + } + + # ==================== 公共 API ==================== + + @property + def tools(self) -> List[Dict[str, Any]]: + """获取所有工具列表(兼容 LiuginManager 接口)""" + return list(self._tools.values()) + + def get_tool(self, name: str) -> Optional[Dict[str, Any]]: + """获取指定工具""" + return self._tools.get(name) + + def get_protocol(self, name: str) -> Optional[str]: + """获取工具使用的协议""" + return self._protocol_map.get(name) + + def execute(self, tool_name: str, args: str = "", **kwargs) -> Dict[str, Any]: + """ + 执行工具调用 + + Args: + tool_name: 工具名称 + args: 参数字符串(Plugin 格式) + **kwargs: 关键字参数(Skill 格式或 MCP arguments) + + Returns: + {"result": "..."} 格式的结果 + """ + tool = self._tools.get(tool_name) + + if not tool: + return {"result": f"未找到工具: {tool_name}"} + + try: + handler = tool['handler'] + protocol = tool['protocol'] + instance = tool.get('instance') + + if protocol == 'skill' and kwargs: + # Skill 协议,使用 kwargs + skill = tool['instance'] + result = skill.run(**kwargs) + return {"result": result.message or str(result.data) if result.success else f"错误: {result.error}"} + + elif protocol == 'liugin' and kwargs and not args: + # Liugin 协议但有 MCP 结构化参数,需要转换 + # 检查插件是否有 convert_mcp_args 方法 + if instance and hasattr(instance, 'convert_mcp_args'): + args = instance.convert_mcp_args(kwargs) + else: + args = self._convert_mcp_args_to_plugin_args(tool_name, kwargs, instance) + + # 执行 handler + result = handler(args) + return {"result": result if result is not None else "无结果"} + + except Exception as e: + return {"result": f"工具执行错误: {e}"} + + # ==================== MCP 协议支持 ==================== + + def get_mcp_tools(self) -> List[Dict[str, Any]]: + """ + 获取 MCP 格式的工具定义列表 + + MCP (Model Context Protocol) 使用 OpenAI Function Calling 格式 + 作为标准工具定义格式 + + Returns: + List of MCP tool definitions + """ + with self._lock: + if self._mcp_tools_cache is None: + self._build_mcp_cache() + return self._mcp_tools_cache.copy() # 返回副本,避免外部修改 + + def mcp_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + """ + MCP 格式的工具调用入口 + + 这是 MCP 协议的主要调用接口,接收结构化参数并返回结果 + + Args: + tool_name: 工具名称 + arguments: 结构化参数 (JSON对象) + + Returns: + MCP 标准响应格式: + { + "content": [ + { + "type": "text", + "text": "结果内容" + } + ], + "isError": false + } + """ + tool = self._tools.get(tool_name) + + if not tool: + return self._mcp_error(f"未找到工具: {tool_name}") + + try: + protocol = tool['protocol'] + instance = tool.get('instance') + + if protocol == 'liugin': + # Liugin 协议: 将 arguments 转换为 args 字符串 + args = self._convert_mcp_args_to_plugin_args(tool_name, arguments, instance) + result = tool['handler'](args) + return self._mcp_success(result if result else "执行完成") + + elif protocol == 'skill': + # Skill 协议: 直接传递 arguments 作为 kwargs + from skills.base import MarkdownSkill + + if isinstance(instance, MarkdownSkill): + return self._mcp_success(f"[Skill: {instance.name}]\n{instance.instructions}") + + if hasattr(instance, 'run'): + result = instance.run(**arguments) + if result.success: + return self._mcp_success(result.message or str(result.data)) + else: + return self._mcp_error(f"执行失败: {result.error}") + + return self._mcp_error(f"未知协议类型: {protocol}") + + except Exception as e: + return self._mcp_error(f"工具执行错误: {str(e)}") + + def _convert_mcp_args_to_plugin_args(self, tool_name: str, arguments: Dict[str, Any], + instance: Any) -> str: + """ + 将 MCP 结构化参数转换为 Plugin 的 args 字符串 + + Args: + tool_name: 工具名称 + arguments: MCP 参数对象 + instance: 插件实例(可能有自定义转换方法) + + Returns: + args 字符串 + """ + # 检查插件是否有自定义的 MCP 参数转换方法 + if instance and hasattr(instance, 'convert_mcp_args'): + return instance.convert_mcp_args(arguments) + + # 默认转换策略 + if 'args' in arguments: + # 如果有 args 字段,直接使用 + return arguments['args'] + + # 将 arguments 转换为命令行格式 + parts = [] + + # 第一个参数通常是操作/命令 + if 'operation' in arguments: + parts.append(arguments['operation']) + elif 'action' in arguments: + parts.append(arguments['action']) + + # 添加其他参数 + for key, value in arguments.items(): + if key in ('operation', 'action'): + continue + + if isinstance(value, bool): + if value: + parts.append(f"-{key}") + elif isinstance(value, (list, tuple)): + for v in value: + parts.append(f"--{key}") + parts.append(self._quote_if_needed(str(v))) + else: + parts.append(f"--{key}") + parts.append(self._quote_if_needed(str(value))) + + return ' '.join(parts) + + def _quote_if_needed(self, s: str) -> str: + """如果字符串包含空格,添加引号""" + if ' ' in s or '"' in s: + return f'"{s}"' + return s + + def _mcp_success(self, text: str) -> Dict[str, Any]: + """生成 MCP 成功响应""" + return { + "content": [ + { + "type": "text", + "text": text + } + ], + "isError": False + } + + def _mcp_error(self, error_msg: str) -> Dict[str, Any]: + """生成 MCP 错误响应""" + return { + "content": [ + { + "type": "text", + "text": error_msg + } + ], + "isError": True + } + + # ==================== 兼容性 API ==================== + + def get_openai_tools(self) -> List[Dict[str, Any]]: + """ + 获取 OpenAI Function Calling 格式的工具定义 + + OpenAI 格式: + { + "type": "function", + "function": { + "name": "...", + "description": "...", + "parameters": {...} + } + } + """ + tools = [] + for mcp_def in self.get_mcp_tools(): + tools.append({ + "type": "function", + "function": { + "name": mcp_def.get("name", ""), + "description": mcp_def.get("description", ""), + "parameters": mcp_def.get("inputSchema", { + "type": "object", + "properties": {} + }) + } + }) + return tools + + def get_anthropic_tools(self) -> List[Dict[str, Any]]: + """ + 获取 Anthropic Tool Use 格式的工具定义 + + Anthropic 格式: + { + "name": "tool_name", + "description": "...", + "input_schema": {...} + } + """ + tools = [] + for mcp_def in self.get_mcp_tools(): + tools.append({ + "name": mcp_def.get("name", ""), + "description": mcp_def.get("description", ""), + "input_schema": mcp_def.get("inputSchema", { + "type": "object", + "properties": {} + }) + }) + return tools + + def search_tools(self, query: str, limit: int = 5) -> List[Dict[str, Any]]: + """ + 搜索工具 + + Args: + query: 搜索关键词 + limit: 返回数量限制 + + Returns: + 匹配的工具列表 + """ + query_lower = query.lower() + # 标准化:移除所有分隔符(连字符、下划线、空格) + query_normalized = query_lower.replace('-', '').replace('_', '').replace(' ', '') + matches = [] + + for tool in self._tools.values(): + score = 0 + name = tool['name'].lower() + name_normalized = name.replace('-', '').replace('_', '') + desc = tool['description'].lower() + keywords = [kw.lower() for kw in tool.get('keywords', [])] + + # 名称完全匹配(忽略分隔符差异) + if query_lower == name or query_normalized == name_normalized: + score += 100 + # 名称包含(标准化后匹配) + elif query_normalized in name_normalized or query_lower in name: + score += 50 + # 描述包含 + if query_lower in desc: + score += 30 + # 关键词匹配 + for kw in keywords: + kw_normalized = kw.replace('-', '').replace('_', '') + if query_lower in kw or query_normalized in kw_normalized: + score += 20 + break + + if score > 0: + matches.append({ + 'name': tool['name'], + 'description': tool['description'], + 'usage': tool.get('usage', ''), + 'keywords': keywords, + 'protocol': tool['protocol'], + 'instance': tool.get('instance'), + 'score': score + }) + + # 按分数排序 + matches.sort(key=lambda x: x['score'], reverse=True) + return matches[:limit] + + def register_tool(self, name: str, handler: callable, description: str = "", + protocol: str = "custom", mcp_definition: Dict = None, **kwargs) -> None: + """ + 动态注册工具 + + Args: + name: 工具名称 + handler: 处理函数 + description: 描述 + protocol: 协议类型 + mcp_definition: MCP 工具定义(可选) + """ + with self._lock: + self._tools[name] = { + 'name': name, + 'description': description, + 'handler': handler, + 'protocol': protocol, + 'mcp_definition': mcp_definition, + **kwargs + } + self._protocol_map[name] = protocol + + # 更新缓存 + if self._mcp_tools_cache is not None: + if mcp_definition: + self._mcp_tools_cache.append(mcp_definition) + else: + self._mcp_tools_cache.append(self._generate_mcp_definition(self._tools[name])) + + def unregister_tool(self, name: str) -> bool: + """注销工具""" + with self._lock: + if name in self._tools: + del self._tools[name] + del self._protocol_map[name] + # 重建缓存 + self._build_mcp_cache() + return True + return False + + def get_skills_system_prompt(self) -> str: + """ + 获取所有技能的系统提示词 + + 用于注入到 AI 的 system prompt 中, + 让 AI 了解如何使用这些技能 + """ + skill_prompts = [] + + for name, tool in self._tools.items(): + if tool['protocol'] == 'skill': + instance = tool.get('instance') + if instance: + # 获取技能指令 + instructions = getattr(instance, 'instructions', '') + scripts_dir = getattr(instance, 'scripts_dir', '') + + prompt_parts = [f"## 技能: {name}"] + prompt_parts.append(f"描述: {tool['description']}") + + if instructions: + # 截取关键部分,避免过长 + prompt_parts.append(f"\n{instructions}") + + if scripts_dir: + prompt_parts.append(f"\n脚本目录: {scripts_dir}") + + skill_prompts.append('\n'.join(prompt_parts)) + + if skill_prompts: + header = "# 已加载的 Agent Skills\n以下技能可用于执行特定任务,调用方式与工具相同:\n" + return header + '\n\n---\n\n'.join(skill_prompts) + + return "" + + def get_tools_prompt(self) -> str: + """ + 获取工具使用提示词(兼容旧接口) + + 包含 Plugin 和 Skill 两种协议的工具说明 + """ + prompts = [] + + # Liugin 协议工具 + plugin_tools = [t for t in self._tools.values() if t['protocol'] == 'liugin'] + if plugin_tools: + prompts.append("## Liugin 工具") + for tool in plugin_tools: + usage = tool.get('usage', '') + if usage: + prompts.append(f"### {tool['name']}\n{usage}\n") + + # Skill 协议工具(使用系统提示词) + skills_prompt = self.get_skills_system_prompt() + if skills_prompt: + prompts.append(skills_prompt) + + return '\n\n'.join(prompts) + + # ==================== MCP Server 模式支持 ==================== + + def get_mcp_server_info(self) -> Dict[str, Any]: + """ + 获取 MCP Server 信息 + + 用于 MCP 客户端连接时的初始化响应 + """ + return { + "name": "xiaoli-tool-server", + "version": "1.0.0", + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": { + "listChanged": True + } + } + } + + def handle_mcp_request(self, request: Dict[str, Any]) -> Dict[str, Any]: + """ + 处理 MCP 协议请求 + + 统一处理 MCP 客户端的各种请求 + + Args: + request: MCP 请求对象 + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list" | "tools/call" | ..., + "params": {...} + } + + Returns: + MCP 响应对象 + { + "jsonrpc": "2.0", + "id": 1, + "result": {...} + } + """ + method = request.get('method', '') + params = request.get('params', {}) + req_id = request.get('id') + + result = None + error = None + + try: + if method == 'tools/list': + result = {"tools": self.get_mcp_tools()} + + elif method == 'tools/call': + tool_name = params.get('name') + arguments = params.get('arguments', {}) + if not tool_name: + error = {"code": -32602, "message": "Missing tool name"} + else: + result = self.mcp_call(tool_name, arguments) + + elif method == 'initialize': + result = self.get_mcp_server_info() + + elif method == 'ping': + result = {} + + else: + error = {"code": -32601, "message": f"Method not found: {method}"} + + except Exception as e: + error = {"code": -32603, "message": str(e)} + + response = {"jsonrpc": "2.0", "id": req_id} + if error: + response["error"] = error + else: + response["result"] = result + + return response \ No newline at end of file diff --git a/websocket_server.py b/websocket_server.py new file mode 100644 index 0000000..c20ae7e --- /dev/null +++ b/websocket_server.py @@ -0,0 +1,1170 @@ +""" +WebSocket 服务端模块 +用于 Clawli 安卓客户端连接 + +支持两种模式: +1. 本地模式 (/remote start) - 局域网直连,需要设置密码 +2. 代理模式 (/remote proxy) - 通过中继服务器,需要设置密码 + +功能: +- 消息收发 +- 工具调用状态推送 +- 任意文件上传保存 +- 图片识别 +""" + +import asyncio +import json +import threading +import time +import base64 +import hashlib +import socket +import os +from datetime import datetime +from typing import Optional, Dict, List + +# 全局变量 +websocket_server = None # 当前活动的服务器实例(供外部访问) +proxy_client = None + +# 文件保存目录 +CLAWLI_FILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawli_files") + +# 确保目录存在 +os.makedirs(CLAWLI_FILES_DIR, exist_ok=True) + +try: + import websockets + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + + +def get_file_category(filename: str) -> str: + """根据文件扩展名获取分类""" + ext = filename.lower().split('.')[-1] if '.' in filename else '' + + # 图片 + if ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']: + return 'image' + # 视频 + if ext in ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm']: + return 'video' + # 音频 + if ext in ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']: + return 'audio' + # 文档 + if ext in ['txt', 'md', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'json', 'csv', 'xml']: + return 'document' + # 代码 + if ext in ['py', 'js', 'ts', 'java', 'c', 'cpp', 'h', 'css', 'html', 'sql', 'sh']: + return 'code' + # 压缩包 + if ext in ['zip', 'rar', '7z', 'tar', 'gz']: + return 'archive' + return 'file' + + +def save_uploaded_file(file_base64: str, filename: str) -> dict: + """ + 保存上传的文件到 clawli_files 目录 + 返回: {success: bool, path: str, error: str, size: int} + """ + try: + # 解码文件 + file_data = base64.b64decode(file_base64) + + # 生成唯一文件名(时间戳 + 原始文件名) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = "".join(c if c.isalnum() or c in '._-' else '_' for c in filename) + unique_name = f"{timestamp}_{safe_name}" + + # 保存路径 + file_path = os.path.join(CLAWLI_FILES_DIR, unique_name) + + # 写入文件 + with open(file_path, 'wb') as f: + f.write(file_data) + + return { + 'success': True, + 'path': file_path, + 'filename': unique_name, + 'original_name': filename, + 'size': len(file_data), + 'category': get_file_category(filename) + } + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + +# ============== 本地服务器 ============== + +class LocalServer: + """本地WebSocket服务器""" + + DEFAULT_PORT = 9079 + + def __init__(self, cli_instance=None, port=None): + self.cli = cli_instance + self.port = port or self.DEFAULT_PORT + self.password = "" + self.running = False + self.server = None + self.clients: Dict = {} + self.loop = None + self.thread = None + + def set_cli(self, cli_instance): + self.cli = cli_instance + + def set_password(self, password: str): + self.password = password + + def start(self, password: str = None): + if password: + self.password = password + + if not self.password: + from colorama import Fore, Style + print(f"{Fore.RED}错误: 请先设置PC密码{Style.RESET_ALL}") + print(f"{Fore.YELLOW}用法: /remote start <端口> <密码>{Style.RESET_ALL}") + return False + + if self.running: + from colorama import Fore, Style + print(f"{Fore.YELLOW}服务器已在运行{Style.RESET_ALL}") + return True + + self.running = True + # 设置 Clawli 模式标志 + if self.cli: + self.cli.is_clawli_mode = True + self.thread = threading.Thread(target=self._run_server, daemon=True) + self.thread.start() + time.sleep(0.5) + + if self.running: + self._print_status() + return True + return False + + def _run_server(self): + try: + async def handle_client(websocket): + client_addr = websocket.remote_address + authenticated = False + + try: + async for message in websocket: + try: + data = json.loads(message) + msg_type = data.get("type", "") + + if msg_type == "heartbeat": + await websocket.send(json.dumps({"type": "heartbeat_ack"})) + continue + + if msg_type == "auth": + client_password = data.get("password", "") + if client_password == self.password: + authenticated = True + self.clients[websocket] = {"address": client_addr} + await websocket.send(json.dumps({ + "type": "auth_success", + "message": "认证成功" + })) + else: + await websocket.send(json.dumps({ + "type": "auth_failed", + "message": "密码错误" + })) + continue + + if not authenticated: + await websocket.send(json.dumps({ + "type": "error", + "message": "请先认证" + })) + continue + + # 文本消息 + if msg_type == "message": + content = data.get("content", "") + files = data.get("files", []) # 文件信息列表 + response = await self._process_message(content, files, websocket) + await websocket.send(json.dumps({ + "type": "message", + "content": response + })) + + # 单个文件上传(兼容旧版) + elif msg_type == "file": + file_base64 = data.get("file", "") + filename = data.get("filename", "file") + file_text = data.get("text", "") + + response = await self._process_single_file( + file_base64, filename, file_text, websocket + ) + await websocket.send(json.dumps({ + "type": "message", + "content": response + })) + + except json.JSONDecodeError: + await websocket.send(json.dumps({ + "type": "error", + "message": "JSON格式错误" + })) + + except Exception as e: + from colorama import Fore, Style + # 不打印断开连接的异常,避免不必要的错误提示 + if "no close frame" not in str(e): + print(f"{Fore.RED}客户端连接异常 [{client_addr}]: {e}{Style.RESET_ALL}") + finally: + if websocket in self.clients: + del self.clients[websocket] + from colorama import Fore, Style + print(f"{Fore.YELLOW}客户端断开连接 [{client_addr}], 等待新连接...{Style.RESET_ALL}") + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + async def start_async(): + # 使用外层已创建的事件循环 + self.server = await websockets.serve( + handle_client, + "0.0.0.0", + self.port, + ping_interval=None, # 禁用 ping,保持永久连接 + ping_timeout=None, + max_size=100 * 1024 * 1024 # 100MB + ) + # 保持服务器运行 + try: + await asyncio.sleep(float('inf')) + except (KeyboardInterrupt, SystemExit): + pass + except Exception as e: + from colorama import Fore, Style + print(f"{Fore.RED}服务器运行异常: {e}{Style.RESET_ALL}") + + # 循环运行服务器,确保持续运行 + while self.running: + try: + self.loop.run_until_complete(start_async()) + except KeyboardInterrupt: + break + except Exception as e: + from colorama import Fore, Style + if self.running: # 只有在需要运行时才打印错误 + print(f"{Fore.RED}服务器重启: {e}{Style.RESET_ALL}") + import time + time.sleep(1) # 等待后重试 + + except ImportError: + from colorama import Fore, Style + print(f"{Fore.RED}错误: websockets 库未安装{Style.RESET_ALL}") + self.running = False + except Exception as e: + from colorama import Fore, Style + # 只在非断连错误时打印 + if "no close frame" not in str(e) and "closed" not in str(e).lower(): + print(f"{Fore.RED}服务器异常: {e}{Style.RESET_ALL}") + # 服务器异常时尝试保持运行状态 + # 不设置 running = False,让服务器继续等待连接 + + async def _send_tool_status(self, websocket, tool_name: str, args: str, status: str, result: str = ""): + """发送工具/文件处理状态""" + try: + # 确保所有值都不是 None,并转换为字符串 + data = { + "type": "tool_status", + "tool": str(tool_name) if tool_name else "", + "args": str(args)[:100] if args else "", + "status": str(status) if status else "", + "result": str(result)[:500] if result else "" + } + json_str = json.dumps(data, ensure_ascii=False) + await websocket.send(json_str) + except Exception as e: + print(f"[Clawli] 发送工具状态失败: {e}") + + async def _send_image(self, websocket, image_data: str, filename: str = "image.png", caption: str = ""): + """发送图片到手机端 + + Args: + websocket: WebSocket连接 + image_data: 图片数据(文件路径或base64编码) + filename: 图片文件名 + caption: 图片说明文字 + """ + try: + # 判断是文件路径还是base64 + if os.path.isfile(image_data): + # 是文件路径,读取并编码 + with open(image_data, 'rb') as f: + file_data = f.read() + image_base64 = base64.b64encode(file_data).decode('utf-8') + filename = os.path.basename(image_data) + else: + # 假设是base64编码 + image_base64 = image_data + + data = { + "type": "image", + "image": image_base64, + "filename": filename, + "caption": caption + } + json_str = json.dumps(data, ensure_ascii=False) + await websocket.send(json_str) + print(f"[Clawli] 图片已发送: {filename}") + return True + except Exception as e: + print(f"[Clawli] 发送图片失败: {e}") + return False + + def send_image_to_clients(self, image_path: str, caption: str = "") -> bool: + """向所有连接的客户端发送图片(同步接口,供外部调用) + + Args: + image_path: 图片文件路径 + caption: 图片说明文字 + + Returns: + bool: 是否成功发送 + """ + if not self.clients: + print("[Clawli] 没有连接的客户端") + return False + + if not os.path.isfile(image_path): + print(f"[Clawli] 图片文件不存在: {image_path}") + return False + + # 在事件循环中异步发送 + if self.loop: + future = asyncio.run_coroutine_threadsafe( + self._broadcast_image(image_path, caption), + self.loop + ) + try: + return future.result(timeout=10) + except Exception as e: + print(f"[Clawli] 发送图片超时: {e}") + return False + return False + + async def _broadcast_image(self, image_path: str, caption: str = ""): + """向所有客户端广播图片""" + success = False + for ws in list(self.clients.keys()): + if await self._send_image(ws, image_path, caption=caption): + success = True + return success + + async def _process_single_file(self, file_base64: str, filename: str, text: str, websocket=None) -> str: + """处理单个文件上传""" + try: + # 发送状态 + if websocket: + await self._send_tool_status(websocket, "文件上传", filename, "calling") + + # 保存文件 + result = save_uploaded_file(file_base64, filename) + + if not result['success']: + if websocket: + await self._send_tool_status(websocket, "文件上传", filename, "error", result.get('error', '未知错误')) + return f"文件上传失败: {result.get('error', '未知错误')}" + + file_path = result['path'] + category = result['category'] + + if websocket: + size = result.get('size', 0) + size_str = f"{size/1024:.1f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB" + await self._send_tool_status(websocket, "文件上传", filename, "success", + f"已保存: {result.get('filename', filename)} ({size_str})") + + # 根据文件类型处理 + if category == 'image': + # 图片走图像识别 + return await self._process_image_file(file_path, text, websocket) + else: + # 其他文件,构建消息 + return await self._process_message(f"@{file_path}", [], websocket, text) + + except Exception as e: + if websocket: + await self._send_tool_status(websocket, "文件上传", filename, "error", str(e)) + return f"处理文件时出错: {str(e)}" + + async def _process_image_file(self, file_path: str, text: str, websocket=None) -> str: + """处理图片文件(走图像识别)""" + if not self.cli: + return "错误: CLI未初始化" + + try: + # 检查图像识别引擎 + image_engine = getattr(self.cli, 'image_engine', None) + if not image_engine: + try: + from image_engine import get_image_engine + image_engine = get_image_engine() + except: + return f"@{file_path}" # 没有图像引擎,直接返回路径 + + if websocket: + await self._send_tool_status(websocket, "图像识别", text or "分析图片", "calling") + + # 读取图片 + with open(file_path, 'rb') as f: + image_data = f.read() + + # 调用图像识别 + try: + result = image_engine.analyze(image_data, text or "请描述这张图片") + + if websocket: + await self._send_tool_status(websocket, "图像识别", text or "分析图片", "success", result[:200] if result else "无结果") + + # 返回识别结果 + 文件路径 + return await self._process_message(f"@{file_path}", [], websocket, + f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}") + + except Exception as e: + if websocket: + await self._send_tool_status(websocket, "图像识别", text or "分析图片", "error", str(e)) + return f"@{file_path}\n\n图像识别失败: {str(e)}" + + except Exception as e: + return f"@{file_path}\n\n处理图片时出错: {str(e)}" + + async def _process_message(self, content: str, files: List[dict] = None, websocket=None, extra_text: str = "") -> str: + """处理消息(支持工具调用循环)""" + if not self.cli: + return "错误: CLI未初始化" + + try: + if not self.cli.current_engine: + return "错误: 没有可用的AI引擎" + + # 构建完整消息 + full_content = content + if extra_text: + full_content = f"{content}\n{extra_text}" + + # 添加clawli标识 + full_content = f"{full_content}\n\n[此消息由clawli手机远程发送]" + + self.cli.shared_conversation_history.append({ + "role": "user", + "content": full_content + }) + + max_loops = 10 + loop_count = 0 + current_input = full_content + final_response = "" + + while loop_count < max_loops: + loop_count += 1 + + system_prompt = self.cli._build_system_prompt( + self.cli.get_liugin_usage_prompts() + ) + + response = self.cli.current_engine.generate_response( + current_input, + system_prompt=system_prompt + ) + + text_content, json_data = self.cli._parse_mixed_response(response) + + # 调试:打印原始响应和解析结果 + print(f"[Clawli] AI响应: {response[:200]}...") + print(f"[Clawli] 解析结果: text={text_content[:50] if text_content else 'None'}..., json={json_data}") + + if json_data: + tool_calls = None + + # 支持多种JSON格式(MCP/Plugin/OpenAI/Anthropic) + if isinstance(json_data, dict): + # 小狸自有格式 + if json_data.get('action') == 'use_tool': + tool_calls = [json_data] + elif 'tool' in json_data and 'args' in json_data: + tool_calls = [json_data] + # OpenAI function_call 格式 + elif 'function_call' in json_data: + fc = json_data['function_call'] + if isinstance(fc, dict): + args = fc.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls = [{ + 'tool': fc.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }] + # MCP/Anthropic 格式 + elif 'name' in json_data and 'arguments' in json_data: + args = json_data.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls = [{ + 'tool': json_data.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }] + # OpenAI tool_calls 格式 + elif json_data.get('type') == 'function' and 'function' in json_data: + func = json_data['function'] + args = func.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls = [{ + 'tool': func.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }] + elif isinstance(json_data, list): + tool_calls = [] + 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', '') + # arguments 可能是 JSON 字符串,需要解析 + 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 + }) + if not tool_calls: + tool_calls = None + + if tool_calls: + print(f"[Clawli] 检测到工具调用: {tool_calls}") + results = [] + for tool_data in tool_calls: + tool_name = tool_data.get('tool', '未知工具') + tool_args = tool_data.get('args', '') + + if websocket: + await self._send_tool_status(websocket, tool_name, tool_args, "calling") + + result = self.cli.process_tool_call(tool_data) + result_text = result.get('result', '无结果') + results.append(result_text) + + if websocket: + await self._send_tool_status(websocket, tool_name, tool_args, "success", result_text) + + self.cli.shared_conversation_history.append({ + "role": "assistant", + "content": response + }) + + tool_result_str = "\n".join([f"工具执行结果: {r}" for r in results]) + current_input = f"{tool_result_str}\n请根据工具执行结果继续回答。\n\n[此消息由clawli手机远程发送]" + self.cli.shared_conversation_history.append({ + "role": "user", + "content": current_input + }) + continue + + if isinstance(json_data, dict) and json_data.get('message'): + final_response = json_data['message'] + self.cli.shared_conversation_history.append({ + "role": "assistant", + "content": final_response + }) + return final_response + + final_response = text_content if text_content else response + self.cli.shared_conversation_history.append({ + "role": "assistant", + "content": final_response + }) + return final_response + + return "错误: 工具调用次数超限" + + except Exception as e: + return f"处理消息时出错: {str(e)}" + + def stop(self): + if not self.running: + return + self.running = False + # 清除 Clawli 模式标志 + if self.cli: + self.cli.is_clawli_mode = False + if self.server and self.loop: + self.loop.call_soon_threadsafe(self.server.close) + from colorama import Fore, Style + print(f"{Fore.YELLOW}本地服务器已停止{Style.RESET_ALL}") + + def _print_status(self): + from colorama import Fore, Style + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + except: + local_ip = "未知" + + print(f"\n{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + print(f"{Fore.GREEN}[本地模式] 远程服务已启动{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + print(f"{Fore.WHITE}本机IP: {Fore.YELLOW}{local_ip}{Style.RESET_ALL}") + print(f"{Fore.WHITE}端口: {Fore.YELLOW}{self.port}{Style.RESET_ALL}") + print(f"{Fore.WHITE}PC密码: {Fore.YELLOW}{'*' * len(self.password)}{Style.RESET_ALL}") + print(f"{Fore.WHITE}文件目录: {Fore.YELLOW}{CLAWLI_FILES_DIR}{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + print(f"{Fore.GREEN}安卓端输入 {local_ip}:{self.port} 和PC密码即可连接{Style.RESET_ALL}\n") + + def get_status(self) -> dict: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + except: + local_ip = "未知" + return { + "running": self.running, + "mode": "local", + "port": self.port, + "local_ip": local_ip, + "connected_clients": len(self.clients), + "files_dir": CLAWLI_FILES_DIR + } + + +# ============== 代理客户端 ============== + +class ProxyClient: + """代理模式客户端""" + + def __init__(self, cli_instance=None): + self.cli = cli_instance + self.server_url = "" + self.server_password = "" + self.pc_password = "" + self.port = 0 + self.websocket = None + self.running = False + self.loop = None + self.thread = None + + def set_cli(self, cli_instance): + self.cli = cli_instance + + def start(self, server_host: str, server_port: int, + server_password: str, pc_port: int, pc_password: str): + self.server_url = f"ws://{server_host}:{server_port}" + self.server_password = server_password + self.port = pc_port + self.pc_password = pc_password + + if not self.server_password or not self.pc_password: + from colorama import Fore, Style + print(f"{Fore.RED}错误: 请提供服务器密码和PC密码{Style.RESET_ALL}") + return False + + self.running = True + self.thread = threading.Thread(target=self._run_client, daemon=True) + self.thread.start() + time.sleep(1) + return self.running + + def _run_client(self): + try: + async def connect(): + async with websockets.connect(self.server_url, max_size=100*1024*1024) as ws: + self.websocket = ws + + await ws.send(json.dumps({ + "type": "auth_server", + "password": self.server_password + })) + + response = await ws.recv() + data = json.loads(response) + + if data.get("type") != "auth_server_success": + from colorama import Fore, Style + print(f"{Fore.RED}服务器认证失败{Style.RESET_ALL}") + self.running = False + return + + await ws.send(json.dumps({ + "type": "register_pc", + "port": self.port, + "password": self.pc_password + })) + + response = await ws.recv() + data = json.loads(response) + + if data.get("type") == "register_success": + self.port = data.get("port", self.port) + self._print_status() + await self._main_loop(ws) + else: + from colorama import Fore, Style + print(f"{Fore.RED}注册失败{Style.RESET_ALL}") + self.running = False + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(connect()) + + except Exception as e: + from colorama import Fore, Style + print(f"{Fore.RED}连接失败: {e}{Style.RESET_ALL}") + self.running = False + + async def _main_loop(self, ws): + try: + async for message in ws: + try: + data = json.loads(message) + msg_type = data.get("type", "") + + if msg_type == "heartbeat_ack": + continue + if msg_type in ["android_connected", "android_disconnected"]: + continue + + if msg_type == "message": + content = data.get("content", "") + files = data.get("files", []) + response = await self._process_message(content, files) + await ws.send(json.dumps({"type": "message", "content": response})) + + if msg_type == "file": + file_base64 = data.get("file", "") + filename = data.get("filename", "file") + file_text = data.get("text", "") + response = await self._process_single_file(file_base64, filename, file_text, ws) + await ws.send(json.dumps({"type": "message", "content": response})) + + if msg_type == "disconnected": + break + + except json.JSONDecodeError: + pass + + except Exception as e: + from colorama import Fore, Style + print(f"{Fore.RED}连接断开: {e}{Style.RESET_ALL}") + finally: + self.running = False + + async def _process_single_file(self, file_base64: str, filename: str, text: str) -> str: + """处理单个文件""" + result = save_uploaded_file(file_base64, filename) + + if not result['success']: + return f"文件上传失败: {result['error']}" + + file_path = result['path'] + category = result['category'] + + if category == 'image': + return await self._process_image_file(file_path, text) + else: + return await self._process_message(f"@{file_path}", [], text) + + async def _process_image_file(self, file_path: str, text: str) -> str: + """处理图片""" + if not self.cli: + return f"@{file_path}" + + try: + image_engine = getattr(self.cli, 'image_engine', None) + if not image_engine: + try: + from image_engine import get_image_engine + image_engine = get_image_engine() + except: + return f"@{file_path}" + + with open(file_path, 'rb') as f: + image_data = f.read() + + result = image_engine.analyze(image_data, text or "请描述这张图片") + return await self._process_message(f"@{file_path}", [], f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}") + + except Exception as e: + return f"@{file_path}\n\n图像识别失败: {str(e)}" + + async def _process_message(self, content: str, files: List[dict] = None, extra_text: str = "") -> str: + """处理消息""" + if not self.cli: + return "错误: CLI未初始化" + + try: + if not self.cli.current_engine: + return "错误: 没有可用的AI引擎" + + full_content = content + if extra_text: + full_content = f"{content}\n{extra_text}" + + full_content = f"{full_content}\n\n[此消息由clawli手机远程发送]" + + self.cli.shared_conversation_history.append({ + "role": "user", + "content": full_content + }) + + max_loops = 10 + loop_count = 0 + current_input = full_content + + while loop_count < max_loops: + loop_count += 1 + + system_prompt = self.cli._build_system_prompt( + self.cli.get_liugin_usage_prompts() + ) + + response = self.cli.current_engine.generate_response( + current_input, + system_prompt=system_prompt + ) + + text_content, json_data = self.cli._parse_mixed_response(response) + + # 调试:打印原始响应和解析结果 + print(f"[Clawli] AI响应: {response[:200]}...") + print(f"[Clawli] 解析结果: text={text_content[:50] if text_content else 'None'}..., json={json_data}") + + if json_data: + tool_calls = None + + # 支持多种JSON格式(MCP/Plugin/OpenAI/Anthropic) + if isinstance(json_data, dict): + # 小狸自有格式 + if json_data.get('action') == 'use_tool': + tool_calls = [json_data] + elif 'tool' in json_data and 'args' in json_data: + tool_calls = [json_data] + # OpenAI function_call 格式 + elif 'function_call' in json_data: + fc = json_data['function_call'] + if isinstance(fc, dict): + args = fc.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls = [{ + 'tool': fc.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }] + # MCP/Anthropic 格式 + elif 'name' in json_data and 'arguments' in json_data: + args = json_data.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls = [{ + 'tool': json_data.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }] + # OpenAI tool_calls 格式 + elif json_data.get('type') == 'function' and 'function' in json_data: + func = json_data['function'] + args = func.get('arguments', '') + # arguments 可能是 JSON 字符串,需要解析 + if isinstance(args, str) and args.startswith('{'): + try: + args = json.loads(args) + except: + pass + tool_calls = [{ + 'tool': func.get('name', ''), + 'args': args if isinstance(args, str) else '', + 'arguments': args if isinstance(args, dict) else None + }] + elif isinstance(json_data, list): + tool_calls = [] + 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', '') + # arguments 可能是 JSON 字符串,需要解析 + 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 + }) + if not tool_calls: + tool_calls = None + + if tool_calls: + print(f"[Clawli] 检测到工具调用: {tool_calls}") + results = [] + for tool_data in tool_calls: + result = self.cli.process_tool_call(tool_data) + results.append(result.get('result', '无结果')) + + self.cli.shared_conversation_history.append({ + "role": "assistant", + "content": response + }) + + tool_result_str = "\n".join([f"工具执行结果: {r}" for r in results]) + current_input = f"{tool_result_str}\n请根据工具执行结果继续回答。\n\n[此消息由clawli手机远程发送]" + self.cli.shared_conversation_history.append({ + "role": "user", + "content": current_input + }) + continue + + if isinstance(json_data, dict) and json_data.get('message'): + final_response = json_data['message'] + self.cli.shared_conversation_history.append({ + "role": "assistant", + "content": final_response + }) + return final_response + + final_response = text_content if text_content else response + self.cli.shared_conversation_history.append({ + "role": "assistant", + "content": final_response + }) + return final_response + + return "错误: 工具调用次数超限" + + except Exception as e: + return f"处理消息时出错: {str(e)}" + + def stop(self): + if not self.running: + return + self.running = False + if self.websocket and self.loop: + async def close(): + await self.websocket.close() + self.loop.call_soon_threadsafe(lambda: asyncio.create_task(close())) + from colorama import Fore, Style + print(f"{Fore.YELLOW}代理连接已断开{Style.RESET_ALL}") + + async def _send_image(self, image_data: str, filename: str = "image.png", caption: str = ""): + """发送图片到手机端(通过代理服务器) + + Args: + image_data: 图片数据(文件路径或base64编码) + filename: 图片文件名 + caption: 图片说明文字 + """ + try: + if not self.websocket: + print("[Clawli Proxy] 没有活动的 WebSocket 连接") + return False + + # 判断是文件路径还是base64 + if os.path.isfile(image_data): + with open(image_data, 'rb') as f: + file_data = f.read() + image_base64 = base64.b64encode(file_data).decode('utf-8') + filename = os.path.basename(image_data) + else: + image_base64 = image_data + + data = { + "type": "image", + "image": image_base64, + "filename": filename, + "caption": caption + } + await self.websocket.send(json.dumps(data, ensure_ascii=False)) + print(f"[Clawli Proxy] 图片已发送: {filename}") + return True + except Exception as e: + print(f"[Clawli Proxy] 发送图片失败: {e}") + return False + + def send_image_to_clients(self, image_path: str, caption: str = "") -> bool: + """发送图片(同步接口,供外部调用)""" + if not self.websocket or not self.running: + print("[Clawli Proxy] 未连接到代理服务器") + return False + + if not os.path.isfile(image_path): + print(f"[Clawli Proxy] 图片文件不存在: {image_path}") + return False + + if self.loop: + future = asyncio.run_coroutine_threadsafe( + self._send_image(image_path, caption=caption), + self.loop + ) + try: + return future.result(timeout=10) + except Exception as e: + print(f"[Clawli Proxy] 发送图片超时: {e}") + return False + return False + + def _print_status(self): + from colorama import Fore, Style + print(f"\n{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + print(f"{Fore.GREEN}[代理模式] 已连接到中继服务器{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}") + print(f"{Fore.WHITE}服务器: {Fore.YELLOW}{self.server_url}{Style.RESET_ALL}") + print(f"{Fore.WHITE}端口: {Fore.YELLOW}{self.port}{Style.RESET_ALL}") + print(f"{Fore.WHITE}文件目录: {Fore.YELLOW}{CLAWLI_FILES_DIR}{Style.RESET_ALL}") + print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}\n") + + def get_status(self) -> dict: + return { + "running": self.running, + "mode": "proxy", + "server": self.server_url, + "port": self.port, + "files_dir": CLAWLI_FILES_DIR + } + + +# ============== 全局接口 ============== + +_local_server: Optional[LocalServer] = None +_proxy_client: Optional[ProxyClient] = None + + +def start_local_server(cli_instance, port: int = None, password: str = None) -> bool: + global _local_server, _proxy_client, websocket_server + + if _proxy_client: + _proxy_client.stop() + _proxy_client = None + + if _local_server is None: + _local_server = LocalServer(cli_instance, port) + else: + _local_server.set_cli(cli_instance) + if port: + _local_server.port = port + + result = _local_server.start(password) + + # 更新全局 websocket_server 引用 + if result: + websocket_server = _local_server + else: + websocket_server = None + + return result + + +def start_proxy_client(cli_instance, server_host: str, server_port: int, + server_password: str, pc_port: int, pc_password: str) -> bool: + global _local_server, _proxy_client, websocket_server + + if _local_server: + _local_server.stop() + _local_server = None + + if _proxy_client is None: + _proxy_client = ProxyClient(cli_instance) + else: + _proxy_client.set_cli(cli_instance) + + result = _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password) + + # 更新全局 websocket_server 引用 + if result: + websocket_server = _proxy_client + else: + websocket_server = None + + return result + + return _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password) + + +def stop_all(): + global _local_server, _proxy_client, websocket_server + + if _local_server: + _local_server.stop() + if _proxy_client: + _proxy_client.stop() + websocket_server = None + + +def get_status() -> dict: + if _local_server and _local_server.running: + return _local_server.get_status() + if _proxy_client and _proxy_client.running: + return _proxy_client.get_status() + return {"running": False} + + +def is_running() -> bool: + return get_status().get("running", False) + + +def get_active_server(): + """获取当前活动的服务器实例(本地或代理)""" + global _local_server, _proxy_client + + if _local_server and _local_server.running: + return _local_server + if _proxy_client and _proxy_client.running: + return _proxy_client + return None diff --git a/开发文档/开发文档.md b/开发文档/开发文档.md new file mode 100644 index 0000000..391ecc5 --- /dev/null +++ b/开发文档/开发文档.md @@ -0,0 +1,738 @@ +# 小狸 Pro-CLI 开发文档 + +> 版本:2.0 | 更新日期:2026年3月 + +--- + +## 目录 + +1. [系统架构概述](#1-系统架构概述) +2. [AI引擎开发规范](#2-ai引擎开发规范) +3. [Plugin协议开发](#3-plugin协议开发) +4. [Skill协议开发](#4-skill协议开发) +5. [MCP协议支持](#5-mcp协议支持) +6. [WebSocket API](#6-websocket-api) +7. [已加载模块列表](#7-已加载模块列表) +8. [最佳实践](#8-最佳实践) +9. [调试与测试](#9-调试与测试) +10. [常见问题](#10-常见问题) + +--- + +## 1. 系统架构概述 + +### 1.1 三大协议支持 + +小狸 Pro-CLI 的工具系统支持三种协议: + +| 协议 | 目录 | 说明 | +|------|------|------| +| **Plugin 协议** | `plugins/` | 小狸自有协议,简单易用 | +| **Skill 协议** | `skills/` | Agent Skills 开源协议,支持 Markdown 和 Python 格式 | +| **MCP 协议** | - | Model Context Protocol,Anthropic/OpenAI 标准 | + +### 1.2 统一工具管理器 + +`unified_tool_manager.py` 协调三种协议,提供统一的工具调用接口: + +```python +from unified_tool_manager import UnifiedToolManager + +manager = UnifiedToolManager(cli_instance) +manager.initialize("plugins", "skills") + +# Plugin 格式调用 +result = manager.execute("file_manager", "read test.txt") + +# MCP 格式调用 +result = manager.mcp_call("file_manager", {"operation": "read", "path": "test.txt"}) +``` + +### 1.3 路由策略 + +- 优先使用 Skill 协议(如果存在同名技能) +- 回退到 Plugin 协议 +- 支持强制指定协议 +- MCP 格式调用统一入口 + +--- + +## 2. AI引擎开发规范 + +### 2.1 文件与类命名规范 + +| 规范 | 说明 | +|------|------| +| 文件命名 | 必须以 `_engine.py` 结尾 | +| 类命名 | 使用驼峰命名,如 `Qwen3CoderPlusAI` | + +**示例:** +- 文件:`qwen3-coder-plus_engine.py` +- 类名:`Qwen3CoderPlusAI` + +### 2.2 引擎配置声明 + +```python +class MyEngineAI: + # 配置属性 + requires_api_key = True # 是否需要API密钥 + default_base_url = "https://api.example.com/v1" + default_model = "my-model" + + def __init__(self): + self.name = "my_engine" + # 初始化代码... +``` + +### 2.3 核心接口 + +```python +def generate_response(self, user_input, tool_results=None, system_prompt=None): + """ + 生成AI响应 + + 参数: + user_input: 用户输入文本 + tool_results: 工具执行结果(可选) + system_prompt: 系统提示词(可选,由主程序生成) + + 返回: + AI响应字符串 + """ + pass + +def get_help_info(self): + """返回引擎帮助信息""" + return "引擎帮助信息..." +``` + +### 2.4 禁止设置超时 + +```python +# ✅ 正确 +response = requests.post(url, json=body, headers=headers, timeout=None) + +# ❌ 错误 +response = requests.post(url, json=body, headers=headers, timeout=30) +``` + +### 2.5 SDK快速开发 + +使用 SDK,只需几行代码就能完成引擎开发: + +```python +from SDK_openAI import * + +class MyAIAI: + def __init__(self): + engine_Online("on") # 云模式 + ai_name("my_ai") + ai_api_url("https://api.example.com/v1") + ai_api_key("your_api_key") + ai_model_id("gpt-3.5-turbo") + + self.name = "my_ai" + self.cli = None + self.shared_conversation_history = None + + def set_cli(self, cli): + set_cli(cli) + self.cli = cli + + def generate_response(self, user_input, tool_results=None, system_prompt=None): + set_shared_history(self.shared_conversation_history) + return generate_response(user_input, tool_results, system_prompt) +``` + +### 2.6 SDK方法说明 + +| 方法 | 说明 | +|------|------| +| `engine_Online("on"/"off")` | 云模式/本地模式 | +| `ai_name(name)` | 设置引擎名称 | +| `ai_api_url(url)` | 设置API地址 | +| `ai_api_key(key)` | 设置API密钥 | +| `ai_model_id(model)` | 设置模型名称 | +| `ai_thinking_engine(...)` | 深度思考引擎配置 | + +### 2.7 深度思考引擎配置 + +适用于 QwQ、DeepSeek-R1 等会输出思考过程的模型: + +```python +ai_thinking_engine( + enable=True, # 启用深度思考 + start_marker="", # 思考开始标记 + end_marker="", # 思考结束标记 + show_thinking=True # 显示思考过程 +) +``` + +--- + +## 3. Plugin协议开发 + +### 3.1 基本结构 + +```python +class Plugin: + """插件描述""" + + def __init__(self): + self.usage = "使用方法说明" + self.cli = None + + def set_cli(self, cli): + """设置CLI引用(可选)""" + self.cli = cli + + def get_tool_info(self): + """返回工具信息(必需)""" + return { + "name": "tool_name", + "description": "工具描述", + "keywords": ["关键词1", "关键词2"], + "usage": self.usage + } + + def get_mcp_definition(self): + """返回MCP格式定义(可选)""" + return { + "name": "tool_name", + "description": "工具描述", + "inputSchema": { + "type": "object", + "properties": { + "operation": {"type": "string"}, + "path": {"type": "string"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments): + """转换MCP参数(可选)""" + return f"{arguments.get('operation', '')} {arguments.get('path', '')}" + + def handle(self, args: str) -> str: + """处理工具调用(必需)""" + try: + # 实现逻辑 + return "处理结果" + except Exception as e: + return f"错误: {str(e)}" +``` + +### 3.2 完整示例:文件管理器 + +```python +import os +from typing import Dict, Any, List + +class Plugin: + """文件管理器插件""" + + def __init__(self): + self.usage = """文件管理器使用方法: +file_manager <操作> <参数> + +操作类型: +- list <路径> - 列出目录内容 +- read <文件路径> - 读取文件内容 +- write <文件路径> -c "内容" - 写入文件 +- delete <路径> - 删除文件或目录 +""" + self.cli = None + + def set_cli(self, cli): + self.cli = cli + + def get_tool_info(self): + return { + "name": "file_manager", + "description": "文件和目录管理工具", + "keywords": ["文件", "目录", "管理", "file"], + "usage": self.usage + } + + def get_mcp_definition(self) -> Dict[str, Any]: + return { + "name": "file_manager", + "description": "文件和目录管理工具", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "read", "write", "delete"], + "description": "操作类型" + }, + "path": {"type": "string", "description": "路径"}, + "content": {"type": "string", "description": "内容"} + }, + "required": ["operation"] + } + } + + def convert_mcp_args(self, arguments: Dict[str, Any]) -> str: + op = arguments.get("operation", "") + path = arguments.get("path", "") + content = arguments.get("content", "") + if content: + return f'{op} {path} -c "{content}"' + return f"{op} {path}" + + def handle(self, args: str) -> str: + parts = args.strip().split(maxsplit=2) + if not parts: + return "错误:请提供操作类型" + + operation = parts[0].lower() + + if operation == "list": + return self._op_list(parts[1:] if len(parts) > 1 else ["."]) + elif operation == "read": + return self._op_read(parts[1:] if len(parts) > 1 else []) + elif operation == "write": + return self._op_write(parts[1:]) + elif operation == "delete": + return self._op_delete(parts[1:]) + + return f"错误:不支持的操作 '{operation}'" + + def _op_list(self, args: List[str]) -> str: + path = args[0] if args else "." + if not os.path.exists(path): + return f"错误:路径不存在: {path}" + # 实现列出目录逻辑... + return f"目录内容: {path}" + + def _op_read(self, args: List[str]) -> str: + if not args: + return "错误:请提供文件路径" + # 实现读取文件逻辑... + return "文件内容..." + + def _op_write(self, args: List[str]) -> str: + if len(args) < 2: + return "错误:请提供文件路径和内容" + # 实现写入文件逻辑... + return "写入成功" + + def _op_delete(self, args: List[str]) -> str: + if not args: + return "错误:请提供路径" + # 实现删除逻辑... + return "删除成功" +``` + +### 3.3 参数格式规范 + +**多操作插件格式:** +``` +<操作类型> <目标对象> [可选内容] +``` +示例:`read file.txt`、`write file.txt 内容` + +**单参数插件格式:** +``` +<待处理内容> +``` +示例:`2+3`、`hello world` + +--- + +## 4. Skill协议开发 + +### 4.1 Python格式技能 + +```python +# skills/my_skill/__init__.py +from skills.base import Skill, SkillResult + +class MySkill(Skill): + """我的技能""" + + name = "my_skill" + description = "技能描述" + tags = ["标签1", "标签2"] + + def get_tool_definition(self): + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": { + "operation": {"type": "string"} + } + } + } + } + + def run(self, **kwargs) -> SkillResult: + try: + operation = kwargs.get("operation", "") + # 实现逻辑 + return SkillResult( + success=True, + message="执行成功", + data={"result": "..."} + ) + except Exception as e: + return SkillResult( + success=False, + error=str(e) + ) +``` + +### 4.2 Markdown格式技能 + +```markdown +# skills/my_skill/SKILL.md + +--- +name: my_skill +description: 技能描述 +tags: [标签1, 标签2] +--- + +# 技能说明 + +这是技能的详细使用说明... + +## 使用方法 + +1. 步骤一 +2. 步骤二 +``` + +--- + +## 5. MCP协议支持 + +### 5.1 MCP工具定义格式 + +```python +{ + "name": "tool_name", + "description": "工具描述", + "inputSchema": { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "参数说明"}, + "param2": {"type": "integer", "description": "参数说明"} + }, + "required": ["param1"] + } +} +``` + +### 5.2 MCP调用格式 + +**请求:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "file_manager", + "arguments": { + "operation": "read", + "path": "test.txt" + } + } +} +``` + +**响应:** +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + {"type": "text", "text": "文件内容..."} + ], + "isError": false + } +} +``` + +### 5.3 统一工具管理器MCP接口 + +```python +# 获取MCP工具列表 +tools = manager.get_mcp_tools() + +# MCP格式调用 +result = manager.mcp_call("file_manager", {"operation": "read", "path": "test.txt"}) + +# 处理MCP请求 +response = manager.handle_mcp_request({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {...} +}) +``` + +--- + +## 6. WebSocket API + +### 6.1 消息类型 + +| 类型 | 方向 | 说明 | +|------|------|------| +| `heartbeat` | 双向 | 心跳检测 | +| `auth` | 客户端→服务端 | 认证请求 | +| `auth_success` / `auth_failed` | 服务端→客户端 | 认证结果 | +| `message` | 双向 | 文本消息 | +| `file` | 客户端→服务端 | 文件上传 | +| `image` | 服务端→客户端 | 图片发送 | +| `tool_status` | 服务端→客户端 | 工具状态 | + +### 6.2 消息格式示例 + +**认证请求:** +```json +{"type": "auth", "password": "your_password"} +``` + +**发送消息:** +```json +{"type": "message", "content": "你好"} +``` + +**上传文件:** +```json +{ + "type": "file", + "file": "base64_encoded_data", + "filename": "example.txt", + "text": "附加说明" +} +``` + +### 6.3 启动远程服务 + +```bash +# 本地模式 +/remote start 9079 your_password + +# 代理模式 +/remote proxy relay.example.com:9079 server_pwd 12345 pc_pwd +``` + +--- + +## 7. 已加载模块列表 + +### 7.1 AI引擎 + +| 引擎名 | 类型 | 说明 | +|--------|------|------| +| ollama | 本地 | Ollama本地模型,无需API密钥 | +| glm | 云端 | 智谱GLM | +| spark | 云端 | 讯飞星火 | +| qwq | 云端 | QwQ深度思考引擎 | +| qwen | 云端 | 通义千问 | + +### 7.2 插件 + +| 插件名 | 功能 | +|--------|------| +| file_manager | 文件管理(14种操作) | +| ai_search | AI搜索 | +| audio_player | 音频播放 | +| cmd_executor | 命令执行 | +| frontend_tester | 前端测试 | +| glados_tts_plugin | GLaDOS语音合成 | +| napcat_qq | QQ机器人 | +| network_tools | 网络工具 | +| ollama_image | Ollama图像 | +| send_image | 发送图片 | +| speech_recognition | 语音识别 | +| task_manager | 任务管理 | +| tool_search | 工具搜索 | + +--- + +## 8. 最佳实践 + +### 8.1 安全性 + +```python +# ❌ 避免使用危险函数 +result = eval(user_input) # 危险! + +# ✅ 安全的替代方案 +import ast +try: + tree = ast.parse(user_input, mode='eval') + result = eval(compile(tree, '', 'eval')) +except: + return "无效表达式" +``` + +### 8.2 输入验证 + +```python +def handle(self, args: str) -> str: + # 参数验证 + if not args: + return "错误:参数不能为空" + + # 长度限制 + if len(args) > 10000: + return "错误:输入过长" + + # 格式验证 + if not re.match(r'^[\w\s\-\.]+$', args): + return "错误:包含非法字符" + + # 继续处理... +``` + +### 8.3 错误处理 + +```python +def handle(self, args: str) -> str: + try: + # 业务逻辑 + return "结果" + except FileNotFoundError: + return "错误:文件不存在" + except PermissionError: + return "错误:权限不足" + except Exception as e: + return f"错误: {str(e)}" +``` + +### 8.4 资源管理 + +```python +# 使用上下文管理器 +with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + +# 或确保资源释放 +try: + f = open(file_path, 'r') + content = f.read() +finally: + f.close() +``` + +--- + +## 9. 调试与测试 + +### 9.1 日志记录 + +```python +def handle(self, args: str) -> str: + if self.cli: + print(f"[DEBUG] 插件被调用: {args}") + # 处理逻辑 + return "结果" +``` + +### 9.2 单元测试 + +```python +import unittest +from plugins.file_manager import Plugin + +class TestFileManager(unittest.TestCase): + def setUp(self): + self.plugin = Plugin() + + def test_get_tool_info(self): + info = self.plugin.get_tool_info() + self.assertIn("name", info) + self.assertEqual(info["name"], "file_manager") + + def test_handle_list(self): + result = self.plugin.handle("list .") + self.assertIsInstance(result, str) + +if __name__ == "__main__": + unittest.main() +``` + +### 9.3 集成测试 + +```python +# integration_test.py +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from ai_cli import AICLI + +def test_integration(): + cli = AICLI() + # 测试插件调用... +``` + +--- + +## 10. 常见问题 + +### Q1: 插件不加载? + +检查以下几点: +- 文件是否放在 `plugins/` 目录 +- 类名是否为 `Plugin` +- 是否实现了 `get_tool_info()` 和 `handle()` 方法 +- 文件名是否为 `.py` 结尾 + +### Q2: 插件不被调用? + +检查: +- 关键词是否匹配用户输入 +- `keywords` 列表是否正确设置 +- 插件是否成功加载(查看启动日志) + +### Q3: MCP格式调用失败? + +确保: +- 实现了 `get_mcp_definition()` 方法 +- 实现了 `convert_mcp_args()` 方法(如果需要) +- `inputSchema` 格式正确 + +### Q4: 如何调试? + +- 在 `handle()` 方法中添加 `print()` 语句 +- 检查返回值是否为字符串 +- 查看主程序日志 + +### Q5: 如何处理大文件? + +```python +# 限制输出长度 +MAX_LENGTH = 10000 +if len(content) > MAX_LENGTH: + content = content[:MAX_LENGTH] + "\n... (内容已截断)" +``` + +--- + +## 附录:插件开发检查清单 + +- [ ] 插件类名是 `Plugin` +- [ ] 实现了 `get_tool_info()` 方法 +- [ ] 实现了 `handle(args)` 方法 +- [ ] 返回字符串结果 +- [ ] 包含 `usage` 属性 +- [ ] 可选:实现 `get_mcp_definition()` 支持 MCP +- [ ] 可选:实现 `convert_mcp_args()` 转换参数 +- [ ] 所有异常都被捕获 +- [ ] 遵循参数格式规范 +- [ ] 遵循 PEP 8 代码规范 +- [ ] 进行了基本测试