xiaoli-cli 7.0.0: 重构底层架构 + 移除工具截断
- 重构 core/ 底层 (7文件): app/runner/engine/parser/history/safety/display - FC原生调用为主,prompt-JSON保底 - 移除所有工具返回截断 (ai_search/git_tools/cmd_executor/browser_auto/network_tools/sub_agent/tool_search) - 删除无用文件 (旧服务器/WebUI/彩蛋/启动器等21个文件) - 清理 constants.py 死代码 - 版本号更新为 7.0.0
This commit is contained in:
commit
78fea7ccdd
57 files changed
+18358
No files matched your search
+49
@@ -0,0 +1,49 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
|
||||
# 虚拟环境
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 配置(含敏感信息)
|
||||
config.json
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# 聊天记录
|
||||
chat_history/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 嵌入式 Python
|
||||
embedded_python/
|
||||
|
||||
# 记忆系统数据(运行时生成)
|
||||
MEMORY.md
|
||||
memory/
|
||||
|
||||
# 生成的图片
|
||||
generated_*.png
|
||||
screenshots/gen_*.png
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
@@ -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.
|
||||
@@ -0,0 +1,354 @@
|
||||
# 小狸 Pro-CLI
|
||||
|
||||
<p align="center">
|
||||
<img src="logo.png" width="120" alt="xiaoli-cli logo">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>智能编程助手 — 代码编辑 · Git 集成 · 浏览器自动化 · 多 Agent 协作 · 图像生成 · 记忆系统</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-6.0.0-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
|
||||
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
|
||||
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
|
||||
<img src="https://img.shields.io/badge/plugins-24-blueviolet" alt="plugins">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 简介
|
||||
|
||||
小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。
|
||||
|
||||
## 特性
|
||||
|
||||
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
|
||||
- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索、自动语法检查
|
||||
- **Git 版本控制** — 完整 Git 操作 + 工作流自动化(smart-commit、changelog)
|
||||
- **浏览器自动化** — Playwright 驱动,导航、交互、截图、JS 执行、PDF 导出
|
||||
- **子 Agent 系统** — 多 Agent 并行协作,独立任务分配与结果回收
|
||||
- **图像生成** — 基于 ComfyUI 在线服务,自然语言/Tag 双模式,支持中文描述
|
||||
- **记忆系统** — 长期记忆 + 每日日记 + AI 写日记 + 全局搜索 + 自动保存对话
|
||||
- **远程通信** — PC 与手机 WebSocket 双向通信
|
||||
- **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI
|
||||
- **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux)
|
||||
- **MCP 协议** — 18 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
|
||||
- **进程保护** — 跨平台单实例保护 + 进程优先级提升 + Windows PPL 风格保护 + 看门狗监控 + 优雅关闭
|
||||
- **18 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
|
||||
- **150+ 个自动化测试** — pytest 覆盖全部插件和核心模块
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
git clone https://gitee.com/shuiyu1123/xiaoli-cli.git
|
||||
cd xiaoli-cli
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
# CLI 模式
|
||||
python ai_cli.py
|
||||
|
||||
# 指定引擎
|
||||
python ai_cli.py --engine openai
|
||||
|
||||
# Windows 一键启动
|
||||
launcher.exe
|
||||
```
|
||||
|
||||
## AI 引擎
|
||||
|
||||
| 引擎 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ollama` | 本地 | Ollama 本地模型,无需 API 密钥 |
|
||||
| `openai` | 云端/本地 | OpenAI 兼容格式,支持 DeepSeek、Grok、硅基流动、本地 vLLM 等 |
|
||||
|
||||
### 配置示例 (config.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"api": {
|
||||
"engines": {
|
||||
"ollama": {
|
||||
"base_url": "http://localhost:11434",
|
||||
"model": "gemma4:31b"
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "your-key",
|
||||
"base_url": "https://api.deepseek.com/v1",
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"default_engine": "ollama",
|
||||
"max_history": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 插件列表 (23 个)
|
||||
|
||||
> 更多社区插件请访问 [xiaoli-cli-plugins](https://gitee.com/shuiyu1123/xiaoli-cli-plugins),使用 `/plugin install <插件码>` 一键安装。
|
||||
|
||||
### 核心生产力
|
||||
|
||||
| 插件 | 操作数 | 说明 |
|
||||
|------|--------|------|
|
||||
| `code_editor` | 24 | 代码编辑+搜索。精准替换、批量编辑、find/regex/symbols/imports/callers/todo/structure + 自动语法检查 |
|
||||
| `git_tools` | 17 | Git 版本控制。status/diff/log/commit/branch + smart-commit/changelog/contributors/stale |
|
||||
| `cmd_executor` | 1 | Shell 命令执行,跨平台,超时控制 |
|
||||
| `file_manager` | 10 | 文件系统。list/read/write/copy/move/delete/search/info/mkdir/append |
|
||||
| `auto_engineer` | 12 | 工程化。lint/format/test/build/deps + 复杂度/安全扫描/质量指标/项目初始化 |
|
||||
| `task_manager` | 7 | 任务管理。add/list/complete/delete/clear,JSON 持久化 |
|
||||
|
||||
### 高级功能
|
||||
|
||||
| 插件 | 操作数 | 说明 |
|
||||
|------|--------|------|
|
||||
| `sub_agent` | 8 | 子 Agent 系统。spawn/list/status/result/send/kill,多 Agent 并行协作 |
|
||||
| `terminal` | 8 | 终端模拟器。AI 的虚拟键盘和屏幕,打字/按键/看屏幕,支持 Python REPL |
|
||||
|
||||
### 辅助工具
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| `browser_auto` | 浏览器自动化。导航/点击/填写/截图/JS执行(基于 Playwright) |
|
||||
| `gui_auto` | 桌面 GUI 自动化。Windows UIA 元素识别/点击/输入(仅 Windows) |
|
||||
| `memory_plugin` | 记忆系统。AI 写日记/搜索记忆/管理聊天记录 |
|
||||
| `scheduler` | 定时任务。支持相对/绝对/重复任务,到期自动激活 AI |
|
||||
| `tool_search` | 工具搜索。按关键词发现可用插件 |
|
||||
| `workflow` | 工作流引擎。YAML 多步骤自动化,变量传递,条件分支 |
|
||||
| `network_tools` | 网络工具。ping/get/status/headers/ip |
|
||||
| `ai_search` | AI 搜索。jina.ai 智能搜索 + 网页内容提取 |
|
||||
| `frontend_tester` | 前端测试。HTML/CSS/JS 语法检查、响应式验证 |
|
||||
| `speech_recognition` | 语音识别。录音 + 语音转文字(Whisper) |
|
||||
| `json_toolkit` | JSON 工具箱。格式化/压缩/校验/路径查询/差异比较 |
|
||||
| `regex_tester` | 正则表达式测试器。匹配/提取/替换/解释 |
|
||||
| `snippet_manager` | 代码片段管理器。保存/搜索/复用常用代码 |
|
||||
| `markdown_tool` | Markdown 工具。TOC/统计/表格生成/待办提取 |
|
||||
| `id_generator` | ID 生成器。UUID/NanoID/短ID/雪花ID/ULID |
|
||||
|
||||
## 记忆系统
|
||||
|
||||
内置持久化记忆系统,AI 可以记住之前的工作:
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 长期记忆 | `MEMORY.md` — AI 自己维护的精华记忆 |
|
||||
| 每日日记 | `memory/YYYY-MM-DD.md` — 带时间戳和标签 |
|
||||
| AI 写日记 | `memory_plugin diary_ai` — 自动标记 [AI观察] |
|
||||
| 自动保存对话 | 每轮对话后自动存 JSON |
|
||||
| 上下文压缩 | 超过 50 条自动 AI 总结,保留最近 10 条 |
|
||||
| 全局搜索 | 搜索 MEMORY + 日记 + 聊天记录 |
|
||||
|
||||
```
|
||||
memory_plugin diary 今天完成了API对接
|
||||
memory_plugin search 定时任务
|
||||
memory_plugin remember 用户偏好深色主题
|
||||
memory_plugin chat_search 图像生成
|
||||
```
|
||||
|
||||
## 远程通信
|
||||
|
||||
通过 WebSocket 实现 PC 与手机双向通信:
|
||||
|
||||
```bash
|
||||
/remote start 9079 mypassword # 启动本地服务
|
||||
/remote stop # 停止服务
|
||||
/remote status # 查看状态
|
||||
```
|
||||
|
||||
功能:手机发消息给 AI、AI 回复推到手机、文件传输、图片识别。
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 模式切换 (`/safe`)
|
||||
|
||||
| 模式 | 说明 |
|
||||
|------|------|
|
||||
| **普通模式** (默认) | AI 识别风险,有风险才请求确认 |
|
||||
| **人工确认** | 所有指令都需要用户确认 |
|
||||
|
||||
### 检查流程
|
||||
|
||||
```
|
||||
工具调用 → safety.check()
|
||||
├─ 快速预检(读取/查询类)→ 直接放行
|
||||
├─ 普通模式 → AI 独立分析风险
|
||||
│ ├─ safe → 放行
|
||||
│ └─ unsafe → 展示风险等级+影响,y/n 确认
|
||||
└─ 特殊指令(rm/delete/chmod)→ 始终确认
|
||||
```
|
||||
|
||||
### 代码执行
|
||||
|
||||
独立的安全代码执行环境:
|
||||
- AST 级危险代码检测
|
||||
- 模块白名单(阻断 25 个高危模块)
|
||||
- 子进程隔离执行
|
||||
- 内存限制
|
||||
|
||||
## MCP 协议支持
|
||||
|
||||
所有 18 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议:
|
||||
|
||||
| 协议 | 说明 |
|
||||
|------|------|
|
||||
| **MCP** | Model Context Protocol 标准定义 |
|
||||
| **FC** | OpenAI Function Calling 原生调用 |
|
||||
| **JSON** | 传统 `{"action": "use_tool", ...}` 格式 |
|
||||
|
||||
## 命令列表
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `/help` | 显示帮助 |
|
||||
| `/safe` | 切换安全模式 |
|
||||
| `/engine list` | 列出可用引擎 |
|
||||
| `/engine switch <引擎>` | 切换引擎 |
|
||||
| `/engine.openai models` | 列出 OpenAI 引擎模型 |
|
||||
| `/chat save/list/open` | 聊天记录管理 |
|
||||
| `/file.read <路径>` | 读取文件 |
|
||||
| `/remote` | 远程连接帮助 |
|
||||
| `/notify` | 切换任务完成通知 |
|
||||
| `/scheduler` / `/remind` | 管理定时任务 |
|
||||
| `/memory` | 管理记忆系统 |
|
||||
| `/quit` | 退出 |
|
||||
|
||||
## Web UI
|
||||
|
||||
WebSocket 实时交互界面,支持完整的 AI 对话体验。
|
||||
|
||||
```bash
|
||||
# 方式 1: 启动 WebSocket 后端(推荐)
|
||||
python webui_server.py --port 8080
|
||||
# 浏览器访问 http://localhost:8080
|
||||
|
||||
# 方式 2: 直接打开静态页面
|
||||
start webui.html # Windows
|
||||
open webui.html # macOS
|
||||
xdg-open webui.html # Linux
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
xiaoli-cli/
|
||||
├── ai_cli.py # 主入口
|
||||
├── webui.html # Web UI 界面
|
||||
├── webui_server.py # Web UI 后端
|
||||
├── launcher.py # 智能启动器
|
||||
├── config.json.example # 配置模板
|
||||
├── easter_egg.py # 彩蛋
|
||||
├── xcli_core/ # 核心模块
|
||||
│ ├── cli_core.py # 主循环 + 提示词构建
|
||||
│ ├── cli_base.py # 初始化 + 引擎/插件加载
|
||||
│ ├── cli_tools.py # 工具调用执行
|
||||
│ ├── cli_clawli.py # 远程模式
|
||||
│ ├── cli_code_exec.py # 代码执行
|
||||
│ ├── cli_display.py # 显示输出
|
||||
│ ├── cli_history.py # 聊天记录
|
||||
│ ├── config.py # 配置管理
|
||||
│ ├── constants.py # 常量定义
|
||||
│ ├── fc_tools.py # MCP → FC 格式转换
|
||||
│ ├── memory.py # 记忆系统
|
||||
│ ├── notification.py # 跨平台系统通知
|
||||
│ ├── plugin_manager.py # 插件管理器
|
||||
│ ├── safety.py # 统一安全层
|
||||
│ ├── sandbox.py # 代码安全沙箱
|
||||
├── ai_engines/ # AI 引擎
|
||||
│ ├── ollama_engine.py
|
||||
│ └── openai_engine.py
|
||||
├── plugins/ # 插件 (18 个)
|
||||
│ ├── code_editor.py
|
||||
│ ├── git_tools.py
|
||||
│ ├── cmd_executor.py
|
||||
│ ├── file_manager.py
|
||||
│ ├── auto_engineer.py
|
||||
│ ├── task_manager.py
|
||||
│ ├── sub_agent.py
|
||||
│ ├── browser_auto.py
|
||||
│ ├── gui_auto.py
|
||||
│ ├── ai_image.py
|
||||
│ ├── image_generator.py
|
||||
│ ├── memory_plugin.py
|
||||
│ ├── scheduler.py
|
||||
│ ├── tool_search.py
|
||||
│ ├── network_tools.py
|
||||
│ ├── ai_search.py
|
||||
│ ├── frontend_tester.py
|
||||
│ ├── speech_recognition.py
|
||||
│ └── terminal.py
|
||||
├── skills/ # 技能
|
||||
├── image_engine/ # 图像识别引擎
|
||||
├── tests/ # 测试套件 (150+ 用例)
|
||||
├── love.txt # 彩蛋情话
|
||||
└── about.txt # 关于信息
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
python -m pytest tests/ -v
|
||||
|
||||
# 语法检查
|
||||
python -c "import py_compile; py_compile.compile('ai_cli.py', doraise=True)"
|
||||
```
|
||||
|
||||
## 插件开发
|
||||
|
||||
小狸支持 `Liugin`(自研协议)和 `Plugin`(通用协议)两种类名,均可被插件管理器自动识别:
|
||||
|
||||
```python
|
||||
class Plugin:
|
||||
"""我的插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = "使用说明"
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "my_tool",
|
||||
"description": "工具描述",
|
||||
"keywords": ["关键词"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "my_tool",
|
||||
"description": "工具描述",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"args": {"type": "string", "description": "参数"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
return "处理结果"
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 链接
|
||||
|
||||
- Gitee: https://gitee.com/shuiyu1123/xiaoli-cli
|
||||
- 插件仓库: https://gitee.com/shuiyu1123/xiaoli-cli-plugins
|
||||
- [更新日志](CHANGELOG.md)
|
||||
- [使用指南](HELP.md)
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""xiaoli-cli 7.0.0 入口"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def main():
|
||||
try:
|
||||
from core.app import XiaoliCLI
|
||||
cli = XiaoliCLI()
|
||||
cli.run()
|
||||
except KeyboardInterrupt:
|
||||
print("\n再见~")
|
||||
except Exception as e:
|
||||
print(f"启动失败: {e}")
|
||||
import traceback; traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
手动引擎 - 让人类在另一个终端扮演 AI
|
||||
消息通过文件传递,另一个终端窗口显示消息并等待人工输入
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到sys.path
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
# 通信用的临时目录
|
||||
COMM_DIR = os.path.join(tempfile.gettempdir(), "xiaoli_manual_engine")
|
||||
|
||||
|
||||
class ManualAI:
|
||||
"""手动引擎 — 让人类在另一个终端扮演 AI"""
|
||||
|
||||
requires_api_key = False
|
||||
|
||||
def __init__(self):
|
||||
self.name = "manual"
|
||||
self.cli = None
|
||||
self.conversation_history = []
|
||||
self.max_history = 10
|
||||
self._subprocess = None
|
||||
self._session_id = str(int(time.time()))
|
||||
self._started = False
|
||||
|
||||
# 确保通信目录存在
|
||||
os.makedirs(COMM_DIR, exist_ok=True)
|
||||
|
||||
def get_help_info(self):
|
||||
return """手动引擎帮助信息
|
||||
========================
|
||||
|
||||
引擎名称: manual
|
||||
描述: 让人类在另一个终端窗口扮演 AI,消息通过文件传递
|
||||
|
||||
工作原理:
|
||||
1. 切换到 manual 引擎时,自动打开一个新终端窗口
|
||||
2. 用户在 CLI 中输入消息,消息会显示在新终端窗口中
|
||||
3. 另一个终端窗口中的人阅读消息,手动输入回复并按回车
|
||||
4. 回复传回 CLI,显示为 AI 的响应
|
||||
|
||||
使用说明:
|
||||
1. /engine switch manual — 切换到手动引擎,自动打开另一个终端
|
||||
2. 在 CLI 中正常对话,消息会转发到另一个终端
|
||||
3. 在另一个终端中手动输入回复
|
||||
4. /engine.manual status — 查看连接状态
|
||||
5. /engine.manual restart — 重启另一个终端窗口
|
||||
|
||||
注意: 需要图形桌面环境才能自动打开新终端窗口。
|
||||
如果是无头服务器,请手动运行通信脚本。
|
||||
"""
|
||||
|
||||
def _get_paths(self):
|
||||
"""获取通信文件路径"""
|
||||
base = os.path.join(COMM_DIR, self._session_id)
|
||||
return {
|
||||
"msg_to_human": base + "_to_human.txt",
|
||||
"msg_from_human": base + "_from_human.txt",
|
||||
"ready": base + "_ready.flag",
|
||||
"alive": base + "_alive.flag",
|
||||
}
|
||||
|
||||
def _spawn_terminal(self):
|
||||
"""在新终端窗口中启动人工回复脚本"""
|
||||
paths = self._get_paths()
|
||||
|
||||
# 写一个临时脚本
|
||||
script_content = self._generate_script(paths)
|
||||
script_path = os.path.join(COMM_DIR, f"worker_{self._session_id}.py")
|
||||
with open(script_path, 'w', encoding='utf-8') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# 根据平台选择终端
|
||||
import platform
|
||||
system = platform.system()
|
||||
|
||||
try:
|
||||
if system == "Windows":
|
||||
self._subprocess = subprocess.Popen(
|
||||
["cmd", "/c", "start", "cmd", "/k", f"python \"{script_path}\""],
|
||||
shell=True
|
||||
)
|
||||
elif system == "Darwin": # macOS
|
||||
self._subprocess = subprocess.Popen([
|
||||
"osascript", "-e",
|
||||
f'tell app "Terminal" to do script "python3 \\"{script_path}\\""'
|
||||
])
|
||||
else: # Linux
|
||||
# 尝试常见的终端模拟器
|
||||
terminals = [
|
||||
["x-terminal-emulator", "-e", f"python3 {script_path}"],
|
||||
["gnome-terminal", "--", "python3", script_path],
|
||||
["konsole", "-e", f"python3 {script_path}"],
|
||||
["xfce4-terminal", "-e", f"python3 {script_path}"],
|
||||
["xterm", "-e", f"python3 {script_path}"],
|
||||
]
|
||||
launched = False
|
||||
for cmd in terminals:
|
||||
try:
|
||||
self._subprocess = subprocess.Popen(cmd)
|
||||
launched = True
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if not launched:
|
||||
print(f"\033[33m[manual] 无法自动打开终端窗口,请手动运行:\033[0m")
|
||||
print(f"\033[36m python3 {script_path}\033[0m")
|
||||
return False
|
||||
|
||||
# 等待对方就绪
|
||||
ready_path = paths["ready"]
|
||||
for _ in range(30): # 最多等 15 秒
|
||||
if os.path.exists(ready_path):
|
||||
self._started = True
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
|
||||
# 超时但不报错,可能终端启动慢
|
||||
self._started = True
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\033[31m[manual] 启动终端失败: {e}\033[0m")
|
||||
print(f"\033[33m[manual] 请手动运行: python3 {script_path}\033[0m")
|
||||
return False
|
||||
|
||||
def _generate_script(self, paths):
|
||||
"""生成人工回复脚本的内容"""
|
||||
return f'''#!/usr/bin/env python3
|
||||
"""小狸手动引擎 — 人工回复终端"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
|
||||
MSG_IN = r"{paths['msg_to_human']}"
|
||||
MSG_OUT = r"{paths['msg_from_human']}"
|
||||
READY = r"{paths['ready']}"
|
||||
ALIVE = r"{paths['alive']}"
|
||||
|
||||
# 标记自己已就绪
|
||||
with open(READY, 'w') as f:
|
||||
f.write("ready")
|
||||
|
||||
# 定期刷新 alive 标记
|
||||
def keep_alive():
|
||||
try:
|
||||
with open(ALIVE, 'w') as f:
|
||||
f.write(str(time.time()))
|
||||
except:
|
||||
pass
|
||||
|
||||
keep_alive()
|
||||
|
||||
print()
|
||||
print("\\033[36m╔══════════════════════════════════════════╗\\033[0m")
|
||||
print("\\033[36m║ 小狸手动引擎 — 人工回复终端 ║\\033[0m")
|
||||
print("\\033[36m║ 在这里输入回复,按回车发送 ║\\033[0m")
|
||||
print("\\033[36m║ 输入 quit 退出 ║\\033[0m")
|
||||
print("\\033[36m╚══════════════════════════════════════════╝\\033[0m")
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
|
||||
last_msg_time = 0
|
||||
|
||||
while True:
|
||||
keep_alive()
|
||||
|
||||
# 检查是否有新消息
|
||||
try:
|
||||
if os.path.exists(MSG_IN):
|
||||
mtime = os.path.getmtime(MSG_IN)
|
||||
if mtime > last_msg_time:
|
||||
last_msg_time = mtime
|
||||
with open(MSG_IN, 'r', encoding='utf-8') as f:
|
||||
msg = f.read()
|
||||
if msg:
|
||||
print("\\033[33m[用户消息]\\033[0m")
|
||||
print(msg)
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查用户输入(非阻塞)
|
||||
try:
|
||||
import select
|
||||
if select.select([sys.stdin], [], [], 0.3)[0]:
|
||||
line = sys.stdin.readline().strip()
|
||||
if line:
|
||||
if line.lower() == "quit":
|
||||
print("\\033[31m[已退出手动引擎]\\033[0m")
|
||||
break
|
||||
# 写回复
|
||||
with open(MSG_OUT, 'w', encoding='utf-8') as f:
|
||||
f.write(line)
|
||||
print(f"\\033[32m[已发送] {{line}}\\033[0m")
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
except:
|
||||
# Windows 没有 select,用阻塞方式
|
||||
try:
|
||||
import msvcrt
|
||||
if msvcrt.kbhit():
|
||||
line = input().strip()
|
||||
if line:
|
||||
if line.lower() == "quit":
|
||||
print("\\033[31m[已退出手动引擎]\\033[0m")
|
||||
break
|
||||
with open(MSG_OUT, 'w', encoding='utf-8') as f:
|
||||
f.write(line)
|
||||
print(f"\\033[32m[已发送] {{line}}\\033[0m")
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
except:
|
||||
time.sleep(0.3)
|
||||
'''
|
||||
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None):
|
||||
"""生成响应 — 转发给另一个终端的人工"""
|
||||
paths = self._get_paths()
|
||||
|
||||
# 如果还没启动终端,现在启动
|
||||
if not self._started:
|
||||
if not self._spawn_terminal():
|
||||
return "[manual] 手动引擎未就绪,无法打开终端窗口。请手动启动通信脚本。"
|
||||
|
||||
# 清除旧的回复文件
|
||||
from_human = paths["msg_from_human"]
|
||||
if os.path.exists(from_human):
|
||||
os.remove(from_human)
|
||||
|
||||
# 把用户消息写入文件
|
||||
msg = user_input
|
||||
if tool_results:
|
||||
msg = f"[工具结果] {tool_results.get('result', '无结果')}\n\n{user_input}"
|
||||
|
||||
to_human = paths["msg_to_human"]
|
||||
with open(to_human, 'w', encoding='utf-8') as f:
|
||||
f.write(msg)
|
||||
|
||||
print(f"\033[36m[manual] 消息已发送到人工终端,等待回复...\033[0m")
|
||||
|
||||
# 等待回复
|
||||
timeout = 300 # 最多等 5 分钟
|
||||
waited = 0
|
||||
while waited < timeout:
|
||||
if os.path.exists(from_human):
|
||||
try:
|
||||
with open(from_human, 'r', encoding='utf-8') as f:
|
||||
response = f.read().strip()
|
||||
if response:
|
||||
# 清理回复文件
|
||||
os.remove(from_human)
|
||||
return response
|
||||
except:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
waited += 0.5
|
||||
|
||||
return "[manual] 等待人工回复超时(5分钟)"
|
||||
|
||||
def handle_command(self, command):
|
||||
"""处理 manual 引擎专属命令"""
|
||||
if command == "status":
|
||||
paths = self._get_paths()
|
||||
alive = os.path.exists(paths["alive"])
|
||||
return f"会话ID: {self._session_id}\n终端状态: {'已连接' if alive else '未连接'}\n通信目录: {COMM_DIR}"
|
||||
elif command == "restart":
|
||||
self._started = False
|
||||
self._session_id = str(int(time.time()))
|
||||
if self._spawn_terminal():
|
||||
return "手动引擎终端已重启"
|
||||
else:
|
||||
return "重启失败,无法打开终端"
|
||||
else:
|
||||
return f"未知命令: {command}\n可用: status, restart"
|
||||
@@ -0,0 +1,491 @@
|
||||
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)
|
||||
|
||||
|
||||
def _safe_get(obj, key, default=None):
|
||||
"""兼容 dict 和 pydantic 对象的安全取值(新版 ollama 返回 pydantic 模型,不支持 .get())"""
|
||||
if obj is None:
|
||||
return default
|
||||
# 1. 先尝试 dict 访问
|
||||
try:
|
||||
return obj[key]
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
# 2. 尝试属性访问(pydantic 模型)
|
||||
val = getattr(obj, key, None)
|
||||
if val is not None:
|
||||
return val
|
||||
# 3. 尝试 model_dump 后的 dict 访问
|
||||
try:
|
||||
return obj.model_dump()[key]
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
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._cancelled = False
|
||||
|
||||
# 直接启用思考标记,由主程序自动处理深度思考模型的响应
|
||||
self.thinking_start_marker = "<thinking>"
|
||||
self.thinking_end_marker = "</thinking>"
|
||||
|
||||
# 检测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 cancel(self):
|
||||
"""取消正在进行的请求"""
|
||||
self._cancelled = True
|
||||
|
||||
def apply_config(self, base_url=None, api_key=None, model=None):
|
||||
"""应用新配置到当前引擎实例(内存),并持久化到 config.json"""
|
||||
if base_url is not None:
|
||||
self.base_url = base_url.rstrip('/')
|
||||
if api_key is not None:
|
||||
self.api_key = api_key
|
||||
if model is not None:
|
||||
self.model = model
|
||||
# 持久化到 config.json
|
||||
try:
|
||||
import os, json
|
||||
config_file = os.path.join(project_root, "config.json")
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
engine_cfg = config.setdefault("api", {}).setdefault("engines", {}).setdefault("ollama", {})
|
||||
engine_cfg["base_url"] = self.base_url
|
||||
engine_cfg["api_key"] = self.api_key
|
||||
engine_cfg["model"] = self.model
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"持久化引擎配置失败: {e}")
|
||||
|
||||
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, stream_start_event=None):
|
||||
"""
|
||||
生成AI响应 - 使用Ollama API
|
||||
|
||||
Args:
|
||||
user_input: 用户输入
|
||||
tool_results: 工具执行结果
|
||||
system_prompt: 系统提示词
|
||||
"""
|
||||
try:
|
||||
# 检查Ollama服务是否运行
|
||||
if not self.is_service_running:
|
||||
return "错误:Ollama服务未运行,请启动Ollama服务后再使用此引擎."
|
||||
|
||||
# 检查模型是否已配置
|
||||
if not self.model or not self.model.strip():
|
||||
return ("错误:Ollama模型未配置。请执行以下操作:\n"
|
||||
" 1. /engine.ollama models → 查看本地可用模型\n"
|
||||
" 2. /engine.ollama set <模型名> → 设置要使用的模型\n"
|
||||
" 或编辑 config.json 中 api.engines.ollama.model 字段")
|
||||
|
||||
# 如果有工具结果,将其作为上下文返回给AI处理
|
||||
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)
|
||||
|
||||
# 构建调用参数
|
||||
# 重置取消标志
|
||||
self._cancelled = False
|
||||
# 获取流式回调
|
||||
stream_cb = None
|
||||
thinking_cb = None
|
||||
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": True if stream_cb else False
|
||||
}
|
||||
|
||||
# 添加采样参数(从引擎配置读取)
|
||||
options = {}
|
||||
for key in ("temperature", "top_k", "top_p", "num_predict", "num_ctx"):
|
||||
val = self.engine_config.get(key)
|
||||
if val is not None:
|
||||
options[key] = val
|
||||
if options:
|
||||
kwargs["options"] = options
|
||||
|
||||
# 使用Ollama Python库调用API
|
||||
response = ollama_client.chat(**kwargs)
|
||||
|
||||
# ── 非流式模式 ──
|
||||
message = _safe_get(response, "message", {})
|
||||
|
||||
# 普通文本响应
|
||||
content = _safe_get(message, "content", "") or ""
|
||||
|
||||
# 处理深度思考:Ollama 把 thinking 和 content 分开返回
|
||||
# 合并到一起,用 <thinking> 标记包裹,让主程序处理
|
||||
thinking_content = _safe_get(message, "thinking", "") or ""
|
||||
if thinking_content:
|
||||
thinking_content = thinking_content.encode('utf-8', 'replace').decode('utf-8')
|
||||
content = content.encode('utf-8', 'replace').decode('utf-8')
|
||||
return f"<thinking>{thinking_content}</thinking>{content}"
|
||||
|
||||
# 清理无效的 UTF-8 代理字符
|
||||
content = content.encode('utf-8', 'replace').decode('utf-8')
|
||||
return content
|
||||
|
||||
except Exception as e:
|
||||
return f"Ollama API调用失败: {str(e)}"
|
||||
|
||||
def _build_messages_with_history(self, system_prompt, user_input):
|
||||
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
|
||||
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 not isinstance(msg, dict) or "role" not in msg:
|
||||
continue
|
||||
|
||||
role = msg["role"]
|
||||
|
||||
# 普通 user/assistant 消息
|
||||
if "content" in msg:
|
||||
clean_content = msg["content"]
|
||||
if isinstance(clean_content, str):
|
||||
clean_content = clean_content.encode('utf-8', 'replace').decode('utf-8')
|
||||
messages.append({"role": role, "content": clean_content})
|
||||
|
||||
# 清理用户输入
|
||||
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_list = _safe_get(response, "models", [])
|
||||
result = []
|
||||
for model in models_list:
|
||||
name = _safe_get(model, "model", "") or _safe_get(model, "name", "")
|
||||
if name:
|
||||
result.append(name)
|
||||
return result if result else ["未找到本地模型"]
|
||||
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:
|
||||
status = _safe_get(part, "status", None)
|
||||
if status:
|
||||
print(f"{Fore.YELLOW}{status}{Style.RESET_ALL}")
|
||||
# 检查是否完成
|
||||
completed = _safe_get(part, "completed", None)
|
||||
total = _safe_get(part, "total", None)
|
||||
if completed is not None and total is not None:
|
||||
if completed == 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
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
OpenAI 兼容格式引擎
|
||||
支持所有兼容 OpenAI API 格式的服务(如 DeepSeek、Grok、硅基流动、本地 vLLM 等)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
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 OpenaiAI:
|
||||
"""OpenAI 兼容格式 AI 引擎 - 支持所有兼容 OpenAI API 的服务"""
|
||||
requires_api_key = True
|
||||
|
||||
def get_help_info(self):
|
||||
"""获取引擎帮助信息"""
|
||||
help_text = """
|
||||
OpenAI 兼容格式引擎帮助信息
|
||||
============================
|
||||
|
||||
引擎名称: openai
|
||||
描述: 通用 OpenAI API 兼容引擎,支持所有兼容 OpenAI 格式的服务
|
||||
|
||||
支持的服务:
|
||||
- OpenAI 官方 API
|
||||
- DeepSeek API
|
||||
- Grok (xAI) API
|
||||
- 硅基流动 (SiliconFlow) API
|
||||
- 本地 vLLM / LiteLLM / OneAPI 等转发服务
|
||||
- 任何兼容 /chat/completions 接口的服务
|
||||
|
||||
配置方式 (config.json):
|
||||
{
|
||||
"api": {
|
||||
"engines": {
|
||||
"openai": {
|
||||
"api_key": "你的API密钥",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
可用命令:
|
||||
/engine.openai models - 列出可用模型(需要服务端支持)
|
||||
/engine.openai set <模型名> - 切换模型
|
||||
/engine.openai info - 显示当前配置信息
|
||||
/engine.openai think - 查看深度思考状态
|
||||
/engine.openai think on - 开启深度思考
|
||||
/engine.openai think off - 关闭深度思考
|
||||
/engine.openai think high - 开启并设置推理强度 (low/medium/high)
|
||||
|
||||
使用说明:
|
||||
1. 在 config.json 中配置 api_key、base_url、model
|
||||
2. 使用 /engine switch openai 切换到此引擎
|
||||
3. 直接输入自然语言与 AI 对话
|
||||
|
||||
注意:
|
||||
- base_url 填写 API 的基础地址,无需包含 /chat/completions
|
||||
- 例如 OpenAI 官方填 https://api.openai.com/v1
|
||||
- 本地服务填 http://localhost:8000/v1
|
||||
"""
|
||||
return help_text
|
||||
|
||||
def __init__(self):
|
||||
self.name = "openai"
|
||||
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("openai", {})
|
||||
self.engine_config = engine_config # 保存为属性
|
||||
self.api_key = engine_config.get("api_key", "")
|
||||
self.base_url = engine_config.get("base_url", "").rstrip('/')
|
||||
self.model = engine_config.get("model", "")
|
||||
self.max_history = config.get("system", {}).get("max_history", 10)
|
||||
# 深度思考配置
|
||||
self.deep_think = engine_config.get("deep_think", False)
|
||||
self.reasoning_effort = engine_config.get("reasoning_effort", "medium")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取配置文件失败: {e}")
|
||||
self.api_key = ""
|
||||
self.base_url = ""
|
||||
self.model = ""
|
||||
self.max_history = 10
|
||||
self.deep_think = False
|
||||
self.reasoning_effort = "medium"
|
||||
|
||||
# 初始化对话历史
|
||||
self.conversation_history = []
|
||||
self.shared_conversation_history = None
|
||||
|
||||
# 思考标记(兼容深度思考模型)
|
||||
self.thinking_start_marker = "<think>"
|
||||
self.thinking_end_marker = "</think>"
|
||||
|
||||
# 检查配置
|
||||
if not self.api_key:
|
||||
print(f"{Fore.YELLOW}警告: openai 引擎的 API 密钥未设置{Style.RESET_ALL}")
|
||||
if not self.base_url:
|
||||
print(f"{Fore.YELLOW}警告: openai 引擎的 base_url 未设置{Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.GREEN}OpenAI 兼容引擎已启用 (base_url: {self.base_url}){Style.RESET_ALL}")
|
||||
|
||||
def _get_headers(self):
|
||||
"""构建请求头"""
|
||||
headers = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
if self.api_key:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
return headers
|
||||
|
||||
def _get_api_url(self):
|
||||
"""获取完整的 API 地址"""
|
||||
url = self.base_url
|
||||
if not url:
|
||||
return ""
|
||||
if not url.endswith('/chat/completions'):
|
||||
url = url.rstrip('/') + '/chat/completions'
|
||||
return url
|
||||
|
||||
def generate_response(self, user_input, tool_results=None, system_prompt=None):
|
||||
"""
|
||||
生成 AI 响应
|
||||
|
||||
Args:
|
||||
user_input: 用户输入
|
||||
tool_results: 工具执行结果
|
||||
system_prompt: 系统提示词
|
||||
"""
|
||||
try:
|
||||
if not self.base_url:
|
||||
return "错误: openai 引擎的 base_url 未配置,请在 config.json 中设置"
|
||||
|
||||
# 处理工具结果
|
||||
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)
|
||||
|
||||
# 构建请求体
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# 添加采样参数(从引擎配置读取)
|
||||
for key in ("temperature", "top_p"):
|
||||
val = self.engine_config.get(key)
|
||||
if val is not None:
|
||||
body[key] = val
|
||||
# top_k 部分 OpenAI 兼容 API 不支持,仅在配置明确时添加
|
||||
top_k = self.engine_config.get("top_k")
|
||||
if top_k is not None:
|
||||
body["top_k"] = top_k
|
||||
|
||||
# ── 深度思考模式 ──
|
||||
self._inject_thinking_prompt = None # 清除标记
|
||||
if self.deep_think:
|
||||
body = self._apply_deep_think(body)
|
||||
# 不支持原生思考的模型,注入提示词
|
||||
if self._inject_thinking_prompt:
|
||||
thinking_hint = self._get_thinking_prompt(self._inject_thinking_prompt)
|
||||
if thinking_hint and messages:
|
||||
# 追加到系统提示词末尾
|
||||
if messages[0].get("role") == "system":
|
||||
messages[0]["content"] += thinking_hint
|
||||
else:
|
||||
messages.insert(0, {"role": "system", "content": thinking_hint})
|
||||
body["messages"] = messages
|
||||
|
||||
# 发送请求(带重试)
|
||||
api_url = self._get_api_url()
|
||||
import time
|
||||
max_retries = 3
|
||||
response = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.post(
|
||||
url=api_url,
|
||||
json=body,
|
||||
headers=self._get_headers(),
|
||||
timeout=None
|
||||
)
|
||||
if response.status_code < 500 or attempt == max_retries - 1:
|
||||
break
|
||||
print(f"{Fore.YELLOW}API 返回 {response.status_code},第 {attempt+1}/{max_retries} 次重试...{Style.RESET_ALL}")
|
||||
time.sleep(2)
|
||||
except requests.exceptions.ConnectionError:
|
||||
if attempt == max_retries - 1:
|
||||
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
|
||||
print(f"{Fore.YELLOW}连接失败,第 {attempt+1}/{max_retries} 次重试...{Style.RESET_ALL}")
|
||||
time.sleep(2)
|
||||
|
||||
if response is None:
|
||||
return "API 调用失败(重试后仍无响应)"
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||
message = response_data["choices"][0].get("message", {})
|
||||
|
||||
# 普通文本响应
|
||||
ai_response = message.get("content", "") or ""
|
||||
reasoning = message.get("reasoning_content", "") or ""
|
||||
|
||||
# 清理无效的 UTF-8 代理字符
|
||||
if ai_response:
|
||||
ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8')
|
||||
ai_response = self._process_thinking_content(ai_response)
|
||||
|
||||
# 处理 reasoning_content 字段(合并到返回值,用 <thinking> 标记包裹)
|
||||
if reasoning:
|
||||
reasoning = reasoning.encode('utf-8', 'replace').decode('utf-8')
|
||||
print(f"{Fore.LIGHTBLACK_EX}[深度思考: {reasoning[:500]}{'...' if len(reasoning) > 500 else ''}]{Style.RESET_ALL}")
|
||||
if ai_response:
|
||||
return f"<thinking>{reasoning}</thinking>{ai_response}"
|
||||
else:
|
||||
return f"<thinking>{reasoning}</thinking>"
|
||||
|
||||
return ai_response or ""
|
||||
else:
|
||||
return f"API 返回格式错误: {response_data}"
|
||||
elif response.status_code == 401:
|
||||
return "API 认证失败,请检查 api_key 是否正确"
|
||||
elif response.status_code == 404:
|
||||
return f"API 端点不存在,请检查 base_url 是否正确: {api_url}"
|
||||
elif response.status_code == 400:
|
||||
return f"API 调用失败: {response.status_code} - {response.text}"
|
||||
else:
|
||||
return f"API 调用失败: {response.status_code} - {response.text}"
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
|
||||
except Exception as e:
|
||||
return f"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 not isinstance(msg, dict) or "role" not in msg:
|
||||
continue
|
||||
|
||||
role = msg["role"]
|
||||
|
||||
# 普通 user/assistant 消息
|
||||
if "content" in msg:
|
||||
clean_content = msg["content"]
|
||||
if isinstance(clean_content, str):
|
||||
clean_content = clean_content.encode('utf-8', 'replace').decode('utf-8')
|
||||
messages.append({"role": role, "content": clean_content})
|
||||
|
||||
# 添加当前用户输入
|
||||
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.base_url:
|
||||
return "错误: openai 引擎的 base_url 未配置"
|
||||
|
||||
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": content})
|
||||
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url=self._get_api_url(),
|
||||
json=body,
|
||||
headers=self._get_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"]
|
||||
ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8')
|
||||
return self._process_thinking_content(ai_response)
|
||||
return f"API 调用失败: {response.status_code} - {response.text}"
|
||||
except Exception as e:
|
||||
return f"API 调用失败: {str(e)}"
|
||||
|
||||
def _process_thinking_content(self, response):
|
||||
"""处理思考内容,将思考部分以灰色文本显示"""
|
||||
import re
|
||||
|
||||
if not self.thinking_start_marker or not self.thinking_end_marker:
|
||||
return response
|
||||
|
||||
if self.thinking_start_marker in response and self.thinking_end_marker in response:
|
||||
parts = response.split(self.thinking_start_marker)
|
||||
if len(parts) > 1:
|
||||
remaining = parts[1]
|
||||
if self.thinking_end_marker in remaining:
|
||||
thinking_part, reply_part = remaining.split(self.thinking_end_marker, 1)
|
||||
thinking_content = thinking_part.strip()
|
||||
reply_content = reply_part.strip()
|
||||
if thinking_content:
|
||||
print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking_content}]{Style.RESET_ALL}")
|
||||
# 如果回复内容为空,返回原始 response 而非空字符串(不丢内容)
|
||||
return reply_content if reply_content else response
|
||||
|
||||
return response
|
||||
|
||||
def _apply_deep_think(self, body: dict) -> dict:
|
||||
"""
|
||||
通用深度思考适配。
|
||||
原生支持的模型用 API 参数,不支持的用提示词模拟。
|
||||
"""
|
||||
model = (self.model or "").lower()
|
||||
effort = self.reasoning_effort # low / medium / high
|
||||
|
||||
# ── 原生支持:API 参数 ──
|
||||
|
||||
# DeepSeek / MiMo
|
||||
if "deepseek" in model or "mimo" in model:
|
||||
body["enable_thinking"] = True
|
||||
body.pop("temperature", None)
|
||||
if effort:
|
||||
body["reasoning_effort"] = effort
|
||||
return body
|
||||
|
||||
# OpenAI o-series (o1, o3, o4-mini 等)
|
||||
if model.startswith("o") and (model[1:2].isdigit() or model[1:3] in ("1-", "3-", "4-")):
|
||||
body["reasoning"] = {"effort": effort}
|
||||
return body
|
||||
|
||||
# Anthropic Claude (通过 OpenAI 兼容层)
|
||||
if "claude" in model or "anthropic" in model:
|
||||
body["thinking"] = {"type": "enabled", "budget_tokens": self._thinking_budget(effort)}
|
||||
return body
|
||||
|
||||
# Grok
|
||||
if "grok" in model:
|
||||
body["thinking"] = {"enabled": True}
|
||||
return body
|
||||
|
||||
# Gemini thinking 模型
|
||||
if "gemini" in model and "thinking" in model:
|
||||
return body # 模型名自带 thinking,不需要额外参数
|
||||
|
||||
# ── 不支持原生思考:用提示词模拟 ──
|
||||
self._inject_thinking_prompt = effort
|
||||
return body
|
||||
|
||||
@staticmethod
|
||||
def _thinking_budget(effort: str) -> int:
|
||||
"""思考 token 预算"""
|
||||
return {"low": 2048, "medium": 8192, "high": 32768}.get(effort, 8192)
|
||||
|
||||
def _get_thinking_prompt(self, effort: str) -> str:
|
||||
"""根据思考等级生成提示词(注入系统提示词末尾)"""
|
||||
if effort == "low":
|
||||
return ""
|
||||
|
||||
if effort == "medium":
|
||||
return (
|
||||
"\n\n【思考模式:中等】\n"
|
||||
"回答前请先逐步思考:\n"
|
||||
"1. 分析用户的核心需求\n"
|
||||
"2. 列出关键因素和约束\n"
|
||||
"3. 考虑可能的方案\n"
|
||||
"4. 选择最优方案并解释理由\n"
|
||||
"然后再给出最终回答。"
|
||||
)
|
||||
|
||||
# high
|
||||
return (
|
||||
"\n\n【思考模式:深度推理】\n"
|
||||
"你必须使用结构化深度推理框架回答问题。严格遵循以下步骤:\n\n"
|
||||
"## 第一步:问题分解\n"
|
||||
"- 将问题拆解为子问题\n"
|
||||
"- 识别关键约束和边界条件\n"
|
||||
"- 明确输入输出要求\n\n"
|
||||
"## 第二步:多角度分析\n"
|
||||
"- 从至少 3 个不同角度分析问题\n"
|
||||
"- 评估每种方案的优缺点\n"
|
||||
"- 考虑边界情况和异常场景\n\n"
|
||||
"## 第三步:方案设计\n"
|
||||
"- 选择最优方案并详细说明理由\n"
|
||||
"- 给出具体的实现步骤\n"
|
||||
"- 预判可能的问题并给出预防措施\n\n"
|
||||
"## 第四步:验证与优化\n"
|
||||
"- 检验方案是否满足所有约束\n"
|
||||
"- 考虑是否有更优解\n"
|
||||
"- 给出最终答案和注意事项\n\n"
|
||||
"## 输出格式\n"
|
||||
"先输出推理过程(用 [思考] 标记),再输出最终答案。\n"
|
||||
"推理过程要详细但不啰嗦,最终答案要简洁明确。"
|
||||
)
|
||||
|
||||
def set_model(self, model_name):
|
||||
"""设置模型"""
|
||||
self.model = model_name
|
||||
print(f"{Fore.GREEN}已切换模型为: {model_name}{Style.RESET_ALL}")
|
||||
|
||||
def list_models(self):
|
||||
"""列出可用模型(调用 API 的 /models 端点)"""
|
||||
if not self.base_url:
|
||||
return ["错误: base_url 未配置"]
|
||||
|
||||
try:
|
||||
models_url = self.base_url.rstrip('/')
|
||||
if models_url.endswith('/chat/completions'):
|
||||
models_url = models_url[:-len('/chat/completions')]
|
||||
models_url = models_url.rstrip('/') + '/models'
|
||||
|
||||
response = requests.get(
|
||||
url=models_url,
|
||||
headers=self._get_headers(),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "data" in data:
|
||||
models = [m.get("id", "unknown") for m in data["data"]]
|
||||
return models
|
||||
else:
|
||||
return ["API 返回格式不标准,无法解析模型列表"]
|
||||
else:
|
||||
return [f"获取模型列表失败: {response.status_code}"]
|
||||
except Exception as e:
|
||||
return [f"获取模型列表失败: {str(e)}"]
|
||||
|
||||
def generate_with_tools(self, messages, tools=None, system_prompt=None):
|
||||
"""
|
||||
原生 Function Calling 模式
|
||||
返回 dict: {"content": str, "tool_calls": list, "reasoning_content": str, "raw": dict}
|
||||
"""
|
||||
try:
|
||||
from colorama import Fore, Style
|
||||
except ImportError:
|
||||
Fore = Style = type('D', (), {'LIGHTBLACK_EX': '', 'RESET_ALL': ''})()
|
||||
|
||||
import requests
|
||||
import time
|
||||
|
||||
if system_prompt:
|
||||
messages = [{"role": "system", "content": system_prompt}] + messages
|
||||
|
||||
body = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"temperature": self.engine_config.get("temperature", 0.7),
|
||||
}
|
||||
top_p = self.engine_config.get("top_p")
|
||||
if top_p is not None:
|
||||
body["top_p"] = top_p
|
||||
top_k = self.engine_config.get("top_k")
|
||||
if top_k is not None:
|
||||
body["top_k"] = top_k
|
||||
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
body["tool_choice"] = "auto"
|
||||
|
||||
api_url = self._get_api_url()
|
||||
max_retries = 3
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.post(
|
||||
url=api_url, json=body, headers=self._get_headers(), timeout=None
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||
message = response_data["choices"][0].get("message", {})
|
||||
content_text = message.get("content", "") or ""
|
||||
reasoning = message.get("reasoning_content", "") or ""
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
|
||||
if reasoning:
|
||||
print(f"{Fore.LIGHTBLACK_EX}[深度思考: {reasoning[:500]}{'...' if len(reasoning) > 500 else ''}]{Style.RESET_ALL}")
|
||||
|
||||
return {
|
||||
"content": content_text,
|
||||
"tool_calls": tool_calls,
|
||||
"reasoning_content": reasoning,
|
||||
"raw": response_data
|
||||
}
|
||||
else:
|
||||
return {"content": f"API 返回格式错误", "tool_calls": [], "raw": response_data}
|
||||
|
||||
elif response.status_code >= 500 and attempt < max_retries - 1:
|
||||
print(f"{Fore.YELLOW}API 返回 {response.status_code},第 {attempt+1}/{max_retries} 次重试...{Style.RESET_ALL}")
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
return {"content": f"API 调用失败: {response.status_code}", "tool_calls": [], "raw": {}}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
if attempt < max_retries - 1:
|
||||
print(f"{Fore.YELLOW}连接失败,第 {attempt+1}/{max_retries} 次重试...{Style.RESET_ALL}")
|
||||
time.sleep(2)
|
||||
continue
|
||||
return {"content": f"无法连接到 API: {api_url}", "tool_calls": [], "raw": {}}
|
||||
except Exception as e:
|
||||
return {"content": f"引擎调用失败: {e}", "tool_calls": [], "raw": {}}
|
||||
|
||||
return {"content": f"API 调用失败(重试{max_retries}次后仍失败)", "tool_calls": [], "raw": {}}
|
||||
|
||||
def handle_command(self, command):
|
||||
"""处理引擎特定命令"""
|
||||
if command == "models":
|
||||
models = self.list_models()
|
||||
print(f"{Fore.GREEN}可用模型列表:{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.startswith("set "):
|
||||
model_name = command[4:].strip()
|
||||
if model_name:
|
||||
self.set_model(model_name)
|
||||
return True
|
||||
else:
|
||||
print(f"{Fore.RED}请提供模型名称. 用法: /engine.openai set <模型名>{Style.RESET_ALL}")
|
||||
return False
|
||||
|
||||
elif command == "info":
|
||||
print(f"{Fore.GREEN}OpenAI 兼容引擎配置:{Style.RESET_ALL}")
|
||||
print(f" base_url: {self.base_url}")
|
||||
print(f" model: {self.model}")
|
||||
print(f" api_key: {'已设置' if self.api_key else '未设置'}")
|
||||
print(f" deep_think: {'开启' if self.deep_think else '关闭'}")
|
||||
if self.deep_think:
|
||||
print(f" reasoning: {self.reasoning_effort}")
|
||||
return True
|
||||
|
||||
elif command.startswith("think"):
|
||||
# /engine.openai think [on|off|low|medium|high]
|
||||
arg = command.replace("think", "").strip().lower()
|
||||
if arg in ("off", "0", "false"):
|
||||
self.deep_think = False
|
||||
print(f"{Fore.YELLOW}深度思考已关闭{Style.RESET_ALL}")
|
||||
elif arg in ("on", "1", "true", ""):
|
||||
self.deep_think = True
|
||||
print(f"{Fore.GREEN}深度思考已开启 (reasoning_effort: {self.reasoning_effort}){Style.RESET_ALL}")
|
||||
elif arg in ("low", "medium", "high"):
|
||||
self.deep_think = True
|
||||
self.reasoning_effort = arg
|
||||
print(f"{Fore.GREEN}深度思考已开启 (reasoning_effort: {arg}){Style.RESET_ALL}")
|
||||
else:
|
||||
print(f"{Fore.RED}用法: /engine.openai think [on|off|low|medium|high]{Style.RESET_ALL}")
|
||||
return True
|
||||
|
||||
else:
|
||||
print(f"{Fore.RED}未知命令. 可用: models, set, info, think{Style.RESET_ALL}")
|
||||
return False
|
||||
@@ -0,0 +1,2 @@
|
||||
"""xiaoli-cli 3.0.0 核心"""
|
||||
from .app import XiaoliCLI
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
"""xiaoli-cli 7.0.0 主类"""
|
||||
import os, sys, json, importlib
|
||||
from datetime import datetime
|
||||
|
||||
from .types import ToolCall, ToolResult
|
||||
from .history import History
|
||||
from .safety import Safety
|
||||
from .display import Display
|
||||
from .engine import EngineAdapter
|
||||
from .runner import Runner
|
||||
|
||||
|
||||
class XiaoliCLI:
|
||||
def __init__(self):
|
||||
self.shared_conversation_history = []
|
||||
self.max_history = 999999
|
||||
self.current_engine = None
|
||||
self.engines = {}
|
||||
self.engine_name = "openai"
|
||||
self.liugin_manager = None
|
||||
self.tools = []
|
||||
self.liugin_commands = {}
|
||||
self.chat_dir = "chat_history"
|
||||
self.memory_manager = None
|
||||
|
||||
self._load_config()
|
||||
self._load_engines()
|
||||
self._load_plugins()
|
||||
self._init_memory()
|
||||
self._init_core()
|
||||
self._register_commands()
|
||||
print("xiaoli-cli 7.0.0 初始化完成")
|
||||
|
||||
def _load_config(self):
|
||||
try:
|
||||
from xcli_core.config import get_system_config
|
||||
self.engine_name = get_system_config("default_engine", "openai")
|
||||
except Exception:
|
||||
self.engine_name = "openai"
|
||||
|
||||
def _load_engines(self):
|
||||
print("正在加载AI引擎...")
|
||||
d = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ai_engines")
|
||||
if not os.path.isdir(d):
|
||||
return
|
||||
for fn in os.listdir(d):
|
||||
if fn.endswith("_engine.py") and not fn.startswith("__"):
|
||||
mn = fn[:-3]
|
||||
try:
|
||||
mod = importlib.import_module(f"ai_engines.{mn}")
|
||||
for an in dir(mod):
|
||||
obj = getattr(mod, an)
|
||||
if isinstance(obj, type) and an.endswith("AI") and hasattr(obj, "generate_response"):
|
||||
eng = obj()
|
||||
eng.cli = self
|
||||
eng.shared_conversation_history = self.shared_conversation_history
|
||||
self.engines[eng.name] = eng
|
||||
print(f" 已加载: {eng.name} ({an})")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] {mn}: {e}")
|
||||
if self.engine_name not in self.engines and self.engines:
|
||||
self.engine_name = list(self.engines.keys())[0]
|
||||
self.current_engine = self.engines.get(self.engine_name)
|
||||
print(f" 共 {len(self.engines)} 个引擎,当前: {self.engine_name}")
|
||||
|
||||
def _load_plugins(self):
|
||||
print("正在加载插件...")
|
||||
try:
|
||||
from unified_tool_manager import UnifiedToolManager
|
||||
self.liugin_manager = UnifiedToolManager(self)
|
||||
pd = os.path.join(os.path.dirname(os.path.dirname(__file__)), "plugins")
|
||||
sd = os.path.join(os.path.dirname(os.path.dirname(__file__)), "skills")
|
||||
self.liugin_manager.initialize(pd, sd)
|
||||
self.tools = self.liugin_manager.tools if hasattr(self.liugin_manager, "tools") else []
|
||||
print(f" 已加载 {len(self.tools)} 个插件")
|
||||
except Exception as e:
|
||||
print(f" [ERROR] 插件加载失败: {e}")
|
||||
import traceback; traceback.print_exc()
|
||||
|
||||
def _init_memory(self):
|
||||
try:
|
||||
from xcli_core.memory import MemoryManager
|
||||
self.memory_manager = MemoryManager(self)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _init_core(self):
|
||||
self.history = History(self.max_history)
|
||||
self.safety = Safety(self, lambda: self.current_engine)
|
||||
self.display = Display()
|
||||
self.engine_adapter = EngineAdapter(self.current_engine)
|
||||
self.runner = Runner(self.engine_adapter, self.liugin_manager,
|
||||
self.history, self.safety, self.display)
|
||||
self.runner.set_prompt_builder(self._build_prompt)
|
||||
|
||||
def _build_prompt(self):
|
||||
p = '你叫小狸,是一个强大的智能编程助手。\n\n'
|
||||
p += '【工具调用】\n当你需要执行操作时,直接调用工具。\n工具执行后,根据结果继续回复或调用下一个工具。\n\n'
|
||||
p += '【补充回复】\n如果你需要补充之前的回复,输出:\n'
|
||||
p += '{"action": "continue", "content": "要补充的内容"}\n\n'
|
||||
p += '【核心工具】\n'
|
||||
p += '1. code_editor - 代码编辑\n2. code_search - 代码搜索\n3. git_tools - Git\n'
|
||||
p += '4. cmd_executor - Shell命令\n5. file_manager - 文件管理\n6. browser_auto - 浏览器\n'
|
||||
p += '7. sub_agent - 子Agent\n8. auto_engineer - 工程化\n9. network_tools - 网络\n'
|
||||
p += '10. ai_search - 网页内容 (url/search/web)\n\n'
|
||||
p += '说话时可适当加nyan表现可爱性格。'
|
||||
return p
|
||||
|
||||
def register_liugin_command(self, name, handler):
|
||||
self.liugin_commands[name] = handler
|
||||
|
||||
def process_conversation(self, user_input):
|
||||
return self.runner.run(user_input)
|
||||
|
||||
def switch_engine(self, name):
|
||||
if name in self.engines:
|
||||
self.engine_name = name
|
||||
self.current_engine = self.engines[name]
|
||||
self.current_engine.shared_conversation_history = self.shared_conversation_history
|
||||
self.engine_adapter = EngineAdapter(self.current_engine)
|
||||
self.runner.set_engine(self.engine_adapter)
|
||||
self.safety._engine = lambda: self.current_engine
|
||||
try:
|
||||
from xcli_core.config import set_system_config
|
||||
set_system_config("default_engine", name)
|
||||
except Exception:
|
||||
pass
|
||||
return f"已切换到: {name}"
|
||||
return f"引擎 {name} 不存在"
|
||||
|
||||
def _register_commands(self):
|
||||
self._commands = {
|
||||
"/help": self._cmd_help,
|
||||
"/engine": self._cmd_engine,
|
||||
"/mode": self._cmd_mode,
|
||||
"/clear": self._cmd_clear,
|
||||
"/history": self._cmd_history,
|
||||
"/tools": self._cmd_tools,
|
||||
"/exit": self._cmd_exit,
|
||||
"/quit": self._cmd_exit,
|
||||
"/save": self._cmd_save,
|
||||
"/config": self._cmd_config,
|
||||
"/new": self._cmd_new,
|
||||
}
|
||||
|
||||
def handle_command(self, user_input):
|
||||
parts = user_input.strip().split(None, 1)
|
||||
cmd = parts[0].lower().lstrip("/")
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
h = self._commands.get("/" + cmd)
|
||||
if h:
|
||||
return h(args)
|
||||
if cmd in self.liugin_commands:
|
||||
try:
|
||||
r = self.liugin_commands[cmd](args)
|
||||
if r: print(r)
|
||||
except Exception as e:
|
||||
print(f"命令失败: {e}")
|
||||
return ""
|
||||
return None
|
||||
|
||||
def _cmd_help(self, a):
|
||||
return "\n".join(["/help - 帮助", "/engine [list|<name>] - 引擎",
|
||||
"/mode [0-3] - 安全模式", "/clear - 清空历史",
|
||||
"/history - 历史", "/tools - 工具", "/save - 保存",
|
||||
"/new - 新对话", "/config - 配置", "/exit - 退出"])
|
||||
|
||||
def _cmd_engine(self, a):
|
||||
if not a or a == "list":
|
||||
lines = [f"当前: {self.engine_name}", "可用:"]
|
||||
for n in self.engines:
|
||||
lines.append(f" {n}{' <==' if n == self.engine_name else ''}")
|
||||
return "\n".join(lines)
|
||||
return self.switch_engine(a)
|
||||
|
||||
def _cmd_mode(self, a):
|
||||
if not a: return f"当前模式: {self.safety.get_mode()}"
|
||||
try:
|
||||
m = int(a)
|
||||
self.safety.set_mode(m)
|
||||
names = {0: "无限制", 1: "普通", 2: "人工", 3: "计划"}
|
||||
return f"已切换: {names.get(m, '未知')}"
|
||||
except ValueError:
|
||||
return "用法: /mode [0-3]"
|
||||
|
||||
def _cmd_clear(self, a):
|
||||
self.history.clear()
|
||||
self.shared_conversation_history.clear()
|
||||
return "已清空"
|
||||
|
||||
def _cmd_history(self, a):
|
||||
msgs = self.history.snapshot()
|
||||
if not msgs: return "无历史"
|
||||
lines = [f"共 {len(msgs)} 条:"]
|
||||
for m in msgs[-10:]:
|
||||
c = m.content[:50] + "..." if len(m.content) > 50 else m.content
|
||||
lines.append(f" [{m.role}] {c}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cmd_tools(self, a):
|
||||
if not self.tools: return "无工具"
|
||||
lines = [f"共 {len(self.tools)} 个:"]
|
||||
for t in self.tools:
|
||||
lines.append(f" {t.get('name','')}: {t.get('description','')[:60]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _cmd_save(self, a):
|
||||
try:
|
||||
os.makedirs(self.chat_dir, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
fp = os.path.join(self.chat_dir, f"{ts}_chat.json")
|
||||
msgs = self.history.snapshot()
|
||||
data = [{"role": m.role, "content": m.content} for m in msgs]
|
||||
with open(fp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
return f"已保存: {fp}"
|
||||
except Exception as e:
|
||||
return f"失败: {e}"
|
||||
|
||||
def _cmd_new(self, a):
|
||||
self._cmd_save("")
|
||||
self.history.clear()
|
||||
self.shared_conversation_history.clear()
|
||||
return "新对话"
|
||||
|
||||
def _cmd_config(self, a):
|
||||
try:
|
||||
with open("config.json", "r", encoding="utf-8") as f:
|
||||
return json.dumps(json.load(f), ensure_ascii=False, indent=2)[:1000]
|
||||
except Exception as e:
|
||||
return f"失败: {e}"
|
||||
|
||||
def _cmd_exit(self, a):
|
||||
self._cmd_save("")
|
||||
return "__EXIT__"
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
from colorama import init, Fore, Style
|
||||
init()
|
||||
except ImportError:
|
||||
class _D:
|
||||
CYAN = GREEN = YELLOW = RESET_ALL = ''
|
||||
Fore = Style = _D()
|
||||
print()
|
||||
print(f" {Fore.CYAN}+========================================+{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}| xiaoli-cli 7.0.0 (重构版) |{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}| 小狸 AI 编程助手 |{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}+========================================+{Style.RESET_ALL}")
|
||||
print()
|
||||
print(f" 引擎: {Fore.GREEN}{self.engine_name}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
print(f" 工具: {Fore.GREEN}{len(self.tools)}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''} 个插件")
|
||||
print(f" FC: {'支持' if self.engine_adapter.supports_fc() else '不支持'}")
|
||||
print(f" 输入 /help 查看命令")
|
||||
print()
|
||||
while True:
|
||||
try:
|
||||
ui = input(f"{Fore.CYAN}[用户ID: xiaoli]{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''} ").strip()
|
||||
if not ui: continue
|
||||
if ui.startswith("/"):
|
||||
r = self.handle_command(ui)
|
||||
if r == "__EXIT__":
|
||||
print(f" {Fore.YELLOW}再见~{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
break
|
||||
if r: print(r)
|
||||
continue
|
||||
self.process_conversation(ui)
|
||||
print()
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n {Fore.YELLOW}(Ctrl+C, /exit退出){Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
except EOFError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f" {Fore.RED}错误: {e}{Style.RESET_ALL if hasattr(Style,'RESET_ALL') else ''}")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""显示输出"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
from colorama import Fore, Style, init
|
||||
init()
|
||||
except ImportError:
|
||||
class _D:
|
||||
CYAN = GREEN = YELLOW = RED = LIGHTBLACK_EX = RESET_ALL = ''
|
||||
Fore = Style = _D()
|
||||
|
||||
|
||||
class Display:
|
||||
def text(self, text, is_continue=False):
|
||||
if text:
|
||||
print(text)
|
||||
|
||||
def thinking(self, text):
|
||||
if text:
|
||||
t = text[:500] + "..." if len(text) > 500 else text
|
||||
print(f"{Style.LIGHTBLACK_EX if hasattr(Style,'LIGHTBLACK_EX') else Fore.LIGHTBLACK_EX}[深度思考: {t}]{Style.RESET_ALL}")
|
||||
|
||||
def tool_start(self, tc):
|
||||
args = tc.args[:80] + "..." if len(tc.args) > 80 else tc.args
|
||||
print(f" {Fore.GREEN}OK 工具:{Fore.RESET_ALL if hasattr(Fore,'RESET_ALL') else Style.RESET_ALL} {tc.name}")
|
||||
print(f" {Fore.CYAN}参数:{Style.RESET_ALL} {args}")
|
||||
|
||||
def tool_result(self, tc, result):
|
||||
text = result.data if result.data is not None else result.error_message
|
||||
if text:
|
||||
s = str(text)
|
||||
if len(s) > 2000:
|
||||
lines = s.split("\n")
|
||||
if len(lines) > 30:
|
||||
s = "\n".join(lines[:30]) + f"\n... (共{len(lines)}行)"
|
||||
print(f" {Fore.CYAN}结果:{Style.RESET_ALL}")
|
||||
for line in s.split("\n"):
|
||||
print(f" {line}")
|
||||
|
||||
def info(self, msg):
|
||||
print(f"{Fore.YELLOW}{msg}{Style.RESET_ALL}")
|
||||
|
||||
def error(self, msg):
|
||||
print(f"{Fore.RED}{msg}{Style.RESET_ALL}")
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"""引擎适配 — FC为主,prompt-JSON保底"""
|
||||
import json, re, uuid
|
||||
from .types import EngineResponse, ToolCall
|
||||
|
||||
|
||||
class EngineAdapter:
|
||||
def __init__(self, engine):
|
||||
self._engine = engine
|
||||
self._fc = hasattr(engine, 'generate_with_tools')
|
||||
|
||||
def supports_fc(self):
|
||||
return self._fc
|
||||
|
||||
def name(self):
|
||||
return getattr(self._engine, 'name', 'unknown')
|
||||
|
||||
def generate(self, messages, tools=None, system_prompt=None):
|
||||
if self._fc and tools:
|
||||
return self._gen_fc(messages, tools, system_prompt)
|
||||
return self._gen_legacy(messages, system_prompt)
|
||||
|
||||
def _gen_fc(self, messages, tools, system_prompt):
|
||||
try:
|
||||
raw = self._engine.generate_with_tools(messages, tools, system_prompt=system_prompt)
|
||||
text = ""
|
||||
thinking = ""
|
||||
tool_calls = None
|
||||
if isinstance(raw, dict):
|
||||
text = raw.get("content", "") or ""
|
||||
thinking = raw.get("reasoning_content", "") or ""
|
||||
tc_list = raw.get("tool_calls", []) or []
|
||||
if tc_list:
|
||||
tool_calls = []
|
||||
for tc in tc_list:
|
||||
func = tc.get("function", {})
|
||||
cid = tc.get("id", str(uuid.uuid4())[:8])
|
||||
name = func.get("name", "")
|
||||
args_str = func.get("arguments", "{}")
|
||||
arguments = None
|
||||
try:
|
||||
if isinstance(args_str, str) and args_str:
|
||||
arguments = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
args = self._to_legacy(name, arguments) if arguments else (args_str or "")
|
||||
tool_calls.append(ToolCall(id=cid, name=name, args=args,
|
||||
arguments=arguments, raw=tc))
|
||||
return EngineResponse(text=text, thinking=thinking, tool_calls=tool_calls, raw=raw)
|
||||
except Exception as e:
|
||||
return EngineResponse(text=f"引擎调用失败: {e}")
|
||||
|
||||
def _gen_legacy(self, messages, system_prompt):
|
||||
user_input = ""
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
user_input = msg.get("content", "")
|
||||
break
|
||||
try:
|
||||
resp = self._engine.generate_response(user_input, system_prompt=system_prompt)
|
||||
except Exception as e:
|
||||
return EngineResponse(text=f"引擎调用失败: {e}")
|
||||
if not resp:
|
||||
return EngineResponse()
|
||||
text, thinking = self._strip_thinking(resp)
|
||||
return EngineResponse(text=text, thinking=thinking, raw=resp)
|
||||
|
||||
def _strip_thinking(self, text):
|
||||
if not text:
|
||||
return "", ""
|
||||
thinking = ""
|
||||
if "<thinking>" in text and "</thinking>" in text:
|
||||
m = re.search(r'<thinking>(.*?)</thinking>(.*)', text, re.DOTALL)
|
||||
if m:
|
||||
thinking = m.group(1).strip()
|
||||
text = m.group(2).strip()
|
||||
start = getattr(self._engine, 'thinking_start_marker', None)
|
||||
end = getattr(self._engine, 'thinking_end_marker', None)
|
||||
if start and end and start in text and end in text:
|
||||
parts = text.split(start)
|
||||
if len(parts) >= 2:
|
||||
rest = parts[1]
|
||||
if end in rest:
|
||||
tp, rp = rest.split(end, 1)
|
||||
thinking = (thinking + "\n" + tp.strip()).strip() if thinking else tp.strip()
|
||||
text = (parts[0] + rp).strip()
|
||||
return text, thinking
|
||||
|
||||
def _to_legacy(self, tool_name, args):
|
||||
"""FC dict参数 → 旧插件字符串参数"""
|
||||
if not args:
|
||||
return ""
|
||||
if tool_name == "ai_search":
|
||||
url = args.get("url", "")
|
||||
q = args.get("query", args.get("search", ""))
|
||||
if url: return f"url {url}"
|
||||
if q: return f"search {q}"
|
||||
if tool_name == "network_tools":
|
||||
op = args.get("operation", args.get("op", "get"))
|
||||
t = args.get("target", args.get("url", args.get("host", "")))
|
||||
if op and t: return f"{op} {t}"
|
||||
if tool_name == "code_editor":
|
||||
op = args.get("operation", args.get("action", ""))
|
||||
fp = args.get("file", args.get("path", args.get("filename", "")))
|
||||
ct = args.get("content", args.get("text", args.get("code", "")))
|
||||
old = args.get("old", args.get("old_code", args.get("find", "")))
|
||||
new = args.get("new", args.get("new_code", args.get("replace", "")))
|
||||
ln = args.get("line", args.get("line_number", ""))
|
||||
sl = args.get("start", args.get("start_line", ""))
|
||||
el = args.get("end", args.get("end_line", ""))
|
||||
pat = args.get("pattern", args.get("file_pattern", ""))
|
||||
d = args.get("directory", args.get("dir", "."))
|
||||
if op == "create" and fp and ct: return f"create {fp}\n{ct}"
|
||||
if op == "write" and fp and ct: return f"write {fp}\n{ct}"
|
||||
if op == "append" and fp and ct: return f"append {fp}\n{ct}"
|
||||
if op in ("read_range", "read"): return f"read_range {fp} {sl or 1} {el or 50}"
|
||||
if op == "edit": return f"edit {fp}\n{old}\n<<<>>>\n{new}"
|
||||
if op == "insert": return f"insert {fp} {ln}\n{ct}"
|
||||
if op in ("find", "search"): return f"find {pat} {d}"
|
||||
if op == "syntax_check": return f"syntax_check {fp}"
|
||||
return json.dumps(args, ensure_ascii=False)
|
||||
if tool_name == "code_search":
|
||||
op = args.get("operation", "find")
|
||||
pat = args.get("pattern", args.get("query", args.get("search", "")))
|
||||
d = args.get("directory", args.get("dir", "."))
|
||||
return f"{op} {pat} {d}"
|
||||
if tool_name == "git_tools":
|
||||
op = args.get("operation", "status")
|
||||
a = args.get("args", "")
|
||||
return f"{op} {a}" if a else op
|
||||
if tool_name == "cmd_executor":
|
||||
return args.get("command", args.get("cmd", args.get("shell", "")))
|
||||
if tool_name == "file_manager":
|
||||
op = args.get("operation", args.get("action", "list"))
|
||||
p = args.get("path", args.get("file", "."))
|
||||
ct = args.get("content", args.get("text", ""))
|
||||
if op in ("read", "list", "info"): return f"{op} {p}"
|
||||
if op == "write" and ct: return f'write {p} -c "{ct}"'
|
||||
if op == "append" and ct: return f'append {p} -c "{ct}"'
|
||||
if op and p: return f"{op} {p}"
|
||||
if tool_name == "browser_auto":
|
||||
op = args.get("operation", "")
|
||||
url = args.get("url", "")
|
||||
if op == "open" and url: return f"open {url}"
|
||||
if tool_name == "auto_engineer":
|
||||
op = args.get("operation", "")
|
||||
t = args.get("target", args.get("path", ""))
|
||||
if op and t: return f"{op} {t}"
|
||||
return op
|
||||
if tool_name == "sub_agent":
|
||||
op = args.get("operation", "list")
|
||||
if op == "create":
|
||||
task = args.get("task", args.get("prompt", ""))
|
||||
return f"create {task}"
|
||||
return op
|
||||
if tool_name in ("task_manager", "scheduler"):
|
||||
return args.get("operation", "list")
|
||||
# 通用
|
||||
if "operation" in args:
|
||||
op = args["operation"]
|
||||
rest = " ".join(str(v) for k, v in args.items() if k != "operation")
|
||||
return f"{op} {rest}".strip()
|
||||
return " ".join(str(v) for v in args.values())
|
||||
|
||||
def handle_command(self, cmd):
|
||||
if hasattr(self._engine, 'handle_command'):
|
||||
return self._engine.handle_command(cmd)
|
||||
return False
|
||||
|
||||
def get_raw(self):
|
||||
return self._engine
|
||||
@@ -0,0 +1,84 @@
|
||||
"""线程安全对话历史"""
|
||||
import json
|
||||
import threading
|
||||
from .types import Message
|
||||
|
||||
|
||||
class History:
|
||||
def __init__(self, max_size=999999):
|
||||
self._msgs = []
|
||||
self._lock = threading.RLock()
|
||||
self._max = max_size
|
||||
|
||||
def add_user(self, text):
|
||||
with self._lock:
|
||||
self._msgs.append(Message("user", text))
|
||||
self._trim()
|
||||
|
||||
def add_assistant(self, text, tool_calls=None):
|
||||
with self._lock:
|
||||
m = Message("assistant", text)
|
||||
if tool_calls:
|
||||
m.tool_calls = tool_calls
|
||||
self._msgs.append(m)
|
||||
self._trim()
|
||||
|
||||
def add_tool(self, call_id, name, result):
|
||||
with self._lock:
|
||||
self._msgs.append(Message("tool", result, tool_call_id=call_id, name=name))
|
||||
self._trim()
|
||||
|
||||
def add_cancel(self, name, args):
|
||||
with self._lock:
|
||||
self._msgs.append(Message("user", f"工具 {name} 已取消。参数: {args}"))
|
||||
self._trim()
|
||||
|
||||
def _trim(self):
|
||||
if len(self._msgs) > self._max:
|
||||
self._msgs = self._msgs[-self._max:]
|
||||
|
||||
def render(self, system_prompt="", current=None, fc=False):
|
||||
"""渲染为消息列表。fc=True 用 role:tool,否则工具结果用 role:user"""
|
||||
with self._lock:
|
||||
result = []
|
||||
if system_prompt:
|
||||
result.append({"role": "system", "content": system_prompt})
|
||||
for m in self._msgs:
|
||||
if m.role == "tool" and not fc:
|
||||
result.append({"role": "user", "content": m.content})
|
||||
else:
|
||||
entry = {"role": m.role, "content": m.content}
|
||||
if fc and m.role == "tool" and m.tool_call_id:
|
||||
entry["tool_call_id"] = m.tool_call_id
|
||||
if m.name:
|
||||
entry["name"] = m.name
|
||||
if fc and m.role == "assistant" and m.tool_calls:
|
||||
# content 为空时设为 None(OpenAI 规范:有 tool_calls 时 content 可为 null)
|
||||
if not entry.get("content"):
|
||||
entry["content"] = None
|
||||
# arguments 必须是 JSON 字符串(OpenAI 规范)
|
||||
entry["tool_calls"] = [
|
||||
{"id": tc.id, "type": "function",
|
||||
"function": {
|
||||
"name": tc.name,
|
||||
"arguments": json.dumps(tc.arguments, ensure_ascii=False) if tc.arguments
|
||||
else json.dumps({"args": tc.args}, ensure_ascii=False)
|
||||
}}
|
||||
for tc in m.tool_calls
|
||||
]
|
||||
result.append(entry)
|
||||
if current:
|
||||
result.append({"role": "user", "content": current})
|
||||
return result
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
return list(self._msgs)
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._msgs.clear()
|
||||
|
||||
def __len__(self):
|
||||
with self._lock:
|
||||
return len(self._msgs)
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""JSON解析 — 保底方案"""
|
||||
import json, re, uuid
|
||||
from .types import ToolCall, Plan
|
||||
|
||||
|
||||
def parse_prompt_json(text):
|
||||
"""解析prompt-JSON,返回 (text_content, tool_calls_or_None)"""
|
||||
text = text.strip()
|
||||
json_str = _extract_json(text)
|
||||
if json_str is None:
|
||||
return text, None
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
return text, None
|
||||
idx = text.find(json_str)
|
||||
prefix = text[:idx].strip() if idx > 0 else ""
|
||||
if isinstance(data, list):
|
||||
calls = [c for c in (_make(c) for c in data) if c]
|
||||
return prefix, calls if calls else None
|
||||
if isinstance(data, dict):
|
||||
c = _make(data)
|
||||
if c:
|
||||
return prefix, [c]
|
||||
return prefix, data
|
||||
return text, None
|
||||
|
||||
|
||||
def _extract_json(text):
|
||||
"""平衡括号扫描"""
|
||||
depth = 0
|
||||
start = -1
|
||||
in_str = False
|
||||
esc = False
|
||||
for i, c in enumerate(text):
|
||||
if esc:
|
||||
esc = False
|
||||
continue
|
||||
if c == '\\' and in_str:
|
||||
esc = True
|
||||
continue
|
||||
if c == '"' and not esc:
|
||||
in_str = not in_str
|
||||
continue
|
||||
if in_str:
|
||||
continue
|
||||
if c == '{':
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0 and start != -1:
|
||||
candidate = text[start:i+1]
|
||||
if '"action"' in candidate or '"tool"' in candidate:
|
||||
return candidate
|
||||
start = -1
|
||||
elif c == '[' and depth == 0:
|
||||
bd = 1
|
||||
j = i + 1
|
||||
in_s = False
|
||||
e2 = False
|
||||
while j < len(text) and bd > 0:
|
||||
cj = text[j]
|
||||
if e2:
|
||||
e2 = False
|
||||
elif cj == '\\' and in_s:
|
||||
e2 = True
|
||||
elif cj == '"' and not e2:
|
||||
in_s = not in_s
|
||||
elif not in_s:
|
||||
if cj == '[': bd += 1
|
||||
elif cj == ']': bd -= 1
|
||||
j += 1
|
||||
if bd == 0:
|
||||
candidate = text[i:j]
|
||||
if '"action"' in candidate or '"tool"' in candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _make(data):
|
||||
"""dict → ToolCall"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
cid = data.get("id", str(uuid.uuid4())[:8])
|
||||
name = ""
|
||||
args = ""
|
||||
arguments = None
|
||||
if data.get("action") == "use_tool":
|
||||
name = data.get("tool", data.get("name", ""))
|
||||
ra = data.get("args", data.get("arguments", ""))
|
||||
if isinstance(ra, str): args = ra
|
||||
elif isinstance(ra, dict):
|
||||
arguments = ra
|
||||
args = json.dumps(ra, ensure_ascii=False)
|
||||
else: args = str(ra)
|
||||
elif "name" in data and "arguments" in data:
|
||||
name = data["name"]
|
||||
ra = data["arguments"]
|
||||
if isinstance(ra, str):
|
||||
args = ra
|
||||
try: arguments = json.loads(ra)
|
||||
except: pass
|
||||
elif isinstance(ra, dict):
|
||||
arguments = ra
|
||||
args = json.dumps(ra, ensure_ascii=False)
|
||||
elif "tool" in data and "args" in data:
|
||||
name = data["tool"]
|
||||
ra = data["args"]
|
||||
if isinstance(ra, str): args = ra
|
||||
elif isinstance(ra, dict):
|
||||
arguments = ra
|
||||
args = json.dumps(ra, ensure_ascii=False)
|
||||
else:
|
||||
return None
|
||||
if not name:
|
||||
return None
|
||||
return ToolCall(id=cid, name=name, args=args, arguments=arguments, raw=data)
|
||||
|
||||
|
||||
def interpret(resp, fc_mode):
|
||||
"""EngineResponse → Plan"""
|
||||
# FC模式
|
||||
if fc_mode and resp.tool_calls:
|
||||
return Plan(action="use_tool", text=resp.text or "", tool_calls=resp.tool_calls)
|
||||
# prompt-JSON
|
||||
text, parsed = parse_prompt_json(resp.text)
|
||||
if parsed is None:
|
||||
return Plan(action="final", text=resp.text)
|
||||
if isinstance(parsed, list):
|
||||
return Plan(action="use_tool", text=text, tool_calls=parsed)
|
||||
if isinstance(parsed, dict):
|
||||
action = parsed.get("action", "")
|
||||
if action == "use_tool":
|
||||
c = _make(parsed)
|
||||
if c:
|
||||
return Plan(action="use_tool", text=text, tool_calls=[c])
|
||||
return Plan(action="final", text=resp.text)
|
||||
if action == "continue" or parsed.get("continue"):
|
||||
msg = parsed.get("content", parsed.get("message", ""))
|
||||
return Plan(action="continue", text=msg or text)
|
||||
if "message" in parsed:
|
||||
return Plan(action="final", text=parsed["message"])
|
||||
return Plan(action="final", text=text or resp.text)
|
||||
return Plan(action="final", text=resp.text)
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
"""对话循环 — 唯一循环,无重复"""
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from .types import ToolCall, ToolResult
|
||||
from xcli_core.tool_result import ErrorCode
|
||||
from .parser import interpret
|
||||
|
||||
|
||||
class Runner:
|
||||
def __init__(self, engine, manager, history, safety, display):
|
||||
self._engine = engine # EngineAdapter
|
||||
self._mgr = manager # UnifiedToolManager
|
||||
self._history = history # History
|
||||
self._safety = safety # Safety
|
||||
self._display = display # Display
|
||||
self._cancel = threading.Event()
|
||||
self._max_rounds = 50
|
||||
self._prompt_builder = None
|
||||
|
||||
def set_prompt_builder(self, b):
|
||||
self._prompt_builder = b
|
||||
|
||||
def set_engine(self, e):
|
||||
self._engine = e
|
||||
|
||||
def run(self, user_input):
|
||||
self._history.add_user(user_input)
|
||||
self._cancel.clear()
|
||||
return self._loop(user_input)
|
||||
|
||||
def _loop(self, seed):
|
||||
rounds = 0
|
||||
current = seed
|
||||
fc = self._engine.supports_fc()
|
||||
final_text = ""
|
||||
|
||||
while rounds < self._max_rounds:
|
||||
rounds += 1
|
||||
if self._cancel.is_set():
|
||||
self._display.info("(用户取消)")
|
||||
break
|
||||
|
||||
# 系统提示词
|
||||
sp = self._prompt_builder() if self._prompt_builder else ""
|
||||
if not fc:
|
||||
sp = (sp + "\n" + self._mgr.get_tools_prompt()) if sp else self._mgr.get_tools_prompt()
|
||||
|
||||
# 生成
|
||||
if fc:
|
||||
msgs = self._history.render(sp, current, fc=True)
|
||||
tools = self._get_tools()
|
||||
else:
|
||||
msgs = self._history.render(sp, current, fc=False)
|
||||
tools = None
|
||||
|
||||
resp = self._engine.generate(msgs, tools, sp)
|
||||
|
||||
if resp.thinking:
|
||||
self._display.thinking(resp.thinking)
|
||||
|
||||
# 空响应
|
||||
if not resp.text and not resp.tool_calls:
|
||||
break
|
||||
|
||||
# 解释
|
||||
plan = interpret(resp, fc)
|
||||
|
||||
# 显示+历史
|
||||
if plan.text:
|
||||
self._history.add_assistant(plan.text)
|
||||
self._display.text(plan.text)
|
||||
|
||||
if plan.action == "final":
|
||||
final_text = plan.text
|
||||
break
|
||||
if plan.action == "continue":
|
||||
current = None
|
||||
continue
|
||||
if plan.action == "use_tool":
|
||||
results = self._exec_tools(plan.tool_calls, fc)
|
||||
if results is None:
|
||||
break
|
||||
current = None
|
||||
continue
|
||||
else:
|
||||
self._display.info(f"(达到最大轮数{self._max_rounds})")
|
||||
|
||||
return final_text
|
||||
|
||||
def _exec_tools(self, calls, fc):
|
||||
if self._cancel.is_set():
|
||||
for tc in calls:
|
||||
self._history.add_cancel(tc.name, tc.args)
|
||||
return None
|
||||
|
||||
results = []
|
||||
for tc in calls:
|
||||
r = self._exec_one(tc)
|
||||
results.append((tc, r))
|
||||
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if fc:
|
||||
self._history.add_assistant("", tool_calls=calls)
|
||||
for tc, r in results:
|
||||
text = r.data if r.data is not None else (r.error_message or "")
|
||||
self._history.add_tool(tc.id, tc.name, str(text or ""))
|
||||
else:
|
||||
if len(results) == 1:
|
||||
tc, r = results[0]
|
||||
text = r.data if r.data is not None else (r.error_message or "")
|
||||
self._history.add_user(f"工具调用结果 (时间: {now}):\n{text}")
|
||||
else:
|
||||
parts = [f"工具{i+1}结果:\n{r.data if r.data is not None else (r.error_message or '')}"
|
||||
for i, (tc, r) in enumerate(results)]
|
||||
self._history.add_user(f"批量工具结果 (时间: {now}):\n" + "\n".join(parts))
|
||||
return results
|
||||
|
||||
def _exec_one(self, tc):
|
||||
if self._cancel.is_set():
|
||||
return ToolResult.fail("用户取消", code=ErrorCode.NOT_IMPLEMENTED, tool_name=tc.name)
|
||||
allowed, reason = self._safety.check(tc)
|
||||
if not allowed:
|
||||
return ToolResult.fail(f"被拒绝: {reason}", code=ErrorCode.PERMISSION_DENIED, tool_name=tc.name)
|
||||
self._display.tool_start(tc)
|
||||
r = self._mgr.execute(tc.name, tc.args)
|
||||
self._display.tool_result(tc, r)
|
||||
return r
|
||||
|
||||
def _get_tools(self):
|
||||
"""生成FC工具定义"""
|
||||
defs = []
|
||||
for tool in self._mgr.tools:
|
||||
mcp = tool.get("mcp_definition")
|
||||
if mcp:
|
||||
defs.append({"type": "function", "function": {
|
||||
"name": mcp.get("name", tool.get("name", "")),
|
||||
"description": mcp.get("description", tool.get("description", "")),
|
||||
"parameters": mcp.get("inputSchema", {"type": "object", "properties": {}})
|
||||
}})
|
||||
else:
|
||||
defs.append({"type": "function", "function": {
|
||||
"name": tool.get("name", ""),
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}})
|
||||
return defs
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
"""安全检查"""
|
||||
import json, re, threading
|
||||
from .types import ToolCall
|
||||
|
||||
MODE_UNRESTRICTED = 0
|
||||
MODE_NORMAL = 1
|
||||
MODE_MANUAL = 2
|
||||
MODE_PLAN = 3
|
||||
|
||||
WHITELIST = {
|
||||
"code_editor": ["read_range", "find ", "regex ", "symbols", "imports", "stats",
|
||||
"structure", "context", "todo", "diff "],
|
||||
"git_tools": ["status", "log", "diff", "branch", "remote", "show", "blame"],
|
||||
"file_manager": ["list", "read", "info", "search"],
|
||||
"ai_search": ["url ", "search ", "web "],
|
||||
"network_tools": ["ping", "status", "headers"],
|
||||
"code_search": ["find", "regex", "symbols"],
|
||||
"task_manager": ["list"],
|
||||
"sub_agent": ["list", "status"],
|
||||
"auto_engineer": ["metrics", "security", "complexity", "suggest"],
|
||||
}
|
||||
|
||||
|
||||
class Safety:
|
||||
def __init__(self, cli=None, engine_provider=None):
|
||||
self._cli = cli
|
||||
self._engine = engine_provider or (lambda: None)
|
||||
self._lock = threading.Lock()
|
||||
self._cache = {}
|
||||
try:
|
||||
from xcli_core.config import get_system_config
|
||||
self.mode = get_system_config("safety_mode", MODE_NORMAL)
|
||||
except Exception:
|
||||
self.mode = MODE_NORMAL
|
||||
|
||||
def check(self, tool_call):
|
||||
"""返回 (allowed, reason)"""
|
||||
# 白名单
|
||||
patterns = WHITELIST.get(tool_call.name, [])
|
||||
args_lower = tool_call.args.strip().lower()
|
||||
for p in patterns:
|
||||
if args_lower.startswith(p.lower()):
|
||||
return True, "白名单放行"
|
||||
|
||||
# 无限制/计划模式
|
||||
if self.mode in (MODE_UNRESTRICTED, MODE_PLAN):
|
||||
return True, "模式自动放行"
|
||||
|
||||
# 人工模式
|
||||
if self.mode == MODE_MANUAL:
|
||||
return self._confirm(tool_call)
|
||||
|
||||
# 普通模式:AI分析
|
||||
return self._ai_analyze(tool_call)
|
||||
|
||||
def _ai_analyze(self, tool_call):
|
||||
engine = self._engine()
|
||||
if not engine:
|
||||
return self._confirm(tool_call)
|
||||
|
||||
key = f"{tool_call.name}:{tool_call.args[:100]}"
|
||||
with self._lock:
|
||||
if key in self._cache:
|
||||
cached = self._cache[key]
|
||||
if cached.get("safe"):
|
||||
return True, "AI分析通过(缓存)"
|
||||
return self._confirm(tool_call)
|
||||
|
||||
prompt = f"""分析工具调用的安全性。只返回JSON:
|
||||
{{"safe": true/false, "level": "低/中/高", "reason": "原因"}}
|
||||
|
||||
工具: {tool_call.name}
|
||||
参数: {tool_call.args[:200]}
|
||||
|
||||
safe=true: 读取/查看/无害操作
|
||||
safe=false: 删除/执行命令/修改配置"""
|
||||
|
||||
try:
|
||||
resp = engine.generate_response(prompt, system_prompt="只返回JSON")
|
||||
result = self._parse(resp)
|
||||
if result:
|
||||
with self._lock:
|
||||
self._cache[key] = result
|
||||
if result.get("safe") is True:
|
||||
return True, "AI分析通过"
|
||||
return self._confirm(tool_call)
|
||||
except Exception:
|
||||
pass
|
||||
return self._confirm(tool_call)
|
||||
|
||||
def _parse(self, resp):
|
||||
if not resp:
|
||||
return None
|
||||
resp = resp.strip()
|
||||
# 去thinking
|
||||
if "</thinking>" in resp:
|
||||
resp = resp.split("</thinking>")[-1].strip()
|
||||
# 去markdown
|
||||
if resp.startswith("```"):
|
||||
resp = resp.split("\n", 1)[-1] if "\n" in resp else resp[3:]
|
||||
if resp.endswith("```"):
|
||||
resp = resp[:-3]
|
||||
try:
|
||||
return json.loads(resp.strip())
|
||||
except json.JSONDecodeError:
|
||||
m = re.search(r'\{.*?"safe".*?\}', resp, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _confirm(self, tool_call):
|
||||
try:
|
||||
from colorama import Fore, Style
|
||||
except ImportError:
|
||||
Fore = Style = type('D', (), {'RED': '', 'CYAN': '', 'YELLOW': '', 'RESET_ALL': ''})()
|
||||
print(f" {Fore.RED}工具: {tool_call.name}{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}参数: {tool_call.args[:100]}{Style.RESET_ALL}")
|
||||
try:
|
||||
choice = input(f" {Fore.YELLOW}是否执行? (y/n): {Style.RESET_ALL}").strip().lower()
|
||||
allowed = choice in ('y', 'yes', '是')
|
||||
return allowed, "用户确认" if allowed else "用户拒绝"
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return True, "非交互模式自动放行"
|
||||
|
||||
def get_mode(self):
|
||||
return self.mode
|
||||
|
||||
def set_mode(self, mode):
|
||||
self.mode = mode
|
||||
try:
|
||||
from xcli_core.config import set_system_config
|
||||
set_system_config("safety_mode", mode)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,37 @@
|
||||
"""核心数据类型"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
from xcli_core.tool_result import ToolResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
id: str
|
||||
name: str
|
||||
args: str
|
||||
arguments: Optional[dict] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineResponse:
|
||||
text: str = ""
|
||||
thinking: str = ""
|
||||
tool_calls: Optional[list] = None
|
||||
raw: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Plan:
|
||||
action: str = "final" # final | continue | use_tool
|
||||
text: str = ""
|
||||
tool_calls: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
role: str # system | user | assistant | tool
|
||||
content: str
|
||||
tool_call_id: Optional[str] = None
|
||||
tool_calls: Optional[list] = None
|
||||
name: Optional[str] = None
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,219 @@
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
import requests
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""网页内容工具 — jina.ai 代理 + AI 直连"""
|
||||
|
||||
_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
_HEADERS = {
|
||||
'User-Agent': _UA,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """网页内容工具
|
||||
|
||||
【jina.ai 代理组】— 通过 jina.ai 服务,返回干净文本:
|
||||
ai_url <网址> - jina 提取网页正文
|
||||
ai_search <搜索词> - jina 网络搜索
|
||||
ai_web <搜索词> - jina 网络搜索(同 ai_search)
|
||||
|
||||
【AI 直连组】— 直接 HTTP 请求,模型自行处理:
|
||||
url <网址> - 直接获取网页原始内容
|
||||
search <搜索词> - 直接搜索(模型自行选择搜索引擎)
|
||||
web <搜索词> - 同 search
|
||||
|
||||
JSON 格式示例:
|
||||
{"action": "use_tool", "tool": "ai_search", "args": "ai_url https://www.example.com"}
|
||||
{"action": "use_tool", "tool": "ai_search", "args": "ai_search Python教程"}
|
||||
{"action": "use_tool", "tool": "ai_search", "args": "url https://api.example.com/data"}
|
||||
|
||||
⚠️ 使用原则:
|
||||
- 优先使用 AI 直连组 (url/search/web),自己获取和处理网页内容
|
||||
- 仅当直连失败、网页需要 JS 渲染、或需要 jina 的智能提取时才用 jina 代理组
|
||||
- 不要滥用 jina,它有速率限制
|
||||
"""
|
||||
self.cli = None
|
||||
self.timeout = 30
|
||||
self.max_retries = 2
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
self.cli.register_liugin_command('ai_search', self.command_handler)
|
||||
|
||||
def command_handler(self, args):
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 1:
|
||||
return "请提供操作: ai_url/ai_search/ai_web/url/search/web <参数>"
|
||||
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": "ai_search",
|
||||
"description": "网页内容工具。ai_url/ai_search/ai_web 用 jina.ai 代理;url/search/web 直接 HTTP 请求让模型自行处理。优先用直连组,jina 仅作备用。",
|
||||
"keywords": ["网址", "url", "网页", "web", "search", "搜索", "fetch", "提取", "jina",
|
||||
"ai_url", "ai_search", "ai_web", "内容", "获取", "联网"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def handle(self, args):
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 1:
|
||||
return "错误:参数不足。可用操作: ai_url/ai_search/ai_web/url/search/web <参数>"
|
||||
|
||||
operation = parts[0].lower()
|
||||
param = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if not param:
|
||||
return f"错误:请提供参数。格式: {operation} <网址或搜索词>"
|
||||
|
||||
# ── jina.ai 代理组 ──
|
||||
if operation == "ai_url":
|
||||
return self._jina_extract(param)
|
||||
|
||||
elif operation == "ai_search":
|
||||
return self._jina_search(param)
|
||||
|
||||
elif operation == "ai_web":
|
||||
return self._jina_search(param)
|
||||
|
||||
# ── AI 直连组 ──
|
||||
elif operation == "url":
|
||||
return self._fetch_raw(param)
|
||||
|
||||
elif operation == "search":
|
||||
return self._direct_search(param)
|
||||
|
||||
elif operation == "web":
|
||||
return self._direct_search(param)
|
||||
|
||||
else:
|
||||
return f"不支持的操作 '{operation}'。可用: ai_url/ai_search/ai_web/url/search/web"
|
||||
except Exception as e:
|
||||
return f"操作错误: {str(e)}"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# jina.ai 代理组
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def _jina_extract(self, url):
|
||||
"""jina.ai 提取网页正文"""
|
||||
jina_url = f"https://r.jina.ai/{url}"
|
||||
result = self._execute_curl(jina_url)
|
||||
if result.startswith("错误:"):
|
||||
return result
|
||||
return f"[jina 提取] 网页内容 (来自 {url}):\n\n{result}"
|
||||
|
||||
def _jina_search(self, query):
|
||||
"""jina.ai 网络搜索"""
|
||||
encoded = quote(query, safe='')
|
||||
jina_url = f"https://s.jina.ai/{encoded}"
|
||||
result = self._execute_curl(jina_url)
|
||||
if result.startswith("错误:"):
|
||||
return result
|
||||
return f"[jina 搜索] 结果 (搜索词: {query}):\n\n{result}"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# AI 直连组
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def _fetch_raw(self, url):
|
||||
"""直接 HTTP GET 获取网页原始内容"""
|
||||
if not url.startswith('http://') and not url.startswith('https://'):
|
||||
url = 'https://' + url
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url, timeout=self.timeout, headers=self._HEADERS, allow_redirects=True
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return f"错误:HTTP {response.status_code}"
|
||||
|
||||
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
|
||||
response.encoding = response.apparent_encoding or 'utf-8'
|
||||
|
||||
content = response.text.strip()
|
||||
if not content:
|
||||
return "错误:未获取到内容"
|
||||
|
||||
return f"[直连] 原始网页内容 (来自 {url}):\n\n{content}"
|
||||
except requests.exceptions.Timeout:
|
||||
return f"错误:请求超时 (>{self.timeout}秒)"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return "错误:网络连接失败"
|
||||
except Exception as e:
|
||||
return f"错误:{str(e)}"
|
||||
|
||||
def _direct_search(self, query):
|
||||
"""直接搜索 — 用搜索引擎获取结果页,模型自行解析"""
|
||||
encoded = quote(query, safe='')
|
||||
# 用必应搜索,返回结果页 HTML,模型自行提取链接和摘要
|
||||
search_url = f"https://www.bing.com/search?q={encoded}"
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
search_url, timeout=self.timeout, headers=self._HEADERS, allow_redirects=True
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return f"错误:HTTP {response.status_code}"
|
||||
|
||||
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
|
||||
response.encoding = response.apparent_encoding or 'utf-8'
|
||||
|
||||
content = response.text.strip()
|
||||
if not content:
|
||||
return "错误:未获取到搜索结果"
|
||||
|
||||
return f"[直连] 搜索结果页 (搜索词: {query}):\n\n{content}"
|
||||
except requests.exceptions.Timeout:
|
||||
return f"错误:请求超时 (>{self.timeout}秒)"
|
||||
except requests.exceptions.ConnectionError:
|
||||
return "错误:网络连接失败"
|
||||
except Exception as e:
|
||||
return f"错误:{str(e)}"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# 通用请求(jina 用,带重试)
|
||||
# ═══════════════════════════════════════
|
||||
|
||||
def _execute_curl(self, url):
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
response = requests.get(
|
||||
url, timeout=self.timeout, headers=self._HEADERS, allow_redirects=True
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return f"错误:HTTP {response.status_code}"
|
||||
|
||||
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
|
||||
response.encoding = response.apparent_encoding or 'utf-8'
|
||||
|
||||
content = response.text.strip()
|
||||
if not content:
|
||||
return "错误:未获取到内容"
|
||||
|
||||
return content
|
||||
except requests.exceptions.Timeout:
|
||||
last_error = f"请求超时 (>{self.timeout}秒)"
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(1)
|
||||
except requests.exceptions.ConnectionError:
|
||||
last_error = "网络连接失败"
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(1)
|
||||
except requests.exceptions.RequestException as e:
|
||||
last_error = str(e)
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
break
|
||||
|
||||
return f"错误:{last_error}"
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
工程化工具插件 - 代码质量 + 项目分析
|
||||
合并原 auto_engineer + project_analyzer,消除重复
|
||||
"""
|
||||
import os
|
||||
import ast
|
||||
import re
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Tuple, List
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""工程化工具 - lint/format/test/build + 代码分析"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """工程化与分析工具
|
||||
|
||||
质量检查:
|
||||
lint [目录] [文件模式] - 代码质量检查 (ruff/flake8)
|
||||
format [目录] [文件模式] - 代码格式化 (ruff/black)
|
||||
test [目录] [参数] - 运行测试 (pytest)
|
||||
build [命令] - 构建项目
|
||||
deps - 检查过时依赖
|
||||
deps-check - 安全漏洞检查
|
||||
pre-commit - 运行 pre-commit 检查
|
||||
|
||||
项目分析:
|
||||
complexity [目录] - 圈复杂度分析
|
||||
duplicates [目录] - 重复代码检测
|
||||
security [目录] - 安全风险扫描
|
||||
metrics [目录] - 代码质量指标
|
||||
suggest [目录] - 改进建议
|
||||
info - 项目信息摘要
|
||||
init [类型] - 初始化项目 (python/web)
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "auto_engineer",
|
||||
"description": "工程化工具 - lint/format/test/build + 代码复杂度/安全/质量分析",
|
||||
"keywords": ["lint", "format", "test", "build", "代码质量", "格式化", "测试",
|
||||
"复杂度", "安全", "分析", "依赖", "项目"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "auto_engineer",
|
||||
"description": "工程化与分析工具",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"options": {"type": "string"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
target = arguments.get("target", "")
|
||||
return f"{op} {target}".strip()
|
||||
|
||||
# ── 工具方法 ──
|
||||
|
||||
def _run(self, cmd: str, cwd: str = None, timeout: int = 60) -> Tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True,
|
||||
cwd=cwd or os.getcwd(), timeout=timeout
|
||||
)
|
||||
output = result.stdout + (("\n" + result.stderr) if result.stderr else "")
|
||||
return result.returncode == 0, output.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"命令超时 ({timeout}s)"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _find_tool(self, *tools) -> str:
|
||||
for tool in tools:
|
||||
mod = tool.replace("-", "_")
|
||||
ok, _ = self._run(f"python3 -c 'import {mod}'", timeout=5)
|
||||
if ok:
|
||||
return tool
|
||||
ok, _ = self._run(f"which {tool}", timeout=5)
|
||||
if ok:
|
||||
return tool
|
||||
return None
|
||||
|
||||
def _walk_py(self, target: str) -> List[str]:
|
||||
files = []
|
||||
skip = {'.git', '__pycache__', '.venv', 'venv', 'node_modules'}
|
||||
for root, dirs, fnames in os.walk(target):
|
||||
dirs[:] = [d for d in dirs if d not in skip]
|
||||
for f in fnames:
|
||||
if f.endswith('.py'):
|
||||
files.append(os.path.join(root, f))
|
||||
return files
|
||||
|
||||
# ── 路由 ──
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=2)
|
||||
if not parts:
|
||||
return self.usage
|
||||
|
||||
op = parts[0].lower()
|
||||
target = parts[1] if len(parts) > 1 else "."
|
||||
options = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
handlers = {
|
||||
"lint": self._lint,
|
||||
"format": self._format,
|
||||
"test": self._test,
|
||||
"build": self._build,
|
||||
"deps": self._deps,
|
||||
"deps-check": self._deps_check,
|
||||
"pre-commit": self._pre_commit,
|
||||
"complexity": self._complexity,
|
||||
"duplicates": self._duplicates,
|
||||
"security": self._security,
|
||||
"metrics": self._metrics,
|
||||
"suggest": self._suggest,
|
||||
"info": self._info,
|
||||
"init": self._init_project,
|
||||
}
|
||||
|
||||
handler = handlers.get(op)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{op}'"
|
||||
try:
|
||||
return handler(target, options)
|
||||
except Exception as e:
|
||||
return f"执行错误: {e}"
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 质量检查
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _lint(self, target: str, options: str) -> str:
|
||||
tool = self._find_tool("ruff", "flake8")
|
||||
if not tool:
|
||||
return " 未安装 lint 工具。安装: pip install ruff"
|
||||
cmd = f"ruff check {target} {options}" if tool == "ruff" else f"flake8 {target} {options}"
|
||||
ok, output = self._run(cmd, timeout=120)
|
||||
if not output:
|
||||
return f" lint 检查通过 ({tool})"
|
||||
lines = output.split('\n')
|
||||
count = len([l for l in lines if l.strip() and not l.startswith(' ')])
|
||||
return f"{'' if not ok else ''} lint ({tool}): {count} 个问题\n\n{output[:2000]}"
|
||||
|
||||
def _format(self, target: str, options: str) -> str:
|
||||
tool = self._find_tool("ruff", "black")
|
||||
if not tool:
|
||||
return " 未安装格式化工具。安装: pip install ruff"
|
||||
cmd = f"ruff format {target} {options}" if tool == "ruff" else f"black {target} {options}"
|
||||
ok, output = self._run(cmd, timeout=120)
|
||||
return f"{'' if ok else ''} 格式化 ({tool})\n{output[:1000]}"
|
||||
|
||||
def _test(self, target: str, options: str) -> str:
|
||||
ok, output = self._run(f"pytest {target} {options} -v", timeout=120)
|
||||
if not ok and "no tests ran" in output.lower():
|
||||
return f" 未找到测试文件\n{output[:1000]}"
|
||||
for line in output.split('\n'):
|
||||
if 'passed' in line or 'failed' in line:
|
||||
return f"{'' if ok else ''} 测试\n{line}\n\n{output[:2000]}"
|
||||
return f"{'' if ok else ''} 测试\n{output[:2000]}"
|
||||
|
||||
def _build(self, target: str, options: str) -> str:
|
||||
if not target or target == ".":
|
||||
if os.path.exists("Makefile"):
|
||||
cmd = "make"
|
||||
elif os.path.exists("pyproject.toml"):
|
||||
cmd = "python3 -m build"
|
||||
elif os.path.exists("package.json"):
|
||||
cmd = "npm run build"
|
||||
else:
|
||||
return " 未找到构建配置"
|
||||
else:
|
||||
cmd = target
|
||||
ok, output = self._run(cmd, timeout=300)
|
||||
return f"{'' if ok else ''} 构建: {cmd}\n{output[:2000]}"
|
||||
|
||||
def _deps(self, target: str, options: str) -> str:
|
||||
ok, output = self._run("pip list --outdated --format=json", timeout=30)
|
||||
if not ok:
|
||||
return f" 检查失败\n{output}"
|
||||
try:
|
||||
outdated = json.loads(output)
|
||||
if not outdated:
|
||||
return " 所有依赖都是最新版本"
|
||||
lines = [f" {len(outdated)} 个依赖可更新:\n"]
|
||||
for pkg in outdated[:20]:
|
||||
lines.append(f" {pkg['name']:30} {pkg['version']:12} → {pkg['latest_version']}")
|
||||
return '\n'.join(lines)
|
||||
except json.JSONDecodeError:
|
||||
return f" 依赖状态:\n{output[:2000]}"
|
||||
|
||||
def _deps_check(self, target: str, options: str) -> str:
|
||||
tool = self._find_tool("pip-audit", "safety")
|
||||
if not tool:
|
||||
return " 未安装安全检查工具。安装: pip install pip-audit"
|
||||
cmd = "pip-audit" if tool == "pip-audit" else "safety check"
|
||||
ok, output = self._run(cmd, timeout=60)
|
||||
return f"{'' if ok else ''} 安全检查 ({tool})\n{output[:2000]}"
|
||||
|
||||
def _pre_commit(self, target: str, options: str) -> str:
|
||||
ok, output = self._run("pre-commit run --all-files", timeout=120)
|
||||
return f"{'' if ok else ''} pre-commit\n{output[:2000]}"
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 项目分析
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _complexity(self, target: str, options: str) -> str:
|
||||
files = self._walk_py(target)
|
||||
results = []
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
tree = ast.parse(f.read())
|
||||
except Exception:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
complexity = 1
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)):
|
||||
complexity += 1
|
||||
elif isinstance(child, ast.BoolOp):
|
||||
complexity += len(child.values) - 1
|
||||
if complexity > 5:
|
||||
rel = os.path.relpath(fp, target)
|
||||
results.append((rel, node.lineno, node.name, complexity))
|
||||
|
||||
if not results:
|
||||
return " 所有函数复杂度正常 (≤5)"
|
||||
|
||||
results.sort(key=lambda x: x[3], reverse=True)
|
||||
lines = [f" 高复杂度函数 ({len(results)} 个):\n"]
|
||||
for file, line, func, c in results[:20]:
|
||||
icon = "" if c > 10 else ""
|
||||
lines.append(f" {icon} {file}:{line} {func}() 复杂度={c}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _duplicates(self, target: str, options: str) -> str:
|
||||
files = self._walk_py(target)
|
||||
func_defs = {}
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
tree = ast.parse(f.read())
|
||||
except Exception:
|
||||
continue
|
||||
rel = os.path.relpath(fp, target)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
func_defs.setdefault(node.name, []).append(f"{rel}:{node.lineno}")
|
||||
|
||||
dupes = {k: v for k, v in func_defs.items() if len(v) > 1}
|
||||
if not dupes:
|
||||
return " 没有重复函数名"
|
||||
lines = [f" 重复函数名 ({len(dupes)} 个):\n"]
|
||||
for name, locs in sorted(dupes.items(), key=lambda x: -len(x[1])):
|
||||
lines.append(f" {name} ({len(locs)} 处):")
|
||||
for loc in locs[:5]:
|
||||
lines.append(f" • {loc}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _security(self, target: str, options: str) -> str:
|
||||
files = self._walk_py(target)
|
||||
patterns = [
|
||||
(r'\beval\s*\(', 'eval() - 代码注入', 'HIGH'),
|
||||
(r'\bexec\s*\(', 'exec() - 代码注入', 'HIGH'),
|
||||
(r'subprocess\.call\s*\(\s*shell\s*=\s*True', 'shell=True - 命令注入', 'HIGH'),
|
||||
(r'pickle\.loads?\s*\(', 'pickle 反序列化', 'HIGH'),
|
||||
(r'password\s*=\s*["\']', '硬编码密码', 'HIGH'),
|
||||
(r'api_key\s*=\s*["\']', '硬编码 API Key', 'HIGH'),
|
||||
]
|
||||
risks = []
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
for pat, desc, level in patterns:
|
||||
if re.search(pat, line, re.IGNORECASE):
|
||||
rel = os.path.relpath(fp, target)
|
||||
risks.append((rel, i, level, desc, line.strip()))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not risks:
|
||||
return " 没有发现安全风险"
|
||||
lines = [f" 安全风险 ({len(risks)} 个):\n"]
|
||||
for file, line, level, desc, code in risks[:20]:
|
||||
lines.append(f" {'' if level == 'HIGH' else ''} {file}:{line} [{level}] {desc}")
|
||||
lines.append(f" {code[:60]}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _metrics(self, target: str, options: str) -> str:
|
||||
files = self._walk_py(target)
|
||||
total = blank = comment = code = funcs = classes = max_lines = 0
|
||||
long_files = []
|
||||
|
||||
for fp in files:
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
continue
|
||||
fl = len(lines)
|
||||
total += fl
|
||||
max_lines = max(max_lines, fl)
|
||||
if fl > 300:
|
||||
long_files.append((os.path.relpath(fp, target), fl))
|
||||
for line in lines:
|
||||
s = line.strip()
|
||||
if not s:
|
||||
blank += 1
|
||||
elif s.startswith('#'):
|
||||
comment += 1
|
||||
else:
|
||||
code += 1
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
tree = ast.parse(f.read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
funcs += 1
|
||||
elif isinstance(node, ast.ClassDef):
|
||||
classes += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rate = comment / max(code, 1) * 100
|
||||
lines = [
|
||||
f" 代码指标 ({target}):\n",
|
||||
f" 文件: {len(files)} | 总行: {total} | 代码: {code}",
|
||||
f" 注释: {comment} ({rate:.1f}%) | 空行: {blank}",
|
||||
f" 函数: {funcs} | 类: {classes} | 最大文件: {max_lines} 行",
|
||||
]
|
||||
if long_files:
|
||||
lines.append(f"\n 超过 300 行的文件 ({len(long_files)}):")
|
||||
for f, l in sorted(long_files, key=lambda x: -x[1])[:5]:
|
||||
lines.append(f" • {f}: {l} 行")
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _suggest(self, target: str, options: str) -> str:
|
||||
files = os.listdir(target) if os.path.isdir(target) else []
|
||||
py_files = self._walk_py(target)
|
||||
suggestions = []
|
||||
|
||||
if 'README.md' not in files:
|
||||
suggestions.append(" 添加 README.md")
|
||||
if 'pyproject.toml' not in files:
|
||||
suggestions.append(" 使用 pyproject.toml")
|
||||
if '.gitignore' not in files:
|
||||
suggestions.append(" 添加 .gitignore")
|
||||
|
||||
test_files = [f for f in py_files if 'test' in os.path.basename(f).lower()]
|
||||
if not test_files and len(py_files) > 5:
|
||||
suggestions.append(" 添加单元测试")
|
||||
|
||||
uses_print = 0
|
||||
uses_log = 0
|
||||
for f in py_files:
|
||||
with open(f, errors='ignore') as fh:
|
||||
content = fh.read()
|
||||
if 'print(' in content:
|
||||
uses_print += 1
|
||||
if 'logging' in content:
|
||||
uses_log += 1
|
||||
|
||||
if uses_print > uses_log and uses_print > 3:
|
||||
suggestions.append(" 考虑用 logging 替代 print")
|
||||
|
||||
return " 改进建议:\n" + '\n'.join(f" {s}" for s in suggestions) if suggestions else " 项目结构良好"
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 项目信息与初始化
|
||||
# ══════════════════════════════════════
|
||||
|
||||
def _info(self, target: str, options: str) -> str:
|
||||
info = []
|
||||
files = os.listdir(target or ".")
|
||||
|
||||
if "pyproject.toml" in files:
|
||||
info.append(" 项目类型: Python (pyproject.toml)")
|
||||
elif "setup.py" in files:
|
||||
info.append(" 项目类型: Python (setup.py)")
|
||||
elif "package.json" in files:
|
||||
info.append(" 项目类型: Node.js")
|
||||
else:
|
||||
info.append(" 项目类型: 未知")
|
||||
|
||||
ok, ver = self._run("python3 --version", timeout=5)
|
||||
if ok:
|
||||
info.append(f" Python: {ver}")
|
||||
|
||||
ok, req = self._run("pip list --format=json", timeout=10)
|
||||
if ok:
|
||||
try:
|
||||
info.append(f" 依赖: {len(json.loads(req))} 个")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ok, git = self._run("git log --oneline -1", timeout=5)
|
||||
if ok:
|
||||
info.append(f" 最新提交: {git}")
|
||||
|
||||
ok, branch = self._run("git branch --show-current", timeout=5)
|
||||
if ok:
|
||||
info.append(f" 分支: {branch}")
|
||||
|
||||
py_count = len([f for f in self._walk_py(target or ".")])
|
||||
info.append(f" Python 文件: {py_count}")
|
||||
|
||||
return '\n'.join(info)
|
||||
|
||||
def _init_project(self, target: str, options: str) -> str:
|
||||
ptype = target if target != "." else "python"
|
||||
if ptype == "python":
|
||||
dirs = ["src", "tests", "docs"]
|
||||
files = {
|
||||
"pyproject.toml": '[project]\nname = "my-project"\nversion = "0.1.0"\n',
|
||||
"README.md": "# My Project\n",
|
||||
".gitignore": "__pycache__/\n*.pyc\n.venv/\n",
|
||||
}
|
||||
elif ptype == "web":
|
||||
dirs = ["src", "public", "tests"]
|
||||
files = {
|
||||
"package.json": '{\n "name": "my-project",\n "version": "1.0.0"\n}\n',
|
||||
".gitignore": "node_modules/\ndist/\n",
|
||||
}
|
||||
else:
|
||||
return f" 不支持: {ptype}。支持: python, web"
|
||||
|
||||
created = []
|
||||
for d in dirs:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
created.append(f" {d}/")
|
||||
for f, content in files.items():
|
||||
if not os.path.exists(f):
|
||||
os.makedirs(os.path.dirname(f) or ".", exist_ok=True)
|
||||
with open(f, 'w') as fp:
|
||||
fp.write(content)
|
||||
created.append(f" {f}")
|
||||
|
||||
return f" 已初始化 {ptype} 项目\n" + '\n'.join(created)
|
||||
@@ -0,0 +1,545 @@
|
||||
"""
|
||||
浏览器自动化插件 - 无头浏览器操控
|
||||
基于 Playwright,支持页面导航、元素交互、截图、JS 执行
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""浏览器自动化 - 网页操控、表单填写、数据抓取、截图"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """浏览器自动化工具
|
||||
|
||||
连接:
|
||||
start - 启动浏览器实例
|
||||
stop - 关闭浏览器
|
||||
status - 查看浏览器状态
|
||||
|
||||
导航:
|
||||
open <URL> - 打开网页
|
||||
back - 后退
|
||||
forward - 前进
|
||||
reload - 刷新页面
|
||||
title - 获取页面标题
|
||||
url - 获取当前 URL
|
||||
|
||||
交互:
|
||||
click <选择器> - 点击元素
|
||||
fill <选择器> <文本> - 填写输入框
|
||||
select <选择器> <值> - 下拉选择
|
||||
check <选择器> - 勾选复选框
|
||||
uncheck <选择器> - 取消勾选
|
||||
hover <选择器> - 鼠标悬停
|
||||
press <按键> - 按键 (Enter/Tab/Escape 等)
|
||||
|
||||
提取:
|
||||
text [选择器] - 获取元素文本 (默认 body)
|
||||
html [选择器] - 获取元素 HTML
|
||||
attr <选择器> <属性名> - 获取元素属性
|
||||
value <选择器> - 获取输入框值
|
||||
eval <JS代码> - 执行 JavaScript
|
||||
query <选择器> - 查询匹配元素列表
|
||||
|
||||
截图:
|
||||
screenshot [路径] - 页面截图 (默认 screenshot.png)
|
||||
screenshot --selector <选择器> - 元素截图
|
||||
|
||||
等待:
|
||||
wait <选择器> [超时秒数] - 等待元素出现
|
||||
wait-gone <选择器> [超时秒数] - 等待元素消失
|
||||
wait-url <URL片段> [超时秒数] - 等待 URL 变化
|
||||
|
||||
高级:
|
||||
pdf [路径] - 保存页面为 PDF
|
||||
cookies - 获取所有 cookies
|
||||
set-cookie <JSON> - 设置 cookie
|
||||
viewport <宽> <高> - 设置视口大小
|
||||
download <URL> [保存路径] - 下载文件
|
||||
"""
|
||||
self.cli = None
|
||||
self._browser = None
|
||||
self._context = None
|
||||
self._page = None
|
||||
self._pw = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "browser_auto",
|
||||
"description": "浏览器自动化 - 网页导航、元素交互、表单填写、数据抓取、截图、JS执行",
|
||||
"keywords": ["浏览器", "browser", "网页", "点击", "截图", "screenshot", "爬虫",
|
||||
"playwright", "自动化", "表单", "抓取", "导航", "puppeteer"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "browser_auto",
|
||||
"description": "浏览器自动化工具",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {"type": "string", "description": "操作类型"},
|
||||
"url": {"type": "string", "description": "网页 URL"},
|
||||
"selector": {"type": "string", "description": "CSS 选择器"},
|
||||
"value": {"type": "string", "description": "输入值"},
|
||||
"script": {"type": "string", "description": "JavaScript 代码"},
|
||||
"path": {"type": "string", "description": "保存路径"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
url = arguments.get("url", "")
|
||||
selector = arguments.get("selector", "")
|
||||
return f"{op} {url} {selector}".strip()
|
||||
|
||||
# ── 路由 ──
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型"
|
||||
|
||||
op = parts[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
handlers = {
|
||||
"start": self._start,
|
||||
"stop": self._stop,
|
||||
"status": self._status,
|
||||
"open": self._open,
|
||||
"back": self._back,
|
||||
"forward": self._forward,
|
||||
"reload": self._reload,
|
||||
"title": self._title,
|
||||
"url": self._url,
|
||||
"click": self._click,
|
||||
"fill": self._fill,
|
||||
"select": self._select,
|
||||
"check": self._check,
|
||||
"uncheck": self._uncheck,
|
||||
"hover": self._hover,
|
||||
"press": self._press,
|
||||
"text": self._text,
|
||||
"html": self._html,
|
||||
"attr": self._attr,
|
||||
"value": self._value,
|
||||
"eval": self._eval,
|
||||
"query": self._query,
|
||||
"screenshot": self._screenshot,
|
||||
"wait": self._wait,
|
||||
"wait-gone": self._wait_gone,
|
||||
"wait-url": self._wait_url,
|
||||
"pdf": self._pdf,
|
||||
"cookies": self._cookies,
|
||||
"set-cookie": self._set_cookie,
|
||||
"viewport": self._viewport,
|
||||
"download": self._download,
|
||||
}
|
||||
|
||||
handler = handlers.get(op)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{op}'"
|
||||
return handler(rest)
|
||||
|
||||
except Exception as e:
|
||||
return f"浏览器自动化错误: {str(e)}"
|
||||
|
||||
# ── 浏览器生命周期 ──
|
||||
|
||||
def _ensure_playwright(self):
|
||||
"""确保 Playwright 已安装并可用"""
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
return sync_playwright
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
def _start(self, args: str) -> str:
|
||||
if self._browser and self._page:
|
||||
return "浏览器已在运行中"
|
||||
|
||||
sync_playwright = self._ensure_playwright()
|
||||
if not sync_playwright:
|
||||
return (" 未安装 Playwright。安装步骤:\n"
|
||||
" pip install playwright\n"
|
||||
" playwright install chromium")
|
||||
|
||||
try:
|
||||
self._pw = sync_playwright().start()
|
||||
self._browser = self._pw.chromium.launch(headless=True)
|
||||
self._context = self._browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
self._page = self._context.new_page()
|
||||
return " 浏览器已启动 (headless Chromium, 1280×720)"
|
||||
except Exception as e:
|
||||
return f" 启动失败: {e}"
|
||||
|
||||
def _stop(self, args: str) -> str:
|
||||
try:
|
||||
if self._context:
|
||||
self._context.close()
|
||||
if self._browser:
|
||||
self._browser.close()
|
||||
if self._pw:
|
||||
self._pw.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._page = None
|
||||
self._context = None
|
||||
self._browser = None
|
||||
self._pw = None
|
||||
return " 浏览器已关闭"
|
||||
|
||||
def _status(self, args: str) -> str:
|
||||
if self._page and not self._page.is_closed():
|
||||
url = self._page.url
|
||||
title = self._page.title()
|
||||
return f" 运行中\n URL: {url}\n 标题: {title}"
|
||||
return " 未运行"
|
||||
|
||||
def _require_page(self):
|
||||
"""确保页面可用,否则自动启动"""
|
||||
if self._page and not self._page.is_closed():
|
||||
return self._page
|
||||
self._start("")
|
||||
return self._page
|
||||
|
||||
# ── 导航 ──
|
||||
|
||||
def _open(self, args: str) -> str:
|
||||
url = args.strip()
|
||||
if not url:
|
||||
return "错误:请提供 URL"
|
||||
if not url.startswith(("http://", "https://", "file://")):
|
||||
url = "https://" + url
|
||||
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||
title = page.title()
|
||||
return f" 已打开: {url}\n 标题: {title}"
|
||||
except Exception as e:
|
||||
return f" 打开失败: {e}"
|
||||
|
||||
def _back(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
page.go_back()
|
||||
return f" 后退 → {page.url}"
|
||||
|
||||
def _forward(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
page.go_forward()
|
||||
return f" 前进 → {page.url}"
|
||||
|
||||
def _reload(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
page.reload()
|
||||
return f" 已刷新: {page.url}"
|
||||
|
||||
def _title(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
return page.title() or "(无标题)"
|
||||
|
||||
def _url(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
return page.url
|
||||
|
||||
# ── 交互 ──
|
||||
|
||||
def _click(self, selector: str) -> str:
|
||||
selector = selector.strip()
|
||||
if not selector:
|
||||
return "错误:请提供 CSS 选择器"
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.click(selector, timeout=10000)
|
||||
return f" 已点击: {selector}"
|
||||
except Exception as e:
|
||||
return f" 点击失败: {e}"
|
||||
|
||||
def _fill(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return "错误:格式: fill <选择器> <文本>"
|
||||
selector, text = parts[0], parts[1]
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.fill(selector, text, timeout=10000)
|
||||
return f" 已填写: {selector}"
|
||||
except Exception as e:
|
||||
return f" 填写失败: {e}"
|
||||
|
||||
def _select(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return "错误:格式: select <选择器> <值>"
|
||||
selector, value = parts[0], parts[1]
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.select_option(selector, value, timeout=10000)
|
||||
return f" 已选择: {selector} = {value}"
|
||||
except Exception as e:
|
||||
return f" 选择失败: {e}"
|
||||
|
||||
def _check(self, selector: str) -> str:
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.check(selector.strip(), timeout=10000)
|
||||
return f" 已勾选: {selector}"
|
||||
except Exception as e:
|
||||
return f" 勾选失败: {e}"
|
||||
|
||||
def _uncheck(self, selector: str) -> str:
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.uncheck(selector.strip(), timeout=10000)
|
||||
return f" 已取消勾选: {selector}"
|
||||
except Exception as e:
|
||||
return f" 取消勾选失败: {e}"
|
||||
|
||||
def _hover(self, selector: str) -> str:
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.hover(selector.strip(), timeout=10000)
|
||||
return f" 悬停: {selector}"
|
||||
except Exception as e:
|
||||
return f" 悬停失败: {e}"
|
||||
|
||||
def _press(self, key: str) -> str:
|
||||
page = self._require_page()
|
||||
page.keyboard.press(key.strip())
|
||||
return f" 按键: {key}"
|
||||
|
||||
# ── 提取 ──
|
||||
|
||||
def _text(self, selector: str) -> str:
|
||||
selector = selector.strip() or "body"
|
||||
page = self._require_page()
|
||||
try:
|
||||
el = page.query_selector(selector)
|
||||
if el:
|
||||
text = el.inner_text()
|
||||
return text
|
||||
return f"未找到元素: {selector}"
|
||||
except Exception as e:
|
||||
return f" 获取文本失败: {e}"
|
||||
|
||||
def _html(self, selector: str) -> str:
|
||||
selector = selector.strip() or "body"
|
||||
page = self._require_page()
|
||||
try:
|
||||
el = page.query_selector(selector)
|
||||
if el:
|
||||
html = el.inner_html()
|
||||
return html
|
||||
return f"未找到元素: {selector}"
|
||||
except Exception as e:
|
||||
return f" 获取 HTML 失败: {e}"
|
||||
|
||||
def _attr(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return "错误:格式: attr <选择器> <属性名>"
|
||||
selector, attr_name = parts[0], parts[1]
|
||||
page = self._require_page()
|
||||
try:
|
||||
el = page.query_selector(selector)
|
||||
if el:
|
||||
val = el.get_attribute(attr_name)
|
||||
return f"{attr_name} = {val}"
|
||||
return f"未找到元素: {selector}"
|
||||
except Exception as e:
|
||||
return f" 获取属性失败: {e}"
|
||||
|
||||
def _value(self, selector: str) -> str:
|
||||
page = self._require_page()
|
||||
try:
|
||||
el = page.query_selector(selector.strip())
|
||||
if el:
|
||||
return el.input_value()
|
||||
return f"未找到元素: {selector}"
|
||||
except Exception as e:
|
||||
return f" 获取值失败: {e}"
|
||||
|
||||
def _eval(self, script: str) -> str:
|
||||
script = script.strip()
|
||||
if not script:
|
||||
return "错误:请提供 JavaScript 代码"
|
||||
page = self._require_page()
|
||||
try:
|
||||
result = page.evaluate(script)
|
||||
if isinstance(result, (dict, list)):
|
||||
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
return f" JS 执行失败: {e}"
|
||||
|
||||
def _query(self, selector: str) -> str:
|
||||
selector = selector.strip()
|
||||
if not selector:
|
||||
return "错误:请提供 CSS 选择器"
|
||||
page = self._require_page()
|
||||
try:
|
||||
elements = page.query_selector_all(selector)
|
||||
if not elements:
|
||||
return f"未找到匹配 '{selector}' 的元素"
|
||||
|
||||
result = [f"找到 {len(elements)} 个元素:\n"]
|
||||
for i, el in enumerate(elements[:20]):
|
||||
tag = el.evaluate("el => el.tagName.toLowerCase()")
|
||||
text = el.inner_text()[:80].replace('\n', ' ').strip()
|
||||
result.append(f" [{i}] <{tag}> {text}")
|
||||
if len(elements) > 20:
|
||||
result.append(f" ... 还有 {len(elements) - 20} 个")
|
||||
return '\n'.join(result)
|
||||
except Exception as e:
|
||||
return f" 查询失败: {e}"
|
||||
|
||||
# ── 截图 ──
|
||||
|
||||
def _screenshot(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
|
||||
# 解析参数
|
||||
path = "screenshot.png"
|
||||
selector = None
|
||||
parts = args.strip().split()
|
||||
i = 0
|
||||
while i < len(parts):
|
||||
if parts[i] == "--selector" and i + 1 < len(parts):
|
||||
selector = parts[i + 1]
|
||||
i += 2
|
||||
elif not parts[i].startswith("-"):
|
||||
path = parts[i]
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
try:
|
||||
if selector:
|
||||
el = page.query_selector(selector)
|
||||
if not el:
|
||||
return f"未找到元素: {selector}"
|
||||
el.screenshot(path=path)
|
||||
else:
|
||||
page.screenshot(path=path, full_page=True)
|
||||
|
||||
size = os.path.getsize(path)
|
||||
return f" 截图已保存: {path} ({size/1024:.1f} KB)"
|
||||
except Exception as e:
|
||||
return f" 截图失败: {e}"
|
||||
|
||||
# ── 等待 ──
|
||||
|
||||
def _wait(self, args: str) -> str:
|
||||
parts = args.strip().split()
|
||||
if not parts:
|
||||
return "错误:请提供选择器"
|
||||
selector = parts[0]
|
||||
timeout = int(parts[1]) * 1000 if len(parts) > 1 else 30000
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.wait_for_selector(selector, timeout=timeout)
|
||||
return f" 元素已出现: {selector}"
|
||||
except Exception:
|
||||
return f" 等待超时: {selector}"
|
||||
|
||||
def _wait_gone(self, args: str) -> str:
|
||||
parts = args.strip().split()
|
||||
if not parts:
|
||||
return "错误:请提供选择器"
|
||||
selector = parts[0]
|
||||
timeout = int(parts[1]) * 1000 if len(parts) > 1 else 30000
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.wait_for_selector(selector, state="hidden", timeout=timeout)
|
||||
return f" 元素已消失: {selector}"
|
||||
except Exception:
|
||||
return f" 等待超时: {selector}"
|
||||
|
||||
def _wait_url(self, args: str) -> str:
|
||||
parts = args.strip().split()
|
||||
if not parts:
|
||||
return "错误:请提供 URL 片段"
|
||||
url_part = parts[0]
|
||||
timeout = int(parts[1]) * 1000 if len(parts) > 1 else 30000
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.wait_for_url(f"**{url_part}**", timeout=timeout)
|
||||
return f" URL 已变化: {page.url}"
|
||||
except Exception:
|
||||
return f" 等待超时: URL 未变化"
|
||||
|
||||
# ── 高级 ──
|
||||
|
||||
def _pdf(self, args: str) -> str:
|
||||
path = args.strip() or "page.pdf"
|
||||
page = self._require_page()
|
||||
try:
|
||||
page.pdf(path=path)
|
||||
size = os.path.getsize(path)
|
||||
return f" PDF 已保存: {path} ({size/1024:.1f} KB)"
|
||||
except Exception as e:
|
||||
return f" PDF 生成失败: {e}"
|
||||
|
||||
def _cookies(self, args: str) -> str:
|
||||
page = self._require_page()
|
||||
cookies = page.context.cookies()
|
||||
if not cookies:
|
||||
return "无 cookies"
|
||||
result = [f"Cookies ({len(cookies)} 个):\n"]
|
||||
for c in cookies[:20]:
|
||||
result.append(f" {c['name']}: {c['value'][:50]} (domain: {c.get('domain', '-')})")
|
||||
return '\n'.join(result)
|
||||
|
||||
def _set_cookie(self, args: str) -> str:
|
||||
try:
|
||||
cookie = json.loads(args.strip())
|
||||
except json.JSONDecodeError:
|
||||
return "错误:cookie 必须是 JSON 格式: {\"name\": \"...\", \"value\": \"...\", \"domain\": \"...\"}"
|
||||
page = self._require_page()
|
||||
page.context.add_cookies([cookie])
|
||||
return f" 已设置 cookie: {cookie.get('name', '?')}"
|
||||
|
||||
def _viewport(self, args: str) -> str:
|
||||
parts = args.strip().split()
|
||||
if len(parts) < 2:
|
||||
return "错误:格式: viewport <宽> <高>"
|
||||
try:
|
||||
w, h = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
return "错误:宽高必须是整数"
|
||||
page = self._require_page()
|
||||
page.set_viewport_size({"width": w, "height": h})
|
||||
return f" 视口已设置: {w}×{h}"
|
||||
|
||||
def _download(self, args: str) -> str:
|
||||
parts = args.strip().split()
|
||||
if not parts:
|
||||
return "错误:格式: download <URL> [保存路径]"
|
||||
url = parts[0]
|
||||
save_path = parts[1] if len(parts) > 1 else os.path.basename(url) or "download"
|
||||
page = self._require_page()
|
||||
try:
|
||||
with page.expect_download() as download_info:
|
||||
page.evaluate(f"window.open('{url}')")
|
||||
download = download_info.value
|
||||
download.save_as(save_path)
|
||||
size = os.path.getsize(save_path)
|
||||
return f" 已下载: {save_path} ({size/1024:.1f} KB)"
|
||||
except Exception as e:
|
||||
return f" 下载失败: {e}"
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
命令执行器插件 - 跨平台 Shell 命令执行
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import platform
|
||||
import sys
|
||||
|
||||
# 添加项目根目录到 path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""Shell 命令执行器 - 跨平台命令行工具"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """Shell 命令执行器
|
||||
|
||||
操作:
|
||||
run <命令> [--timeout 秒] - 执行 Shell 命令 (默认超时 30s)
|
||||
|
||||
示例:
|
||||
run ls -la - 列出当前目录
|
||||
run python3 --version - 查看 Python 版本
|
||||
run git status - 查看 Git 状态
|
||||
run make build --timeout 60 - 执行构建 (60秒超时)
|
||||
|
||||
注意:
|
||||
- 跨平台兼容 (Linux/macOS/Windows)
|
||||
- 输出限制 5000 字符
|
||||
- 某些危险命令会被拦截
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "cmd_executor",
|
||||
"description": "Shell 命令执行器 - 跨平台执行系统命令,支持超时控制",
|
||||
"keywords": ["命令", "执行", "shell", "cmd", "终端", "命令行", "subprocess"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "cmd_executor",
|
||||
"description": "Shell 命令执行器",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "要执行的命令"},
|
||||
"timeout": {"type": "integer", "description": "超时秒数", "default": 30}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
cmd = arguments.get("command", "")
|
||||
timeout = arguments.get("timeout", "")
|
||||
result = f"run {cmd}"
|
||||
if timeout:
|
||||
result += f" --timeout {timeout}"
|
||||
return result
|
||||
|
||||
# 危险命令黑名单
|
||||
BLOCKED = {
|
||||
'rm -rf /', 'rm -rf ~', 'mkfs', 'dd if=', 'wipefs', 'shred',
|
||||
':(){ :|:& };:', 'chmod -R 777 /', 'mv / ', 'wget http',
|
||||
}
|
||||
|
||||
def handle(self, args: str):
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return ToolResult.missing("操作。可用: run <命令>")
|
||||
|
||||
operation = parts[0].lower()
|
||||
if operation != "run":
|
||||
return ToolResult.fail(f"不支持的操作 '{operation}'。可用: run", ErrorCode.UNSUPPORTED_OP)
|
||||
|
||||
if len(parts) < 2:
|
||||
return ToolResult.missing("要执行的命令")
|
||||
|
||||
rest = parts[1]
|
||||
|
||||
# 解析 --timeout
|
||||
timeout = 30
|
||||
timeout_match = re.search(r'--timeout\s+(\d+)', rest)
|
||||
if timeout_match:
|
||||
timeout = int(timeout_match.group(1))
|
||||
rest = re.sub(r'--timeout\s+\d+', '', rest).strip()
|
||||
|
||||
if not rest:
|
||||
return ToolResult.missing("要执行的命令")
|
||||
|
||||
# 安全检查
|
||||
for blocked in self.BLOCKED:
|
||||
if blocked in rest:
|
||||
return ToolResult.fail(f"安全拦截: 检测到危险命令模式 '{blocked}'", ErrorCode.PERMISSION_DENIED)
|
||||
|
||||
return self._execute(rest, timeout)
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(str(e), ErrorCode.INTERNAL_ERROR, tool_name="cmd_executor")
|
||||
|
||||
def _execute(self, command: str, timeout: int):
|
||||
try:
|
||||
use_shell = platform.system() == 'Windows'
|
||||
|
||||
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()
|
||||
rc = result.returncode
|
||||
|
||||
output = ""
|
||||
if stdout:
|
||||
output += stdout + "\n"
|
||||
if stderr:
|
||||
output += f"[stderr]\n{stderr}\n"
|
||||
if rc != 0:
|
||||
output += f"[exit code: {rc}]\n"
|
||||
|
||||
if not output:
|
||||
output = f"命令执行完成,无输出 (超时: {timeout}s)"
|
||||
|
||||
if rc != 0:
|
||||
return ToolResult.fail(output.strip(), ErrorCode.EXEC_FAILED, tool_name="cmd_executor")
|
||||
return ToolResult.ok(output.strip(), tool_name="cmd_executor")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return ToolResult.timeout(timeout, tool_name="cmd_executor")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED, tool_name="cmd_executor")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
编译验证插件 - 批量语法/编译检查
|
||||
对项目内 Python 文件做 ast.parse + py_compile 双重验证,
|
||||
支持按目录、单文件或全项目扫描,并发执行加速。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import ast
|
||||
import time
|
||||
import shutil
|
||||
import py_compile
|
||||
import concurrent.futures
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||
|
||||
try:
|
||||
from colorama import Fore, Style, init as _colorama_init
|
||||
_colorama_init(autoreset=False)
|
||||
_HAS_COLOR = True
|
||||
except Exception:
|
||||
_HAS_COLOR = False
|
||||
|
||||
class _Stub:
|
||||
def __getattr__(self, _): return ""
|
||||
Fore = Style = _Stub()
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SKIP_DIRS = {
|
||||
"__pycache__", ".git", ".venv", "venv", "env", ".env",
|
||||
"node_modules", ".idea", ".vscode", "dist", "build",
|
||||
".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages",
|
||||
}
|
||||
|
||||
SKIP_FILES = {
|
||||
"setup.py",
|
||||
}
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""编译验证 - 批量语法检查 / 编译验证 / 清理 __pycache__"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """编译验证工具
|
||||
|
||||
操作:
|
||||
all - 验证项目内全部 Python 文件
|
||||
core - 仅验证 xcli_core/ 目录
|
||||
engines - 仅验证 ai_engines/ 目录
|
||||
plugins - 仅验证 plugins/ 目录
|
||||
dir <目录> - 验证指定目录
|
||||
check <文件> - 验证单个文件 (可多次指定)
|
||||
clean [目录] - 清理 __pycache__ (默认项目根)
|
||||
stats - 统计各目录 Python 文件数量
|
||||
|
||||
选项:
|
||||
-v / --verbose - 显示每个文件的结果
|
||||
-j / --jobs <N> - 并发线程数 (默认 8)
|
||||
-q / --quiet - 仅在结束时打印汇总
|
||||
|
||||
输出:
|
||||
✅ 绿色 = 通过
|
||||
❌ 红色 = 失败
|
||||
汇总: 总数/通过/失败 + 失败文件详情
|
||||
|
||||
示例:
|
||||
check all -v
|
||||
check core
|
||||
check plugins/compile_check.py
|
||||
check dir ai_engines -j 16
|
||||
check clean
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "compile_check",
|
||||
"description": "编译验证工具 — 批量语法/编译检查(ast.parse + py_compile),支持目录、单文件、全项目扫描,并发加速,清理 __pycache__",
|
||||
"keywords": ["编译", "验证", "语法", "检查", "compile", "syntax",
|
||||
"check", "lint", "py_compile", "ast", "批量",
|
||||
"validate", "verify", "clean", "pycache"],
|
||||
"usage": self.usage,
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "compile_check",
|
||||
"description": "编译验证工具",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["all", "core", "engines", "plugins",
|
||||
"dir", "check", "clean", "stats"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"path": {"type": "string",
|
||||
"description": "目录或文件路径"},
|
||||
"paths": {"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "多文件路径列表"},
|
||||
"verbose": {"type": "boolean", "default": False},
|
||||
"jobs": {"type": "integer", "default": 8},
|
||||
"quiet": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
path = arguments.get("path", "")
|
||||
return f"{op} {path}".strip()
|
||||
|
||||
def handle(self, args: str):
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return self._print_usage()
|
||||
|
||||
operation = parts[0].lower()
|
||||
opts = self._extract_options(parts[1:])
|
||||
|
||||
if operation in ("help", "-h", "--help"):
|
||||
return self._print_usage()
|
||||
|
||||
routes = {
|
||||
"all": self._op_all,
|
||||
"core": self._op_core,
|
||||
"engines": self._op_engines,
|
||||
"plugins": self._op_plugins,
|
||||
"dir": self._op_dir,
|
||||
"check": self._op_check,
|
||||
"clean": self._op_clean,
|
||||
"stats": self._op_stats,
|
||||
}
|
||||
handler = routes.get(operation)
|
||||
if not handler:
|
||||
return ToolResult.fail(
|
||||
f"不支持的操作 '{operation}'",
|
||||
ErrorCode.UNSUPPORTED_OP,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
return handler(opts)
|
||||
|
||||
except Exception as e:
|
||||
return ToolResult.fail(
|
||||
f"编译验证错误: {e}",
|
||||
ErrorCode.EXEC_FAILED,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
def _print_usage(self):
|
||||
self._emit(self.usage)
|
||||
return ToolResult.ok("usage_printed", tool_name="compile_check")
|
||||
|
||||
def _out(self, msg: str):
|
||||
"""统一输出"""
|
||||
if self.cli is not None and hasattr(self.cli, "_output"):
|
||||
self.cli._output(msg)
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
def _emit(self, msg: str, end: str = "\n"):
|
||||
"""统一输出,适配 CLI
|
||||
|
||||
- CLI 模式: 直接输出,尊重 end,支持 end="" 的进度条刷新
|
||||
"""
|
||||
if self.cli is not None and hasattr(self.cli, "_output"):
|
||||
self.cli._output(msg)
|
||||
else:
|
||||
print(msg, end=end)
|
||||
|
||||
def _ok(self, msg: str) -> str:
|
||||
return f"{Fore.GREEN}{msg}{Style.RESET_ALL}"
|
||||
|
||||
def _err(self, msg: str) -> str:
|
||||
return f"{Fore.RED}{msg}{Style.RESET_ALL}"
|
||||
|
||||
def _warn(self, msg: str) -> str:
|
||||
return f"{Fore.YELLOW}{msg}{Style.RESET_ALL}"
|
||||
|
||||
def _dim(self, msg: str) -> str:
|
||||
return f"{Fore.CYAN}{msg}{Style.RESET_ALL}"
|
||||
|
||||
def _banner(self, title: str) -> str:
|
||||
bar = "═" * max(20, len(title) + 4)
|
||||
return f"{Fore.CYAN}{bar}\n {title}\n{bar}{Style.RESET_ALL}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == " " and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _extract_options(self, args: List[str]) -> dict:
|
||||
opts = {
|
||||
"positional": [],
|
||||
"verbose": False,
|
||||
"quiet": False,
|
||||
"jobs": 8,
|
||||
}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a in ("-v", "--verbose"):
|
||||
opts["verbose"] = True
|
||||
elif a in ("-q", "--quiet"):
|
||||
opts["quiet"] = True
|
||||
elif a in ("-j", "--jobs") and i + 1 < len(args):
|
||||
try:
|
||||
opts["jobs"] = max(1, int(args[i + 1]))
|
||||
except ValueError:
|
||||
pass
|
||||
i += 1
|
||||
elif not a.startswith("-"):
|
||||
opts["positional"].append(a)
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
def _collect_py_files(self, root: str) -> List[str]:
|
||||
root = os.path.abspath(root)
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
result: List[str] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for fn in filenames:
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
if fn in SKIP_FILES:
|
||||
continue
|
||||
result.append(os.path.join(dirpath, fn))
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
def _verify_one(self, filepath: str) -> Tuple[str, bool, str]:
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
with open(filepath, "r", encoding="gbk") as f:
|
||||
src = f.read()
|
||||
except Exception as e:
|
||||
return filepath, False, f"读取失败: {e}"
|
||||
except Exception as e:
|
||||
return filepath, False, f"读取失败: {e}"
|
||||
|
||||
try:
|
||||
ast.parse(src, filename=filepath)
|
||||
except SyntaxError as e:
|
||||
line = getattr(e, "lineno", "?")
|
||||
col = getattr(e, "offset", "?")
|
||||
msg = getattr(e, "msg", str(e))
|
||||
return filepath, False, f"SyntaxError 第{line}行第{col}列: {msg}"
|
||||
|
||||
try:
|
||||
py_compile.compile(filepath, doraise=True, quiet=2)
|
||||
except py_compile.PyCompileError as e:
|
||||
return filepath, False, f"PyCompileError: {e.msg if hasattr(e, 'msg') else e}"
|
||||
except Exception as e:
|
||||
return filepath, False, f"编译失败: {e}"
|
||||
|
||||
return filepath, True, ""
|
||||
|
||||
def _run_batch(
|
||||
self,
|
||||
files: List[str],
|
||||
title: str,
|
||||
verbose: bool = False,
|
||||
quiet: bool = False,
|
||||
jobs: int = 8,
|
||||
) -> Tuple[int, int, List[Tuple[str, str]]]:
|
||||
total = len(files)
|
||||
if total == 0:
|
||||
self._emit(self._warn(f"{title}: 没有可验证的 Python 文件"))
|
||||
return 0, 0, []
|
||||
|
||||
if not quiet:
|
||||
self._emit(self._banner(f"{title} ({total} 个文件, {jobs} 线程)"))
|
||||
self._emit("")
|
||||
|
||||
passed = 0
|
||||
failed: List[Tuple[str, str]] = []
|
||||
start = time.time()
|
||||
|
||||
results: List[Tuple[str, bool, str]] = []
|
||||
if jobs == 1:
|
||||
for f in files:
|
||||
results.append(self._verify_one(f))
|
||||
else:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as ex:
|
||||
futures = {ex.submit(self._verify_one, f): f for f in files}
|
||||
done = 0
|
||||
for fut in concurrent.futures.as_completed(futures):
|
||||
results.append(fut.result())
|
||||
done += 1
|
||||
if not quiet and not verbose and total > 0:
|
||||
pct = done * 100 // total
|
||||
rel = os.path.relpath(futures[fut], PROJECT_ROOT)
|
||||
self._emit(f"\r 进度: [{done}/{total}] {pct:3d}% {rel[:40]:<40}", end="")
|
||||
if not quiet and not verbose:
|
||||
self._emit("")
|
||||
|
||||
results.sort(key=lambda x: x[0])
|
||||
|
||||
if verbose and not quiet:
|
||||
self._emit("")
|
||||
|
||||
for filepath, ok, err in results:
|
||||
rel = os.path.relpath(filepath, PROJECT_ROOT)
|
||||
if ok:
|
||||
passed += 1
|
||||
if verbose and not quiet:
|
||||
self._emit(f" {self._ok('✓')} {rel}")
|
||||
else:
|
||||
failed.append((rel, err))
|
||||
if not quiet:
|
||||
self._emit(f" {self._err('✗')} {rel}")
|
||||
self._emit(f" {self._err(err)}")
|
||||
|
||||
elapsed = time.time() - start
|
||||
|
||||
self._emit("")
|
||||
self._emit("─" * 60)
|
||||
if failed:
|
||||
self._emit(f" {self._err('✗ 失败:')} {len(failed)}/{total}")
|
||||
self._emit(f" {self._ok('✓ 通过:')} {passed}/{total}")
|
||||
self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}")
|
||||
self._emit("")
|
||||
self._emit(self._err("失败列表:"))
|
||||
for rel, err in failed:
|
||||
self._emit(f" • {self._err(rel)}")
|
||||
first_line = err.split("\n")[0][:120]
|
||||
self._emit(f" {first_line}")
|
||||
else:
|
||||
self._emit(f" {self._ok(f'✓ 全部通过:')} {passed}/{total} 个文件")
|
||||
self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}")
|
||||
self._emit("─" * 60)
|
||||
|
||||
return passed, len(failed), failed
|
||||
|
||||
def _op_all(self, opts: dict):
|
||||
files = self._collect_py_files(PROJECT_ROOT)
|
||||
passed, failed, _ = self._run_batch(
|
||||
files,
|
||||
"全项目编译验证",
|
||||
verbose=opts["verbose"],
|
||||
quiet=opts["quiet"],
|
||||
jobs=opts["jobs"],
|
||||
)
|
||||
if failed == 0:
|
||||
return ToolResult.ok(
|
||||
f"全部通过 ({passed}/{passed})",
|
||||
tool_name="compile_check",
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"有 {failed} 个文件编译失败",
|
||||
ErrorCode.EXEC_FAILED,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
def _op_core(self, opts: dict):
|
||||
return self._scan_subdir("xcli_core", "xcli_core 编译验证", opts)
|
||||
|
||||
def _op_engines(self, opts: dict):
|
||||
return self._scan_subdir("ai_engines", "ai_engines 编译验证", opts)
|
||||
|
||||
def _op_plugins(self, opts: dict):
|
||||
return self._scan_subdir("plugins", "plugins 编译验证", opts)
|
||||
|
||||
def _scan_subdir(self, sub: str, title: str, opts: dict):
|
||||
target = os.path.join(PROJECT_ROOT, sub)
|
||||
if not os.path.isdir(target):
|
||||
return ToolResult.not_found(f"目录: {sub}", tool_name="compile_check")
|
||||
files = self._collect_py_files(target)
|
||||
passed, failed, _ = self._run_batch(
|
||||
files, title,
|
||||
verbose=opts["verbose"],
|
||||
quiet=opts["quiet"],
|
||||
jobs=opts["jobs"],
|
||||
)
|
||||
if failed == 0:
|
||||
return ToolResult.ok(
|
||||
f"{sub} 全部通过 ({passed})",
|
||||
tool_name="compile_check",
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"{sub} 有 {failed} 个文件编译失败",
|
||||
ErrorCode.EXEC_FAILED,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
def _op_dir(self, opts: dict):
|
||||
if not opts["positional"]:
|
||||
return ToolResult.missing("目录路径", tool_name="compile_check")
|
||||
target = os.path.abspath(opts["positional"][0])
|
||||
if not os.path.isdir(target):
|
||||
return ToolResult.not_found(f"目录: {target}", tool_name="compile_check")
|
||||
files = self._collect_py_files(target)
|
||||
passed, failed, _ = self._run_batch(
|
||||
files,
|
||||
f"目录验证: {os.path.relpath(target, PROJECT_ROOT)}",
|
||||
verbose=opts["verbose"],
|
||||
quiet=opts["quiet"],
|
||||
jobs=opts["jobs"],
|
||||
)
|
||||
if failed == 0:
|
||||
return ToolResult.ok(
|
||||
f"目录全部通过 ({passed})",
|
||||
tool_name="compile_check",
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"目录有 {failed} 个文件编译失败",
|
||||
ErrorCode.EXEC_FAILED,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
def _op_check(self, opts: dict):
|
||||
if not opts["positional"]:
|
||||
return ToolResult.missing("文件路径", tool_name="compile_check")
|
||||
|
||||
targets = []
|
||||
for p in opts["positional"]:
|
||||
fp = os.path.abspath(p)
|
||||
if not os.path.isfile(fp):
|
||||
return ToolResult.not_found(
|
||||
f"文件: {p}", tool_name="compile_check",
|
||||
)
|
||||
targets.append(fp)
|
||||
|
||||
if len(targets) == 1:
|
||||
self._emit(self._banner(
|
||||
f"单文件验证: {os.path.relpath(targets[0], PROJECT_ROOT)}"
|
||||
))
|
||||
filepath, ok, err = self._verify_one(targets[0])
|
||||
rel = os.path.relpath(filepath, PROJECT_ROOT)
|
||||
self._emit("")
|
||||
if ok:
|
||||
self._emit(f" {self._ok('✓ 通过')} {rel}")
|
||||
self._emit("─" * 60)
|
||||
return ToolResult.ok(f"通过: {rel}", tool_name="compile_check")
|
||||
else:
|
||||
self._emit(f" {self._err('✗ 失败')} {rel}")
|
||||
self._emit(f" {self._err(err)}")
|
||||
self._emit("─" * 60)
|
||||
return ToolResult.fail(
|
||||
f"编译失败: {rel}",
|
||||
ErrorCode.EXEC_FAILED,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
passed, failed, _ = self._run_batch(
|
||||
targets,
|
||||
f"批量验证 ({len(targets)} 个文件)",
|
||||
verbose=opts["verbose"],
|
||||
quiet=opts["quiet"],
|
||||
jobs=opts["jobs"],
|
||||
)
|
||||
if failed == 0:
|
||||
return ToolResult.ok(
|
||||
f"全部通过 ({passed})",
|
||||
tool_name="compile_check",
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"有 {failed} 个文件编译失败",
|
||||
ErrorCode.EXEC_FAILED,
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
def _op_clean(self, opts: dict):
|
||||
target = PROJECT_ROOT
|
||||
if opts["positional"]:
|
||||
target = os.path.abspath(opts["positional"][0])
|
||||
if not os.path.isdir(target):
|
||||
return ToolResult.not_found(
|
||||
f"目录: {target}", tool_name="compile_check",
|
||||
)
|
||||
|
||||
self._emit(self._banner(f"清理 __pycache__: {os.path.relpath(target, PROJECT_ROOT)}"))
|
||||
removed = 0
|
||||
for dirpath, dirnames, _ in os.walk(target):
|
||||
for d in list(dirnames):
|
||||
if d == "__pycache__":
|
||||
full = os.path.join(dirpath, d)
|
||||
try:
|
||||
shutil.rmtree(full)
|
||||
removed += 1
|
||||
rel = os.path.relpath(full, PROJECT_ROOT)
|
||||
if not opts["quiet"]:
|
||||
self._emit(f" {self._ok('✓ 已删除')} {rel}")
|
||||
except Exception as e:
|
||||
rel = os.path.relpath(full, PROJECT_ROOT)
|
||||
self._emit(f" {self._err('✗ 失败')} {rel}: {e}")
|
||||
self._emit("")
|
||||
self._emit("─" * 60)
|
||||
if removed:
|
||||
self._emit(f" {self._ok(f'✓ 已清理')} {removed} 个 __pycache__")
|
||||
else:
|
||||
self._emit(f" {self._dim('没有可清理的 __pycache__')}")
|
||||
self._emit("─" * 60)
|
||||
|
||||
return ToolResult.ok(
|
||||
f"清理完成 ({removed} 个目录)",
|
||||
tool_name="compile_check",
|
||||
)
|
||||
|
||||
def _op_stats(self, opts: dict):
|
||||
self._emit(self._banner("项目 Python 文件统计"))
|
||||
|
||||
subdirs = [
|
||||
("xcli_core", "xcli_core"),
|
||||
("ai_engines", "ai_engines"),
|
||||
("plugins", "plugins"),
|
||||
]
|
||||
|
||||
total = 0
|
||||
rows: List[Tuple[str, int]] = []
|
||||
for sub, label in subdirs:
|
||||
target = os.path.join(PROJECT_ROOT, sub)
|
||||
if os.path.isdir(target):
|
||||
count = len(self._collect_py_files(target))
|
||||
rows.append((label, count))
|
||||
total += count
|
||||
|
||||
other = len(self._collect_py_files(PROJECT_ROOT)) - total
|
||||
rows.append(("其他(根目录等)", other))
|
||||
total += other
|
||||
|
||||
self._emit("")
|
||||
self._emit(f" {'目录':<20} {'文件数':>8}")
|
||||
self._emit(" " + "─" * 30)
|
||||
for label, count in rows:
|
||||
bar = "█" * min(count, 40) if count else ""
|
||||
color = Fore.GREEN if count else Fore.CYAN
|
||||
self._emit(f" {label:<20} {count:>8} {color}{bar}{Style.RESET_ALL}")
|
||||
self._emit(" " + "─" * 30)
|
||||
self._emit(f" {'合计':<20} {total:>8}")
|
||||
self._emit("")
|
||||
self._emit("─" * 60)
|
||||
|
||||
return ToolResult.ok(
|
||||
f"统计完成 (共 {total} 个文件)",
|
||||
tool_name="compile_check",
|
||||
)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
文件管理器插件 - 文件系统操作
|
||||
聚焦文件系统操作,代码编辑交给 code_editor
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
# 添加项目根目录到 path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""文件管理器 - 文件和目录的增删改查"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """文件管理器
|
||||
|
||||
操作:
|
||||
list <路径> [-p "模式"] - 列出目录内容
|
||||
read <文件> [-n 行数] - 读取文件内容
|
||||
write <文件> -c "内容" - 写入文件
|
||||
append <文件> -c "内容" - 追加内容
|
||||
copy <源> <目标> - 复制文件/目录
|
||||
move <源> <目标> - 移动/重命名
|
||||
delete <路径> [-r] - 删除文件/目录
|
||||
search <目录> [-p 模式] [-c 内容] - 搜索文件
|
||||
info <路径> - 文件详细信息
|
||||
mkdir <路径> - 创建目录
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "file_manager",
|
||||
"description": "文件管理器 - 文件和目录的创建、读写、复制、移动、删除、搜索",
|
||||
"keywords": ["文件", "目录", "管理", "file", "复制", "删除", "搜索", "mkdir",
|
||||
"读取", "写入", "移动", "重命名"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "file_manager",
|
||||
"description": "文件管理器",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["list", "read", "write", "append", "copy",
|
||||
"move", "delete", "search", "info", "mkdir"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"path": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"destination": {"type": "string"},
|
||||
"pattern": {"type": "string"},
|
||||
"recursive": {"type": "boolean", "default": False}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
path = arguments.get("path", "")
|
||||
return f"{op} {path}".strip()
|
||||
|
||||
def handle(self, args: str):
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return ToolResult.missing("操作类型")
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return ToolResult.fail(f"不支持的操作 '{operation}'", ErrorCode.UNSUPPORTED_OP)
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"文件操作错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _op_list(self, args: List[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.isdir(path):
|
||||
return ToolResult.not_found(f"目录: {path}")
|
||||
|
||||
items = []
|
||||
try:
|
||||
for item in os.listdir(path):
|
||||
full = os.path.join(path, item)
|
||||
if pattern and not Path(item).match(pattern):
|
||||
continue
|
||||
if os.path.isdir(full):
|
||||
items.append(('dir', item, ''))
|
||||
else:
|
||||
size = os.path.getsize(full)
|
||||
size_str = f"{size/1024:.1f}KB" if size > 1024 else f"{size}B"
|
||||
items.append(('file', item, size_str))
|
||||
|
||||
items.sort(key=lambda x: (0 if x[0] == 'dir' else 1, x[1].lower()))
|
||||
dirs = sum(1 for x in items if x[0] == 'dir')
|
||||
files = len(items) - dirs
|
||||
|
||||
result = [f" {path}", "─" * 50]
|
||||
for t, name, size in items[:100]:
|
||||
result.append(f" {'' if t == 'dir' else ''} {name}{'/' if t == 'dir' else f' ({size})'}")
|
||||
if len(items) > 100:
|
||||
result.append(f" ... 还有 {len(items) - 100} 项")
|
||||
result.append("─" * 50)
|
||||
result.append(f"共 {dirs} 个文件夹, {files} 个文件")
|
||||
return ToolResult.ok('\n'.join(result), tool_name="file_manager")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED, tool_name="file_manager")
|
||||
|
||||
def _op_read(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("文件路径")
|
||||
file_path = os.path.abspath(args[0])
|
||||
max_lines = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "-n" and i + 1 < len(args):
|
||||
try:
|
||||
max_lines = int(args[i + 1])
|
||||
except ValueError:
|
||||
return ToolResult.fail("行数必须是整数", ErrorCode.INVALID_ARGS)
|
||||
i += 1
|
||||
i += 1
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return ToolResult.not_found(f"文件: {file_path}")
|
||||
|
||||
size = os.path.getsize(file_path)
|
||||
if size > 10 * 1024 * 1024:
|
||||
return ToolResult.fail(f"文件过大 ({size/1024/1024:.1f}MB),请用 -n 限制行数", ErrorCode.INVALID_ARGS)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
if max_lines:
|
||||
lines = []
|
||||
total = 0
|
||||
for i, line in enumerate(f):
|
||||
total = i + 1
|
||||
if i < max_lines:
|
||||
lines.append(f"{i+1:4d} | {line.rstrip()}")
|
||||
content = '\n'.join(lines)
|
||||
header = f" {file_path} ({size}B, 前 {min(max_lines, total)}/{total} 行)"
|
||||
else:
|
||||
all_lines = f.readlines()
|
||||
content = '\n'.join(f"{i+1:4d} | {l.rstrip()}" for i, l in enumerate(all_lines))
|
||||
header = f" {file_path} ({size}B, {len(all_lines)} 行)"
|
||||
|
||||
return ToolResult.ok(f"{header}\n{'─' * 50}\n{content}", tool_name="file_manager")
|
||||
except UnicodeDecodeError:
|
||||
return ToolResult.fail("无法解码(可能是二进制文件)", ErrorCode.DECODE_ERROR)
|
||||
|
||||
def _op_write(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.fail("格式: write <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
||||
fp = os.path.abspath(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 ToolResult.missing("内容 (-c 参数)")
|
||||
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
|
||||
with open(fp, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return ToolResult.ok(f"已写入: {fp} ({len(content)} 字符)", tool_name="file_manager")
|
||||
|
||||
def _op_append(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.fail("格式: append <文件> -c <内容>", ErrorCode.MISSING_ARGS)
|
||||
fp = os.path.abspath(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 ToolResult.missing("内容 (-c 参数)")
|
||||
if not os.path.isfile(fp):
|
||||
return ToolResult.not_found(f"文件: {fp}")
|
||||
with open(fp, 'a', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return ToolResult.ok(f"已追加: {fp}", tool_name="file_manager")
|
||||
|
||||
def _op_copy(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.missing("源和目标路径")
|
||||
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
|
||||
if not os.path.exists(src):
|
||||
return ToolResult.not_found(f"源: {src}")
|
||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||
if os.path.isfile(src):
|
||||
shutil.copy2(src, dst)
|
||||
else:
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
return ToolResult.ok(f"已复制:\n {src}\n → {dst}", tool_name="file_manager")
|
||||
|
||||
def _op_move(self, args: List[str]):
|
||||
if len(args) < 2:
|
||||
return ToolResult.missing("源和目标路径")
|
||||
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
|
||||
if not os.path.exists(src):
|
||||
return ToolResult.not_found(f"源: {src}")
|
||||
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
|
||||
shutil.move(src, dst)
|
||||
return ToolResult.ok(f"已移动:\n {src}\n → {dst}", tool_name="file_manager")
|
||||
|
||||
def _op_delete(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("路径")
|
||||
path = os.path.abspath(args[0])
|
||||
recursive = "-r" in args
|
||||
if not os.path.exists(path):
|
||||
return ToolResult.not_found(f"路径: {path}")
|
||||
if os.path.isfile(path):
|
||||
os.remove(path)
|
||||
return ToolResult.ok(f"已删除文件: {path}", tool_name="file_manager")
|
||||
elif os.path.isdir(path):
|
||||
if recursive:
|
||||
shutil.rmtree(path)
|
||||
return ToolResult.ok(f"已删除目录: {path}", tool_name="file_manager")
|
||||
elif not os.listdir(path):
|
||||
os.rmdir(path)
|
||||
return ToolResult.ok(f"已删除空目录: {path}", tool_name="file_manager")
|
||||
else:
|
||||
return ToolResult.fail("目录不为空,用 -r 递归删除", ErrorCode.INVALID_ARGS)
|
||||
|
||||
def _op_search(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("搜索目录")
|
||||
directory = os.path.abspath(args[0])
|
||||
pattern, content_search = None, 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
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
return ToolResult.not_found(f"目录: {directory}")
|
||||
|
||||
results = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for fn in files:
|
||||
if pattern and not Path(fn).match(pattern):
|
||||
continue
|
||||
fp = os.path.join(root, fn)
|
||||
rel = os.path.relpath(fp, directory)
|
||||
if content_search:
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if content_search in line:
|
||||
results.append(f"{rel}:{i}: {line.strip()[:80]}")
|
||||
if len(results) >= 50:
|
||||
break
|
||||
except (UnicodeDecodeError, IOError):
|
||||
continue
|
||||
else:
|
||||
results.append(rel)
|
||||
if len(results) >= 50:
|
||||
break
|
||||
if len(results) >= 50:
|
||||
break
|
||||
|
||||
return ToolResult.ok(f"搜索结果 ({len(results)}):\n" + '\n'.join(results), tool_name="file_manager") if results else ToolResult.ok("未找到匹配", tool_name="file_manager")
|
||||
|
||||
def _op_info(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("路径")
|
||||
path = os.path.abspath(args[0])
|
||||
if not os.path.exists(path):
|
||||
return ToolResult.not_found(f"路径: {path}")
|
||||
|
||||
stat = os.stat(path)
|
||||
result = [
|
||||
f"路径: {path}",
|
||||
f"类型: {'目录' if os.path.isdir(path) else '文件'}",
|
||||
f"大小: {stat.st_size:,} 字节 ({stat.st_size/1024:.1f} KB)",
|
||||
f"修改: {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}",
|
||||
f"访问: {datetime.fromtimestamp(stat.st_atime):%Y-%m-%d %H:%M:%S}",
|
||||
]
|
||||
if os.path.isfile(path):
|
||||
ext = os.path.splitext(path)[1]
|
||||
result.append(f"扩展名: {ext or '无'}")
|
||||
return ToolResult.ok('\n'.join(result), tool_name="file_manager")
|
||||
|
||||
def _op_mkdir(self, args: List[str]):
|
||||
if not args:
|
||||
return ToolResult.missing("目录路径")
|
||||
path = os.path.abspath(args[0])
|
||||
if os.path.exists(path):
|
||||
return ToolResult.fail(f"已存在: {path}", ErrorCode.INVALID_ARGS)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return ToolResult.ok(f"已创建: {path}", tool_name="file_manager")
|
||||
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
前端测试验证插件 - 类似 iFlow CLI 的 frontend-tester
|
||||
支持HTML/CSS/JS自动化测试和UI验证
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
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文件路径> - 验证HTML文件的语法和结构
|
||||
- test <CSS文件路径> - 测试CSS文件的样式和兼容性
|
||||
- check <JS文件路径> - 检查JS文件的语法和潜在问题
|
||||
- preview <HTML文件路径> - 在默认浏览器中打开预览
|
||||
- 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 get_mcp_definition(self):
|
||||
return {
|
||||
"name": "frontend_tester",
|
||||
"description": "前端测试验证工具,支持HTML/CSS/JS自动化测试",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["validate", "lint", "test"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "目标文件路径"
|
||||
}
|
||||
},
|
||||
"required": ["operation", "target"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
target = arguments.get("target", "")
|
||||
return f"{op} {target}"
|
||||
|
||||
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 <HTML文件路径>"
|
||||
file_path = parts[1]
|
||||
return self._validate_html(file_path)
|
||||
|
||||
elif operation == "test":
|
||||
# 测试CSS文件
|
||||
if len(parts) < 2:
|
||||
return "错误:请提供CSS文件路径。格式: test <CSS文件路径>"
|
||||
file_path = parts[1]
|
||||
return self._test_css(file_path)
|
||||
|
||||
elif operation == "check":
|
||||
# 检查JS文件
|
||||
if len(parts) < 2:
|
||||
return "错误:请提供JS文件路径。格式: check <JS文件路径>"
|
||||
file_path = parts[1]
|
||||
return self._check_js(file_path)
|
||||
|
||||
elif operation == "preview":
|
||||
# 在浏览器中预览
|
||||
if len(parts) < 2:
|
||||
return "错误:请提供HTML文件路径。格式: preview <HTML文件路径>"
|
||||
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('<!DOCTYPE'):
|
||||
issues.append({
|
||||
"level": "warning",
|
||||
"message": "缺少DOCTYPE声明,建议添加 <!DOCTYPE html>"
|
||||
})
|
||||
|
||||
# 检查lang属性
|
||||
if '<html' in content and 'lang=' not in content:
|
||||
issues.append({
|
||||
"level": "warning",
|
||||
"message": "建议在<html>标签中添加lang属性,如 lang=\"zh-CN\""
|
||||
})
|
||||
|
||||
# 检查meta标签
|
||||
if '<meta charset=' not in content:
|
||||
issues.append({
|
||||
"level": "error",
|
||||
"message": "缺少字符集meta标签,建议添加 <meta charset=\"UTF-8\">"
|
||||
})
|
||||
|
||||
if '<meta name="viewport"' not in content:
|
||||
issues.append({
|
||||
"level": "warning",
|
||||
"message": "缺少viewport meta标签,建议添加以支持响应式设计"
|
||||
})
|
||||
|
||||
# 检查title标签
|
||||
if '<title>' not in content:
|
||||
issues.append({
|
||||
"level": "error",
|
||||
"message": "缺少<title>标签"
|
||||
})
|
||||
|
||||
# 统计元素
|
||||
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 re.search(r'(?<![=!])==(?!=)', 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 = Liugin()
|
||||
print("前端测试验证插件测试:")
|
||||
|
||||
print("\n1. 创建测试HTML文件:")
|
||||
test_html = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>测试页面</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>测试</h1>
|
||||
</body>
|
||||
</html>"""
|
||||
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()
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Git 工具插件 - 统一的版本控制能力
|
||||
合并原 git_tools + git_workflow,消除重复
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""Git 版本控制 - 基础操作 + 工作流自动化"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """Git 版本控制工具
|
||||
|
||||
基础操作:
|
||||
status - 查看仓库状态
|
||||
diff [文件路径] - 查看未暂存变更
|
||||
diff --cached - 查看已暂存变更
|
||||
log [数量] - 提交历史 (默认10条)
|
||||
log --oneline [数量] - 简洁提交历史
|
||||
add <文件路径> - 暂存文件
|
||||
add . - 暂存所有变更
|
||||
commit <提交信息> - 提交变更
|
||||
branch - 查看分支列表
|
||||
branch <名称> - 创建新分支
|
||||
checkout <分支名> - 切换分支
|
||||
checkout -b <分支名> - 创建并切换分支
|
||||
stash - 暂存当前变更
|
||||
stash pop - 恢复暂存的变更
|
||||
show <commit> - 查看指定提交
|
||||
blame <文件路径> - 逐行修改记录
|
||||
remote - 查看远程仓库
|
||||
init - 初始化仓库
|
||||
clone <URL> [目录] - 克隆仓库
|
||||
|
||||
工作流:
|
||||
smart-commit [消息] - 智能提交 (自动 add + 规范化消息)
|
||||
changelog [版本] - 生成 CHANGELOG
|
||||
summary - 变更摘要 (未提交改动)
|
||||
contributors - 贡献者统计
|
||||
stale [天数] - 查找长期未更新文件
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "git_tools",
|
||||
"description": "Git 版本控制 - 状态查看、提交、分支管理、diff、历史、blame、工作流自动化",
|
||||
"keywords": ["git", "版本控制", "提交", "分支", "commit", "branch", "status", "diff",
|
||||
"changelog", "log", "blame", "stash", "checkout", "merge"],
|
||||
"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": "操作参数"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
args = arguments.get("args", "")
|
||||
return f"{op} {args}".strip()
|
||||
|
||||
# ── 执行 Git 命令 ──
|
||||
|
||||
def _run_git(self, args: list, cwd: Optional[str] = None) -> Tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git"] + args,
|
||||
capture_output=True, text=True,
|
||||
cwd=cwd, timeout=30
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
return result.returncode == 0, output
|
||||
except FileNotFoundError:
|
||||
return False, "git 未安装,请先安装 Git"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Git 命令执行超时"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _run_shell(self, cmd: str, cwd: Optional[str] = None) -> Tuple[bool, str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True,
|
||||
cwd=cwd or os.getcwd(), timeout=30
|
||||
)
|
||||
return result.returncode == 0, (result.stdout + result.stderr).strip()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def _get_cwd(self):
|
||||
if self.cli and hasattr(self.cli, 'current_dir'):
|
||||
return self.cli.current_dir
|
||||
return None
|
||||
|
||||
# ── 路由 ──
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return self._status()
|
||||
|
||||
op = parts[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
cwd = self._get_cwd()
|
||||
|
||||
handlers = {
|
||||
# 基础
|
||||
"status": lambda r: self._status(cwd),
|
||||
"diff": lambda r: self._diff(r, cwd),
|
||||
"log": lambda r: self._log(r, cwd),
|
||||
"add": lambda r: self._add(r, cwd),
|
||||
"commit": lambda r: self._commit(r, cwd),
|
||||
"branch": lambda r: self._branch(r, cwd),
|
||||
"checkout": lambda r: self._checkout(r, cwd),
|
||||
"stash": lambda r: self._stash(r, cwd),
|
||||
"show": lambda r: self._show(r, cwd),
|
||||
"blame": lambda r: self._blame(r, cwd),
|
||||
"remote": lambda r: self._remote(cwd),
|
||||
"init": lambda r: self._init(r, cwd),
|
||||
"clone": lambda r: self._clone(r, cwd),
|
||||
# 工作流
|
||||
"smart-commit": lambda r: self._smart_commit(r, cwd),
|
||||
"changelog": lambda r: self._changelog(r, cwd),
|
||||
"summary": lambda r: self._summary(cwd),
|
||||
"contributors": lambda r: self._contributors(cwd),
|
||||
"stale": lambda r: self._stale(r, cwd),
|
||||
}
|
||||
|
||||
handler = handlers.get(op)
|
||||
if handler:
|
||||
return handler(rest)
|
||||
|
||||
# 回退:直接执行 git 命令
|
||||
ok, output = self._run_git(args.split(), cwd)
|
||||
return output if ok else f"Git 错误: {output}"
|
||||
|
||||
except Exception as e:
|
||||
return f"Git 工具错误: {str(e)}"
|
||||
|
||||
# ── 基础操作 ──
|
||||
|
||||
def _status(self, cwd=None) -> str:
|
||||
ok, output = self._run_git(["status", "--porcelain"], cwd)
|
||||
if not ok:
|
||||
return f"Git 错误: {output}"
|
||||
|
||||
if not output:
|
||||
# 获取分支
|
||||
_, branch = self._run_git(["branch", "--show-current"], cwd)
|
||||
return f" 工作区干净 (分支: {branch})"
|
||||
|
||||
lines = output.split('\n')
|
||||
staged, unstaged, untracked = [], [], []
|
||||
|
||||
for line in lines:
|
||||
if len(line) < 4:
|
||||
continue
|
||||
s, filename = line[:2], line[3:]
|
||||
if s[0] in 'MADRC':
|
||||
staged.append(f" {s[0]} {filename}")
|
||||
if s[1] in 'MD':
|
||||
unstaged.append(f" ! {s[1]} {filename}")
|
||||
if s == '??':
|
||||
untracked.append(f" ? {filename}")
|
||||
|
||||
_, branch = self._run_git(["branch", "--show-current"], cwd)
|
||||
result = [f" Git 状态 (分支: {branch})"]
|
||||
|
||||
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)
|
||||
|
||||
return '\n'.join(result)
|
||||
|
||||
def _diff(self, args: str, cwd=None) -> str:
|
||||
args = args.strip()
|
||||
if args == "--cached":
|
||||
git_args = ["diff", "--cached"]
|
||||
elif args:
|
||||
git_args = ["diff", args]
|
||||
else:
|
||||
git_args = ["diff"]
|
||||
|
||||
ok, output = self._run_git(git_args, cwd)
|
||||
if not ok:
|
||||
return f"Git 错误: {output}"
|
||||
if not output:
|
||||
return "没有变更"
|
||||
return output
|
||||
|
||||
def _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}"
|
||||
return output or "暂无提交历史"
|
||||
|
||||
def _add(self, args: str, cwd=None) -> str:
|
||||
target = args.strip()
|
||||
if not target:
|
||||
return "错误:请提供文件路径(使用 . 暂存所有)"
|
||||
ok, output = self._run_git(["add", target], cwd)
|
||||
return f" 已暂存: {target}" if ok else f"Git add 失败: {output}"
|
||||
|
||||
def _commit(self, args: str, cwd=None) -> str:
|
||||
message = args.strip()
|
||||
if not message:
|
||||
return "错误:请提供提交信息"
|
||||
ok, output = self._run_git(["commit", "-m", message], cwd)
|
||||
return f" 提交成功:\n{output}" if ok else f"Git commit 失败: {output}"
|
||||
|
||||
def _branch(self, args: str, cwd=None) -> str:
|
||||
if not args.strip():
|
||||
ok, output = self._run_git(["branch", "-a"], cwd)
|
||||
return f" 分支列表:\n{output}" if ok else f"Git 错误: {output}"
|
||||
else:
|
||||
ok, output = self._run_git(["branch", args.strip()], cwd)
|
||||
return f" 已创建分支: {args.strip()}" if ok else f"创建分支失败: {output}"
|
||||
|
||||
def _checkout(self, args: str, cwd=None) -> str:
|
||||
args = args.strip()
|
||||
if args.startswith("-b "):
|
||||
branch = args[3:].strip()
|
||||
ok, output = self._run_git(["checkout", "-b", branch], cwd)
|
||||
else:
|
||||
branch = args
|
||||
ok, output = self._run_git(["checkout", branch], cwd)
|
||||
return f" 已切换到: {branch}" if ok else f"切换失败: {output}"
|
||||
|
||||
def _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)
|
||||
return f" {output}" if ok else f"Git stash 失败: {output}"
|
||||
|
||||
def _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}"
|
||||
return output
|
||||
|
||||
def _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')
|
||||
result = []
|
||||
author = ""
|
||||
for line in lines:
|
||||
if line.startswith('author '):
|
||||
author = line[7:]
|
||||
elif line.startswith('\t'):
|
||||
result.append(f" {author}: {line[1:]}")
|
||||
|
||||
if len(result) > 100:
|
||||
result = result[:100]
|
||||
result.append(f" ... (共 {len(result)} 行)")
|
||||
|
||||
return f" {path} 修改记录:\n" + '\n'.join(result)
|
||||
|
||||
def _remote(self, cwd=None) -> str:
|
||||
ok, output = self._run_git(["remote", "-v"], cwd)
|
||||
if not ok:
|
||||
return f"Git 错误: {output}"
|
||||
return output or "没有配置远程仓库"
|
||||
|
||||
def _init(self, args: str, cwd=None) -> str:
|
||||
path = args.strip() or "."
|
||||
ok, output = self._run_git(["init", path], cwd)
|
||||
return f" {output}" if ok else f"Git init 失败: {output}"
|
||||
|
||||
def _clone(self, args: str, cwd=None) -> str:
|
||||
parts = args.split()
|
||||
if not parts:
|
||||
return "错误:请提供仓库 URL"
|
||||
cmd = ["clone", parts[0]]
|
||||
if len(parts) > 1:
|
||||
cmd.append(parts[1])
|
||||
ok, output = self._run_git(cmd, cwd)
|
||||
return f" 克隆成功:\n{output}" if ok else f"Git clone 失败: {output}"
|
||||
|
||||
# ── 工作流 ──
|
||||
|
||||
def _smart_commit(self, message: str, cwd=None) -> str:
|
||||
"""智能提交:自动 add + 规范化消息"""
|
||||
ok, status = self._run_git(["status", "--porcelain"], cwd)
|
||||
if not status:
|
||||
return " 工作区干净,没有需要提交的变更"
|
||||
|
||||
self._run_git(["add", "-A"], cwd)
|
||||
|
||||
if not message:
|
||||
message = self._auto_commit_message(status)
|
||||
|
||||
# conventional commits 检查
|
||||
if not re.match(r'^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?: .+', message):
|
||||
if 'test' in message.lower():
|
||||
message = f"test: {message}"
|
||||
elif 'doc' in message.lower() or 'readme' in message.lower():
|
||||
message = f"docs: {message}"
|
||||
else:
|
||||
message = f"feat: {message}"
|
||||
|
||||
ok, output = self._run_git(["commit", "-m", message], cwd)
|
||||
if ok:
|
||||
_, hash_out = self._run_git(["log", "--oneline", "-1"], cwd)
|
||||
return f" 已提交: {message}\n {hash_out}"
|
||||
return f" 提交失败: {output}"
|
||||
|
||||
def _auto_commit_message(self, status: str) -> str:
|
||||
lines = status.strip().split('\n')
|
||||
added = len([l for l in lines if l.startswith('A ') or (len(l) > 1 and l.startswith('A') and l[1] == ' ')])
|
||||
modified = len([l for l in lines if l.startswith('M ') or (len(l) > 1 and l[1] == 'M')])
|
||||
deleted = len([l for l in lines if l.startswith('D ') or (len(l) > 1 and l[1] == 'D')])
|
||||
|
||||
parts = []
|
||||
if added:
|
||||
parts.append(f"添加 {added} 个文件")
|
||||
if modified:
|
||||
parts.append(f"修改 {modified} 个文件")
|
||||
if deleted:
|
||||
parts.append(f"删除 {deleted} 个文件")
|
||||
return ", ".join(parts) if parts else "更新代码"
|
||||
|
||||
def _changelog(self, version: str, cwd=None) -> str:
|
||||
ok, output = self._run_shell(
|
||||
'git log --oneline --format="%s" | head -50', cwd)
|
||||
if not ok:
|
||||
return f" {output}"
|
||||
|
||||
commits = output.strip().split('\n')
|
||||
categories = {
|
||||
'feat': [], 'fix': [], 'docs': [], 'refactor': [],
|
||||
'test': [], 'chore': [], 'other': []
|
||||
}
|
||||
|
||||
for commit in commits:
|
||||
matched = False
|
||||
for prefix in categories:
|
||||
if commit.startswith(prefix + ':') or commit.startswith(prefix + '('):
|
||||
categories[prefix].append(commit)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
categories['other'].append(commit)
|
||||
|
||||
labels = {
|
||||
'feat': ' 新增', 'fix': ' 修复', 'docs': ' 文档',
|
||||
'refactor': ' 重构', 'test': ' 测试', 'chore': ' 杂项',
|
||||
'other': ' 其他'
|
||||
}
|
||||
|
||||
ver = version or "未发布"
|
||||
result = [f"## {ver}\n"]
|
||||
for prefix, label in labels.items():
|
||||
items = categories[prefix]
|
||||
if items:
|
||||
result.append(f"\n### {label}")
|
||||
for item in items[:10]:
|
||||
result.append(f"- {item}")
|
||||
|
||||
return '\n'.join(result)
|
||||
|
||||
def _summary(self, cwd=None) -> str:
|
||||
ok, output = self._run_git(["diff", "--stat"], cwd)
|
||||
if not output:
|
||||
return " 没有未提交的变更"
|
||||
_, short = self._run_git(["diff", "--shortstat"], cwd)
|
||||
return f" 变更摘要:\n\n{short}\n\n{output[:2000]}"
|
||||
|
||||
def _contributors(self, cwd=None) -> str:
|
||||
ok, output = self._run_git(["shortlog", "-sn", "--all", "--no-merges"], cwd)
|
||||
if not ok:
|
||||
return f" {output}"
|
||||
lines = output.strip().split('\n')
|
||||
result = [f" 贡献者 ({len(lines)} 人):\n"]
|
||||
for line in lines[:20]:
|
||||
result.append(f" {line.strip()}")
|
||||
return '\n'.join(result)
|
||||
|
||||
def _stale(self, args: str, cwd=None) -> str:
|
||||
days = int(args) if args.strip().isdigit() else 90
|
||||
ok, output = self._run_shell(
|
||||
f"git log --diff-filter=A --format='%aI %n' --since='{days} days ago' | head -20", cwd)
|
||||
return f" 最近 {days} 天新增的文件:\n{output[:2000]}" if ok else f" {output}"
|
||||
+1036
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
UUID / ID 生成器插件 - 生成各种格式的唯一标识符
|
||||
支持 UUID v1/v4/v5、NanoID、短ID、雪花ID、自定义格式
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import hashlib
|
||||
import time
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""UUID / ID 生成器 - 多格式唯一标识符生成"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """UUID / ID 生成器
|
||||
|
||||
操作:
|
||||
uuid [-v 版本] [-n 数量] - 生成 UUID(默认 v4)
|
||||
v1=时间戳 v4=随机 v5=命名
|
||||
nanoid [-s 长度] [-n 数量] - 生成 NanoID(默认21位)
|
||||
short [-s 长度] [-n 数量] - 生成短ID(默认8位)
|
||||
snowflake [-n 数量] - 生成雪花ID(时间排序)
|
||||
custom <模板> [-n 数量] - 自定义格式生成
|
||||
模板: {U}=大写字母 {L}=小写 {D}=数字 {X}=十六进制
|
||||
ulid [-n 数量] - 生成 ULID(时间排序+随机)
|
||||
timestamp - 当前时间戳(秒/毫秒/微秒)
|
||||
from_name <命名空间> <名称> - 基于名称生成确定性 UUID (v5)
|
||||
batch <类型> [-n 数量] [-o 文件] - 批量生成并保存到文件
|
||||
|
||||
示例:
|
||||
id_generator uuid
|
||||
id_generator uuid -v 1 -n 5
|
||||
id_generator nanoid -s 16
|
||||
id_generator short -s 6 -n 10
|
||||
id_generator custom "USER-{D}{D}{D}{D}{U}{U}" -n 5
|
||||
id_generator snowflake -n 3
|
||||
id_generator batch uuid -n 100 -o ids.txt
|
||||
"""
|
||||
self.cli = None
|
||||
self._machine_id = random.randint(0, 1023)
|
||||
self._sequence = 0
|
||||
self._last_ts = 0
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "id_generator",
|
||||
"description": "UUID/ID 生成器 - 支持 UUID、NanoID、短ID、雪花ID、ULID、自定义格式",
|
||||
"keywords": ["uuid", "id", "生成", "唯一", "标识", "nanoid", "snowflake", "ulid", "短id"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "id_generator",
|
||||
"description": "UUID/ID 生成器,支持多种格式",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["uuid", "nanoid", "short", "snowflake", "custom",
|
||||
"ulid", "timestamp", "from_name", "batch"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"version": {"type": "integer", "description": "UUID 版本 (1/4/5)"},
|
||||
"length": {"type": "integer", "description": "ID 长度"},
|
||||
"count": {"type": "integer", "description": "生成数量"},
|
||||
"template": {"type": "string", "description": "自定义模板"},
|
||||
"namespace": {"type": "string", "description": "命名空间"},
|
||||
"name": {"type": "string", "description": "名称"},
|
||||
"output": {"type": "string", "description": "输出文件"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
count = arguments.get("count", "")
|
||||
parts = [op]
|
||||
if count:
|
||||
parts.extend(["-n", str(count)])
|
||||
return " ".join(parts)
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return self._op_uuid([])
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"uuid": self._op_uuid,
|
||||
"nanoid": self._op_nanoid,
|
||||
"short": self._op_short,
|
||||
"snowflake": self._op_snowflake,
|
||||
"custom": self._op_custom,
|
||||
"ulid": self._op_ulid,
|
||||
"timestamp": self._op_timestamp,
|
||||
"from_name": self._op_from_name,
|
||||
"batch": self._op_batch,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"ID 生成错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _parse_opts(self, args: List[str]) -> dict:
|
||||
opts = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i].startswith("-") and i + 1 < len(args):
|
||||
key = args[i].lstrip("-")
|
||||
opts[key] = args[i + 1]
|
||||
i += 1
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
def _op_uuid(self, args: List[str]) -> str:
|
||||
opts = self._parse_opts(args)
|
||||
version = int(opts.get("v", 4))
|
||||
count = int(opts.get("n", 1))
|
||||
count = min(count, 1000)
|
||||
|
||||
ids = []
|
||||
for _ in range(count):
|
||||
if version == 1:
|
||||
ids.append(str(uuid.uuid1()))
|
||||
elif version == 4:
|
||||
ids.append(str(uuid.uuid4()))
|
||||
elif version == 5:
|
||||
ids.append(str(uuid.uuid5(uuid.NAMESPACE_DNS, "example.com")))
|
||||
else:
|
||||
return f"错误:不支持的 UUID 版本: {version}"
|
||||
|
||||
header = f" UUID v{version} ({count} 个):\n"
|
||||
if count <= 20:
|
||||
return header + "\n".join(f" {u}" for u in ids)
|
||||
else:
|
||||
return header + "\n".join(f" {u}" for u in ids[:10]) + f"\n ... (共 {count} 个)"
|
||||
|
||||
def _op_nanoid(self, args: List[str]) -> str:
|
||||
opts = self._parse_opts(args)
|
||||
size = int(opts.get("s", 21))
|
||||
count = int(opts.get("n", 1))
|
||||
count = min(count, 1000)
|
||||
|
||||
alphabet = string.ascii_letters + string.digits + "_-"
|
||||
ids = []
|
||||
for _ in range(count):
|
||||
ids.append(''.join(random.choices(alphabet, k=size)))
|
||||
|
||||
header = f" NanoID (长度 {size}, {count} 个):\n"
|
||||
if count <= 20:
|
||||
return header + "\n".join(f" {i}" for i in ids)
|
||||
else:
|
||||
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
|
||||
|
||||
def _op_short(self, args: List[str]) -> str:
|
||||
opts = self._parse_opts(args)
|
||||
size = int(opts.get("s", 8))
|
||||
count = int(opts.get("n", 1))
|
||||
count = min(count, 1000)
|
||||
|
||||
chars = string.ascii_letters + string.digits
|
||||
ids = []
|
||||
for _ in range(count):
|
||||
ids.append(''.join(random.choices(chars, k=size)))
|
||||
|
||||
header = f" 短ID (长度 {size}, {count} 个):\n"
|
||||
if count <= 20:
|
||||
return header + "\n".join(f" {i}" for i in ids)
|
||||
else:
|
||||
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
|
||||
|
||||
def _op_snowflake(self, args: List[str]) -> str:
|
||||
opts = self._parse_opts(args)
|
||||
count = int(opts.get("n", 1))
|
||||
count = min(count, 1000)
|
||||
|
||||
# Twitter Snowflake: 41位时间戳 + 10位机器ID + 12位序列号
|
||||
epoch = 1288834974657 # Twitter epoch (2010-11-04)
|
||||
|
||||
ids = []
|
||||
for _ in range(count):
|
||||
ts = int(time.time() * 1000) - epoch
|
||||
if ts == self._last_ts:
|
||||
self._sequence = (self._sequence + 1) & 0xFFF
|
||||
if self._sequence == 0:
|
||||
while ts <= self._last_ts:
|
||||
ts = int(time.time() * 1000) - epoch
|
||||
else:
|
||||
self._sequence = 0
|
||||
self._last_ts = ts
|
||||
|
||||
snowflake_id = (ts << 22) | (self._machine_id << 12) | self._sequence
|
||||
ids.append(str(snowflake_id))
|
||||
|
||||
header = f"❄️ 雪花ID ({count} 个):\n"
|
||||
if count <= 20:
|
||||
return header + "\n".join(f" {i}" for i in ids)
|
||||
else:
|
||||
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
|
||||
|
||||
def _op_custom(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供模板\n示例: custom \"ORDER-{D}{D}{D}{D}-{U}{U}{U}\""
|
||||
template = args[0]
|
||||
opts = self._parse_opts(args[1:])
|
||||
count = int(opts.get("n", 1))
|
||||
count = min(count, 1000)
|
||||
|
||||
ids = []
|
||||
for _ in range(count):
|
||||
result = []
|
||||
for ch in template:
|
||||
if ch == '{':
|
||||
continue # handled below
|
||||
result.append(ch)
|
||||
# Actually use proper template parsing
|
||||
ids.append(self._expand_template(template))
|
||||
|
||||
header = f" ️ 自定义ID (模板: {template}, {count} 个):\n"
|
||||
if count <= 20:
|
||||
return header + "\n".join(f" {i}" for i in ids)
|
||||
else:
|
||||
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
|
||||
|
||||
def _expand_template(self, template: str) -> str:
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(template):
|
||||
if template[i] == '{' and i + 2 < len(template) and template[i + 2] == '}':
|
||||
token = template[i + 1]
|
||||
if token == 'U':
|
||||
result.append(random.choice(string.ascii_uppercase))
|
||||
elif token == 'L':
|
||||
result.append(random.choice(string.ascii_lowercase))
|
||||
elif token == 'D':
|
||||
result.append(random.choice(string.digits))
|
||||
elif token == 'X':
|
||||
result.append(random.choice('0123456789ABCDEF'))
|
||||
elif token == 'x':
|
||||
result.append(random.choice('0123456789abcdef'))
|
||||
elif token == 'A':
|
||||
result.append(random.choice(string.ascii_letters))
|
||||
else:
|
||||
result.append(template[i:i + 3])
|
||||
i += 3
|
||||
else:
|
||||
result.append(template[i])
|
||||
i += 1
|
||||
return ''.join(result)
|
||||
|
||||
def _op_ulid(self, args: List[str]) -> str:
|
||||
opts = self._parse_opts(args)
|
||||
count = int(opts.get("n", 1))
|
||||
count = min(count, 1000)
|
||||
|
||||
# ULID: 48位时间戳 + 80位随机数, Crockford Base32
|
||||
alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
ids = []
|
||||
for _ in range(count):
|
||||
ts = int(time.time() * 1000)
|
||||
# 时间部分 (10 chars)
|
||||
t_chars = []
|
||||
t = ts
|
||||
for _ in range(10):
|
||||
t_chars.append(alphabet[t & 0x1F])
|
||||
t >>= 5
|
||||
t_chars.reverse()
|
||||
# 随机部分 (16 chars)
|
||||
r_chars = [random.choice(alphabet) for _ in range(16)]
|
||||
ids.append(''.join(t_chars) + ''.join(r_chars))
|
||||
|
||||
header = f" ULID ({count} 个):\n"
|
||||
if count <= 20:
|
||||
return header + "\n".join(f" {i}" for i in ids)
|
||||
else:
|
||||
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
|
||||
|
||||
def _op_timestamp(self, args: List[str]) -> str:
|
||||
now = time.time()
|
||||
return (f"⏱ 当前时间戳:\n"
|
||||
f" 秒: {int(now)}\n"
|
||||
f" 毫秒: {int(now * 1000)}\n"
|
||||
f" 微秒: {int(now * 1000000)}\n"
|
||||
f" ISO: {datetime.now().isoformat()}")
|
||||
|
||||
def _op_from_name(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: from_name <命名空间> <名称>\n命名空间: dns, url, oid, x500"
|
||||
ns_map = {"dns": uuid.NAMESPACE_DNS, "url": uuid.NAMESPACE_URL,
|
||||
"oid": uuid.NAMESPACE_OID, "x500": uuid.NAMESPACE_X500}
|
||||
ns = ns_map.get(args[0].lower(), uuid.NAMESPACE_DNS)
|
||||
name = args[1]
|
||||
result = uuid.uuid5(ns, name)
|
||||
return f" 确定性 UUID v5:\n 命名空间: {args[0]}\n 名称: {name}\n UUID: {result}"
|
||||
|
||||
def _op_batch(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供类型 (uuid/nanoid/short/snowflake/ulid)"
|
||||
id_type = args[0]
|
||||
opts = self._parse_opts(args[1:])
|
||||
count = int(opts.get("n", 10))
|
||||
output = opts.get("o", "")
|
||||
|
||||
# 生成对应类型的ID
|
||||
gen_args = [f"-n", str(count)]
|
||||
handlers = {
|
||||
"uuid": self._op_uuid,
|
||||
"nanoid": self._op_nanoid,
|
||||
"short": self._op_short,
|
||||
"snowflake": self._op_snowflake,
|
||||
"ulid": self._op_ulid,
|
||||
}
|
||||
handler = handlers.get(id_type)
|
||||
if not handler:
|
||||
return f"错误:不支持的类型 '{id_type}'"
|
||||
|
||||
result = handler(gen_args)
|
||||
|
||||
if output:
|
||||
# 提取纯ID保存到文件
|
||||
ids = [line.strip().lstrip(' ') for line in result.split('\n')
|
||||
if line.strip() and not any(c in line for c in [' ', '️', '❄️'])]
|
||||
try:
|
||||
with open(output, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(ids))
|
||||
return f"{result}\n\n 已保存到: {output}"
|
||||
except Exception as e:
|
||||
return f"{result}\n\n 保存失败: {e}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("ID 生成器测试:")
|
||||
print(plugin.handle('uuid'))
|
||||
print(plugin.handle('uuid -v 1 -n 3'))
|
||||
print(plugin.handle('nanoid -s 16'))
|
||||
print(plugin.handle('short -s 6 -n 5'))
|
||||
print(plugin.handle('snowflake -n 3'))
|
||||
print(plugin.handle('ulid'))
|
||||
print(plugin.handle('timestamp'))
|
||||
print(plugin.handle('custom "ORD-{D}{D}{D}{D}-{U}{U}{U}" -n 5'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
图片读取插件 — 从图片中提取文字,发送给 AI
|
||||
=============================================
|
||||
功能:读取图片 → OCR 提取文字 → 返回给 AI
|
||||
支持:Tesseract OCR / Windows PowerShell OCR / 纯 Python 元数据
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
import subprocess
|
||||
import platform
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_IS_WIN = platform.system() == "Windows"
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""图片读取 — 从图片中提取文字"""
|
||||
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "image_reader",
|
||||
"description": "图片读取工具 — 从图片中提取文字(OCR),获取图片信息,"
|
||||
"将图片内容转为文本发送给 AI",
|
||||
"keywords": [
|
||||
"图片", "image", "OCR", "文字识别", "读图", "截图",
|
||||
"提取文字", "图片转文字", "识别", "tesseract",
|
||||
],
|
||||
"usage": (
|
||||
"image_reader <操作> <路径>\n\n"
|
||||
"操作:\n"
|
||||
" ocr <图片路径> - 从图片中提取文字\n"
|
||||
" info <图片路径> - 获取图片信息(尺寸/格式/大小)\n"
|
||||
" read <图片路径> - 综合读取(信息 + OCR)\n"
|
||||
" ascii <图片路径> [宽] - 转 ASCII 字符画(终端显示)\n"
|
||||
" base64 <图片路径> - 转 Base64 编码\n"
|
||||
" hash <图片路径> - 计算图片哈希值\n\n"
|
||||
"示例:\n"
|
||||
" image_reader ocr screenshot.png - 识别截图中的文字\n"
|
||||
" image_reader read photo.jpg - 综合读取图片\n"
|
||||
" image_reader ascii logo.png 40 - 转40列宽ASCII画\n"
|
||||
),
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "image_reader",
|
||||
"description": "图片读取工具 — 从图片中提取文字(OCR)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["ocr", "info", "read", "ascii", "base64", "hash"],
|
||||
"description": "操作类型",
|
||||
},
|
||||
"path": {"type": "string", "description": "图片文件路径"},
|
||||
"width": {"type": "integer", "description": "ASCII 画宽度"},
|
||||
},
|
||||
"required": ["action", "path"],
|
||||
},
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
action = arguments.get("action", "")
|
||||
path = arguments.get("path", "")
|
||||
width = arguments.get("width", "")
|
||||
parts = [action, path]
|
||||
if width:
|
||||
parts.append(str(width))
|
||||
return " ".join(parts)
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=2)
|
||||
if not parts:
|
||||
return "请提供操作和图片路径"
|
||||
action = parts[0].lower()
|
||||
path = parts[1] if len(parts) > 1 else ""
|
||||
extra = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
if not path:
|
||||
return "请提供图片路径"
|
||||
|
||||
# 展开 ~ 和环境变量
|
||||
path = os.path.expanduser(os.path.expandvars(path))
|
||||
if not os.path.isfile(path):
|
||||
return f"文件不存在: {path}"
|
||||
|
||||
handlers = {
|
||||
"ocr": self._ocr,
|
||||
"info": self._info,
|
||||
"read": self._read,
|
||||
"ascii": self._ascii,
|
||||
"base64": self._b64,
|
||||
"hash": self._hash,
|
||||
}
|
||||
h = handlers.get(action)
|
||||
if not h:
|
||||
return f"不支持的操作 '{action}'"
|
||||
try:
|
||||
return h(path, extra)
|
||||
except Exception as e:
|
||||
return f"错误: {e}"
|
||||
|
||||
# ── 图片信息 ──
|
||||
|
||||
def _info(self, path: str, _: str) -> str:
|
||||
size = os.path.getsize(path)
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
lines = [
|
||||
f"图片: {os.path.basename(path)}",
|
||||
f"格式: {ext}",
|
||||
f"大小: {self._human_size(size)}",
|
||||
]
|
||||
# 尝试用 PIL 获取尺寸
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(path) as img:
|
||||
lines.append(f"尺寸: {img.width} x {img.height}")
|
||||
lines.append(f"模式: {img.mode}")
|
||||
if hasattr(img, 'info'):
|
||||
dpi = img.info.get('dpi')
|
||||
if dpi:
|
||||
lines.append(f"DPI: {dpi}")
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
lines.append(f"读取详情失败: {e}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── OCR 文字识别 ──
|
||||
|
||||
def _ocr(self, path: str, _: str) -> str:
|
||||
# 方案1: Tesseract
|
||||
result = self._ocr_tesseract(path)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 方案2: Windows PowerShell OCR
|
||||
if _IS_WIN:
|
||||
result = self._ocr_windows(path)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 方案3: 用 PIL 读取图片元数据,至少告诉用户图片是什么
|
||||
return (
|
||||
"未找到 OCR 引擎。请安装以下任一工具:\n"
|
||||
" - Tesseract: https://github.com/tesseract-ocr/tesseract\n"
|
||||
" - Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\n"
|
||||
" - macOS: brew install tesseract\n"
|
||||
" - Windows: 下载 UB-Mannheim installer\n\n"
|
||||
f"图片信息:\n{self._info(path, '')}"
|
||||
)
|
||||
|
||||
def _ocr_tesseract(self, path: str) -> str:
|
||||
"""用 Tesseract OCR 提取文字"""
|
||||
# 检查 tesseract 是否可用
|
||||
try:
|
||||
subprocess.run(["tesseract", "--version"],
|
||||
capture_output=True, timeout=5)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
# 执行 OCR
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 检测语言:中文+英文
|
||||
cmd = ["tesseract", path, tmp_path[:-4], "-l", "chi_sim+eng"]
|
||||
subprocess.run(cmd, capture_output=True, timeout=30)
|
||||
|
||||
if os.path.exists(tmp_path):
|
||||
with open(tmp_path, "r", encoding="utf-8") as f:
|
||||
text = f.read().strip()
|
||||
if text:
|
||||
return f"OCR 识别结果:\n{text}"
|
||||
return "OCR 未识别到文字"
|
||||
except subprocess.TimeoutExpired:
|
||||
return "OCR 超时"
|
||||
except Exception as e:
|
||||
return f"OCR 失败: {e}"
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _ocr_windows(self, path: str) -> str:
|
||||
"""用 Windows 内置 OCR (PowerShell + Windows.Media.Ocr)"""
|
||||
try:
|
||||
ps_script = f'''
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$bitmap = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
|
||||
$ocrEngine = [Windows.Media.Ocr.OcrEngine, Windows.Media.Ocr, ContentType=WindowsRuntime]
|
||||
$ocr = $ocrEngine.TryCreateFromUserProfileLanguages()
|
||||
$file = Get-Item "{path}"
|
||||
$stream = [System.IO.File]::OpenRead($file.FullName)
|
||||
$decoder = [Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream).GetAwaiter().GetResult()
|
||||
$bitmap = $decoder.GetSoftwareBitmapAsync().GetAwaiter().GetResult()
|
||||
$result = $ocr.RecognizeAsync($bitmap).GetAwaiter().GetResult()
|
||||
$result.Text
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["powershell", "-Command", ps_script],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return f"OCR 识别结果:\n{result.stdout.strip()}"
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
# ── 综合读取 ──
|
||||
|
||||
def _read(self, path: str, _: str) -> str:
|
||||
"""信息 + OCR"""
|
||||
info = self._info(path, "")
|
||||
ocr = self._ocr(path, "")
|
||||
return f"{info}\n\n{ocr}"
|
||||
|
||||
# ── ASCII 字符画 ──
|
||||
|
||||
def _ascii(self, path: str, extra: str) -> str:
|
||||
"""将图片转为 ASCII 字符画"""
|
||||
width = int(extra) if extra.strip().isdigit() else 60
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return "需要 Pillow 库: pip install Pillow"
|
||||
|
||||
chars = " .:-=+*#%@"
|
||||
|
||||
with Image.open(path) as img:
|
||||
# 转灰度
|
||||
img = img.convert("L")
|
||||
# 缩放
|
||||
ratio = img.height / img.width
|
||||
new_height = int(width * ratio * 0.5) # 字符高宽比约 2:1
|
||||
img = img.resize((width, new_height))
|
||||
|
||||
lines = []
|
||||
for y in range(new_height):
|
||||
row = ""
|
||||
for x in range(width):
|
||||
pixel = img.getpixel((x, y))
|
||||
idx = pixel * (len(chars) - 1) // 255
|
||||
row += chars[idx]
|
||||
lines.append(row)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ── Base64 编码 ──
|
||||
|
||||
def _b64(self, path: str, _: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
ext = os.path.splitext(path)[1].lower().lstrip(".")
|
||||
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"
|
||||
}.get(ext, "image/png")
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{mime};base64,{b64[:100]}... ({len(b64)} 字符)"
|
||||
|
||||
# ── 哈希 ──
|
||||
|
||||
def _hash(self, path: str, _: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
md5 = hashlib.md5(data).hexdigest()
|
||||
sha = hashlib.sha256(data).hexdigest()
|
||||
return f"MD5: {md5}\nSHA256: {sha}"
|
||||
|
||||
# ── 工具 ──
|
||||
|
||||
@staticmethod
|
||||
def _human_size(size: int) -> str:
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
JSON 工具箱插件 - JSON 格式化、校验、查询、转换
|
||||
提供 JSON 美化、压缩、验证、路径查询、类型转换等功能
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""JSON 工具箱 - 格式化/校验/查询/转换"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """JSON 工具箱
|
||||
|
||||
操作:
|
||||
format <文件或JSON> - 美化 JSON(自动缩进)
|
||||
minify <文件或JSON> - 压缩 JSON(去除空白)
|
||||
validate <文件或JSON> - 校验 JSON 是否合法
|
||||
query <文件或JSON> <路径> - 按路径查询值(如 users[0].name)
|
||||
keys <文件或JSON> - 列出所有键(扁平化)
|
||||
diff <文件1> <文件2> - 比较两个 JSON 的差异
|
||||
to_csv <文件或JSON> - 将 JSON 数组转为 CSV
|
||||
merge <文件1> <文件2> - 合并两个 JSON 对象
|
||||
sample <文件或JSON> [-n 数量] - 从数组中随机采样
|
||||
schema <文件或JSON> - 推断 JSON Schema
|
||||
|
||||
示例:
|
||||
json_toolkit format data.json
|
||||
json_toolkit validate '{"name": "test"}'
|
||||
json_toolkit query config.json "database.host"
|
||||
json_toolkit keys data.json
|
||||
json_toolkit diff a.json b.json
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "json_toolkit",
|
||||
"description": "JSON 工具箱 - 格式化、压缩、校验、路径查询、键提取、差异比较、转换",
|
||||
"keywords": ["json", "格式化", "校验", "查询", "格式", "转换", "美化", "压缩", "validate", "format"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "json_toolkit",
|
||||
"description": "JSON 工具箱,支持格式化、压缩、校验、路径查询、键提取、差异比较",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["format", "minify", "validate", "query", "keys",
|
||||
"diff", "to_csv", "merge", "sample", "schema"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"input": {"type": "string", "description": "JSON 字符串或文件路径"},
|
||||
"path": {"type": "string", "description": "查询路径(query 操作用)"},
|
||||
"input2": {"type": "string", "description": "第二个 JSON 输入(diff/merge 用)"},
|
||||
"count": {"type": "integer", "description": "采样数量(sample 操作用)"}
|
||||
},
|
||||
"required": ["operation", "input"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
inp = arguments.get("input", "")
|
||||
path = arguments.get("path", "")
|
||||
parts = [op, inp]
|
||||
if path:
|
||||
parts.append(path)
|
||||
return " ".join(parts)
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型"
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"format": self._op_format,
|
||||
"minify": self._op_minify,
|
||||
"validate": self._op_validate,
|
||||
"query": self._op_query,
|
||||
"keys": self._op_keys,
|
||||
"diff": self._op_diff,
|
||||
"to_csv": self._op_to_csv,
|
||||
"merge": self._op_merge,
|
||||
"sample": self._op_sample,
|
||||
"schema": self._op_schema,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'\n支持: {', '.join(handlers.keys())}"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"JSON 工具错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _load_json(self, text: str):
|
||||
"""加载 JSON(支持文件路径或 JSON 字符串)"""
|
||||
if os.path.isfile(text):
|
||||
with open(text, 'r', encoding='utf-8') as f:
|
||||
return json.load(f), f"文件: {text}"
|
||||
try:
|
||||
return json.loads(text), "JSON 字符串"
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"无效的 JSON: {e}")
|
||||
|
||||
def _op_format(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
data, source = self._load_json(args[0])
|
||||
formatted = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
return f"✅ 格式化结果 ({source}):\n{formatted}"
|
||||
|
||||
def _op_minify(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
data, source = self._load_json(args[0])
|
||||
minified = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
|
||||
original_len = len(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
saved = original_len - len(minified)
|
||||
return f"✅ 压缩结果 ({source}):\n{minified}\n\n📊 大小: {len(minified)} 字符 (节省 {saved} 字符, {saved/original_len*100:.1f}%)"
|
||||
|
||||
def _op_validate(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
text = args[0]
|
||||
try:
|
||||
if os.path.isfile(text):
|
||||
with open(text, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
source = f"文件: {text}"
|
||||
else:
|
||||
data = json.loads(text)
|
||||
source = "JSON 字符串"
|
||||
|
||||
depth = self._get_depth(data)
|
||||
keys_count = self._count_keys(data)
|
||||
|
||||
result = f"✅ JSON 合法 ({source})\n"
|
||||
result += f" 类型: {type(data).__name__}\n"
|
||||
result += f" 嵌套深度: {depth}\n"
|
||||
result += f" 键总数: {keys_count}"
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
return f"❌ JSON 无效:\n 行 {e.lineno}, 列 {e.colno}: {e.msg}"
|
||||
except Exception as e:
|
||||
return f"❌ 验证失败: {e}"
|
||||
|
||||
def _op_query(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: query <JSON或文件> <路径>\n示例: query data.json users[0].name"
|
||||
data, _ = self._load_json(args[0])
|
||||
path = args[1]
|
||||
|
||||
result = data
|
||||
parts = re.findall(r'\w+|\[\d+\]', path)
|
||||
current_path = ""
|
||||
|
||||
for part in parts:
|
||||
if part.startswith('['):
|
||||
idx = int(part[1:-1])
|
||||
current_path += part
|
||||
if isinstance(result, list) and 0 <= idx < len(result):
|
||||
result = result[idx]
|
||||
else:
|
||||
return f"❌ 路径 '{current_path}' 无效: 索引越界或非数组"
|
||||
else:
|
||||
current_path += f".{part}" if current_path else part
|
||||
if isinstance(result, dict) and part in result:
|
||||
result = result[part]
|
||||
else:
|
||||
return f"❌ 路径 '{current_path}' 无效: 键不存在"
|
||||
|
||||
output = json.dumps(result, indent=2, ensure_ascii=False) if isinstance(result, (dict, list)) else str(result)
|
||||
return f"🔍 查询结果 ({path}):\n{output}"
|
||||
|
||||
def _op_keys(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
data, _ = self._load_json(args[0])
|
||||
|
||||
keys = []
|
||||
self._flatten_keys(data, "", keys)
|
||||
|
||||
result = f"🔑 键列表 (共 {len(keys)} 个):\n"
|
||||
for k in keys:
|
||||
result += f" {k}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _flatten_keys(self, obj, prefix, keys):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
full_key = f"{prefix}.{k}" if prefix else k
|
||||
keys.append(full_key)
|
||||
self._flatten_keys(v, full_key, keys)
|
||||
elif isinstance(obj, list) and obj:
|
||||
self._flatten_keys(obj[0], f"{prefix}[0]", keys)
|
||||
|
||||
def _op_diff(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:请提供两个 JSON 文件或字符串"
|
||||
data1, src1 = self._load_json(args[0])
|
||||
data2, src2 = self._load_json(args[1])
|
||||
|
||||
diffs = []
|
||||
self._compare(data1, data2, "", diffs)
|
||||
|
||||
if not diffs:
|
||||
return "✅ 两个 JSON 完全相同"
|
||||
|
||||
result = f"🔀 差异比较 ({len(diffs)} 处不同):\n"
|
||||
for d in diffs[:50]:
|
||||
result += f" {d}\n"
|
||||
if len(diffs) > 50:
|
||||
result += f" ... 还有 {len(diffs) - 50} 处差异"
|
||||
return result.rstrip()
|
||||
|
||||
def _compare(self, a, b, path, diffs):
|
||||
if type(a) != type(b):
|
||||
diffs.append(f" 类型不同 @ {path or '根'}: {type(a).__name__} vs {type(b).__name__}")
|
||||
return
|
||||
if isinstance(a, dict):
|
||||
all_keys = set(a.keys()) | set(b.keys())
|
||||
for k in sorted(all_keys):
|
||||
p = f"{path}.{k}" if path else k
|
||||
if k not in a:
|
||||
diffs.append(f"➕ 新增 @ {p}: {b[k]}")
|
||||
elif k not in b:
|
||||
diffs.append(f"➖ 删除 @ {p}: {a[k]}")
|
||||
else:
|
||||
self._compare(a[k], b[k], p, diffs)
|
||||
elif isinstance(a, list):
|
||||
if len(a) != len(b):
|
||||
diffs.append(f" 数组长度不同 @ {path}: {len(a)} vs {len(b)}")
|
||||
for i in range(min(len(a), len(b))):
|
||||
self._compare(a[i], b[i], f"{path}[{i}]", diffs)
|
||||
else:
|
||||
if a != b:
|
||||
diffs.append(f" 值不同 @ {path}: {a!r} → {b!r}")
|
||||
|
||||
def _op_to_csv(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
data, _ = self._load_json(args[0])
|
||||
|
||||
if not isinstance(data, list) or not data:
|
||||
return "错误:需要非空 JSON 数组"
|
||||
if not isinstance(data[0], dict):
|
||||
return "错误:数组元素必须是对象"
|
||||
|
||||
headers = list(data[0].keys())
|
||||
lines = [",".join(headers)]
|
||||
for item in data:
|
||||
row = []
|
||||
for h in headers:
|
||||
val = str(item.get(h, ""))
|
||||
if "," in val or '"' in val or "\n" in val:
|
||||
val = '"' + val.replace('"', '""') + '"'
|
||||
row.append(val)
|
||||
lines.append(",".join(row))
|
||||
|
||||
csv_content = "\n".join(lines)
|
||||
return f"📊 CSV 转换结果 ({len(data)} 行):\n{csv_content}"
|
||||
|
||||
def _op_merge(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:请提供两个 JSON 文件或字符串"
|
||||
data1, _ = self._load_json(args[0])
|
||||
data2, _ = self._load_json(args[1])
|
||||
|
||||
if not isinstance(data1, dict) or not isinstance(data2, dict):
|
||||
return "错误:合并仅支持 JSON 对象"
|
||||
|
||||
merged = self._deep_merge(data1, data2)
|
||||
return f"✅ 合并结果:\n{json.dumps(merged, indent=2, ensure_ascii=False)}"
|
||||
|
||||
def _deep_merge(self, a, b):
|
||||
result = a.copy()
|
||||
for k, v in b.items():
|
||||
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
|
||||
result[k] = self._deep_merge(result[k], v)
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
def _op_sample(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
data, _ = self._load_json(args[0])
|
||||
count = 3
|
||||
for i, a in enumerate(args):
|
||||
if a == "-n" and i + 1 < len(args):
|
||||
count = int(args[i + 1])
|
||||
|
||||
if not isinstance(data, list):
|
||||
return "错误:需要 JSON 数组"
|
||||
|
||||
import random
|
||||
sampled = random.sample(data, min(count, len(data)))
|
||||
return f"🎲 随机采样 ({len(sampled)}/{len(data)}):\n{json.dumps(sampled, indent=2, ensure_ascii=False)}"
|
||||
|
||||
def _op_schema(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 文件或字符串"
|
||||
data, _ = self._load_json(args[0])
|
||||
schema = self._infer_schema(data)
|
||||
return f"📋 推断 Schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
|
||||
def _infer_schema(self, obj, depth=0):
|
||||
if depth > 10:
|
||||
return {"type": "any"}
|
||||
if isinstance(obj, dict):
|
||||
props = {}
|
||||
for k, v in obj.items():
|
||||
props[k] = self._infer_schema(v, depth + 1)
|
||||
return {"type": "object", "properties": props}
|
||||
elif isinstance(obj, list):
|
||||
if obj:
|
||||
return {"type": "array", "items": self._infer_schema(obj[0], depth + 1)}
|
||||
return {"type": "array", "items": {}}
|
||||
elif isinstance(obj, bool):
|
||||
return {"type": "boolean"}
|
||||
elif isinstance(obj, int):
|
||||
return {"type": "integer"}
|
||||
elif isinstance(obj, float):
|
||||
return {"type": "number"}
|
||||
else:
|
||||
return {"type": "string"}
|
||||
|
||||
def _get_depth(self, obj, depth=0):
|
||||
if depth > 50:
|
||||
return depth
|
||||
if isinstance(obj, dict):
|
||||
return max((self._get_depth(v, depth + 1) for v in obj.values()), default=depth)
|
||||
elif isinstance(obj, list):
|
||||
return max((self._get_depth(v, depth + 1) for v in obj), default=depth)
|
||||
return depth
|
||||
|
||||
def _count_keys(self, obj):
|
||||
if isinstance(obj, dict):
|
||||
return len(obj) + sum(self._count_keys(v) for v in obj.values())
|
||||
elif isinstance(obj, list):
|
||||
return sum(self._count_keys(v) for v in obj)
|
||||
return 0
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("JSON 工具箱测试:")
|
||||
print(plugin.handle('validate \'{"name": "test", "age": 25}\''))
|
||||
print(plugin.handle('format \'{"name":"test","items":[1,2,3]}\''))
|
||||
print(plugin.handle('keys \'{"database": {"host": "localhost", "port": 3306}}\''))
|
||||
print(plugin.handle('query \'{"users": [{"name": "Alice"}]}\' users[0].name'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
Markdown 工具插件 - Markdown 渲染、目录生成、格式转换
|
||||
支持 TOC 生成、标题提取、表格生成、格式化、统计
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""Markdown 工具 - TOC/渲染/格式转换/统计"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """Markdown 工具
|
||||
|
||||
操作:
|
||||
toc <文件或Markdown> - 生成目录 (Table of Contents)
|
||||
headings <文件或Markdown> - 提取所有标题
|
||||
stats <文件或Markdown> - 统计信息(字数/行数/段落数等)
|
||||
table <数据> - 从 JSON 数组生成 Markdown 表格
|
||||
checklist <文件> - 提取所有待办事项
|
||||
links <文件或Markdown> - 提取所有链接
|
||||
images <文件或Markdown> - 提取所有图片
|
||||
frontmatter <文件> - 提取 YAML frontmatter
|
||||
format <文件或Markdown> - 格式化(标题层级、列表缩进等)
|
||||
wordcount <文件或Markdown> - 详细字数统计
|
||||
outline <文件或Markdown> - 生成大纲视图
|
||||
lint <文件> - Markdown 语法检查
|
||||
|
||||
示例:
|
||||
markdown_tool toc README.md
|
||||
markdown_tool stats article.md
|
||||
markdown_tool table '[{"name":"Alice","age":25},{"name":"Bob","age":30}]'
|
||||
markdown_tool checklist TODO.md
|
||||
markdown_tool links README.md
|
||||
markdown_tool lint README.md
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "markdown_tool",
|
||||
"description": "Markdown 工具 - TOC 生成、标题提取、统计、表格生成、语法检查",
|
||||
"keywords": ["markdown", "md", "目录", "toc", "标题", "表格", "统计", "链接", "检查"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "markdown_tool",
|
||||
"description": "Markdown 工具,支持 TOC、统计、表格生成、链接提取、语法检查",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["toc", "headings", "stats", "table", "checklist",
|
||||
"links", "images", "frontmatter", "format",
|
||||
"wordcount", "outline", "lint"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"input": {"type": "string", "description": "Markdown 文件路径或内容"},
|
||||
"data": {"type": "string", "description": "JSON 数据(table 操作用)"}
|
||||
},
|
||||
"required": ["operation", "input"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
inp = arguments.get("input", "")
|
||||
return f"{op} {inp}".strip()
|
||||
|
||||
def _load_content(self, text: str) -> Tuple[str, str]:
|
||||
"""加载内容(文件路径或文本)"""
|
||||
if os.path.isfile(text):
|
||||
with open(text, 'r', encoding='utf-8') as f:
|
||||
return f.read(), f"文件: {text}"
|
||||
return text, "文本输入"
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型"
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"toc": self._op_toc,
|
||||
"headings": self._op_headings,
|
||||
"stats": self._op_stats,
|
||||
"table": self._op_table,
|
||||
"checklist": self._op_checklist,
|
||||
"links": self._op_links,
|
||||
"images": self._op_images,
|
||||
"frontmatter": self._op_frontmatter,
|
||||
"format": self._op_format,
|
||||
"wordcount": self._op_wordcount,
|
||||
"outline": self._op_outline,
|
||||
"lint": self._op_lint,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"Markdown 工具错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _extract_headings(self, content: str) -> List[Tuple[int, str, str]]:
|
||||
"""提取标题: [(level, text, slug)]"""
|
||||
headings = []
|
||||
in_code = False
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('```'):
|
||||
in_code = not in_code
|
||||
continue
|
||||
if in_code:
|
||||
continue
|
||||
match = re.match(r'^(#{1,6})\s+(.+)', line)
|
||||
if match:
|
||||
level = len(match.group(1))
|
||||
text = match.group(2).strip()
|
||||
# 生成 slug
|
||||
slug = re.sub(r'[^\w\s-]', '', text.lower())
|
||||
slug = re.sub(r'\s+', '-', slug.strip())
|
||||
headings.append((level, text, slug))
|
||||
return headings
|
||||
|
||||
def _op_toc(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
headings = self._extract_headings(content)
|
||||
|
||||
if not headings:
|
||||
return "📭 未找到标题"
|
||||
|
||||
result = f" 目录 ({source}):\n"
|
||||
result += "─" * 40 + "\n"
|
||||
for level, text, slug in headings:
|
||||
indent = " " * (level - 1)
|
||||
result += f"{indent}- [{text}](#{slug})\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_headings(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
headings = self._extract_headings(content)
|
||||
|
||||
if not headings:
|
||||
return "📭 未找到标题"
|
||||
|
||||
result = f" 标题列表 ({len(headings)} 个):\n"
|
||||
for level, text, _ in headings:
|
||||
prefix = "#" * level
|
||||
result += f" {prefix:8s} {text}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_stats(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
|
||||
lines = content.split('\n')
|
||||
total_lines = len(lines)
|
||||
blank_lines = sum(1 for l in lines if not l.strip())
|
||||
code_blocks = content.count('```') // 2
|
||||
headings = len(self._extract_headings(content))
|
||||
links = len(re.findall(r'\[([^\]]+)\]\([^)]+\)', content))
|
||||
images = len(re.findall(r'!\[([^\]]*)\]\([^)]+\)', content))
|
||||
# 中文字符
|
||||
chinese = len(re.findall(r'[\u4e00-\u9fff]', content))
|
||||
# 英文单词
|
||||
english = len(re.findall(r'[a-zA-Z]+', content))
|
||||
|
||||
# 段落数(连续非空行)
|
||||
paragraphs = 0
|
||||
in_para = False
|
||||
for l in lines:
|
||||
if l.strip():
|
||||
if not in_para:
|
||||
paragraphs += 1
|
||||
in_para = True
|
||||
else:
|
||||
in_para = False
|
||||
|
||||
result = f"📊 Markdown 统计 ({source})\n"
|
||||
result += "─" * 35 + "\n"
|
||||
result += f" 总行数: {total_lines}\n"
|
||||
result += f" 空行: {blank_lines}\n"
|
||||
result += f" 段落: {paragraphs}\n"
|
||||
result += f" 代码块: {code_blocks}\n"
|
||||
result += f" 标题: {headings}\n"
|
||||
result += f" 链接: {links}\n"
|
||||
result += f" 图片: {images}\n"
|
||||
result += f" 中文字: {chinese}\n"
|
||||
result += f" 英文词: {english}\n"
|
||||
result += f" 字符数: {len(content)}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_table(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 JSON 数组"
|
||||
data_str = args[0]
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
except json.JSONDecodeError as e:
|
||||
return f"❌ JSON 解析错误: {e}"
|
||||
|
||||
if not isinstance(data, list) or not data:
|
||||
return "❌ 需要非空 JSON 数组"
|
||||
if not isinstance(data[0], dict):
|
||||
return "❌ 数组元素必须是对象"
|
||||
|
||||
headers = list(data[0].keys())
|
||||
# 计算列宽
|
||||
widths = {h: len(h) for h in headers}
|
||||
for row in data:
|
||||
for h in headers:
|
||||
widths[h] = max(widths[h], len(str(row.get(h, ""))))
|
||||
|
||||
# 生成表格
|
||||
header_line = "| " + " | ".join(h.ljust(widths[h]) for h in headers) + " |"
|
||||
sep_line = "| " + " | ".join("-" * widths[h] for h in headers) + " |"
|
||||
rows = []
|
||||
for row in data:
|
||||
rows.append("| " + " | ".join(str(row.get(h, "")).ljust(widths[h]) for h in headers) + " |")
|
||||
|
||||
result = f" Markdown 表格 ({len(data)} 行):\n\n"
|
||||
result += header_line + "\n" + sep_line + "\n" + "\n".join(rows)
|
||||
return result
|
||||
|
||||
def _op_checklist(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件"
|
||||
content, source = self._load_content(args[0])
|
||||
|
||||
todos = []
|
||||
done = []
|
||||
for i, line in enumerate(content.split('\n'), 1):
|
||||
match = re.match(r'^(\s*)[-*]\s*\[([ xX])\]\s*(.+)', line)
|
||||
if match:
|
||||
indent = match.group(1)
|
||||
checked = match.group(2).lower() == 'x'
|
||||
text = match.group(3).strip()
|
||||
if checked:
|
||||
done.append((i, text))
|
||||
else:
|
||||
todos.append((i, text))
|
||||
|
||||
result = f" 待办事项 ({source}):\n"
|
||||
result += "─" * 45 + "\n"
|
||||
result += f" ☑ 已完成: {len(done)} 个\n"
|
||||
result += f" 未完成: {len(todos)} 个\n"
|
||||
result += "─" * 45 + "\n"
|
||||
|
||||
if todos:
|
||||
result += "\n 未完成:\n"
|
||||
for line_no, text in todos[:30]:
|
||||
result += f" L{line_no:4d} {text}\n"
|
||||
if done:
|
||||
result += "\n 已完成:\n"
|
||||
for line_no, text in done[:20]:
|
||||
result += f" L{line_no:4d} {text}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_links(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
|
||||
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', content)
|
||||
if not links:
|
||||
return f"📭 未找到链接 ({source})"
|
||||
|
||||
result = f" 链接列表 ({len(links)} 个, {source}):\n"
|
||||
result += "─" * 50 + "\n"
|
||||
seen = set()
|
||||
for text, url in links:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
result += f" {text[:30]:30s} → {url}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_images(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
|
||||
images = re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', content)
|
||||
if not images:
|
||||
return f"📭 未找到图片 ({source})"
|
||||
|
||||
result = f" ️ 图片列表 ({len(images)} 个, {source}):\n"
|
||||
result += "─" * 50 + "\n"
|
||||
seen = set()
|
||||
for alt, url in images:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
result += f" {alt[:25]:25s} → {url}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_frontmatter(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件"
|
||||
content, source = self._load_content(args[0])
|
||||
|
||||
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not match:
|
||||
return f"📭 未找到 frontmatter ({source})"
|
||||
|
||||
fm = match.group(1)
|
||||
result = f" Frontmatter ({source}):\n"
|
||||
result += "─" * 35 + "\n"
|
||||
result += fm
|
||||
return result
|
||||
|
||||
def _op_format(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
lines = content.split('\n')
|
||||
formatted = []
|
||||
prev_blank = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# 确保标题前后有空行
|
||||
if re.match(r'^#{1,6}\s', stripped):
|
||||
if formatted and not prev_blank:
|
||||
formatted.append('')
|
||||
formatted.append(stripped)
|
||||
prev_blank = False
|
||||
continue
|
||||
# 确保列表项格式一致
|
||||
if re.match(r'^[-*]\s', stripped):
|
||||
formatted.append(re.sub(r'^[-*]\s', '- ', stripped))
|
||||
prev_blank = False
|
||||
continue
|
||||
# 去除多余空行
|
||||
if not stripped:
|
||||
if not prev_blank:
|
||||
formatted.append('')
|
||||
prev_blank = True
|
||||
continue
|
||||
|
||||
prev_blank = False
|
||||
formatted.append(line.rstrip())
|
||||
|
||||
# 确保文件末尾有换行
|
||||
while formatted and not formatted[-1]:
|
||||
formatted.pop()
|
||||
formatted.append('')
|
||||
|
||||
return f"✅ 格式化完成 ({source}):\n" + '\n'.join(formatted)
|
||||
|
||||
def _op_wordcount(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
|
||||
# 移除代码块
|
||||
no_code = re.sub(r'```[\s\S]*?```', '', content)
|
||||
# 移除行内代码
|
||||
no_code = re.sub(r'`[^`]+`', '', no_code)
|
||||
# 移除链接标记
|
||||
no_code = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', no_code)
|
||||
# 移除图片标记
|
||||
no_code = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', no_code)
|
||||
# 移除标题标记
|
||||
no_code = re.sub(r'^#{1,6}\s+', '', no_code, flags=re.MULTILINE)
|
||||
# 移除加粗/斜体标记
|
||||
no_code = re.sub(r'\*{1,2}([^*]+)\*{1,2}', r'\1', no_code)
|
||||
|
||||
chinese = len(re.findall(r'[\u4e00-\u9fff]', no_code))
|
||||
english_words = re.findall(r'[a-zA-Z]+', no_code)
|
||||
english = len(english_words)
|
||||
total_chars = len(no_code.strip())
|
||||
|
||||
# 阅读时间估算(中文 300字/分钟, 英文 200词/分钟)
|
||||
read_time_cn = chinese / 300
|
||||
read_time_en = english / 200
|
||||
read_time = read_time_cn + read_time_en
|
||||
|
||||
result = f" 详细字数统计 ({source}):\n"
|
||||
result += "─" * 35 + "\n"
|
||||
result += f" 中文字数: {chinese}\n"
|
||||
result += f" 英文单词: {english}\n"
|
||||
result += f" 总字符数: {total_chars}\n"
|
||||
result += f" 预计阅读: {read_time:.1f} 分钟\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_outline(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件或内容"
|
||||
content, source = self._load_content(args[0])
|
||||
headings = self._extract_headings(content)
|
||||
|
||||
if not headings:
|
||||
return "📭 未找到标题"
|
||||
|
||||
result = f" 大纲视图 ({source}):\n"
|
||||
result += "═" * 40 + "\n"
|
||||
for level, text, _ in headings:
|
||||
if level == 1:
|
||||
result += f"\n {text}\n"
|
||||
result += "─" * 30 + "\n"
|
||||
elif level == 2:
|
||||
result += f" {text}\n"
|
||||
elif level == 3:
|
||||
result += f" {text}\n"
|
||||
else:
|
||||
result += f" {'·' * (level - 3)} {text}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_lint(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 Markdown 文件"
|
||||
file_path = args[0]
|
||||
if not os.path.isfile(file_path):
|
||||
return f"❌ 文件不存在: {file_path}"
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
|
||||
issues = []
|
||||
in_code = False
|
||||
prev_blank = False
|
||||
heading_levels = []
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
|
||||
# 代码块跟踪
|
||||
if stripped.startswith('```'):
|
||||
in_code = not in_code
|
||||
continue
|
||||
if in_code:
|
||||
continue
|
||||
|
||||
# 检查标题格式
|
||||
heading_match = re.match(r'^(#{1,6})\s+(.+)', line)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
text = heading_match.group(2)
|
||||
# 检查标题前后空行
|
||||
if i > 1 and lines[i - 2].strip() and not lines[i - 2].startswith('#'):
|
||||
issues.append(f" L{i:4d} 标题前缺少空行")
|
||||
# 检查标题层级跳跃
|
||||
if heading_levels and level > heading_levels[-1] + 1:
|
||||
issues.append(f" L{i:4d} 标题层级跳跃 ({heading_levels[-1]} → {level})")
|
||||
heading_levels.append(level)
|
||||
# 检查标题末尾空格
|
||||
if text != text.rstrip():
|
||||
issues.append(f" L{i:4d} 标题末尾有多余空格")
|
||||
else:
|
||||
# 检查行尾空格
|
||||
if line != line.rstrip() and stripped:
|
||||
trailing = len(line) - len(line.rstrip())
|
||||
if trailing >= 2: # 2个以上空格是换行符
|
||||
pass # 这是合法的 Markdown 换行
|
||||
elif trailing > 0:
|
||||
issues.append(f" L{i:4d} 行尾有多余空格")
|
||||
|
||||
# 检查连续空行
|
||||
if not stripped:
|
||||
if prev_blank:
|
||||
issues.append(f" L{i:4d} 连续空行")
|
||||
prev_blank = True
|
||||
else:
|
||||
prev_blank = False
|
||||
|
||||
# 检查列表格式
|
||||
list_match = re.match(r'^(\s*)[-*+]\s', line)
|
||||
if list_match:
|
||||
indent = len(list_match.group(1))
|
||||
if indent % 2 != 0 and indent > 0:
|
||||
issues.append(f" L{i:4d} 列表缩进不是2的倍数")
|
||||
|
||||
# 检查链接格式
|
||||
if re.search(r'\[[^\]]*\]\(\s*\)', line):
|
||||
issues.append(f" L{i:4d} 链接URL为空")
|
||||
|
||||
# 检查重复词
|
||||
words = stripped.lower().split()
|
||||
for j in range(len(words) - 1):
|
||||
if len(words[j]) > 2 and words[j] == words[j + 1]:
|
||||
issues.append(f" L{i:4d} 重复词: '{words[j]}'")
|
||||
|
||||
result = f" ️ Markdown 语法检查 ({file_path}):\n"
|
||||
result += "─" * 45 + "\n"
|
||||
|
||||
if not issues:
|
||||
result += " 未发现问题!\n"
|
||||
else:
|
||||
result += f" 发现 {len(issues)} 个问题:\n\n"
|
||||
for issue in issues[:50]:
|
||||
result += f" {issue}\n"
|
||||
if len(issues) > 50:
|
||||
result += f"\n ... 还有 {len(issues) - 50} 个问题\n"
|
||||
|
||||
return result.rstrip()
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("Markdown 工具测试:")
|
||||
print(plugin.handle('headings "# Hello\\n## World\\n### Sub"'))
|
||||
print(plugin.handle('stats "# Hello\\n\\nThis is a test."'))
|
||||
print(plugin.handle('table \'[{"name":"Alice","age":25},{"name":"Bob","age":30}]\''))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
记忆系统插件 — 包装 xcli_core/memory.py 中的 MemoryManager
|
||||
使 AI 可以通过工具接口读写日记、搜索记忆、管理聊天记录、操作永久性记忆
|
||||
"""
|
||||
import os
|
||||
import 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 xcli_core.memory import MemoryManager
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""持久化记忆系统 — AI 可读写永久记忆、日记、搜索历史对话"""
|
||||
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
self.memory = None
|
||||
self.usage = """持久化记忆系统工具
|
||||
|
||||
操作:
|
||||
diary <内容> - 写当日日记
|
||||
diary_ai <内容> - AI 写观察日记(标记为 AI 观察)
|
||||
remember <内容> - 记录到长期记忆 (MEMORY.md 备忘 section)
|
||||
search <关键词> - 全局搜索(日记+记忆+聊天记录)
|
||||
context - 查看当前注入的记忆上下文
|
||||
diary_list - 列出所有日记文件
|
||||
diary_read <日期> - 读取指定日期的日记(如 2026-04-27)
|
||||
|
||||
永久性记忆 (MEMORY.md) 操作:
|
||||
memory_read - 读取整个 MEMORY.md
|
||||
memory_write <内容> - 追加到 MEMORY.md 备忘 section
|
||||
memory_sections - 列出所有记忆分类(section)
|
||||
memory_read_section <名称> - 读取指定 section 的内容
|
||||
memory_write_section <名称> <内容> - 写入/覆盖指定 section(不存在则新建)
|
||||
memory_delete_section <名称> - 删除指定 section
|
||||
|
||||
聊天记录:
|
||||
chat_list - 列出最近的聊天记录
|
||||
chat_search <关键词> - 在聊天记录中搜索
|
||||
|
||||
JSON 格式示例:
|
||||
{"action": "use_tool", "tool": "memory_plugin", "args": "remember 用户偏好深色主题"}
|
||||
{"action": "use_tool", "tool": "memory_plugin", "args": "memory_write_section 用户偏好 - 喜欢简洁的代码风格\n- 使用 Python 3.10+"}
|
||||
{"action": "use_tool", "tool": "memory_plugin", "args": "memory_read_section 用户偏好"}
|
||||
{"action": "use_tool", "tool": "memory_plugin", "args": "search 定时任务"}
|
||||
"""
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
self.memory = MemoryManager(project_dir)
|
||||
cli.memory_manager = self.memory
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "memory_plugin",
|
||||
"description": "持久化记忆系统 — AI 可读写永久记忆(MEMORY.md)、写日记、搜索历史对话。支持按 section 分类存储和修改记忆。",
|
||||
"keywords": ["记忆", "memory", "日记", "diary", "搜索", "search", "历史", "history",
|
||||
"remember", "聊天记录", "chat", "上下文", "context", "压缩", "compress",
|
||||
"永久", "persistent", "存储", "store", "读取", "read", "修改", "modify"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
if not self.memory:
|
||||
return "错误:记忆系统未初始化"
|
||||
|
||||
if not args or not args.strip():
|
||||
return self._help()
|
||||
|
||||
parts = args.strip().split(None, 1)
|
||||
action = parts[0].lower()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
handlers = {
|
||||
"diary": self._diary,
|
||||
"diary_ai": self._diary_ai,
|
||||
"remember": self._remember,
|
||||
"search": self._search,
|
||||
"context": self._context,
|
||||
"diary_list": self._diary_list,
|
||||
"diary_read": self._diary_read,
|
||||
"chat_list": self._chat_list,
|
||||
"chat_search": self._chat_search,
|
||||
"memory_read": self._memory_read,
|
||||
"memory_write": self._memory_write,
|
||||
"memory_sections": self._memory_sections,
|
||||
"memory_read_section": self._memory_read_section,
|
||||
"memory_write_section": self._memory_write_section,
|
||||
"memory_delete_section": self._memory_delete_section,
|
||||
"help": lambda _: self._help(),
|
||||
}
|
||||
|
||||
handler = handlers.get(action)
|
||||
if not handler:
|
||||
return f"未知操作: {action}\n" + self._help()
|
||||
|
||||
return handler(rest)
|
||||
|
||||
# ── 日记 ──
|
||||
|
||||
def _diary(self, content: str) -> str:
|
||||
if not content:
|
||||
return "错误:请提供日记内容\n用法: diary <内容>"
|
||||
self.memory.write_diary(content)
|
||||
return f"日记已写入 {self.memory._today_file()}"
|
||||
|
||||
def _diary_ai(self, content: str) -> str:
|
||||
if not content:
|
||||
return "错误:请提供 AI 观察内容\n用法: diary_ai <内容>"
|
||||
self.memory.write_ai_diary(content)
|
||||
return "AI 观察日记已写入"
|
||||
|
||||
def _diary_list(self, _: str) -> str:
|
||||
files = self.memory.list_diary_files()
|
||||
if not files:
|
||||
return "还没有日记文件"
|
||||
return "日记文件:\n" + "\n".join(f" {f}" for f in files[:20])
|
||||
|
||||
def _diary_read(self, date_str: str) -> str:
|
||||
if not date_str:
|
||||
return "错误:请提供日期\n用法: diary_read <日期,如 2026-04-27>"
|
||||
content = self.memory.load_diary(date_str)
|
||||
return content if content else f"没有找到 {date_str} 的日记"
|
||||
|
||||
# ── 永久性记忆 (MEMORY.md) ──
|
||||
|
||||
def _remember(self, content: str) -> str:
|
||||
"""快速记录到备忘 section"""
|
||||
if not content:
|
||||
return "错误:请提供要记住的内容\n用法: remember <内容>"
|
||||
self.memory.append_memory("备忘", f"- {content}")
|
||||
return "已记录到 MEMORY.md 长期记忆 (备忘 section)"
|
||||
|
||||
def _memory_read(self, _: str) -> str:
|
||||
content = self.memory.load_memory()
|
||||
return content if content else "MEMORY.md 为空"
|
||||
|
||||
def _memory_write(self, content: str) -> str:
|
||||
"""追加到备忘 section"""
|
||||
if not content:
|
||||
return "错误:请提供内容\n用法: memory_write <内容>"
|
||||
self.memory.append_memory("备忘", f"- {content}")
|
||||
return "已追加到 MEMORY.md (备忘 section)"
|
||||
|
||||
def _memory_sections(self, _: str) -> str:
|
||||
"""列出所有 section"""
|
||||
sections = self.memory.list_memory_sections()
|
||||
if not sections:
|
||||
return "MEMORY.md 为空,没有任何 section"
|
||||
return "MEMORY.md 中的 section:\n" + "\n".join(f" {i+1}. {s}" for i, s in enumerate(sections))
|
||||
|
||||
def _memory_read_section(self, section: str) -> str:
|
||||
"""读取指定 section"""
|
||||
if not section:
|
||||
return "错误:请提供 section 名称\n用法: memory_read_section <名称>"
|
||||
content = self.memory.read_memory_section(section)
|
||||
if not content:
|
||||
return f"section '{section}' 不存在或为空"
|
||||
return f"## {section}\n{content}"
|
||||
|
||||
def _memory_write_section(self, args: str) -> str:
|
||||
"""写入/覆盖指定 section"""
|
||||
if not args:
|
||||
return "错误:请提供 section 名称和内容\n用法: memory_write_section <名称> <内容>"
|
||||
parts = args.split(None, 1)
|
||||
section = parts[0]
|
||||
content = parts[1] if len(parts) > 1 else ""
|
||||
if not content:
|
||||
return "错误:请提供内容\n用法: memory_write_section <名称> <内容>"
|
||||
self.memory.write_memory_section(section, content)
|
||||
return f"已写入 MEMORY.md section '{section}'"
|
||||
|
||||
def _memory_delete_section(self, section: str) -> str:
|
||||
"""删除指定 section"""
|
||||
if not section:
|
||||
return "错误:请提供 section 名称\n用法: memory_delete_section <名称>"
|
||||
if self.memory.delete_memory_section(section):
|
||||
return f"已删除 MEMORY.md section '{section}'"
|
||||
return f"section '{section}' 不存在"
|
||||
|
||||
# ── 搜索 ──
|
||||
|
||||
def _search(self, keyword: str) -> str:
|
||||
if not keyword:
|
||||
return "错误:请提供搜索关键词\n用法: search <关键词>"
|
||||
results = self.memory.search_memory(keyword)
|
||||
if not results:
|
||||
return f"未找到与 '{keyword}' 相关的记忆"
|
||||
lines = [f"搜索 '{keyword}' 的结果 ({len(results)} 条):"]
|
||||
for r in results:
|
||||
source = r.get("source", "")
|
||||
snippet = r.get("snippet", "")
|
||||
role = r.get("role", "")
|
||||
line_num = r.get("line", "")
|
||||
prefix = f"[{role}] " if role else ""
|
||||
line_info = f" (L{line_num})" if line_num else ""
|
||||
lines.append(f" {source}{line_info}: {prefix}{snippet}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _context(self, _: str) -> str:
|
||||
ctx = self.memory.build_memory_context()
|
||||
return ctx if ctx else "当前没有记忆上下文"
|
||||
|
||||
# ── 聊天记录 ──
|
||||
|
||||
def _chat_list(self, _: str) -> str:
|
||||
chats = self.memory.list_chats()
|
||||
if not chats:
|
||||
return "没有保存的聊天记录"
|
||||
lines = ["最近的聊天记录:"]
|
||||
for c in chats:
|
||||
lines.append(f" {c['name']} ({c['time']}, {c['size_kb']}KB)")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _chat_search(self, keyword: str) -> str:
|
||||
if not keyword:
|
||||
return "错误:请提供关键词\n用法: chat_search <关键词>"
|
||||
results = self.memory.search_chats(keyword)
|
||||
if not results:
|
||||
return f"未在聊天记录中找到 '{keyword}'"
|
||||
lines = [f"聊天记录搜索 '{keyword}':"]
|
||||
for r in results:
|
||||
name = r.get("name", r.get("file", ""))
|
||||
lines.append(f" {name} [{r.get('role', '')}]: {r.get('snippet', '')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _help(self) -> str:
|
||||
return """记忆系统用法:
|
||||
|
||||
日记:
|
||||
diary <内容> - 写当日日记
|
||||
diary_ai <内容> - AI 写观察日记
|
||||
diary_list - 列出日记
|
||||
diary_read <日期> - 读取日记
|
||||
|
||||
永久性记忆 (MEMORY.md):
|
||||
remember <内容> - 快速记到备忘
|
||||
memory_read - 读取整个 MEMORY.md
|
||||
memory_write <内容> - 追加到备忘
|
||||
memory_sections - 列出所有 section
|
||||
memory_read_section <名称> - 读取指定 section
|
||||
memory_write_section <名称> <内容> - 写入/覆盖 section
|
||||
memory_delete_section <名称> - 删除 section
|
||||
|
||||
搜索:
|
||||
search <关键词> - 全局搜索
|
||||
chat_search <关键词> - 搜索聊天记录
|
||||
context - 查看记忆上下文"""
|
||||
@@ -0,0 +1,295 @@
|
||||
import requests
|
||||
import time
|
||||
import socket
|
||||
import ssl
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
from colorama import Fore, Style
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""网络工具插件"""
|
||||
|
||||
_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
self.timeout = 10
|
||||
self.max_retries = 2
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
self.cli.register_liugin_command('net', self.command_handler)
|
||||
self.cli.register_liugin_command('ping', lambda a: self.handle(f"ping {a}"))
|
||||
self.cli.register_liugin_command('website', lambda a: self.handle(f"status {a}"))
|
||||
|
||||
def command_handler(self, args):
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return "用法: network_tools <操作> <参数>"
|
||||
return self.handle(f"{parts[0].lower()} {parts[1] if len(parts) > 1 else ''}")
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "network_tools",
|
||||
"description": "网络工具:ping/get/status/headers/ip/dns/ssl/port/post/put/delete",
|
||||
"keywords": ["网络", "ping", "get", "post", "dns", "ssl", "port", "ip", "headers", "status"],
|
||||
"usage": (
|
||||
"network_tools <操作> <参数>\n\n"
|
||||
"读取: get/status/headers <URL>\n"
|
||||
"诊断: ping/dns/ssl <域名>\n"
|
||||
"扫描: port <域名> [端口范围]\n"
|
||||
"请求: post/put/delete <URL> [JSON体]\n"
|
||||
"解析: ip <域名>"
|
||||
),
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "network_tools",
|
||||
"description": "网络工具:读取网页、诊断连接、发送HTTP请求、DNS/SSL/端口扫描",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["ping", "get", "status", "headers", "ip", "dns", "ssl", "port", "post", "put", "delete"],
|
||||
},
|
||||
"target": {"type": "string", "description": "URL、域名或IP"},
|
||||
"extra": {"type": "string", "description": "附加参数:JSON body 或端口范围"}
|
||||
},
|
||||
"required": ["operation", "target"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
target = arguments.get("target", "")
|
||||
extra = arguments.get("extra", "")
|
||||
return f"{op} {target} {extra}".strip()
|
||||
|
||||
def handle(self, args):
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=2)
|
||||
if not parts:
|
||||
return "参数不足"
|
||||
op = parts[0].lower()
|
||||
target = parts[1].strip() if len(parts) > 1 else ""
|
||||
extra = parts[2].strip() if len(parts) > 2 else ""
|
||||
|
||||
if op == "post":
|
||||
return self._api(target, "POST", extra)
|
||||
elif op == "put":
|
||||
return self._api(target, "PUT", extra)
|
||||
elif op == "delete":
|
||||
return self._api(target, "DELETE", extra)
|
||||
elif op == "ping":
|
||||
return self._ping(target)
|
||||
elif op == "get":
|
||||
return self._get(target)
|
||||
elif op == "status":
|
||||
return self._status(target)
|
||||
elif op == "headers":
|
||||
return self._headers(target)
|
||||
elif op == "ip":
|
||||
return self._ip(target)
|
||||
elif op == "dns":
|
||||
return self._dns(target)
|
||||
elif op == "ssl":
|
||||
return self._ssl(target)
|
||||
elif op == "port":
|
||||
return self._port(target, extra)
|
||||
else:
|
||||
return f"不支持: {op}。支持: ping/get/status/headers/ip/dns/ssl/port/post/put/delete"
|
||||
except Exception as e:
|
||||
return f"错误: {e}"
|
||||
|
||||
def _url(self, t):
|
||||
return t if t.startswith(('http://', 'https://')) else 'https://' + t
|
||||
|
||||
def _req(self, url, method='GET', data=None, ct=None):
|
||||
headers = {'User-Agent': self._UA, 'Accept': '*/*'}
|
||||
if ct:
|
||||
headers['Content-Type'] = ct
|
||||
for i in range(self.max_retries + 1):
|
||||
try:
|
||||
r = requests.request(method, url, timeout=self.timeout, headers=headers, allow_redirects=True,
|
||||
json=data if ct == 'application/json' else None,
|
||||
data=data if (ct and ct != 'application/json') else None)
|
||||
if not r.encoding or r.encoding.lower() == 'iso-8859-1':
|
||||
r.encoding = r.apparent_encoding or 'utf-8'
|
||||
return r
|
||||
except requests.exceptions.Timeout:
|
||||
if i < self.max_retries:
|
||||
time.sleep(1)
|
||||
continue
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _ping(self, t):
|
||||
url = self._url(t)
|
||||
start = time.time()
|
||||
r = self._req(url)
|
||||
ms = (time.time() - start) * 1000
|
||||
if not r:
|
||||
return f"Ping {t}: 连接失败"
|
||||
status = "成功" if r.status_code < 400 else f"HTTP {r.status_code}"
|
||||
extra = f"\n 重定向: {r.url}" if r.url != url else ""
|
||||
return f"Ping {t}:\n 状态: {status}\n 延迟: {ms:.0f}ms\n HTTP: {r.status_code}{extra}"
|
||||
|
||||
def _get(self, t):
|
||||
url = self._url(t)
|
||||
r = self._req(url)
|
||||
if not r:
|
||||
return f"GET {t}: 失败"
|
||||
content = r.text
|
||||
return f"GET {url} [{r.status_code}]:\n{content}"
|
||||
|
||||
def _status(self, t):
|
||||
url = self._url(t)
|
||||
r = self._req(url)
|
||||
if not r:
|
||||
return f"STATUS {t}: 失败"
|
||||
return (
|
||||
f"STATUS {url}:\n"
|
||||
f" 状态码: {r.status_code} {r.reason}\n"
|
||||
f" 延迟: {r.elapsed.total_seconds()*1000:.0f}ms\n"
|
||||
f" 大小: {len(r.content)} bytes\n"
|
||||
f" 服务器: {r.headers.get('Server', '?')}\n"
|
||||
f" 类型: {r.headers.get('Content-Type', '?')}\n"
|
||||
f" 最终URL: {r.url}"
|
||||
)
|
||||
|
||||
def _headers(self, t):
|
||||
url = self._url(t)
|
||||
r = self._req(url, method='HEAD')
|
||||
if not r:
|
||||
r = self._req(url)
|
||||
if not r:
|
||||
return f"HEADERS {t}: 失败"
|
||||
lines = [f"HEADERS {url}:"]
|
||||
for k, v in r.headers.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _ip(self, t):
|
||||
if t.startswith(('http://', 'https://')):
|
||||
t = urlparse(t).netloc
|
||||
try:
|
||||
ips = list(set(addr[4][0] for addr in socket.getaddrinfo(t, None)))
|
||||
return f"IP {t}:\n" + "\n".join(f" {ip}" for ip in ips)
|
||||
except socket.gaierror:
|
||||
return f"IP {t}: 解析失败"
|
||||
|
||||
def _dns(self, t):
|
||||
if t.startswith(('http://', 'https://')):
|
||||
t = urlparse(t).netloc
|
||||
lines = [f"DNS {t}:"]
|
||||
try:
|
||||
seen = set()
|
||||
for item in socket.getaddrinfo(t, None):
|
||||
ip = item[4][0]
|
||||
fam = "IPv4" if item[0] == socket.AF_INET else "IPv6"
|
||||
if (ip, fam) not in seen:
|
||||
seen.add((ip, fam))
|
||||
lines.append(f" {fam}: {ip}")
|
||||
except socket.gaierror as e:
|
||||
lines.append(f" 失败: {e}")
|
||||
fqdn = socket.getfqdn(t)
|
||||
if fqdn != t:
|
||||
lines.append(f" FQDN: {fqdn}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _ssl(self, t):
|
||||
if t.startswith(('http://', 'https://')):
|
||||
t = urlparse(t).netloc
|
||||
host = t.split(':')[0]
|
||||
lines = [f"SSL {t}:"]
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
with ctx.wrap_socket(socket.socket(), server_hostname=host) as s:
|
||||
s.settimeout(self.timeout)
|
||||
s.connect((host, 443))
|
||||
cert = s.getpeercert()
|
||||
subj = dict(x[0] for x in cert.get('subject', []))
|
||||
iss = dict(x[0] for x in cert.get('issuer', []))
|
||||
lines.append(f" 主题: {subj.get('commonName', '?')}")
|
||||
lines.append(f" 颁发: {iss.get('organizationName', iss.get('commonName', '?'))}")
|
||||
lines.append(f" 生效: {cert.get('notBefore', '?')}")
|
||||
lines.append(f" 过期: {cert.get('notAfter', '?')}")
|
||||
san = cert.get('subjectAltName', [])
|
||||
if san:
|
||||
doms = [v for _, v in san[:5]]
|
||||
lines.append(f" SAN: {', '.join(doms)}{'...' if len(san) > 5 else ''}")
|
||||
lines.append(f" 状态: 有效")
|
||||
except ssl.SSLCertVerificationError as e:
|
||||
lines.append(f" 状态: 验证失败 - {e}")
|
||||
except socket.timeout:
|
||||
lines.append(f" 状态: 超时")
|
||||
except ConnectionRefusedError:
|
||||
lines.append(f" 状态: 连接被拒绝")
|
||||
except Exception as e:
|
||||
lines.append(f" 状态: {e}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _port(self, t, rng=''):
|
||||
if t.startswith(('http://', 'https://')):
|
||||
t = urlparse(t).netloc.split(':')[0]
|
||||
if '-' in rng:
|
||||
try:
|
||||
s, e = rng.split('-', 1)
|
||||
s, e = max(1, int(s)), min(65535, int(e))
|
||||
except ValueError:
|
||||
s, e = 1, 1024
|
||||
else:
|
||||
s, e = 1, 1024
|
||||
try:
|
||||
ip = socket.gethostbyname(t)
|
||||
except socket.gaierror:
|
||||
return f"PORT {t}: 解析失败"
|
||||
common = {21:'FTP',22:'SSH',23:'Telnet',25:'SMTP',53:'DNS',80:'HTTP',110:'POP3',143:'IMAP',443:'HTTPS',993:'IMAPS',995:'POP3S',3306:'MySQL',3389:'RDP',5432:'PostgreSQL',6379:'Redis',8080:'HTTP-Alt',8443:'HTTPS-Alt',27017:'MongoDB'}
|
||||
open_p = []
|
||||
for p in range(s, e + 1):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(0.3)
|
||||
if sock.connect_ex((ip, p)) == 0:
|
||||
open_p.append((p, common.get(p, '?')))
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
lines = [f"PORT {t} ({s}-{e}), IP={ip}:"]
|
||||
if open_p:
|
||||
for p, svc in sorted(open_p):
|
||||
lines.append(f" {p}/{svc}")
|
||||
else:
|
||||
lines.append(f" 无开放端口")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _api(self, url, method, body_str):
|
||||
url = self._url(url)
|
||||
data = None
|
||||
ct = None
|
||||
if body_str:
|
||||
try:
|
||||
data = json.loads(body_str)
|
||||
ct = 'application/json'
|
||||
except json.JSONDecodeError:
|
||||
data = body_str
|
||||
ct = 'text/plain'
|
||||
r = self._req(url, method=method, data=data, ct=ct)
|
||||
if not r:
|
||||
return f"{method} {url}: 失败"
|
||||
lines = [f"{method} {url} [{r.status_code}]"]
|
||||
ctype = r.headers.get('Content-Type', '')
|
||||
if 'json' in ctype:
|
||||
try:
|
||||
body = json.dumps(r.json(), ensure_ascii=False, indent=2)
|
||||
lines.append(body)
|
||||
except Exception:
|
||||
lines.append(r.text)
|
||||
else:
|
||||
lines.append(r.text)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
正则表达式测试器插件 - 测试、调试、管理正则表达式
|
||||
支持匹配测试、提取、替换、常用模式库
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""正则表达式测试器 - 测试、调试、管理正则"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """正则表达式测试器
|
||||
|
||||
操作:
|
||||
test <正则> <文本> [-f flags] - 测试正则是否匹配
|
||||
match <正则> <文本> [-f flags] - 提取所有匹配项
|
||||
replace <正则> <文本> <替换> - 测试替换
|
||||
groups <正则> <文本> - 显示分组信息
|
||||
explain <正则> - 解释正则含义
|
||||
flags <正则> <文本> - 测试不同标志组合
|
||||
pattern [名称] - 列出/查看常用模式
|
||||
save <名称> <正则> [-d 描述] - 保存自定义模式
|
||||
delete_pattern <名称> - 删除自定义模式
|
||||
bench <正则> <文本> [-n 次数] - 性能测试
|
||||
|
||||
标志 (flags):
|
||||
i - 忽略大小写 m - 多行模式 s - 点匹配换行
|
||||
x - 详细模式 a - ASCII模式
|
||||
|
||||
示例:
|
||||
regex_tester test "\\d+" "abc123def456"
|
||||
regex_tester match "[a-z]+@[a-z]+\\.[a-z]+" "test@example.com foo@bar.org"
|
||||
regex_tester groups "(\\w+)@(\\w+)\\.(\\w+)" "user@domain.com"
|
||||
regex_tester explain "[a-z]+@\\w+\\.\\w+"
|
||||
regex_tester pattern email
|
||||
"""
|
||||
self.cli = None
|
||||
self.patterns_file = "regex_patterns.json"
|
||||
self.custom_patterns = self._load_patterns()
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "regex_tester",
|
||||
"description": "正则表达式测试器 - 测试匹配、提取、替换、解释、常用模式库",
|
||||
"keywords": ["正则", "regex", "匹配", "表达式", "测试", "替换", "提取", "模式"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "regex_tester",
|
||||
"description": "正则表达式测试器,支持匹配测试、提取、替换、解释、性能测试",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["test", "match", "replace", "groups", "explain",
|
||||
"flags", "pattern", "save", "bench"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"pattern": {"type": "string", "description": "正则表达式"},
|
||||
"text": {"type": "string", "description": "测试文本"},
|
||||
"replacement": {"type": "string", "description": "替换文本"},
|
||||
"flags": {"type": "string", "description": "标志 (i/m/s/x)"},
|
||||
"name": {"type": "string", "description": "模式名称"},
|
||||
"description": {"type": "string", "description": "模式描述"},
|
||||
"count": {"type": "integer", "description": "测试次数"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
pattern = arguments.get("pattern", "")
|
||||
text = arguments.get("text", "")
|
||||
return f"{op} {pattern} {text}".strip()
|
||||
|
||||
def _load_patterns(self):
|
||||
try:
|
||||
if os.path.exists(self.patterns_file):
|
||||
with open(self.patterns_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return self._default_patterns()
|
||||
|
||||
def _save_patterns(self):
|
||||
try:
|
||||
with open(self.patterns_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.custom_patterns, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _default_patterns(self):
|
||||
return {
|
||||
"email": {"pattern": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "desc": "邮箱地址"},
|
||||
"phone_cn": {"pattern": r"1[3-9]\d{9}", "desc": "中国手机号"},
|
||||
"url": {"pattern": r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", "desc": "URL链接"},
|
||||
"ipv4": {"pattern": r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "desc": "IPv4地址"},
|
||||
"date": {"pattern": r"\d{4}[-/]\d{1,2}[-/]\d{1,2}", "desc": "日期格式"},
|
||||
"time": {"pattern": r"\d{2}:\d{2}(:\d{2})?", "desc": "时间格式"},
|
||||
"chinese": {"pattern": r"[\u4e00-\u9fff]+", "desc": "中文字符"},
|
||||
"id_card": {"pattern": r"\d{17}[\dXx]", "desc": "身份证号"},
|
||||
"html_tag": {"pattern": r"<[^>]+>", "desc": "HTML标签"},
|
||||
"ip_port": {"pattern": r"\b(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}\b", "desc": "IP:端口"},
|
||||
"mac_addr": {"pattern": r"([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}", "desc": "MAC地址"},
|
||||
"hex_color": {"pattern": r"#[0-9A-Fa-f]{3,8}", "desc": "十六进制颜色"},
|
||||
"number": {"pattern": r"-?\d+\.?\d*", "desc": "数字(整数/浮点)"},
|
||||
"word": {"pattern": r"\b\w+\b", "desc": "单词"},
|
||||
}
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型"
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"test": self._op_test,
|
||||
"match": self._op_match,
|
||||
"replace": self._op_replace,
|
||||
"groups": self._op_groups,
|
||||
"explain": self._op_explain,
|
||||
"flags": self._op_flags,
|
||||
"pattern": self._op_pattern,
|
||||
"save": self._op_save,
|
||||
"delete_pattern": self._op_delete_pattern,
|
||||
"bench": self._op_bench,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"正则测试错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _parse_flags(self, flags_str: str) -> int:
|
||||
flags = 0
|
||||
for c in flags_str.lower():
|
||||
if c == 'i': flags |= re.IGNORECASE
|
||||
elif c == 'm': flags |= re.MULTILINE
|
||||
elif c == 's': flags |= re.DOTALL
|
||||
elif c == 'x': flags |= re.VERBOSE
|
||||
return flags
|
||||
|
||||
def _op_test(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: test <正则> <文本> [-f 标志]"
|
||||
pattern_str, text = args[0], args[1]
|
||||
flags = 0
|
||||
for i, a in enumerate(args):
|
||||
if a == "-f" and i + 1 < len(args):
|
||||
flags = self._parse_flags(args[i + 1])
|
||||
|
||||
try:
|
||||
pattern = re.compile(pattern_str, flags)
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
return (f"✅ 匹配成功!\n"
|
||||
f" 正则: {pattern_str}\n"
|
||||
f" 位置: {match.start()}-{match.end()}\n"
|
||||
f" 匹配: '{match.group()}'\n"
|
||||
f" 全文: {pattern.findall(text)}")
|
||||
else:
|
||||
return f"❌ 无匹配\n 正则: {pattern_str}\n 文本: {text[:100]}"
|
||||
except re.error as e:
|
||||
return f"❌ 正则语法错误: {e}"
|
||||
|
||||
def _op_match(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: match <正则> <文本>"
|
||||
pattern_str, text = args[0], args[1]
|
||||
flags = 0
|
||||
for i, a in enumerate(args):
|
||||
if a == "-f" and i + 1 < len(args):
|
||||
flags = self._parse_flags(args[i + 1])
|
||||
|
||||
try:
|
||||
pattern = re.compile(pattern_str, flags)
|
||||
matches = pattern.finditer(text)
|
||||
|
||||
results = []
|
||||
for m in matches:
|
||||
results.append(f" [{m.start():3d}-{m.end():3d}] '{m.group()}'")
|
||||
|
||||
if results:
|
||||
return f" 找到 {len(results)} 个匹配:\n" + "\n".join(results)
|
||||
else:
|
||||
return "❌ 未找到匹配"
|
||||
except re.error as e:
|
||||
return f"❌ 正则语法错误: {e}"
|
||||
|
||||
def _op_replace(self, args: List[str]) -> str:
|
||||
if len(args) < 3:
|
||||
return "错误:格式: replace <正则> <文本> <替换>"
|
||||
pattern_str, text, replacement = args[0], args[1], args[2]
|
||||
|
||||
try:
|
||||
result = re.sub(pattern_str, replacement, text)
|
||||
count = len(re.findall(pattern_str, text))
|
||||
return (f" 替换结果 ({count} 处):\n"
|
||||
f" 原文: {text}\n"
|
||||
f" 结果: {result}")
|
||||
except re.error as e:
|
||||
return f"❌ 正则语法错误: {e}"
|
||||
|
||||
def _op_groups(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: groups <正则> <文本>"
|
||||
pattern_str, text = args[0], args[1]
|
||||
|
||||
try:
|
||||
pattern = re.compile(pattern_str)
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return "❌ 无匹配"
|
||||
|
||||
result = f" 分组信息:\n"
|
||||
result += f" 完整匹配: '{match.group()}' (span: {match.span()})\n"
|
||||
for i, group in enumerate(match.groups(), 1):
|
||||
result += f" 分组 {i}: '{group}' (span: {match.span(i) if group else 'N/A'})\n"
|
||||
if match.groupdict():
|
||||
result += f" 命名分组:\n"
|
||||
for name, value in match.groupdict().items():
|
||||
result += f" {name}: '{value}'\n"
|
||||
return result.rstrip()
|
||||
except re.error as e:
|
||||
return f"❌ 正则语法错误: {e}"
|
||||
|
||||
def _op_explain(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供正则表达式"
|
||||
pattern_str = args[0]
|
||||
|
||||
explanations = []
|
||||
i = 0
|
||||
while i < len(pattern_str):
|
||||
ch = pattern_str[i]
|
||||
|
||||
if ch == '\\':
|
||||
if i + 1 < len(pattern_str):
|
||||
next_ch = pattern_str[i + 1]
|
||||
escape_map = {
|
||||
'd': '数字 [0-9]', 'D': '非数字', 'w': '字母/数字/下划线',
|
||||
'W': '非字母数字下划线', 's': '空白字符', 'S': '非空白字符',
|
||||
'b': '单词边界', 'B': '非单词边界', 'n': '换行', 't': '制表符',
|
||||
}
|
||||
if next_ch in escape_map:
|
||||
explanations.append(('\\' + next_ch, escape_map[next_ch]))
|
||||
else:
|
||||
explanations.append(('\\' + next_ch, f'转义字符 {next_ch}'))
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if ch == '.':
|
||||
explanations.append(('.', '任意字符(除换行)'))
|
||||
elif ch == '^':
|
||||
explanations.append(('^', '行/字符串开头'))
|
||||
elif ch == '$':
|
||||
explanations.append(('$', '行/字符串结尾'))
|
||||
elif ch == '*':
|
||||
explanations.append(('*', '0次或多次'))
|
||||
elif ch == '+':
|
||||
explanations.append(('+', '1次或多次'))
|
||||
elif ch == '?':
|
||||
explanations.append(('?', '0次或1次'))
|
||||
elif ch == '{':
|
||||
end = pattern_str.find('}', i)
|
||||
if end != -1:
|
||||
quantifier = pattern_str[i:end + 1]
|
||||
explanations.append((quantifier, f'量词: {quantifier}'))
|
||||
i = end + 1
|
||||
continue
|
||||
elif ch == '[':
|
||||
end = pattern_str.find(']', i)
|
||||
if end != -1:
|
||||
char_class = pattern_str[i:end + 1]
|
||||
explanations.append((char_class, f'字符类: {char_class}'))
|
||||
i = end + 1
|
||||
continue
|
||||
elif ch == '(':
|
||||
if i + 1 < len(pattern_str) and pattern_str[i + 1] == '?':
|
||||
if i + 2 < len(pattern_str):
|
||||
special = pattern_str[i + 2]
|
||||
if special == ':':
|
||||
explanations.append(('(?:', '非捕获组'))
|
||||
elif special == '=':
|
||||
explanations.append(('(?=', '正向前瞻'))
|
||||
elif special == '!':
|
||||
explanations.append(('(?!', '负向前瞻'))
|
||||
i += 3
|
||||
continue
|
||||
explanations.append(('(', '捕获组开始'))
|
||||
elif ch == ')':
|
||||
explanations.append((')', '组结束'))
|
||||
elif ch == '|':
|
||||
explanations.append(('|', '或 (交替)'))
|
||||
|
||||
i += 1
|
||||
|
||||
result = f" 正则解释: {pattern_str}\n"
|
||||
result += "─" * 45 + "\n"
|
||||
for token, meaning in explanations:
|
||||
result += f" {token:12s} → {meaning}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_flags(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: flags <正则> <文本>"
|
||||
pattern_str, text = args[0], args[1]
|
||||
|
||||
flag_combos = [
|
||||
("", 0, "默认"),
|
||||
("i", re.IGNORECASE, "忽略大小写"),
|
||||
("m", re.MULTILINE, "多行模式"),
|
||||
("s", re.DOTALL, "点匹配换行"),
|
||||
("im", re.IGNORECASE | re.MULTILINE, "忽略大小写+多行"),
|
||||
]
|
||||
|
||||
result = f" ️ 标志测试: {pattern_str}\n"
|
||||
result += "─" * 45 + "\n"
|
||||
for flag_str, flag_val, desc in flag_combos:
|
||||
try:
|
||||
matches = re.findall(pattern_str, text, flag_val)
|
||||
count = len(matches) if isinstance(matches, list) else 1
|
||||
result += f" [{flag_str or '-':3s}] {desc:12s} → {count} 个匹配: {matches[:3]}\n"
|
||||
except re.error as e:
|
||||
result += f" [{flag_str or '-':3s}] {desc:12s} → 错误: {e}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_pattern(self, args: List[str]) -> str:
|
||||
if args:
|
||||
name = args[0]
|
||||
if name in self.custom_patterns:
|
||||
p = self.custom_patterns[name]
|
||||
return f" 模式 '{name}':\n 正则: {p['pattern']}\n 描述: {p.get('desc', '')}"
|
||||
return f"❌ 未找到模式: {name}"
|
||||
|
||||
result = " 常用正则模式:\n"
|
||||
result += "─" * 50 + "\n"
|
||||
for name, p in self.custom_patterns.items():
|
||||
result += f" {name:15s} {p['pattern'][:35]:35s} {p.get('desc', '')}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_save(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: save <名称> <正则> [-d 描述]"
|
||||
name, pattern_str = args[0], args[1]
|
||||
desc = ""
|
||||
for i, a in enumerate(args):
|
||||
if a == "-d" and i + 1 < len(args):
|
||||
desc = args[i + 1]
|
||||
|
||||
# 验证正则
|
||||
try:
|
||||
re.compile(pattern_str)
|
||||
except re.error as e:
|
||||
return f"❌ 正则语法错误: {e}"
|
||||
|
||||
self.custom_patterns[name] = {"pattern": pattern_str, "desc": desc}
|
||||
self._save_patterns()
|
||||
return f"✅ 已保存模式 '{name}': {pattern_str}"
|
||||
|
||||
def _op_delete_pattern(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供模式名称"
|
||||
name = args[0]
|
||||
if name in self.custom_patterns:
|
||||
del self.custom_patterns[name]
|
||||
self._save_patterns()
|
||||
return f" ️ 已删除模式: {name}"
|
||||
return f"❌ 未找到模式: {name}"
|
||||
|
||||
def _op_bench(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:格式: bench <正则> <文本> [-n 次数]"
|
||||
pattern_str, text = args[0], args[1]
|
||||
count = 10000
|
||||
for i, a in enumerate(args):
|
||||
if a == "-n" and i + 1 < len(args):
|
||||
count = int(args[i + 1])
|
||||
|
||||
try:
|
||||
import time
|
||||
pattern = re.compile(pattern_str)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(count):
|
||||
pattern.findall(text)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
per_op = elapsed / count * 1_000_000 # 微秒
|
||||
return (f"⏱ 性能测试:\n"
|
||||
f" 正则: {pattern_str}\n"
|
||||
f" 次数: {count}\n"
|
||||
f" 总耗时: {elapsed:.3f}s\n"
|
||||
f" 每次: {per_op:.2f}μs\n"
|
||||
f" 吞吐: {count / elapsed:.0f} ops/s")
|
||||
except re.error as e:
|
||||
return f"❌ 正则语法错误: {e}"
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("正则表达式测试器测试:")
|
||||
print(plugin.handle('test "\\d+" "abc123def456"'))
|
||||
print(plugin.handle('match "[a-z]+" "hello world foo bar"'))
|
||||
print(plugin.handle('groups "(\\w+)@(\\w+)\\.(\\w+)" "user@domain.com"'))
|
||||
print(plugin.handle('explain "\\d{4}-\\d{2}-\\d{2}"'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
定时任务插件 - AI 可设定定时任务,到时自动激活提醒
|
||||
支持一次性和重复性定时任务
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from colorama import Fore, Style
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""定时任务插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """定时任务工具使用方法:
|
||||
|
||||
JSON格式示例:
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "add 30m 提醒我检查代码"} - 30分钟后提醒
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "add 2h 提交代码"} - 2小时后提醒
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "add 14:30 开会"} - 今天14:30提醒
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "add 2026-04-28 10:00 发布v5.2"} - 指定日期时间提醒
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "list"} - 列出所有定时任务
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "delete 1"} - 删除任务ID为1的任务
|
||||
{"action": "use_tool", "tool": "scheduler", "args": "clear"} - 清除所有已完成/已取消的任务
|
||||
|
||||
时间格式说明:
|
||||
- 相对时间: 30s / 30m / 2h / 1d(秒/分/时/天)
|
||||
- 今日时间: HH:MM(如 14:30)
|
||||
- 绝对时间: YYYY-MM-DD HH:MM(如 2026-04-28 10:00)
|
||||
- 重复任务: 每个时间前加 r: 前缀,如 r:30m / r:2h / r:14:30
|
||||
重复任务会自动重新调度,不会自动删除
|
||||
|
||||
功能说明:
|
||||
- add <时间> <描述> - 添加定时任务
|
||||
- list - 列出所有定时任务
|
||||
- delete <任务ID> - 删除指定任务
|
||||
- clear - 清除已完成/已取消的任务
|
||||
|
||||
当定时任务到期时,AI 会自动被激活并收到提醒。"""
|
||||
|
||||
self.cli = None
|
||||
self.tasks_file = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"scheduler_tasks.json"
|
||||
)
|
||||
self.tasks = []
|
||||
self._lock = threading.Lock()
|
||||
self._monitor_thread = None
|
||||
self._monitor_running = False
|
||||
self._load_tasks()
|
||||
|
||||
def set_cli(self, cli):
|
||||
"""设置CLI实例引用"""
|
||||
self.cli = cli
|
||||
self.cli.register_liugin_command('scheduler', self.command_handler)
|
||||
self.cli.register_liugin_command('remind', self.command_handler)
|
||||
# 启动后台监控线程
|
||||
self._start_monitor()
|
||||
|
||||
def command_handler(self, args):
|
||||
"""处理 /scheduler 或 /remind 命令"""
|
||||
return self.handle(args)
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "scheduler",
|
||||
"description": "定时任务工具,AI 可设定定时提醒。支持相对时间(30m/2h)、今日时间(14:30)、绝对时间(2026-04-28 10:00)、重复任务(r:30m)",
|
||||
"keywords": ["定时", "提醒", "scheduler", "remind", "闹钟", "alarm", "schedule", "计划"],
|
||||
"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:
|
||||
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:
|
||||
pass
|
||||
|
||||
# ── 时间解析 ──
|
||||
|
||||
def _parse_time(self, time_str: str) -> tuple:
|
||||
"""
|
||||
解析时间字符串
|
||||
返回: (datetime, is_repeat, repeat_seconds)
|
||||
"""
|
||||
time_str = time_str.strip()
|
||||
now = datetime.now()
|
||||
is_repeat = False
|
||||
repeat_seconds = 0
|
||||
|
||||
# 检查是否是重复任务
|
||||
if time_str.startswith('r:'):
|
||||
is_repeat = True
|
||||
time_str = time_str[2:].strip()
|
||||
|
||||
# 相对时间: 30s / 30m / 2h / 1d
|
||||
if time_str[-1:].lower() in ('s', 'm', 'h', 'd') and time_str[:-1].strip().isdigit():
|
||||
num = int(time_str[:-1].strip())
|
||||
unit = time_str[-1].lower()
|
||||
multipliers = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
|
||||
seconds = num * multipliers[unit]
|
||||
if is_repeat:
|
||||
repeat_seconds = seconds
|
||||
return now + timedelta(seconds=seconds), is_repeat, repeat_seconds
|
||||
|
||||
# 绝对时间: YYYY-MM-DD HH:MM
|
||||
try:
|
||||
dt = datetime.strptime(time_str, "%Y-%m-%d %H:%M")
|
||||
if is_repeat:
|
||||
# 重复任务的绝对时间当作每日重复
|
||||
repeat_seconds = 86400 # 24h
|
||||
return dt, is_repeat, repeat_seconds
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 今日时间: HH:MM
|
||||
try:
|
||||
dt = datetime.strptime(time_str, "%H:%M")
|
||||
target = now.replace(hour=dt.hour, minute=dt.minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
if is_repeat:
|
||||
repeat_seconds = 86400
|
||||
return target, is_repeat, repeat_seconds
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None, False, 0
|
||||
|
||||
# ── 核心操作 ──
|
||||
|
||||
def _add_task(self, time_str: str, message: str) -> str:
|
||||
"""添加定时任务"""
|
||||
target_time, is_repeat, repeat_seconds = self._parse_time(time_str)
|
||||
if target_time is None:
|
||||
return f"无法解析时间: {time_str}。支持格式: 30m / 2h / 14:30 / 2026-04-28 10:00 / r:30m"
|
||||
|
||||
with self._lock:
|
||||
task_id = max([t.get('id', 0) for t in self.tasks], default=0) + 1
|
||||
task = {
|
||||
"id": task_id,
|
||||
"message": message,
|
||||
"trigger_at": target_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"repeat": is_repeat,
|
||||
"repeat_seconds": repeat_seconds,
|
||||
"status": "pending", # pending / fired / canceled
|
||||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
self.tasks.append(task)
|
||||
self._save_tasks()
|
||||
|
||||
time_display = target_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
repeat_info = f" (每{self._format_seconds(repeat_seconds)}重复)" if is_repeat else ""
|
||||
return f"定时任务 #{task_id} 已创建{repeat_info}\n⏰ {time_display} → {message}"
|
||||
|
||||
def _list_tasks(self) -> str:
|
||||
"""列出所有定时任务"""
|
||||
with self._lock:
|
||||
if not self.tasks:
|
||||
return "暂无定时任务"
|
||||
|
||||
lines = []
|
||||
now = datetime.now()
|
||||
for t in self.tasks:
|
||||
tid = t['id']
|
||||
status = t['status']
|
||||
msg = t['message']
|
||||
trigger = t['trigger_at']
|
||||
repeat = t.get('repeat', False)
|
||||
|
||||
# 状态图标
|
||||
if status == 'pending':
|
||||
remaining = self._calc_remaining(trigger, now)
|
||||
icon = "⏰"
|
||||
suffix = f" ({remaining})" if remaining else ""
|
||||
repeat_tag = f" [每{self._format_seconds(t.get('repeat_seconds', 0))}重复]" if repeat else ""
|
||||
lines.append(f" {icon} #{tid}{suffix}{repeat_tag}: {msg}")
|
||||
elif status == 'fired':
|
||||
lines.append(f" ✅ #{tid} (已触发): {msg}")
|
||||
elif status == 'canceled':
|
||||
lines.append(f" ❌ #{tid} (已取消): {msg}")
|
||||
|
||||
return "\n".join(lines) if lines else "暂无定时任务"
|
||||
|
||||
def _delete_task(self, task_id_str: str) -> str:
|
||||
"""删除指定任务"""
|
||||
try:
|
||||
task_id = int(task_id_str)
|
||||
except ValueError:
|
||||
return f"无效的任务ID: {task_id_str}"
|
||||
|
||||
with self._lock:
|
||||
for t in self.tasks:
|
||||
if t['id'] == task_id:
|
||||
t['status'] = 'canceled'
|
||||
self._save_tasks()
|
||||
return f"定时任务 #{task_id} 已取消: {t['message']}"
|
||||
return f"未找到任务 #{task_id}"
|
||||
|
||||
def _clear_tasks(self) -> str:
|
||||
"""清除已完成/已取消的任务"""
|
||||
with self._lock:
|
||||
before = len(self.tasks)
|
||||
self.tasks = [t for t in self.tasks if t['status'] == 'pending']
|
||||
removed = before - len(self.tasks)
|
||||
self._save_tasks()
|
||||
return f"已清除 {removed} 个已结束的任务" if removed else "没有需要清除的任务"
|
||||
|
||||
# ── 后台监控 ──
|
||||
|
||||
def _start_monitor(self):
|
||||
"""启动后台监控线程"""
|
||||
if self._monitor_running:
|
||||
return
|
||||
self._monitor_running = True
|
||||
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
|
||||
def _stop_monitor(self):
|
||||
"""停止后台监控"""
|
||||
self._monitor_running = False
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""后台监控循环 — 每秒检查到期任务"""
|
||||
while self._monitor_running:
|
||||
try:
|
||||
self._check_due_tasks()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
def _check_due_tasks(self):
|
||||
"""检查并触发到期任务"""
|
||||
now = datetime.now()
|
||||
now_str = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with self._lock:
|
||||
for task in self.tasks:
|
||||
if task['status'] != 'pending':
|
||||
continue
|
||||
|
||||
trigger_at = task['trigger_at']
|
||||
if now_str >= trigger_at:
|
||||
# 触发任务
|
||||
self._fire_task(task, now)
|
||||
|
||||
# 保存可能的变更
|
||||
self._save_tasks()
|
||||
|
||||
def _fire_task(self, task: dict, now: datetime):
|
||||
"""触发一个到期任务"""
|
||||
task_id = task['id']
|
||||
message = task['message']
|
||||
is_repeat = task.get('repeat', False)
|
||||
repeat_seconds = task.get('repeat_seconds', 0)
|
||||
|
||||
# 标记为已触发
|
||||
task['status'] = 'fired'
|
||||
task['fired_at'] = now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 重复任务:重新调度
|
||||
if is_repeat and repeat_seconds > 0:
|
||||
next_time = now + timedelta(seconds=repeat_seconds)
|
||||
# 创建新的重复任务
|
||||
new_task = {
|
||||
"id": max([t.get('id', 0) for t in self.tasks], default=0) + 1,
|
||||
"message": message,
|
||||
"trigger_at": next_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"repeat": True,
|
||||
"repeat_seconds": repeat_seconds,
|
||||
"status": "pending",
|
||||
"created_at": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"parent_id": task_id,
|
||||
}
|
||||
self.tasks.append(new_task)
|
||||
|
||||
# 发送系统通知
|
||||
try:
|
||||
from xcli_core.notification import notify_task_complete
|
||||
notify_task_complete(f"定时提醒: {message}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 打印醒目提醒
|
||||
if not is_generating:
|
||||
# CLI 模式
|
||||
print()
|
||||
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}")
|
||||
print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}")
|
||||
print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}")
|
||||
print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}")
|
||||
if is_repeat:
|
||||
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
|
||||
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
|
||||
|
||||
# 自动激活 AI — 将提醒注入对话
|
||||
self._activate_ai(task_id, message)
|
||||
|
||||
def _activate_ai(self, task_id: int, message: str):
|
||||
"""激活 AI 处理定时任务提醒"""
|
||||
if not self.cli:
|
||||
return
|
||||
|
||||
reminder = (
|
||||
f"[系统定时提醒] 定时任务 #{task_id} 已到期。\n"
|
||||
f"任务内容: {message}\n"
|
||||
f"请根据任务内容进行相应的操作或提醒用户。"
|
||||
)
|
||||
|
||||
# CLI 模式:在新线程中调用,避免阻塞监控
|
||||
def _cli_activate():
|
||||
try:
|
||||
# 先给用户一个视觉提示
|
||||
print(f"\n{Fore.GREEN} AI 正在处理定时提醒...{Style.RESET_ALL}")
|
||||
print(f"{Fore.WHITE}> {Style.RESET_ALL}", end='', flush=True)
|
||||
self.cli.process_conversation(reminder)
|
||||
# 重新显示输入提示
|
||||
print(f"\n{Fore.WHITE}> {Style.RESET_ALL}", end='', flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_cli_activate, daemon=True).start()
|
||||
|
||||
# ── 辅助函数 ──
|
||||
|
||||
@staticmethod
|
||||
def _format_seconds(seconds: int) -> str:
|
||||
"""格式化秒数为可读文本"""
|
||||
if seconds <= 0:
|
||||
return ""
|
||||
if seconds < 60:
|
||||
return f"{seconds}秒"
|
||||
if seconds < 3600:
|
||||
return f"{seconds // 60}分钟"
|
||||
if seconds < 86400:
|
||||
h = seconds // 3600
|
||||
m = (seconds % 3600) // 60
|
||||
return f"{h}小时{m}分钟" if m else f"{h}小时"
|
||||
d = seconds // 86400
|
||||
return f"{d}天"
|
||||
|
||||
@staticmethod
|
||||
def _calc_remaining(trigger_str: str, now: datetime) -> str:
|
||||
"""计算剩余时间"""
|
||||
try:
|
||||
trigger = datetime.strptime(trigger_str, "%Y-%m-%d %H:%M:%S")
|
||||
diff = trigger - now
|
||||
if diff.total_seconds() <= 0:
|
||||
return "即将触发"
|
||||
secs = int(diff.total_seconds())
|
||||
if secs < 60:
|
||||
return f"剩余 {secs}秒"
|
||||
if secs < 3600:
|
||||
return f"剩余 {secs // 60}分钟"
|
||||
if secs < 86400:
|
||||
h = secs // 3600
|
||||
m = (secs % 3600) // 60
|
||||
return f"剩余 {h}小时{m}分钟" if m else f"剩余 {h}小时"
|
||||
d = secs // 86400
|
||||
return f"剩余 {d}天"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# ── 插件入口 ──
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
"""处理工具调用"""
|
||||
if not args or not args.strip():
|
||||
return self.usage
|
||||
|
||||
parts = args.strip().split(None, 1)
|
||||
action = parts[0].lower()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if action == 'add':
|
||||
if not rest:
|
||||
return "用法: add <时间> <描述>\n示例: add 30m 检查代码 / add 14:30 开会 / add r:2h 站会"
|
||||
# 分离时间和描述
|
||||
time_parts = rest.split(None, 1)
|
||||
if len(time_parts) < 2:
|
||||
return "请同时提供时间和描述。示例: add 30m 检查代码"
|
||||
return self._add_task(time_parts[0], time_parts[1])
|
||||
|
||||
elif action == 'list':
|
||||
return self._list_tasks()
|
||||
|
||||
elif action in ('delete', 'cancel', 'rm'):
|
||||
if not rest:
|
||||
return "用法: delete <任务ID>"
|
||||
return self._delete_task(rest)
|
||||
|
||||
elif action == 'clear':
|
||||
return self._clear_tasks()
|
||||
|
||||
else:
|
||||
return f"未知操作: {action}\n可用操作: add / list / delete / clear"
|
||||
|
||||
def get_mcp_definition(self):
|
||||
"""返回 MCP 工具定义"""
|
||||
return {
|
||||
"name": "scheduler",
|
||||
"description": "定时任务工具,设定定时提醒。到期时 AI 会自动激活并收到提醒。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["add", "list", "delete", "clear"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "时间: 30m(相对) / 14:30(今日) / 2026-04-28 10:00(绝对) / r:30m(重复)"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "任务描述/提醒内容"
|
||||
},
|
||||
"task_id": {
|
||||
"type": "integer",
|
||||
"description": "任务ID (用于 delete)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
代码片段管理器插件 - 保存、搜索、复用常用代码片段
|
||||
支持分类、标签、搜索、导出,JSON 持久化存储
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""代码片段管理器 - 保存和复用代码片段"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """代码片段管理器
|
||||
|
||||
操作:
|
||||
add <名称> -l <语言> -c "代码" [-t 标签1,标签2] [-d 描述]
|
||||
- 添加代码片段
|
||||
list [-l 语言] [-t 标签] - 列出片段(可按语言/标签过滤)
|
||||
get <名称或ID> - 获取片段详情
|
||||
search <关键词> - 搜索片段(名称/描述/代码/标签)
|
||||
delete <名称或ID> - 删除片段
|
||||
edit <名称或ID> -c "新代码" - 更新片段代码
|
||||
export [文件] - 导出所有片段
|
||||
import <文件> - 导入片段
|
||||
tags - 列出所有标签
|
||||
stats - 统计信息
|
||||
copy <名称或ID> - 复制到剪贴板(如可用)
|
||||
recent [-n 数量] - 最近添加的片段
|
||||
|
||||
示例:
|
||||
snippet_manager add quick_sort -l python -c "def qsort(arr): ..." -t 排序,算法
|
||||
snippet_manager list -l python
|
||||
snippet_manager search 排序
|
||||
snippet_manager get quick_sort
|
||||
"""
|
||||
self.cli = None
|
||||
self.snippets_file = "snippets.json"
|
||||
self.snippets = []
|
||||
self._load_snippets()
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "snippet_manager",
|
||||
"description": "代码片段管理器 - 保存、搜索、复用常用代码片段,支持分类和标签",
|
||||
"keywords": ["代码", "片段", "snippet", "模板", "复用", "保存", "搜索", "代码片段"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "snippet_manager",
|
||||
"description": "代码片段管理器,支持添加、搜索、获取、删除代码片段",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["add", "list", "get", "search", "delete",
|
||||
"edit", "export", "import", "tags", "stats", "recent", "copy"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"name": {"type": "string", "description": "片段名称或ID"},
|
||||
"language": {"type": "string", "description": "编程语言"},
|
||||
"code": {"type": "string", "description": "代码内容"},
|
||||
"tags": {"type": "string", "description": "标签(逗号分隔)"},
|
||||
"description": {"type": "string", "description": "描述"},
|
||||
"keyword": {"type": "string", "description": "搜索关键词"},
|
||||
"file": {"type": "string", "description": "文件路径"},
|
||||
"count": {"type": "integer", "description": "数量"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
name = arguments.get("name", "")
|
||||
parts = [op]
|
||||
if name:
|
||||
parts.append(name)
|
||||
if arguments.get("keyword"):
|
||||
parts.append(arguments["keyword"])
|
||||
if arguments.get("file"):
|
||||
parts.append(arguments["file"])
|
||||
if arguments.get("count"):
|
||||
parts.append(str(arguments["count"]))
|
||||
if arguments.get("language"):
|
||||
parts.extend(["-l", arguments["language"]])
|
||||
if arguments.get("code"):
|
||||
parts.extend(["-c", f'"{arguments["code"]}"'])
|
||||
if arguments.get("tags"):
|
||||
parts.extend(["-t", arguments["tags"]])
|
||||
if arguments.get("description"):
|
||||
parts.extend(["-d", f'"{arguments["description"]}"'])
|
||||
return " ".join(parts)
|
||||
|
||||
def _load_snippets(self):
|
||||
try:
|
||||
if os.path.exists(self.snippets_file):
|
||||
with open(self.snippets_file, 'r', encoding='utf-8') as f:
|
||||
self.snippets = json.load(f)
|
||||
except Exception:
|
||||
self.snippets = []
|
||||
|
||||
def _save_snippets(self):
|
||||
try:
|
||||
with open(self.snippets_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.snippets, f, ensure_ascii=False, indent=2)
|
||||
return None
|
||||
except Exception as e:
|
||||
return f"保存失败: {e}"
|
||||
|
||||
def _gen_id(self, name: str) -> str:
|
||||
return hashlib.md5(f"{name}{time.time()}".encode()).hexdigest()[:8]
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return self._op_list([])
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"add": self._op_add,
|
||||
"list": self._op_list,
|
||||
"get": self._op_get,
|
||||
"search": self._op_search,
|
||||
"delete": self._op_delete,
|
||||
"edit": self._op_edit,
|
||||
"export": self._op_export,
|
||||
"import": self._op_import,
|
||||
"tags": self._op_tags,
|
||||
"stats": self._op_stats,
|
||||
"recent": self._op_recent,
|
||||
"copy": self._op_copy,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"片段管理错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _op_add(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供片段名称"
|
||||
name = args[0]
|
||||
language, code, tags, desc = "", "", [], ""
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "-l" and i + 1 < len(args):
|
||||
language = args[i + 1]; i += 1
|
||||
elif args[i] == "-c" and i + 1 < len(args):
|
||||
code = args[i + 1]; i += 1
|
||||
elif args[i] == "-t" and i + 1 < len(args):
|
||||
tags = [t.strip() for t in args[i + 1].split(",") if t.strip()]; i += 1
|
||||
elif args[i] == "-d" and i + 1 < len(args):
|
||||
desc = args[i + 1]; i += 1
|
||||
i += 1
|
||||
|
||||
if not code:
|
||||
return "错误:请用 -c 提供代码内容"
|
||||
|
||||
# 检查重名
|
||||
for s in self.snippets:
|
||||
if s["name"] == name:
|
||||
return f"错误:片段 '{name}' 已存在,用 edit 更新"
|
||||
|
||||
snippet = {
|
||||
"id": self._gen_id(name),
|
||||
"name": name,
|
||||
"language": language,
|
||||
"code": code,
|
||||
"tags": tags,
|
||||
"description": desc,
|
||||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"usage_count": 0
|
||||
}
|
||||
self.snippets.append(snippet)
|
||||
err = self._save_snippets()
|
||||
if err:
|
||||
return err
|
||||
return f"✅ 已添加片段 '{name}' (ID: {snippet['id']}, 语言: {language or '未指定'})"
|
||||
|
||||
def _op_list(self, args: List[str]) -> str:
|
||||
lang_filter, tag_filter = None, None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "-l" and i + 1 < len(args):
|
||||
lang_filter = args[i + 1].lower(); i += 1
|
||||
elif args[i] == "-t" and i + 1 < len(args):
|
||||
tag_filter = args[i + 1].lower(); i += 1
|
||||
i += 1
|
||||
|
||||
filtered = self.snippets
|
||||
if lang_filter:
|
||||
filtered = [s for s in filtered if s.get("language", "").lower() == lang_filter]
|
||||
if tag_filter:
|
||||
filtered = [s for s in filtered if any(tag_filter in t.lower() for t in s.get("tags", []))]
|
||||
|
||||
if not filtered:
|
||||
return "📭 没有找到代码片段"
|
||||
|
||||
result = f"\n📝 代码片段列表 (共 {len(filtered)} 个)\n"
|
||||
result += "─" * 55 + "\n"
|
||||
for s in filtered:
|
||||
lang = s.get("language", "未知")
|
||||
tags = ", ".join(s.get("tags", [])) if s.get("tags") else ""
|
||||
code_preview = s.get("code", "")[:60].replace("\n", "↵")
|
||||
result += f" [{s['id']}] {s['name']} ({lang})\n"
|
||||
if tags:
|
||||
result += f" ️ {tags}\n"
|
||||
result += f" {code_preview}...\n"
|
||||
result += f" ⏱ {s.get('created_at', '未知')}\n\n"
|
||||
|
||||
result += "─" * 55
|
||||
return result
|
||||
|
||||
def _op_get(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供片段名称或ID"
|
||||
key = args[0]
|
||||
snippet = self._find_snippet(key)
|
||||
if not snippet:
|
||||
return f"❌ 未找到片段: {key}"
|
||||
|
||||
snippet["usage_count"] = snippet.get("usage_count", 0) + 1
|
||||
err = self._save_snippets()
|
||||
|
||||
result = f"\n 片段: {snippet['name']}\n"
|
||||
result += "─" * 50 + "\n"
|
||||
result += f" ID: {snippet['id']}\n"
|
||||
result += f" 语言: {snippet.get('language', '未指定')}\n"
|
||||
if snippet.get("tags"):
|
||||
result += f" ️ 标签: {', '.join(snippet['tags'])}\n"
|
||||
if snippet.get("description"):
|
||||
result += f" 描述: {snippet['description']}\n"
|
||||
result += f" 创建: {snippet.get('created_at', '未知')}\n"
|
||||
result += f" 使用: {snippet.get('usage_count', 0)} 次\n"
|
||||
result += "─" * 50 + "\n"
|
||||
result += f" 代码:\n{snippet['code']}\n"
|
||||
result += "─" * 50
|
||||
return result
|
||||
|
||||
def _op_search(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供搜索关键词"
|
||||
keyword = " ".join(args).lower()
|
||||
|
||||
results = []
|
||||
for s in self.snippets:
|
||||
searchable = f"{s['name']} {s.get('description', '')} {s.get('code', '')} {' '.join(s.get('tags', []))} {s.get('language', '')}".lower()
|
||||
if keyword in searchable:
|
||||
results.append(s)
|
||||
|
||||
if not results:
|
||||
return f" 未找到匹配 '{keyword}' 的片段"
|
||||
|
||||
output = f" 搜索结果 ({len(results)} 个):\n"
|
||||
output += "─" * 50 + "\n"
|
||||
for s in results:
|
||||
lang = s.get("language", "")
|
||||
output += f" [{s['id']}] {s['name']} ({lang}) - {s.get('description', '')[:40]}\n"
|
||||
return output.rstrip()
|
||||
|
||||
def _op_delete(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供片段名称或ID"
|
||||
key = args[0]
|
||||
for i, s in enumerate(self.snippets):
|
||||
if s["name"] == key or s["id"] == key:
|
||||
removed = self.snippets.pop(i)
|
||||
err = self._save_snippets()
|
||||
return f" ️ 已删除片段: {removed['name']}" + (f" (警告: {err})" if err else "")
|
||||
return f"❌ 未找到片段: {key}"
|
||||
|
||||
def _op_edit(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供片段名称或ID"
|
||||
key = args[0]
|
||||
snippet = self._find_snippet(key)
|
||||
if not snippet:
|
||||
return f"❌ 未找到片段: {key}"
|
||||
|
||||
new_code = None
|
||||
new_desc = None
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "-c" and i + 1 < len(args):
|
||||
new_code = args[i + 1]; i += 1
|
||||
elif args[i] == "-d" and i + 1 < len(args):
|
||||
new_desc = args[i + 1]; i += 1
|
||||
i += 1
|
||||
|
||||
if new_code:
|
||||
snippet["code"] = new_code
|
||||
if new_desc:
|
||||
snippet["description"] = new_desc
|
||||
snippet["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if self._save_snippets():
|
||||
return f"✅ 已更新片段: {snippet['name']}"
|
||||
return f"❌ 更新片段失败: 写入磁盘出错"
|
||||
|
||||
def _op_export(self, args: List[str]) -> str:
|
||||
output_file = args[0] if args else "snippets_export.json"
|
||||
try:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.snippets, f, ensure_ascii=False, indent=2)
|
||||
return f"✅ 已导出 {len(self.snippets)} 个片段到: {output_file}"
|
||||
except Exception as e:
|
||||
return f"❌ 导出失败: {e}"
|
||||
|
||||
def _op_import(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供导入文件路径"
|
||||
file_path = args[0]
|
||||
if not os.path.isfile(file_path):
|
||||
return f"❌ 文件不存在: {file_path}"
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
imported = json.load(f)
|
||||
if not isinstance(imported, list):
|
||||
return "❌ 文件格式错误:需要 JSON 数组"
|
||||
|
||||
existing_names = {s["name"] for s in self.snippets}
|
||||
added = 0
|
||||
for s in imported:
|
||||
if isinstance(s, dict) and s.get("name") and s["name"] not in existing_names:
|
||||
s["id"] = self._gen_id(s["name"])
|
||||
self.snippets.append(s)
|
||||
existing_names.add(s["name"])
|
||||
added += 1
|
||||
|
||||
if self._save_snippets():
|
||||
return f"✅ 已导入 {added} 个片段 (跳过 {len(imported) - added} 个重复)"
|
||||
return f"❌ 导入失败: 写入磁盘出错"
|
||||
except Exception as e:
|
||||
return f"❌ 导入失败: {e}"
|
||||
|
||||
def _op_tags(self, args: List[str]) -> str:
|
||||
tag_count = {}
|
||||
for s in self.snippets:
|
||||
for t in s.get("tags", []):
|
||||
tag_count[t] = tag_count.get(t, 0) + 1
|
||||
|
||||
if not tag_count:
|
||||
return " ️ 暂无标签"
|
||||
|
||||
result = " ️ 标签列表:\n"
|
||||
for tag, count in sorted(tag_count.items(), key=lambda x: -x[1]):
|
||||
result += f" {tag} ({count})\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_stats(self, args: List[str]) -> str:
|
||||
total = len(self.snippets)
|
||||
if not total:
|
||||
return "📊 暂无片段"
|
||||
|
||||
lang_count = {}
|
||||
total_usage = 0
|
||||
for s in self.snippets:
|
||||
lang = s.get("language", "未知")
|
||||
lang_count[lang] = lang_count.get(lang, 0) + 1
|
||||
total_usage += s.get("usage_count", 0)
|
||||
|
||||
result = f"📊 片段统计\n"
|
||||
result += "─" * 30 + "\n"
|
||||
result += f" 总数: {total}\n"
|
||||
result += f" 总使用: {total_usage} 次\n"
|
||||
result += f" 语言分布:\n"
|
||||
for lang, count in sorted(lang_count.items(), key=lambda x: -x[1]):
|
||||
result += f" {lang}: {count}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_recent(self, args: List[str]) -> str:
|
||||
count = 5
|
||||
for i, a in enumerate(args):
|
||||
if a == "-n" and i + 1 < len(args):
|
||||
count = int(args[i + 1])
|
||||
|
||||
recent = sorted(self.snippets, key=lambda s: s.get("created_at", ""), reverse=True)[:count]
|
||||
if not recent:
|
||||
return "📭 暂无片段"
|
||||
|
||||
result = f"⏰ 最近 {len(recent)} 个片段:\n"
|
||||
for s in recent:
|
||||
result += f" [{s['id']}] {s['name']} - {s.get('created_at', '')}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_copy(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供片段名称或ID"
|
||||
snippet = self._find_snippet(args[0])
|
||||
if not snippet:
|
||||
return f"❌ 未找到片段: {args[0]}"
|
||||
|
||||
snippet["usage_count"] = snippet.get("usage_count", 0) + 1
|
||||
err = self._save_snippets()
|
||||
if err:
|
||||
return err
|
||||
return f" 代码内容 ({snippet['name']}):\n{snippet['code']}"
|
||||
|
||||
def _find_snippet(self, key: str) -> Optional[dict]:
|
||||
for s in self.snippets:
|
||||
if s["name"] == key or s["id"] == key:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("代码片段管理器测试:")
|
||||
print(plugin.handle('add hello -l python -c "print(\'Hello, World!\')" -t 示例,入门 -d 最简单的Python程序'))
|
||||
print(plugin.handle('add fib -l python -c "def fib(n): return n if n < 2 else fib(n-1)+fib(n-2)" -t 算法,递归'))
|
||||
print(plugin.handle('list'))
|
||||
print(plugin.handle('search python'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -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 = Liugin()
|
||||
print("语音识别插件")
|
||||
print(f"临时目录: {plugin.temp_dir}")
|
||||
print("\n" + plugin.usage)
|
||||
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
子 Agent 插件 - 多 Agent 协作系统
|
||||
主 AI 可以创建子 Agent、下达指令、获取结果
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class SubAgent:
|
||||
"""单个子 Agent 实例"""
|
||||
|
||||
def __init__(self, agent_id: str, name: str, task: str, system_prompt: str):
|
||||
self.id = agent_id
|
||||
self.name = name
|
||||
self.task = task
|
||||
self.status = "idle" # idle / running / done / failed
|
||||
self.system_prompt = system_prompt
|
||||
self.conversation = [] # 独立对话历史
|
||||
self.result = None
|
||||
self.error = None
|
||||
self.created_at = datetime.now().strftime("%H:%M:%S")
|
||||
self.finished_at = None
|
||||
self.steps = 0 # 已执行步数
|
||||
self.max_steps = 50
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"task": self.task,
|
||||
"status": self.status,
|
||||
"steps": self.steps,
|
||||
"created_at": self.created_at,
|
||||
"finished_at": self.finished_at,
|
||||
"has_result": self.result is not None,
|
||||
"has_error": self.error is not None,
|
||||
}
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""子 Agent 系统 - 创建、管理、协作"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """子 Agent 系统 - 多 Agent 协作
|
||||
|
||||
管理:
|
||||
spawn <名称> <任务描述> - 创建子 Agent 并分配任务
|
||||
spawn <名称> <任务> --system <提示词> - 创建并指定系统提示词
|
||||
list - 列出所有子 Agent
|
||||
status <ID> - 查看子 Agent 状态
|
||||
result <ID> - 获取子 Agent 执行结果
|
||||
send <ID> <消息> - 向子 Agent 发送消息
|
||||
kill <ID> - 终止子 Agent
|
||||
clean - 清理已完成的 Agent
|
||||
stats - 统计信息
|
||||
|
||||
消息格式 (AI 调用):
|
||||
{"action": "use_tool", "tool": "sub_agent", "args": "spawn coder 请帮我重构 main.py"}
|
||||
{"action": "use_tool", "tool": "sub_agent", "args": "result agent_xxx"}
|
||||
{"action": "use_tool", "tool": "sub_agent", "args": "send agent_xxx 用户要求加上类型提示"}
|
||||
|
||||
说明:
|
||||
- 子 Agent 独立运行,有自己的对话历史
|
||||
- 子 Agent 可以使用 code_editor、git_tools 等工具
|
||||
- 主 AI 可以同时管理多个子 Agent
|
||||
- 子 Agent 完成后通过 result 获取结果
|
||||
"""
|
||||
self.cli = None
|
||||
self.agents: Dict[str, SubAgent] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "sub_agent",
|
||||
"description": "子 Agent 系统 - 创建子 Agent 执行独立任务,支持并行多任务、结果回收、消息传递",
|
||||
"keywords": ["agent", "子agent", "并行", "任务", "协程", "多agent", "spawn", "sub"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "sub_agent",
|
||||
"description": "子 Agent 多任务协作系统",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {"type": "string", "description": "操作: spawn/list/status/result/send/kill/clean/stats"},
|
||||
"name": {"type": "string", "description": "Agent 名称"},
|
||||
"task": {"type": "string", "description": "任务描述"},
|
||||
"agent_id": {"type": "string", "description": "Agent ID"},
|
||||
"message": {"type": "string", "description": "发送的消息"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
name = arguments.get("name", "")
|
||||
task = arguments.get("task", "")
|
||||
return f"{op} {name} {task}".strip()
|
||||
|
||||
# ── 路由 ──
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型"
|
||||
|
||||
op = parts[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
handlers = {
|
||||
"spawn": self._spawn,
|
||||
"list": self._list,
|
||||
"status": self._status,
|
||||
"result": self._result,
|
||||
"send": self._send,
|
||||
"kill": self._kill,
|
||||
"clean": self._clean,
|
||||
"stats": self._stats,
|
||||
}
|
||||
|
||||
handler = handlers.get(op)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{op}'"
|
||||
return handler(rest)
|
||||
|
||||
except Exception as e:
|
||||
return f"子 Agent 错误: {str(e)}"
|
||||
|
||||
# ── 操作 ──
|
||||
|
||||
def _spawn(self, args: str) -> str:
|
||||
"""创建子 Agent"""
|
||||
# 解析 --system 参数
|
||||
system_prompt = ""
|
||||
if " --system " in args:
|
||||
args, system_prompt = args.split(" --system ", 1)
|
||||
system_prompt = system_prompt.strip()
|
||||
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return "错误:格式: spawn <名称> <任务描述>"
|
||||
|
||||
name = parts[0].strip()
|
||||
task = parts[1].strip()
|
||||
|
||||
# 生成 ID
|
||||
agent_id = f"agent_{name}_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
# 默认系统提示词
|
||||
if not system_prompt:
|
||||
system_prompt = (
|
||||
f"你是子 Agent「{name}」,负责执行以下任务:\n"
|
||||
f"{task}\n\n"
|
||||
"你可以使用 code_editor、git_tools、cmd_executor、file_manager 等工具。\n"
|
||||
"完成任务后,用以下格式报告结果:\n"
|
||||
'{"status": "done", "summary": "简要总结", "details": "详细结果"}\n\n'
|
||||
"请开始执行任务。"
|
||||
)
|
||||
|
||||
agent = SubAgent(agent_id, name, task, system_prompt)
|
||||
with self._lock:
|
||||
self.agents[agent_id] = agent
|
||||
|
||||
# 在后台线程执行
|
||||
thread = threading.Thread(target=self._run_agent, args=(agent,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return (f" 已创建子 Agent\n"
|
||||
f" ID: {agent_id}\n"
|
||||
f" 名称: {name}\n"
|
||||
f" 任务: {task}\n"
|
||||
f" 状态: running")
|
||||
|
||||
def _run_agent(self, agent: SubAgent):
|
||||
"""在后台执行子 Agent"""
|
||||
with agent._lock:
|
||||
agent.status = "running"
|
||||
|
||||
try:
|
||||
engine = None
|
||||
if self.cli and hasattr(self.cli, 'current_engine'):
|
||||
engine = self.cli.current_engine
|
||||
|
||||
if not engine:
|
||||
with agent._lock:
|
||||
agent.status = "failed"
|
||||
agent.error = "无可用的 AI 引擎"
|
||||
agent.finished_at = datetime.now().strftime("%H:%M:%S")
|
||||
return
|
||||
|
||||
# 第一轮:让子 Agent 开始执行任务
|
||||
current_input = agent.task
|
||||
max_steps = agent.max_steps
|
||||
|
||||
while agent.steps < max_steps:
|
||||
with agent._lock:
|
||||
if agent.status != "running":
|
||||
break
|
||||
agent.steps += 1
|
||||
|
||||
# 调用引擎
|
||||
response = engine.generate_response(
|
||||
current_input,
|
||||
system_prompt=agent.system_prompt
|
||||
)
|
||||
|
||||
if not response:
|
||||
with agent._lock:
|
||||
agent.status = "failed"
|
||||
agent.error = "引擎返回空响应"
|
||||
break
|
||||
|
||||
# 记录对话
|
||||
with agent._lock:
|
||||
agent.conversation.append({"role": "user", "content": current_input})
|
||||
agent.conversation.append({"role": "assistant", "content": response})
|
||||
|
||||
# 检查是否包含完成信号
|
||||
if self._is_task_done(response):
|
||||
with agent._lock:
|
||||
agent.status = "done"
|
||||
agent.result = response
|
||||
break
|
||||
|
||||
# 检查是否需要工具调用
|
||||
tool_result = self._try_execute_tool(response)
|
||||
if tool_result:
|
||||
current_input = f"工具执行结果:\n{tool_result}\n\n请继续执行任务。"
|
||||
else:
|
||||
with agent._lock:
|
||||
agent.status = "done"
|
||||
agent.result = response
|
||||
break
|
||||
|
||||
with agent._lock:
|
||||
if agent.status == "running":
|
||||
agent.status = "done"
|
||||
agent.result = "任务执行结束。"
|
||||
agent.finished_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
except Exception as e:
|
||||
with agent._lock:
|
||||
agent.status = "failed"
|
||||
agent.error = str(e)
|
||||
agent.finished_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
def _is_task_done(self, response: str) -> bool:
|
||||
"""检查响应中是否包含完成信号"""
|
||||
done_markers = ['"status": "done"', '"status":"done"',
|
||||
'任务完成', '已完成', '执行完毕']
|
||||
return any(m in response for m in done_markers)
|
||||
|
||||
def _try_execute_tool(self, response: str) -> Optional[str]:
|
||||
"""尝试从响应中提取并执行工具调用"""
|
||||
if not self.cli:
|
||||
return None
|
||||
|
||||
# 查找 JSON 工具调用
|
||||
import re
|
||||
|
||||
# 模式: {"action": "use_tool", "tool": "...", "args": "..."}
|
||||
patterns = [
|
||||
r'\{"action"\s*:\s*"use_tool"\s*,\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*"([^"]*(?:\\.[^"]*)*)"\s*\}',
|
||||
r'\{"action"\s*:\s*"use_tool"\s*,\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, response, re.DOTALL)
|
||||
if match:
|
||||
tool_name = match.group(1)
|
||||
tool_args = match.group(2)
|
||||
|
||||
# 反转义
|
||||
tool_args = tool_args.replace('\\"', '"').replace('\\n', '\n')
|
||||
|
||||
# 查找并执行工具
|
||||
if hasattr(self.cli, 'liugin_manager'):
|
||||
for tool in self.cli.liugin_manager.tools:
|
||||
if tool.get('name') == tool_name:
|
||||
handler = tool.get('handler')
|
||||
if handler:
|
||||
try:
|
||||
result = handler(tool_args)
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"工具执行错误: {e}"
|
||||
break
|
||||
|
||||
# 模式2: 多个 JSON 指令 (数组)
|
||||
array_match = re.search(r'\[\s*\{.*?\}\s*\]', response, re.DOTALL)
|
||||
if array_match:
|
||||
try:
|
||||
commands = json.loads(array_match.group())
|
||||
if isinstance(commands, list):
|
||||
results = []
|
||||
for cmd in commands:
|
||||
if isinstance(cmd, dict) and cmd.get('action') == 'use_tool':
|
||||
tool_name = cmd.get('tool', '')
|
||||
tool_args = cmd.get('args', '')
|
||||
if isinstance(tool_args, dict):
|
||||
tool_args = json.dumps(tool_args, ensure_ascii=False)
|
||||
|
||||
for tool in self.cli.liugin_manager.tools:
|
||||
if tool.get('name') == tool_name:
|
||||
handler = tool.get('handler')
|
||||
if handler:
|
||||
try:
|
||||
r = handler(tool_args)
|
||||
results.append(f"[{tool_name}] {r}")
|
||||
except Exception as e:
|
||||
results.append(f"[{tool_name}] 错误: {e}")
|
||||
break
|
||||
if results:
|
||||
return '\n'.join(results)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _find_agent(self, agent_id: str):
|
||||
"""线程安全地查找 Agent(精确匹配 + 模糊匹配)"""
|
||||
with self._lock:
|
||||
agent = self.agents.get(agent_id)
|
||||
if not agent:
|
||||
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
|
||||
if matches:
|
||||
agent = matches[0]
|
||||
return agent
|
||||
|
||||
def _list(self, args: str) -> str:
|
||||
"""列出所有子 Agent"""
|
||||
with self._lock:
|
||||
if not self.agents:
|
||||
return "没有子 Agent"
|
||||
agents_list = list(self.agents.values())
|
||||
|
||||
result = [f"子 Agent 列表 ({len(agents_list)} 个):\n"]
|
||||
|
||||
# 按状态分组
|
||||
running = [a for a in agents_list if a.status == "running"]
|
||||
done = [a for a in agents_list if a.status == "done"]
|
||||
failed = [a for a in agents_list if a.status == "failed"]
|
||||
|
||||
if running:
|
||||
result.append(f" 运行中 ({len(running)}):")
|
||||
for a in running:
|
||||
result.append(f" {a.id} [{a.name}] 步骤:{a.steps} 任务:{a.task[:40]}")
|
||||
|
||||
if done:
|
||||
result.append(f" 已完成 ({len(done)}):")
|
||||
for a in done:
|
||||
result.append(f" {a.id} [{a.name}] 任务:{a.task[:40]}")
|
||||
|
||||
if failed:
|
||||
result.append(f" 失败 ({len(failed)}):")
|
||||
for a in failed:
|
||||
result.append(f" {a.id} [{a.name}] 错误:{a.error[:40] if a.error else '?'}")
|
||||
|
||||
return '\n'.join(result)
|
||||
|
||||
def _status(self, args: str) -> str:
|
||||
"""查看子 Agent 状态"""
|
||||
agent_id = args.strip()
|
||||
if not agent_id:
|
||||
return "错误:请提供 Agent ID"
|
||||
|
||||
agent = self._find_agent(agent_id)
|
||||
if not agent:
|
||||
return f"错误:未找到 Agent '{agent_id}'"
|
||||
|
||||
lines = [
|
||||
f"Agent: {agent.id}",
|
||||
f" 名称: {agent.name}",
|
||||
f" 任务: {agent.task}",
|
||||
f" 状态: {agent.status}",
|
||||
f" 步骤: {agent.steps}",
|
||||
f" 创建: {agent.created_at}",
|
||||
]
|
||||
if agent.finished_at:
|
||||
lines.append(f" 完成: {agent.finished_at}")
|
||||
if agent.error:
|
||||
lines.append(f" 错误: {agent.error}")
|
||||
lines.append(f" 对话轮次: {len(agent.conversation)}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _result(self, args: str) -> str:
|
||||
"""获取子 Agent 结果"""
|
||||
agent_id = args.strip()
|
||||
if not agent_id:
|
||||
return "错误:请提供 Agent ID"
|
||||
|
||||
agent = self._find_agent(agent_id)
|
||||
if not agent:
|
||||
return f"错误:未找到 Agent '{agent_id}'"
|
||||
|
||||
if agent.status == "running":
|
||||
return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps})"
|
||||
|
||||
if agent.status == "failed":
|
||||
return f"Agent {agent.id} 执行失败: {agent.error}"
|
||||
|
||||
if agent.result:
|
||||
result = agent.result
|
||||
return f"Agent {agent.id} 结果:\n{result}"
|
||||
|
||||
return f"Agent {agent.id} 暂无结果"
|
||||
|
||||
def _send(self, args: str) -> str:
|
||||
"""向子 Agent 发送消息"""
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return "错误:格式: send <ID> <消息>"
|
||||
|
||||
agent_id = parts[0]
|
||||
message = parts[1]
|
||||
|
||||
agent = self._find_agent(agent_id)
|
||||
if not agent:
|
||||
return f"错误:未找到 Agent '{agent_id}'"
|
||||
|
||||
if agent.status != "running":
|
||||
return f"Agent {agent.id} 未在运行 (状态: {agent.status})"
|
||||
|
||||
# 在后台线程处理消息
|
||||
thread = threading.Thread(target=self._process_message, args=(agent, message), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return f" 消息已发送给 {agent.id}"
|
||||
|
||||
def _process_message(self, agent: SubAgent, message: str, depth: int = 0):
|
||||
"""处理发送给子 Agent 的消息"""
|
||||
if depth > 10:
|
||||
return "递归深度超限"
|
||||
try:
|
||||
engine = None
|
||||
if self.cli and hasattr(self.cli, 'current_engine'):
|
||||
engine = self.cli.current_engine
|
||||
|
||||
if not engine:
|
||||
return
|
||||
|
||||
with agent._lock:
|
||||
agent.conversation.append({"role": "user", "content": message})
|
||||
agent.steps += 1
|
||||
|
||||
response = engine.generate_response(
|
||||
message,
|
||||
system_prompt=agent.system_prompt
|
||||
)
|
||||
|
||||
if response:
|
||||
with agent._lock:
|
||||
agent.conversation.append({"role": "assistant", "content": response})
|
||||
|
||||
if self._is_task_done(response):
|
||||
with agent._lock:
|
||||
agent.status = "done"
|
||||
agent.result = response
|
||||
agent.finished_at = datetime.now().strftime("%H:%M:%S")
|
||||
else:
|
||||
tool_result = self._try_execute_tool(response)
|
||||
if tool_result:
|
||||
# 继续执行
|
||||
self._process_message(agent, f"工具结果:\n{tool_result}\n请继续。", depth + 1)
|
||||
|
||||
except Exception as e:
|
||||
with agent._lock:
|
||||
agent.error = str(e)
|
||||
agent.status = "failed"
|
||||
agent.finished_at = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
def _kill(self, args: str) -> str:
|
||||
"""终止子 Agent"""
|
||||
agent_id = args.strip()
|
||||
agent = self._find_agent(agent_id)
|
||||
if not agent:
|
||||
return f"错误:未找到 Agent '{agent_id}'"
|
||||
|
||||
with agent._lock:
|
||||
agent.status = "failed"
|
||||
agent.error = "被主 Agent 终止"
|
||||
agent.finished_at = datetime.now().strftime("%H:%M:%S")
|
||||
return f" 已终止 {agent.id}"
|
||||
|
||||
def _clean(self, args: str) -> str:
|
||||
"""清理已完成的 Agent"""
|
||||
with self._lock:
|
||||
to_remove = [aid for aid, a in self.agents.items()
|
||||
if a.status in ("done", "failed")]
|
||||
for aid in to_remove:
|
||||
del self.agents[aid]
|
||||
|
||||
return f" 已清理 {len(to_remove)} 个已完成的 Agent"
|
||||
|
||||
def _stats(self, args: str) -> str:
|
||||
"""统计信息"""
|
||||
with self._lock:
|
||||
total = len(self.agents)
|
||||
running = sum(1 for a in self.agents.values() if a.status == "running")
|
||||
done = sum(1 for a in self.agents.values() if a.status == "done")
|
||||
failed = sum(1 for a in self.agents.values() if a.status == "failed")
|
||||
total_steps = sum(a.steps for a in self.agents.values())
|
||||
|
||||
return (f" 子 Agent 统计\n"
|
||||
f" 总数: {total}\n"
|
||||
f" 运行中: {running}\n"
|
||||
f" 已完成: {done}\n"
|
||||
f" 失败: {failed}\n"
|
||||
f" 总步数: {total_steps}")
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
任务管理插件 - 提供任务追踪和进度管理功能
|
||||
"""
|
||||
|
||||
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 get_mcp_definition(self):
|
||||
return {
|
||||
"name": "task_manager",
|
||||
"description": "任务管理工具,支持添加、完成、删除任务",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["add", "done", "delete", "list", "clear"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "任务内容(add/done/delete 时使用)"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
task = arguments.get("task", "")
|
||||
return f"{op} {task}".strip()
|
||||
|
||||
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 = Liugin()
|
||||
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()
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
终端模拟器 v2 — AI 的虚拟键盘和屏幕
|
||||
=====================================
|
||||
设计理念:AI 面前有一个终端,它能做的事就是:
|
||||
1. 打字(type)
|
||||
2. 按键(key)
|
||||
3. 看屏幕(read)
|
||||
就这么简单。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import threading
|
||||
import subprocess
|
||||
import platform
|
||||
from collections import deque
|
||||
|
||||
_IS_WIN = platform.system() == "Windows"
|
||||
|
||||
|
||||
class Terminal:
|
||||
"""一个终端 = 一个子进程 + 一个屏幕缓冲"""
|
||||
|
||||
def __init__(self, sid: str, shell: str = "", cwd: str = ""):
|
||||
self.id = sid
|
||||
self.shell = shell or self._default_shell()
|
||||
self.cwd = cwd or os.getcwd()
|
||||
self._proc = None
|
||||
self._screen = deque(maxlen=5000) # 屏幕行
|
||||
self._raw = "" # 未处理原始输出
|
||||
self._lock = threading.Lock()
|
||||
self._start()
|
||||
|
||||
@staticmethod
|
||||
def _default_shell():
|
||||
if _IS_WIN:
|
||||
return os.environ.get("COMSPEC", "cmd.exe")
|
||||
for s in ["/bin/bash", "/bin/sh"]:
|
||||
if os.path.isfile(s):
|
||||
return s
|
||||
return "/bin/sh"
|
||||
|
||||
def _start(self):
|
||||
env = {**os.environ, "TERM": "dumb", "NO_COLOR": "1",
|
||||
"PYTHONUNBUFFERED": "1", "PROMPT_COMMAND": ""}
|
||||
if not _IS_WIN:
|
||||
base = os.path.basename(self.shell)
|
||||
if "bash" in base:
|
||||
env["PS1"] = ""
|
||||
args = [self.shell, "--norc", "--noprofile"]
|
||||
elif "python" in base:
|
||||
args = [self.shell, "-i"]
|
||||
else:
|
||||
args = [self.shell]
|
||||
flags = 0
|
||||
else:
|
||||
args = [self.shell, "/K", "chcp 65001 >nul"]
|
||||
flags = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, bufsize=0, cwd=self.cwd,
|
||||
env=env, creationflags=flags if _IS_WIN else 0,
|
||||
)
|
||||
threading.Thread(target=self._reader, daemon=True).start()
|
||||
time.sleep(0.3)
|
||||
|
||||
def _reader(self):
|
||||
fd = self._proc.stdout.fileno()
|
||||
while self._proc.poll() is None:
|
||||
try:
|
||||
chunk = os.read(fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
text = chunk.decode("utf-8", errors="replace")
|
||||
with self._lock:
|
||||
self._raw += text
|
||||
for line in text.split("\n"):
|
||||
self._screen.append(line)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
|
||||
def alive(self):
|
||||
return self._proc and self._proc.poll() is None
|
||||
|
||||
# ── 核心操作:打字 ──
|
||||
|
||||
def type(self, text: str):
|
||||
"""像人一样打字,每个字符发到 stdin"""
|
||||
if not self.alive():
|
||||
return
|
||||
try:
|
||||
self._proc.stdin.write(text.encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
except (OSError, BrokenPipeError):
|
||||
pass
|
||||
|
||||
# ── 核心操作:按键 ──
|
||||
|
||||
_KEYS = {
|
||||
"enter": "\n", "return": "\n",
|
||||
"tab": "\t",
|
||||
"backspace": "\x7f", "bs": "\x7f",
|
||||
"esc": "\x1b", "escape": "\x1b",
|
||||
"space": " ",
|
||||
"delete": "\x1b[3~", "del": "\x1b[3~",
|
||||
"insert": "\x1b[2~",
|
||||
"up": "\x1b[A", "down": "\x1b[B",
|
||||
"right": "\x1b[C", "left": "\x1b[D",
|
||||
"home": "\x1b[H", "end": "\x1b[F",
|
||||
"pageup": "\x1b[5~", "pagedown": "\x1b[6~",
|
||||
"f1": "\x1bOP", "f2": "\x1bOQ", "f3": "\x1bOR", "f4": "\x1bOS",
|
||||
"f5": "\x1b[15~", "f6": "\x1b[17~", "f7": "\x1b[18~", "f8": "\x1b[19~",
|
||||
"f9": "\x1b[20~", "f10": "\x1b[21~", "f11": "\x1b[23~", "f12": "\x1b[24~",
|
||||
}
|
||||
|
||||
# Ctrl+ 字母映射
|
||||
_CTRL = {chr(i): chr(i - 64) for i in range(65, 91)} # A-Z → \x01-\x1a
|
||||
|
||||
def key(self, name: str):
|
||||
"""
|
||||
按一个键。
|
||||
支持: enter, tab, esc, up, down, left, right,
|
||||
ctrl+c, ctrl+d, ctrl+z, ctrl+l, ctrl+a, ctrl+e,
|
||||
f1-f12, backspace, delete, home, end, space, ...
|
||||
"""
|
||||
if not self.alive():
|
||||
return
|
||||
name = name.lower().strip()
|
||||
|
||||
# Ctrl+ 组合
|
||||
if name.startswith("ctrl+"):
|
||||
letter = name[5:]
|
||||
seq = self._CTRL.get(letter.upper())
|
||||
if seq:
|
||||
self._send_raw(seq)
|
||||
return
|
||||
|
||||
# 普通按键
|
||||
seq = self._KEYS.get(name)
|
||||
if seq:
|
||||
self._send_raw(seq)
|
||||
elif len(name) == 1:
|
||||
# 单个字符直接发
|
||||
self._send_raw(name)
|
||||
|
||||
def combo(self, *keys):
|
||||
"""
|
||||
组合键,如 combo("ctrl", "shift", "t")
|
||||
目前主要支持 ctrl+单字母
|
||||
"""
|
||||
parts = [k.lower() for k in keys]
|
||||
if "ctrl" in parts and len(parts) == 2:
|
||||
other = [k for k in parts if k != "ctrl"][0]
|
||||
self.key(f"ctrl+{other}")
|
||||
elif "alt" in parts:
|
||||
# Alt+key → ESC + key
|
||||
other = [k for k in parts if k != "alt"][0]
|
||||
self._send_raw("\x1b" + other)
|
||||
elif "shift" in parts:
|
||||
other = [k for k in parts if k != "shift"][0]
|
||||
self._send_raw(other.upper() if len(other) == 1 else other)
|
||||
|
||||
def _send_raw(self, data: str):
|
||||
try:
|
||||
self._proc.stdin.write(data.encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
except (OSError, BrokenPipeError):
|
||||
pass
|
||||
|
||||
# ── 核心操作:看屏幕 ──
|
||||
|
||||
def read(self, lines: int = 0) -> str:
|
||||
"""读取屏幕当前内容"""
|
||||
with self._lock:
|
||||
if lines > 0:
|
||||
buf = list(self._screen)[-lines:]
|
||||
else:
|
||||
buf = list(self._screen)
|
||||
return self._clean("\n".join(buf))
|
||||
|
||||
def read_new(self, timeout: float = 1) -> str:
|
||||
"""等待并读取新输出"""
|
||||
with self._lock:
|
||||
self._raw = ""
|
||||
time.sleep(timeout)
|
||||
with self._lock:
|
||||
out = self._raw
|
||||
self._raw = ""
|
||||
return self._clean(out)
|
||||
|
||||
def wait(self, seconds: float = 1):
|
||||
"""等待,让终端输出完"""
|
||||
time.sleep(seconds)
|
||||
|
||||
def _clean(self, text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
||||
text = re.sub(r'\x1b\][^\x07]*\x07', '', text)
|
||||
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)
|
||||
text = text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
text = re.sub(r'\n{4,}', '\n\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
if self._proc and self._proc.poll() is None:
|
||||
self._send_raw("exit\n")
|
||||
time.sleep(0.2)
|
||||
if self._proc.poll() is None:
|
||||
self._proc.terminate()
|
||||
try:
|
||||
self._proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc = None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Liugin 插件
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class Liugin:
|
||||
"""终端模拟器 v2 — AI 的虚拟键盘和屏幕"""
|
||||
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
self._terminals: dict = {}
|
||||
self._current = "main"
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "terminal",
|
||||
"description": "终端模拟器 — AI 的虚拟键盘和屏幕。"
|
||||
"启动终端、打字、按键、看屏幕,像人一样操作。",
|
||||
"keywords": [
|
||||
"终端", "terminal", "shell", "键盘", "按键", "打字",
|
||||
"屏幕", "交互", "REPL", "cmd", "bash", "python",
|
||||
],
|
||||
"usage": (
|
||||
"terminal <操作> [参数]\n\n"
|
||||
" open [shell] - 打开终端,返回初始画面\n"
|
||||
" type <文字> - 打字(发送到终端)\n"
|
||||
" key <按键名> - 按一个键(enter/tab/ctrl+c/方向键...)\n"
|
||||
" read [行数] - 看屏幕内容\n"
|
||||
" last [行数] - 看最近N行(默认20,避免上下文爆炸)\n"
|
||||
" wait [秒] - 等待输出完毕\n"
|
||||
" switch <ID> - 切换终端\n"
|
||||
" list - 列出所有终端\n"
|
||||
" close [ID] - 关闭终端\n\n"
|
||||
"按键: enter tab esc space backspace delete\n"
|
||||
" up down left right home end\n"
|
||||
" ctrl+c ctrl+d ctrl+z ctrl+l ctrl+a ctrl+e\n"
|
||||
" f1-f12\n\n"
|
||||
"示例:\n"
|
||||
" terminal open - 打开 bash\n"
|
||||
" terminal open python3 - 打开 Python\n"
|
||||
" terminal type ls -la - 打字\n"
|
||||
" terminal key enter - 按回车\n"
|
||||
" terminal read - 看屏幕\n"
|
||||
" terminal key ctrl+c - 中断"
|
||||
),
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "terminal",
|
||||
"description": "终端模拟器 — AI 的虚拟键盘和屏幕",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["open", "type", "key", "read", "last", "wait",
|
||||
"switch", "list", "close"],
|
||||
},
|
||||
"text": {"type": "string", "description": "要打的字 / shell路径"},
|
||||
"key": {"type": "string", "description": "按键名"},
|
||||
"last": {"type": "integer", "description": "最近N行"},
|
||||
"seconds": {"type": "number", "description": "等待秒数"},
|
||||
"session": {"type": "string", "description": "终端 ID"},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
a = arguments.get("action", "")
|
||||
parts = [a]
|
||||
for k in ["text", "key", "lines", "seconds", "session"]:
|
||||
v = arguments.get(k)
|
||||
if v is not None:
|
||||
parts.append(str(v))
|
||||
return " ".join(parts)
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return self._help()
|
||||
action = parts[0].lower()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
# 支持 `read 20` 省写
|
||||
if rest.isdigit() and action in ('read', 'last'):
|
||||
action, rest = 'last', rest
|
||||
|
||||
h = {
|
||||
"open": self._h_open, "type": self._h_type,
|
||||
"key": self._h_key, "read": self._h_read,
|
||||
"last": self._h_last, "wait": self._h_wait,
|
||||
"switch": self._h_switch, "list": self._h_list,
|
||||
"close": self._h_close,
|
||||
}.get(action)
|
||||
if not h:
|
||||
return f"不支持 '{action}'\n{self._help()}"
|
||||
try:
|
||||
return h(rest)
|
||||
except Exception as e:
|
||||
return f"终端错误: {e}"
|
||||
|
||||
def _help(self):
|
||||
return (
|
||||
"terminal open [shell] - 打开终端\n"
|
||||
"terminal type <文字> - 打字\n"
|
||||
"terminal key <按键> - 按键\n"
|
||||
"terminal read [行数] - 看屏幕\n"
|
||||
"terminal wait [秒] - 等待\n"
|
||||
"terminal list - 列出终端\n"
|
||||
"terminal close - 关闭"
|
||||
)
|
||||
|
||||
def _get(self, sid: str = "") -> Terminal:
|
||||
sid = sid or self._current
|
||||
if sid not in self._terminals:
|
||||
self._terminals[sid] = Terminal(sid)
|
||||
return self._terminals[sid]
|
||||
|
||||
def _h_open(self, rest: str) -> str:
|
||||
sid = f"s{len(self._terminals) + 1}" if self._terminals else "main"
|
||||
t = Terminal(sid, shell=rest.strip())
|
||||
self._terminals[sid] = t
|
||||
self._current = sid
|
||||
screen = t.read()
|
||||
return f"✓ 终端 {sid} 已打开\n{screen}"
|
||||
|
||||
def _h_type(self, rest: str) -> str:
|
||||
if not rest:
|
||||
return "请提供要打的字"
|
||||
t = self._get()
|
||||
t.type(rest)
|
||||
t.wait(0.3)
|
||||
return f"← 打字: {rest}"
|
||||
|
||||
def _h_key(self, rest: str) -> str:
|
||||
if not rest:
|
||||
return "请提供按键名"
|
||||
t = self._get()
|
||||
t.key(rest)
|
||||
t.wait(0.3)
|
||||
return f"← 按键: {rest}"
|
||||
|
||||
def _h_read(self, rest: str) -> str:
|
||||
t = self._get()
|
||||
return t.read() or "(屏幕为空)"
|
||||
|
||||
def _h_last(self, rest: str) -> str:
|
||||
t = self._get()
|
||||
n = int(rest) if rest.strip().isdigit() else 20
|
||||
return t.read(n) or "(屏幕为空)"
|
||||
|
||||
def _h_wait(self, rest: str) -> str:
|
||||
secs = float(rest) if rest.strip() else 1.0
|
||||
t = self._get()
|
||||
t.wait(secs)
|
||||
return t.read()
|
||||
|
||||
def _h_switch(self, rest: str) -> str:
|
||||
sid = rest.strip()
|
||||
if sid not in self._terminals:
|
||||
return f"终端 '{sid}' 不存在"
|
||||
self._current = sid
|
||||
return f"✓ 切换到 {sid}"
|
||||
|
||||
def _h_list(self, _: str) -> str:
|
||||
if not self._terminals:
|
||||
return "无终端"
|
||||
lines = []
|
||||
for sid, t in self._terminals.items():
|
||||
cur = " ← 当前" if sid == self._current else ""
|
||||
dot = "●" if t.alive() else "○"
|
||||
lines.append(f" {dot} {sid} [{t.shell}]{cur}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _h_close(self, rest: str) -> str:
|
||||
sid = rest.strip() or self._current
|
||||
if sid not in self._terminals:
|
||||
return f"终端 '{sid}' 不存在"
|
||||
self._terminals[sid].close()
|
||||
del self._terminals[sid]
|
||||
if self._current == sid:
|
||||
self._current = next(iter(self._terminals), "main")
|
||||
return f"✓ 终端 {sid} 已关闭"
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
工具搜索助手 - 支持按需发现工具
|
||||
"""
|
||||
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 get_mcp_definition(self):
|
||||
return {
|
||||
"name": "tool_search",
|
||||
"description": "工具搜索助手,搜索可用工具的用法和信息",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "搜索关键词或工具名"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
return arguments.get("query", "")
|
||||
|
||||
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:
|
||||
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']
|
||||
result_lines.append(f" 用法: {usage}")
|
||||
|
||||
result_lines.append("")
|
||||
|
||||
return "\n".join(result_lines)
|
||||
else:
|
||||
return "错误:无法访问工具管理器。"
|
||||
|
||||
except Exception as e:
|
||||
return f"工具搜索失败: {str(e)}"
|
||||
@@ -0,0 +1,808 @@
|
||||
"""
|
||||
工作流插件 - YAML 定义的多步骤自动化流程
|
||||
|
||||
支持:
|
||||
- 顺序执行多步骤
|
||||
- 步骤间变量传递 ({{prev.output}})
|
||||
- 条件分支 (on_fail / on_success)
|
||||
- 错误处理 (continue_on_error)
|
||||
- 状态持久化 (断点续跑)
|
||||
- 调用其他插件作为步骤
|
||||
|
||||
用法:
|
||||
/workflow run <file.yaml>
|
||||
/workflow list
|
||||
/workflow status <workflow_id>
|
||||
/workflow resume <workflow_id>
|
||||
/workflow abort <workflow_id>
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from colorama import Fore, Style
|
||||
|
||||
|
||||
# ── YAML 解析(内置轻量实现,不依赖 PyYAML) ──
|
||||
|
||||
def _parse_yaml(text: str) -> dict:
|
||||
"""轻量 YAML 解析器,支持工作流所需的子集"""
|
||||
import yaml # 延迟导入,失败时回退
|
||||
return yaml.safe_load(text)
|
||||
|
||||
|
||||
def _parse_yaml_fallback(text: str) -> dict:
|
||||
"""内置回退解析器,仅支持简单结构"""
|
||||
result = {}
|
||||
current_key = None
|
||||
current_list = None
|
||||
current_dict = None
|
||||
|
||||
for line in text.split('\n'):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith('#'):
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
if indent == 0 and ':' in stripped:
|
||||
key, _, val = stripped.partition(':')
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
if val:
|
||||
result[key] = _yaml_value(val)
|
||||
else:
|
||||
result[key] = None
|
||||
current_key = key
|
||||
current_list = None
|
||||
current_dict = None
|
||||
elif indent > 0 and stripped.startswith('- '):
|
||||
if current_key:
|
||||
if not isinstance(result.get(current_key), list):
|
||||
result[current_key] = []
|
||||
item_text = stripped[2:].strip()
|
||||
if ':' in item_text:
|
||||
item = {}
|
||||
k, _, v = item_text.partition(':')
|
||||
item[k.strip()] = _yaml_value(v.strip())
|
||||
current_dict = item
|
||||
result[current_key].append(item)
|
||||
else:
|
||||
result[current_key].append(_yaml_value(item_text))
|
||||
elif indent > 0 and ':' in stripped and current_dict is not None:
|
||||
key, _, val = stripped.partition(':')
|
||||
current_dict[key.strip()] = _yaml_value(val.strip())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _yaml_value(s: str):
|
||||
"""转换单个 YAML 值"""
|
||||
if not s:
|
||||
return None
|
||||
# 去除引号
|
||||
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
|
||||
return s[1:-1]
|
||||
if s.lower() in ('true', 'yes', 'on'):
|
||||
return True
|
||||
if s.lower() in ('false', 'no', 'off'):
|
||||
return False
|
||||
if s.lower() in ('null', 'none', '~'):
|
||||
return None
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def _load_yaml_file(path: str) -> dict:
|
||||
"""加载 YAML 文件"""
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
try:
|
||||
return _parse_yaml(text)
|
||||
except ImportError:
|
||||
return _parse_yaml_fallback(text)
|
||||
|
||||
|
||||
# ── 变量替换引擎 ──
|
||||
|
||||
def _resolve_vars(text: str, context: dict) -> str:
|
||||
"""
|
||||
替换 {{key}} 和 {{key.nested}} 形式的变量
|
||||
|
||||
支持:
|
||||
{{var}} → context['var']
|
||||
{{step.output}} → context['steps']['step']['output']
|
||||
{{step.exit_code}} → context['steps']['step']['exit_code']
|
||||
{{env.HOME}} → os.environ['HOME']
|
||||
{{args.name}} → context['args']['name']
|
||||
"""
|
||||
def replacer(match):
|
||||
path = match.group(1).strip()
|
||||
parts = path.split('.')
|
||||
|
||||
if parts[0] == 'env':
|
||||
return os.environ.get('.'.join(parts[1:]), '')
|
||||
|
||||
if parts[0] == 'args' and len(parts) > 1:
|
||||
val = context.get('args', {})
|
||||
for p in parts[1:]:
|
||||
if isinstance(val, dict):
|
||||
val = val.get(p, '')
|
||||
else:
|
||||
return ''
|
||||
return str(val)
|
||||
|
||||
if parts[0] == 'steps' and len(parts) >= 3:
|
||||
step_name = parts[1]
|
||||
attr = parts[2]
|
||||
step_data = context.get('steps', {}).get(step_name, {})
|
||||
val = step_data.get(attr, '')
|
||||
# 支持 deeper nesting: {{steps.foo.output.field}}
|
||||
for p in parts[3:]:
|
||||
if isinstance(val, dict):
|
||||
val = val.get(p, '')
|
||||
else:
|
||||
return ''
|
||||
return str(val)
|
||||
|
||||
# 顶层变量
|
||||
val = context
|
||||
for p in parts:
|
||||
if isinstance(val, dict):
|
||||
val = val.get(p, '')
|
||||
else:
|
||||
return ''
|
||||
return str(val)
|
||||
|
||||
return re.sub(r'\{\{(.+?)\}\}', replacer, text)
|
||||
|
||||
|
||||
def _resolve_value(val, context):
|
||||
"""递归解析值中的变量(支持 dict/list/str)"""
|
||||
if isinstance(val, str):
|
||||
return _resolve_vars(val, context)
|
||||
elif isinstance(val, dict):
|
||||
return {k: _resolve_value(v, context) for k, v in val.items()}
|
||||
elif isinstance(val, list):
|
||||
return [_resolve_value(item, context) for item in val]
|
||||
return val
|
||||
|
||||
|
||||
# ── 工作流引擎 ──
|
||||
|
||||
class WorkflowEngine:
|
||||
"""工作流执行引擎"""
|
||||
|
||||
def __init__(self, cli_instance=None):
|
||||
self.cli = cli_instance
|
||||
self.workflows_dir = "workflows"
|
||||
self.state_dir = os.path.join(self.workflows_dir, ".state")
|
||||
os.makedirs(self.state_dir, exist_ok=True)
|
||||
|
||||
def _out(self, msg):
|
||||
"""统一输出"""
|
||||
print(msg)
|
||||
|
||||
def run(self, path: str, args: dict = None) -> str:
|
||||
"""执行工作流"""
|
||||
if not os.path.exists(path):
|
||||
return f"❌ 工作流文件不存在: {path}"
|
||||
|
||||
try:
|
||||
wf = _load_yaml_file(path)
|
||||
except Exception as e:
|
||||
return f"❌ 解析工作流文件失败: {e}"
|
||||
|
||||
wf_name = wf.get('name', os.path.basename(path).replace('.yaml', '').replace('.yml', ''))
|
||||
steps = wf.get('steps', [])
|
||||
if not steps:
|
||||
return f"❌ 工作流 '{wf_name}' 没有步骤"
|
||||
|
||||
wf_id = f"{wf_name}-{uuid.uuid4().hex[:8]}"
|
||||
context = {
|
||||
'args': args or {},
|
||||
'steps': {},
|
||||
'workflow': {
|
||||
'id': wf_id,
|
||||
'name': wf_name,
|
||||
'path': path,
|
||||
'started_at': datetime.now().isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
# 保存初始状态
|
||||
self._save_state(wf_id, {
|
||||
'workflow': wf_name,
|
||||
'path': path,
|
||||
'status': 'running',
|
||||
'current_step': 0,
|
||||
'context': context,
|
||||
'started_at': context['workflow']['started_at'],
|
||||
})
|
||||
|
||||
self._out(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}")
|
||||
self._out(f" 共 {len(steps)} 个步骤\n")
|
||||
|
||||
failed = False
|
||||
for i, step in enumerate(steps):
|
||||
step_name = step.get('name', f'step_{i+1}')
|
||||
step_type = step.get('type', 'tool')
|
||||
condition = step.get('if', None)
|
||||
continue_on_error = step.get('continue_on_error', False)
|
||||
|
||||
# 检查条件
|
||||
if condition:
|
||||
resolved_condition = _resolve_vars(condition, context)
|
||||
if not self._eval_condition(resolved_condition):
|
||||
self._out(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}")
|
||||
context['steps'][step_name] = {
|
||||
'output': '', 'exit_code': 0, 'skipped': True
|
||||
}
|
||||
continue
|
||||
|
||||
# 解析步骤参数
|
||||
resolved_step = _resolve_value(step, context)
|
||||
|
||||
self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
|
||||
|
||||
# 更新状态
|
||||
self._save_state(wf_id, {
|
||||
'workflow': wf_name,
|
||||
'path': path,
|
||||
'status': 'running',
|
||||
'current_step': i,
|
||||
'current_step_name': step_name,
|
||||
'context': context,
|
||||
'started_at': context['workflow']['started_at'],
|
||||
})
|
||||
|
||||
try:
|
||||
result = self._execute_step(resolved_step, context)
|
||||
context['steps'][step_name] = result
|
||||
|
||||
if result.get('exit_code', 0) == 0:
|
||||
output_preview = str(result.get('output', ''))[:100]
|
||||
self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}"
|
||||
f"{' → ' + output_preview if output_preview else ''}")
|
||||
else:
|
||||
failed = True
|
||||
self._out(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}")
|
||||
self._out(f" {result.get('error', result.get('output', ''))[:200]}")
|
||||
|
||||
if not continue_on_error:
|
||||
# 检查 on_fail 分支
|
||||
on_fail = resolved_step.get('on_fail')
|
||||
if on_fail:
|
||||
self._out(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}")
|
||||
self._execute_on_fail(on_fail, context)
|
||||
break
|
||||
else:
|
||||
self._out(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}")
|
||||
|
||||
except Exception as e:
|
||||
failed = True
|
||||
context['steps'][step_name] = {
|
||||
'output': '', 'exit_code': 1, 'error': str(e)
|
||||
}
|
||||
self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
|
||||
if not continue_on_error:
|
||||
break
|
||||
|
||||
# 完成状态
|
||||
status = 'failed' if failed else 'completed'
|
||||
context['workflow']['finished_at'] = datetime.now().isoformat()
|
||||
context['workflow']['status'] = status
|
||||
|
||||
self._save_state(wf_id, {
|
||||
'workflow': wf_name,
|
||||
'path': path,
|
||||
'status': status,
|
||||
'current_step': len(steps),
|
||||
'context': context,
|
||||
'started_at': context['workflow']['started_at'],
|
||||
'finished_at': context['workflow']['finished_at'],
|
||||
})
|
||||
|
||||
icon = '✅' if not failed else '❌'
|
||||
self._out(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})")
|
||||
return f"{icon} 工作流 '{wf_name}' {status}"
|
||||
|
||||
def resume(self, wf_id: str) -> str:
|
||||
"""从断点恢复执行工作流"""
|
||||
state = self._load_state(wf_id)
|
||||
if not state:
|
||||
return f"❌ 未找到工作流状态: {wf_id}"
|
||||
|
||||
if state.get('status') == 'completed':
|
||||
return f"ℹ️ 工作流已完成: {wf_id}"
|
||||
|
||||
path = state.get('path', '')
|
||||
if not os.path.exists(path):
|
||||
return f"❌ 工作流文件不存在: {path}"
|
||||
|
||||
try:
|
||||
wf = _load_yaml_file(path)
|
||||
except Exception as e:
|
||||
return f"❌ 解析工作流文件失败: {e}"
|
||||
|
||||
steps = wf.get('steps', [])
|
||||
start_from = state.get('current_step', 0)
|
||||
context = state.get('context', {})
|
||||
|
||||
self._out(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}")
|
||||
|
||||
failed = False
|
||||
for i in range(start_from, len(steps)):
|
||||
step = steps[i]
|
||||
step_name = step.get('name', f'step_{i+1}')
|
||||
step_type = step.get('type', 'tool')
|
||||
continue_on_error = step.get('continue_on_error', False)
|
||||
|
||||
resolved_step = _resolve_value(step, context)
|
||||
self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
|
||||
|
||||
try:
|
||||
result = self._execute_step(resolved_step, context)
|
||||
context['steps'][step_name] = result
|
||||
|
||||
if result.get('exit_code', 0) == 0:
|
||||
self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}")
|
||||
else:
|
||||
failed = True
|
||||
self._out(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}")
|
||||
if not continue_on_error:
|
||||
break
|
||||
except Exception as e:
|
||||
failed = True
|
||||
context['steps'][step_name] = {'output': '', 'exit_code': 1, 'error': str(e)}
|
||||
self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
|
||||
if not continue_on_error:
|
||||
break
|
||||
|
||||
status = 'failed' if failed else 'completed'
|
||||
self._save_state(wf_id, {**state, 'status': status, 'current_step': len(steps)})
|
||||
|
||||
return f"{'✅' if not failed else '❌'} 工作流 {status}"
|
||||
|
||||
def list_workflows(self) -> str:
|
||||
"""列出所有工作流文件"""
|
||||
if not os.path.exists(self.workflows_dir):
|
||||
return "📁 workflows/ 目录不存在"
|
||||
|
||||
files = [f for f in os.listdir(self.workflows_dir)
|
||||
if f.endswith(('.yaml', '.yml')) and not f.startswith('.')]
|
||||
|
||||
if not files:
|
||||
return "📁 workflows/ 目录中没有工作流文件"
|
||||
|
||||
result = f"📁 工作流列表 ({len(files)} 个):\n"
|
||||
for f in sorted(files):
|
||||
path = os.path.join(self.workflows_dir, f)
|
||||
try:
|
||||
wf = _load_yaml_file(path)
|
||||
name = wf.get('name', f.replace('.yaml', '').replace('.yml', ''))
|
||||
desc = wf.get('description', '')
|
||||
steps_count = len(wf.get('steps', []))
|
||||
result += f" 📄 {f}\n"
|
||||
result += f" 名称: {name} 步骤: {steps_count}\n"
|
||||
if desc:
|
||||
result += f" 描述: {desc}\n"
|
||||
except Exception:
|
||||
result += f" 📄 {f} (解析失败)\n"
|
||||
return result
|
||||
|
||||
def status(self, wf_id: str) -> str:
|
||||
"""查看工作流状态"""
|
||||
state = self._load_state(wf_id)
|
||||
if not state:
|
||||
return f"❌ 未找到工作流: {wf_id}"
|
||||
|
||||
ctx = state.get('context', {})
|
||||
wf_info = ctx.get('workflow', {})
|
||||
steps_done = ctx.get('steps', {})
|
||||
|
||||
lines = [
|
||||
f"📋 工作流: {state.get('workflow', '?')}",
|
||||
f" ID: {wf_id}",
|
||||
f" 状态: {state.get('status', '?')}",
|
||||
f" 进度: 步骤 {state.get('current_step', 0)}",
|
||||
f" 开始: {state.get('started_at', '?')}",
|
||||
]
|
||||
if state.get('finished_at'):
|
||||
lines.append(f" 结束: {state['finished_at']}")
|
||||
|
||||
if steps_done:
|
||||
lines.append(f" 已完成步骤:")
|
||||
for name, data in steps_done.items():
|
||||
icon = '✓' if data.get('exit_code', 0) == 0 else ('⏭' if data.get('skipped') else '✗')
|
||||
lines.append(f" {icon} {name}")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def list_states(self) -> str:
|
||||
"""列出所有工作流运行状态"""
|
||||
if not os.path.exists(self.state_dir):
|
||||
return "没有工作流运行记录"
|
||||
|
||||
files = [f for f in os.listdir(self.state_dir) if f.endswith('.json')]
|
||||
if not files:
|
||||
return "没有工作流运行记录"
|
||||
|
||||
result = f"📋 工作流运行记录 ({len(files)} 个):\n"
|
||||
for f in sorted(files, reverse=True)[:20]:
|
||||
wf_id = f.replace('.json', '')
|
||||
try:
|
||||
with open(os.path.join(self.state_dir, f), 'r') as fh:
|
||||
state = json.load(fh)
|
||||
status_icon = {'running': '🔄', 'completed': '✅', 'failed': '❌'}.get(state.get('status'), '❓')
|
||||
result += f" {status_icon} {wf_id} — {state.get('workflow', '?')} ({state.get('status', '?')})\n"
|
||||
except Exception:
|
||||
result += f" ❓ {wf_id}\n"
|
||||
return result
|
||||
|
||||
def abort(self, wf_id: str) -> str:
|
||||
"""中止工作流"""
|
||||
state = self._load_state(wf_id)
|
||||
if not state:
|
||||
return f"❌ 未找到工作流: {wf_id}"
|
||||
|
||||
state['status'] = 'aborted'
|
||||
self._save_state(wf_id, state)
|
||||
return f"🛑 工作流已中止: {wf_id}"
|
||||
|
||||
# ── 步骤执行 ──
|
||||
|
||||
def _execute_step(self, step: dict, context: dict) -> dict:
|
||||
"""执行单个步骤"""
|
||||
step_type = step.get('type', 'tool')
|
||||
|
||||
if step_type == 'tool':
|
||||
return self._exec_tool_step(step, context)
|
||||
elif step_type == 'shell':
|
||||
return self._exec_shell_step(step, context)
|
||||
elif step_type == 'set':
|
||||
return self._exec_set_step(step, context)
|
||||
elif step_type == 'echo':
|
||||
return self._exec_echo_step(step, context)
|
||||
elif step_type == 'wait':
|
||||
return self._exec_wait_step(step, context)
|
||||
elif step_type == 'if':
|
||||
return self._exec_if_step(step, context)
|
||||
else:
|
||||
return {'output': '', 'exit_code': 1, 'error': f'未知步骤类型: {step_type}'}
|
||||
|
||||
def _exec_tool_step(self, step: dict, context: dict) -> dict:
|
||||
"""执行插件工具步骤"""
|
||||
tool_name = step.get('tool', '')
|
||||
tool_args = step.get('args', '')
|
||||
|
||||
if not tool_name:
|
||||
return {'output': '', 'exit_code': 1, 'error': '未指定 tool 名称'}
|
||||
|
||||
if not self.cli:
|
||||
return {'output': '', 'exit_code': 1, 'error': 'CLI 实例不可用'}
|
||||
|
||||
try:
|
||||
# 获取工具管理器
|
||||
tool_mgr = getattr(self.cli, 'tool_manager', None) or getattr(self.cli, 'liugin_manager', None)
|
||||
if not tool_mgr:
|
||||
return {'output': '', 'exit_code': 1, 'error': '工具管理器不可用'}
|
||||
|
||||
# 解析参数
|
||||
args_str = str(tool_args) if tool_args else ''
|
||||
|
||||
# 调用工具
|
||||
if hasattr(tool_mgr, 'execute'):
|
||||
result = tool_mgr.execute(tool_name, args_str)
|
||||
output = result.get('result', '') if isinstance(result, dict) else str(result)
|
||||
elif hasattr(tool_mgr, 'tools'):
|
||||
# LiuginManager 兼容
|
||||
tool = tool_mgr.get_tool_by_name(tool_name)
|
||||
if tool and 'handler' in tool:
|
||||
output = tool['handler'](args_str)
|
||||
else:
|
||||
return {'output': '', 'exit_code': 1, 'error': f'未找到工具: {tool_name}'}
|
||||
else:
|
||||
return {'output': '', 'exit_code': 1, 'error': '无法调用工具'}
|
||||
|
||||
return {'output': str(output), 'exit_code': 0}
|
||||
|
||||
except Exception as e:
|
||||
return {'output': '', 'exit_code': 1, 'error': str(e)}
|
||||
|
||||
def _exec_shell_step(self, step: dict, context: dict) -> dict:
|
||||
"""执行 shell 命令步骤"""
|
||||
import subprocess
|
||||
|
||||
cmd = step.get('command', step.get('cmd', ''))
|
||||
if not cmd:
|
||||
return {'output': '', 'exit_code': 1, 'error': '未指定 command'}
|
||||
|
||||
cwd = step.get('cwd', None)
|
||||
timeout = step.get('timeout', 60)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=cwd
|
||||
)
|
||||
return {
|
||||
'output': result.stdout.strip(),
|
||||
'error': result.stderr.strip(),
|
||||
'exit_code': result.returncode,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'output': '', 'exit_code': 1, 'error': f'命令超时 ({timeout}s)'}
|
||||
except Exception as e:
|
||||
return {'output': '', 'exit_code': 1, 'error': str(e)}
|
||||
|
||||
def _exec_set_step(self, step: dict, context: dict) -> dict:
|
||||
"""设置变量步骤"""
|
||||
variables = step.get('variables', step.get('vars', {}))
|
||||
for key, val in variables.items():
|
||||
context[key] = _resolve_value(val, context)
|
||||
return {'output': f'设置 {len(variables)} 个变量', 'exit_code': 0}
|
||||
|
||||
def _exec_echo_step(self, step: dict, context: dict) -> dict:
|
||||
"""打印步骤"""
|
||||
message = step.get('message', step.get('text', ''))
|
||||
resolved = _resolve_vars(str(message), context)
|
||||
self._out(f" 💬 {resolved}")
|
||||
return {'output': resolved, 'exit_code': 0}
|
||||
|
||||
def _exec_wait_step(self, step: dict, context: dict) -> dict:
|
||||
"""等待步骤"""
|
||||
seconds = step.get('seconds', step.get('time', 1))
|
||||
time.sleep(seconds)
|
||||
return {'output': f'等待 {seconds} 秒', 'exit_code': 0}
|
||||
|
||||
def _exec_if_step(self, step: dict, context: dict) -> dict:
|
||||
"""条件分支步骤"""
|
||||
condition = step.get('condition', '')
|
||||
resolved = _resolve_vars(str(condition), context)
|
||||
|
||||
if self._eval_condition(resolved):
|
||||
then_steps = step.get('then', [])
|
||||
return self._run_sub_steps(then_steps, context)
|
||||
else:
|
||||
else_steps = step.get('else', [])
|
||||
if else_steps:
|
||||
return self._run_sub_steps(else_steps, context)
|
||||
return {'output': '条件不满足,无 else 分支', 'exit_code': 0}
|
||||
|
||||
def _run_sub_steps(self, steps: list, context: dict) -> dict:
|
||||
"""执行子步骤列表"""
|
||||
outputs = []
|
||||
for sub_step in steps:
|
||||
result = self._execute_step(sub_step, context)
|
||||
outputs.append(result.get('output', ''))
|
||||
if result.get('exit_code', 0) != 0:
|
||||
return result
|
||||
return {'output': '\n'.join(outputs), 'exit_code': 0}
|
||||
|
||||
def _eval_condition(self, condition: str) -> bool:
|
||||
"""评估条件表达式"""
|
||||
condition = condition.strip()
|
||||
if not condition:
|
||||
return True
|
||||
|
||||
# 简单比较: "value == expected"
|
||||
for op in ['==', '!=', '>=', '<=', '>', '<']:
|
||||
if op in condition:
|
||||
left, _, right = condition.partition(op)
|
||||
left = left.strip().strip('"').strip("'")
|
||||
right = right.strip().strip('"').strip("'")
|
||||
try:
|
||||
left_n, right_n = float(left), float(right)
|
||||
if op == '==': return left_n == right_n
|
||||
if op == '!=': return left_n != right_n
|
||||
if op == '>=': return left_n >= right_n
|
||||
if op == '<=': return left_n <= right_n
|
||||
if op == '>': return left_n > right_n
|
||||
if op == '<': return left_n < right_n
|
||||
except ValueError:
|
||||
if op == '==': return left == right
|
||||
if op == '!=': return left != right
|
||||
|
||||
# 布尔值
|
||||
if condition.lower() in ('true', '1', 'yes'):
|
||||
return True
|
||||
if condition.lower() in ('false', '0', 'no', ''):
|
||||
return False
|
||||
|
||||
# 非空即真
|
||||
return bool(condition)
|
||||
|
||||
def _execute_on_fail(self, on_fail, context: dict):
|
||||
"""执行 on_fail 处理"""
|
||||
if isinstance(on_fail, str):
|
||||
self._out(f" 💬 {on_fail}")
|
||||
elif isinstance(on_fail, list):
|
||||
for step in on_fail:
|
||||
self._execute_step(step, context)
|
||||
|
||||
# ── 状态持久化 ──
|
||||
|
||||
def _save_state(self, wf_id: str, state: dict):
|
||||
"""保存工作流状态"""
|
||||
path = os.path.join(self.state_dir, f"{wf_id}.json")
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(state, f, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception as e:
|
||||
self._out(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}")
|
||||
|
||||
def _load_state(self, wf_id: str) -> dict:
|
||||
"""加载工作流状态"""
|
||||
path = os.path.join(self.state_dir, f"{wf_id}.json")
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ── Liugin 插件接口 ──
|
||||
|
||||
class Liugin:
|
||||
"""工作流插件"""
|
||||
|
||||
def __init__(self):
|
||||
self.engine = None
|
||||
self.usage = """工作流工具使用方法:
|
||||
|
||||
JSON格式示例:
|
||||
{"action": "use_tool", "tool": "workflow", "args": "run deploy.yaml"} - 执行工作流
|
||||
{"action": "use_tool", "tool": "workflow", "args": "list"} - 列出工作流文件
|
||||
{"action": "use_tool", "tool": "workflow", "args": "states"} - 查看运行记录
|
||||
{"action": "use_tool", "tool": "workflow", "args": "status <id>"} - 查看状态
|
||||
{"action": "use_tool", "tool": "workflow", "args": "resume <id>"} - 恢复执行
|
||||
{"action": "use_tool", "tool": "workflow", "args": "abort <id>"} - 中止工作流
|
||||
|
||||
工作流 YAML 格式示例:
|
||||
name: deploy
|
||||
description: 部署流程
|
||||
steps:
|
||||
- name: lint
|
||||
type: shell
|
||||
command: "npm run lint"
|
||||
- name: test
|
||||
type: shell
|
||||
command: "npm test"
|
||||
continue_on_error: true
|
||||
- name: build
|
||||
type: shell
|
||||
command: "npm run build"
|
||||
if: "{{test.exit_code}} == 0"
|
||||
- name: notify
|
||||
type: echo
|
||||
message: "构建完成: {{build.output}}"
|
||||
|
||||
支持的步骤类型:
|
||||
- tool: 调用插件工具 (tool, args)
|
||||
- shell: 执行系统命令 (command, cwd, timeout)
|
||||
- echo: 打印消息 (message)
|
||||
- wait: 等待 (seconds)
|
||||
- set: 设置变量 (variables)
|
||||
- if: 条件分支 (condition, then, else)
|
||||
|
||||
变量引用:
|
||||
{{steps.step_name.output}} - 步骤输出
|
||||
{{steps.step_name.exit_code}} - 步骤退出码
|
||||
{{env.VAR_NAME}} - 环境变量
|
||||
{{args.param}} - 工作流参数"""
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
self.engine = WorkflowEngine(cli)
|
||||
cli.register_liugin_command('workflow', self.command_handler)
|
||||
cli.register_liugin_command('wf', self.command_handler)
|
||||
|
||||
def command_handler(self, args):
|
||||
return self.handle(args)
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "workflow",
|
||||
"description": "工作流引擎 - YAML 定义的多步骤自动化流程,支持变量传递、条件分支、断点续跑",
|
||||
"keywords": ["workflow", "工作流", "流程", "自动化", "pipeline", "deploy", "build", "run"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "workflow",
|
||||
"description": "工作流引擎 - 执行、管理 YAML 定义的自动化工作流",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["run", "list", "states", "status", "resume", "abort"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "工作流文件路径(run) 或工作流ID(status/resume/abort)"
|
||||
},
|
||||
"args": {
|
||||
"type": "object",
|
||||
"description": "工作流参数 (run 时使用)"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
target = arguments.get("target", "")
|
||||
args = arguments.get("args", "")
|
||||
if args and isinstance(args, dict):
|
||||
args_str = ' '.join(f'{k}={v}' for k, v in args.items())
|
||||
return f"{op} {target} {args_str}".strip()
|
||||
return f"{op} {target}".strip()
|
||||
|
||||
def handle(self, args):
|
||||
try:
|
||||
args = args.strip()
|
||||
if not args:
|
||||
return self.usage
|
||||
|
||||
parts = args.split(None, 2)
|
||||
action = parts[0].lower()
|
||||
|
||||
if action == 'run':
|
||||
if len(parts) < 2:
|
||||
return "❌ 请指定工作流文件: workflow run <file.yaml>"
|
||||
path = parts[1]
|
||||
# 解析内联参数
|
||||
wf_args = {}
|
||||
if len(parts) > 2:
|
||||
for part in parts[2].split():
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
wf_args[k] = v
|
||||
return self.engine.run(path, wf_args)
|
||||
|
||||
elif action == 'list':
|
||||
return self.engine.list_workflows()
|
||||
|
||||
elif action == 'states':
|
||||
return self.engine.list_states()
|
||||
|
||||
elif action == 'status':
|
||||
if len(parts) < 2:
|
||||
return "❌ 请指定工作流 ID: workflow status <id>"
|
||||
return self.engine.status(parts[1])
|
||||
|
||||
elif action == 'resume':
|
||||
if len(parts) < 2:
|
||||
return "❌ 请指定工作流 ID: workflow resume <id>"
|
||||
return self.engine.resume(parts[1])
|
||||
|
||||
elif action == 'abort':
|
||||
if len(parts) < 2:
|
||||
return "❌ 请指定工作流 ID: workflow abort <id>"
|
||||
return self.engine.abort(parts[1])
|
||||
|
||||
else:
|
||||
# 默认当作 run
|
||||
if os.path.exists(action):
|
||||
return self.engine.run(action)
|
||||
return f"❌ 未知操作: {action}\n\n{self.usage}"
|
||||
|
||||
except Exception as e:
|
||||
return f"❌ 工作流操作错误: {e}"
|
||||
@@ -0,0 +1,28 @@
|
||||
# 小狸插件配置 - 由启动器自动生成
|
||||
ENABLE_AI_SEARCH = True
|
||||
ENABLE_AUTO_ENGINEER = True
|
||||
ENABLE_BROWSER_AUTO = True
|
||||
ENABLE_CMD_EXECUTOR = True
|
||||
ENABLE_CODE_EDITOR = True
|
||||
ENABLE_COMPILE_CHECK = True
|
||||
ENABLE_FILE_MANAGER = True
|
||||
ENABLE_FRONTEND_TESTER = True
|
||||
ENABLE_GIT_TOOLS = True
|
||||
ENABLE_GUI_AUTO = True
|
||||
ENABLE_ID_GENERATOR = True
|
||||
ENABLE_IMAGE_READER = True
|
||||
ENABLE_JSON_TOOLKIT = True
|
||||
ENABLE_MARKDOWN_TOOL = True
|
||||
ENABLE_MEMORY_PLUGIN = True
|
||||
ENABLE_NETWORK_TOOLS = True
|
||||
ENABLE_REGEX_TESTER = True
|
||||
ENABLE_SCHEDULER = True
|
||||
ENABLE_SNIPPET_MANAGER = True
|
||||
ENABLE_SPEECH_RECOGNITION = True
|
||||
ENABLE_SUB_AGENT = True
|
||||
ENABLE_TASK_MANAGER = True
|
||||
ENABLE_TERMINAL = True
|
||||
ENABLE_TOOL_SEARCH = True
|
||||
ENABLE_WORKFLOW = True
|
||||
|
||||
ENABLE_ONEBOT = False
|
||||
@@ -0,0 +1,12 @@
|
||||
# 小狸 Pro-CLI 依赖
|
||||
# 生成于 2026-07-05 00:17
|
||||
|
||||
PIL
|
||||
ascii_magic
|
||||
colorama
|
||||
cv2
|
||||
numpy
|
||||
ollama
|
||||
openai
|
||||
requests
|
||||
tkinter
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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'
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
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 = ""
|
||||
parameters: Dict[str, Any] = field(default_factory=dict) # 从 frontmatter 解析
|
||||
|
||||
def get_tool_definition(self) -> Dict[str, Any]:
|
||||
"""获取 OpenAI 格式的工具定义 - 从元数据动态生成"""
|
||||
properties = {}
|
||||
required = []
|
||||
|
||||
if self.parameters:
|
||||
# 从 frontmatter 中的 parameters 生成
|
||||
for name, schema in self.parameters.items():
|
||||
if isinstance(schema, dict):
|
||||
properties[name] = schema
|
||||
if schema.get("required", False):
|
||||
required.append(name)
|
||||
else:
|
||||
# 通用定义:operation + args
|
||||
properties = {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": f"要执行的操作(可用工具: {', '.join(self.tools)})" if self.tools else "要执行的操作"
|
||||
},
|
||||
"args": {
|
||||
"type": "string",
|
||||
"description": "操作参数"
|
||||
}
|
||||
}
|
||||
required = ["operation"]
|
||||
|
||||
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 格式的工具定义"""
|
||||
tool_def = self.get_tool_definition()
|
||||
func = tool_def.get("function", {})
|
||||
return {
|
||||
"name": func.get("name", self.name),
|
||||
"description": func.get("description", self.description),
|
||||
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
代码统计 Skill - 统计项目代码行数、文件数等
|
||||
"""
|
||||
import os
|
||||
from skills.base import Skill, SkillResult, SkillParameter, ParameterType
|
||||
|
||||
|
||||
class CodeStatsSkill(Skill):
|
||||
"""代码统计技能 - 统计目录中的代码行数、文件数、注释率等"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "code_stats"
|
||||
self.description = "统计项目代码量:文件数、代码行、注释行、空行、注释率"
|
||||
self.version = "1.0.0"
|
||||
self.author = "mimo"
|
||||
self.tags = ["代码", "统计", "stats", "lines"]
|
||||
|
||||
@property
|
||||
def parameters(self):
|
||||
return [
|
||||
SkillParameter(
|
||||
name="directory",
|
||||
param_type=ParameterType.STRING,
|
||||
description="要统计的目录路径",
|
||||
required=True
|
||||
),
|
||||
SkillParameter(
|
||||
name="file_pattern",
|
||||
param_type=ParameterType.STRING,
|
||||
description="文件匹配模式,如 *.py",
|
||||
required=False,
|
||||
default="*.py"
|
||||
),
|
||||
]
|
||||
|
||||
def execute(self, **kwargs) -> SkillResult:
|
||||
directory = kwargs.get("directory", ".")
|
||||
file_pattern = kwargs.get("file_pattern", "*.py")
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
return SkillResult(success=False, error=f"目录不存在: {directory}")
|
||||
|
||||
# 确定文件扩展名
|
||||
if file_pattern.startswith("*."):
|
||||
ext = file_pattern[1:]
|
||||
file_filter = lambda f: f.endswith(ext)
|
||||
else:
|
||||
file_filter = lambda f: True
|
||||
|
||||
total_files = 0
|
||||
total_lines = 0
|
||||
total_code = 0
|
||||
total_blank = 0
|
||||
total_comment = 0
|
||||
file_details = []
|
||||
|
||||
skip_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env'}
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith('.')]
|
||||
|
||||
for filename in sorted(files):
|
||||
if not file_filter(filename):
|
||||
continue
|
||||
|
||||
filepath = os.path.join(root, filename)
|
||||
rel_path = os.path.relpath(filepath, directory)
|
||||
total_files += 1
|
||||
|
||||
file_lines = 0
|
||||
file_code = 0
|
||||
file_blank = 0
|
||||
file_comment = 0
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
total_lines += 1
|
||||
file_lines += 1
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
total_blank += 1
|
||||
file_blank += 1
|
||||
elif stripped.startswith('#'):
|
||||
total_comment += 1
|
||||
file_comment += 1
|
||||
else:
|
||||
total_code += 1
|
||||
file_code += 1
|
||||
except (UnicodeDecodeError, PermissionError):
|
||||
continue
|
||||
|
||||
file_details.append({
|
||||
"file": rel_path,
|
||||
"lines": file_lines,
|
||||
"code": file_code,
|
||||
"comment": file_comment,
|
||||
"blank": file_blank,
|
||||
})
|
||||
|
||||
# 按代码行数排序
|
||||
file_details.sort(key=lambda x: x["code"], reverse=True)
|
||||
|
||||
# 构建报告
|
||||
comment_rate = (total_comment / max(total_code, 1)) * 100
|
||||
|
||||
report_lines = [
|
||||
f" 代码统计 ({directory})",
|
||||
f"{'─' * 40}",
|
||||
f" 文件数: {total_files}",
|
||||
f" 总行数: {total_lines}",
|
||||
f" 代码行: {total_code}",
|
||||
f" 注释行: {total_comment}",
|
||||
f" 空行: {total_blank}",
|
||||
f" 注释率: {comment_rate:.1f}%",
|
||||
]
|
||||
|
||||
if file_details:
|
||||
report_lines.append(f"\n 文件明细 (按代码行数排序):")
|
||||
for fd in file_details[:20]:
|
||||
report_lines.append(
|
||||
f" {fd['file']:40} {fd['code']:5} 行代码"
|
||||
)
|
||||
if len(file_details) > 20:
|
||||
report_lines.append(f" ... 还有 {len(file_details) - 20} 个文件")
|
||||
|
||||
return SkillResult(
|
||||
success=True,
|
||||
message='\n'.join(report_lines),
|
||||
data={
|
||||
"total_files": total_files,
|
||||
"total_lines": total_lines,
|
||||
"total_code": total_code,
|
||||
"total_comment": total_comment,
|
||||
"total_blank": total_blank,
|
||||
"comment_rate": comment_rate,
|
||||
"files": file_details
|
||||
}
|
||||
)
|
||||
@@ -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 <path> [--recursive] [--pattern "*.py"]
|
||||
python scripts/file_ops.py read <file_path> [--encoding utf-8] [--lines N]
|
||||
python scripts/file_ops.py write <file_path> --content "内容" [--append]
|
||||
python scripts/file_ops.py copy <source> <destination>
|
||||
python scripts/file_ops.py move <source> <destination>
|
||||
python scripts/file_ops.py delete <path> [--recursive]
|
||||
python scripts/file_ops.py search <directory> --pattern "*.py" [--content "搜索词"]
|
||||
python scripts/file_ops.py info <path>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,671 @@
|
||||
#!/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)
|
||||
with open(file_path, 'r', encoding=encoding) as f2:
|
||||
total_lines = sum(1 for _ in f2)
|
||||
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()
|
||||
@@ -0,0 +1,671 @@
|
||||
"""
|
||||
统一工具管理器 - 协调 Liugin、Skill 和 MCP 三套协议
|
||||
自动判断并路由到正确的处理器
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, List, Any, Optional, Union, Callable
|
||||
from colorama import Fore, Style
|
||||
from xcli_core.tool_result import ToolResult, ErrorCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
"""初始化并加载所有工具"""
|
||||
self._plugins_dir = plugins_dir
|
||||
self._skills_dir = skills_dir
|
||||
self._load_plugins(plugins_dir)
|
||||
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 shutdown(self) -> None:
|
||||
"""关闭管理器,清理所有插件实例"""
|
||||
with self._lock:
|
||||
for name, tool in self._tools.items():
|
||||
instance = tool.get('instance')
|
||||
if instance and hasattr(instance, 'cleanup'):
|
||||
try:
|
||||
instance.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
self._tools.clear()
|
||||
self._protocol_map.clear()
|
||||
self._mcp_tools_cache = None
|
||||
|
||||
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_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()
|
||||
|
||||
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_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():
|
||||
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', {})
|
||||
|
||||
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
|
||||
}
|
||||
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"""
|
||||
from skills.base import MarkdownSkill, Skill
|
||||
|
||||
def handler(args: str):
|
||||
"""Skill handler - 返回 ToolResult"""
|
||||
kwargs = {}
|
||||
|
||||
args = args.strip()
|
||||
if args.startswith('{'):
|
||||
try:
|
||||
kwargs = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
kwargs = {'operation': args}
|
||||
elif '=' in args:
|
||||
for part in args.split():
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
kwargs[k.strip()] = v.strip()
|
||||
else:
|
||||
kwargs.setdefault('operation', part)
|
||||
else:
|
||||
kwargs = {'operation': args}
|
||||
|
||||
if isinstance(skill, MarkdownSkill):
|
||||
return ToolResult.ok(f"[Skill: {skill.name}]\n{skill.instructions}")
|
||||
|
||||
if hasattr(skill, 'run'):
|
||||
result = skill.run(**kwargs)
|
||||
if result.success:
|
||||
return ToolResult.ok(result.message or str(result.data))
|
||||
else:
|
||||
return ToolResult.fail(str(result.error), ErrorCode.EXEC_FAILED)
|
||||
|
||||
return ToolResult.fail(f"未知的技能类型: {type(skill)}", ErrorCode.NOT_IMPLEMENTED)
|
||||
|
||||
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:
|
||||
normalized = self._normalize_mcp_definition(mcp_def)
|
||||
self._mcp_tools_cache.append(normalized)
|
||||
else:
|
||||
self._mcp_tools_cache.append(self._generate_mcp_definition(tool))
|
||||
|
||||
def _normalize_mcp_definition(self, mcp_def: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""标准化 MCP 工具定义"""
|
||||
if 'name' in mcp_def and ('inputSchema' in mcp_def or 'description' in mcp_def):
|
||||
return mcp_def
|
||||
|
||||
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 标准的工具定义"""
|
||||
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) -> ToolResult:
|
||||
"""执行工具调用,返回 ToolResult"""
|
||||
with self._lock:
|
||||
tool = self._tools.get(tool_name)
|
||||
|
||||
if not tool:
|
||||
return ToolResult.fail(f"未找到工具: {tool_name}", ErrorCode.NOT_FOUND, tool_name=tool_name)
|
||||
|
||||
try:
|
||||
handler = tool['handler']
|
||||
protocol = tool['protocol']
|
||||
instance = tool.get('instance')
|
||||
|
||||
if protocol == 'skill' and kwargs:
|
||||
skill = tool['instance']
|
||||
result = skill.run(**kwargs)
|
||||
if result.success:
|
||||
return ToolResult.ok(result.message or str(result.data), tool_name=tool_name)
|
||||
else:
|
||||
return ToolResult.fail(str(result.error), ErrorCode.EXEC_FAILED, tool_name=tool_name)
|
||||
|
||||
elif protocol == 'liugin' and kwargs and not 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)
|
||||
|
||||
result = handler(args)
|
||||
# 兼容:handler 可能返回 ToolResult 或字符串
|
||||
if isinstance(result, ToolResult):
|
||||
result.tool_name = tool_name
|
||||
return result
|
||||
return ToolResult.ok(result if result is not None else "执行完成", tool_name=tool_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"工具 {tool_name} 执行异常")
|
||||
return ToolResult.fail(str(e), ErrorCode.INTERNAL_ERROR, tool_name=tool_name)
|
||||
|
||||
def execute_dict(self, tool_name: str, args: str = "", **kwargs) -> Dict[str, Any]:
|
||||
"""执行工具调用,返回 dict(兼容旧接口)"""
|
||||
return self.execute(tool_name, args, **kwargs).to_dict()
|
||||
|
||||
# ==================== MCP 协议支持 ====================
|
||||
|
||||
def get_mcp_tools(self) -> List[Dict[str, Any]]:
|
||||
"""获取 MCP 格式的工具定义列表"""
|
||||
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 格式的工具调用入口"""
|
||||
tool = self._tools.get(tool_name)
|
||||
|
||||
if not tool:
|
||||
return ToolResult.fail(f"未找到工具: {tool_name}", ErrorCode.NOT_FOUND, tool_name=tool_name).to_mcp()
|
||||
|
||||
try:
|
||||
protocol = tool['protocol']
|
||||
instance = tool.get('instance')
|
||||
|
||||
if protocol == 'liugin':
|
||||
args = self._convert_mcp_args_to_plugin_args(tool_name, arguments, instance)
|
||||
result = tool['handler'](args)
|
||||
# 兼容:handler 可能返回 ToolResult 或字符串
|
||||
if isinstance(result, ToolResult):
|
||||
result.tool_name = tool_name
|
||||
return result.to_mcp()
|
||||
return ToolResult.ok(result or "执行完成", tool_name=tool_name).to_mcp()
|
||||
|
||||
elif protocol == 'skill':
|
||||
from skills.base import MarkdownSkill
|
||||
|
||||
if isinstance(instance, MarkdownSkill):
|
||||
return ToolResult.ok(f"[Skill: {instance.name}]\n{instance.instructions}", tool_name=tool_name).to_mcp()
|
||||
|
||||
if hasattr(instance, 'run'):
|
||||
result = instance.run(**arguments)
|
||||
if result.success:
|
||||
return ToolResult.ok(result.message or str(result.data), tool_name=tool_name).to_mcp()
|
||||
else:
|
||||
return ToolResult.fail(str(result.error), ErrorCode.EXEC_FAILED, tool_name=tool_name).to_mcp()
|
||||
|
||||
return ToolResult.fail(f"未知协议类型: {protocol}", ErrorCode.INTERNAL_ERROR, tool_name=tool_name).to_mcp()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"MCP 调用 {tool_name} 异常")
|
||||
return ToolResult.fail(str(e), ErrorCode.INTERNAL_ERROR, tool_name=tool_name).to_mcp()
|
||||
|
||||
def _convert_mcp_args_to_plugin_args(self, tool_name: str, arguments: Dict[str, Any],
|
||||
instance: Any) -> str:
|
||||
"""将 MCP 结构化参数转换为 Plugin 的 args 字符串"""
|
||||
if instance and hasattr(instance, 'convert_mcp_args'):
|
||||
return instance.convert_mcp_args(arguments)
|
||||
|
||||
if 'args' in arguments:
|
||||
return arguments['args']
|
||||
|
||||
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 成功响应(兼容旧接口,推荐用 ToolResult.ok().to_mcp())"""
|
||||
return ToolResult.ok(text).to_mcp()
|
||||
|
||||
def _mcp_error(self, error_msg: str) -> Dict[str, Any]:
|
||||
"""MCP 错误响应(兼容旧接口,推荐用 ToolResult.fail().to_mcp())"""
|
||||
return ToolResult.fail(error_msg).to_mcp()
|
||||
|
||||
# ==================== 兼容性 API ====================
|
||||
|
||||
def get_openai_tools(self) -> List[Dict[str, Any]]:
|
||||
"""获取 OpenAI Function Calling 格式的工具定义"""
|
||||
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 格式的工具定义"""
|
||||
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]]:
|
||||
"""搜索工具"""
|
||||
query_lower = query.lower()
|
||||
query_normalized = query_lower.replace('-', '').replace('_', '').replace(' ', '')
|
||||
matches = []
|
||||
|
||||
for tool in self._tools.values():
|
||||
score = self._score_tool_match(tool, query_lower, query_normalized)
|
||||
if score > 0:
|
||||
matches.append({
|
||||
'name': tool['name'],
|
||||
'description': tool['description'],
|
||||
'usage': tool.get('usage', ''),
|
||||
'keywords': [kw.lower() for kw in tool.get('keywords', [])],
|
||||
'protocol': tool['protocol'],
|
||||
'instance': tool.get('instance'),
|
||||
'score': score
|
||||
})
|
||||
|
||||
matches.sort(key=lambda x: x['score'], reverse=True)
|
||||
return matches[:limit]
|
||||
|
||||
def _score_tool_match(self, tool: Dict, query_lower: str, query_normalized: str) -> int:
|
||||
"""计算工具匹配分数"""
|
||||
score = 0
|
||||
name = tool.get('name', '').lower()
|
||||
name_normalized = name.replace('-', '').replace('_', '')
|
||||
desc = tool.get('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
|
||||
return score
|
||||
|
||||
def register_tool(self, name: str, handler: callable, description: str = "",
|
||||
protocol: str = "custom", mcp_definition: Dict = None, **kwargs) -> None:
|
||||
"""动态注册工具"""
|
||||
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:
|
||||
instance = self._tools[name].get('instance')
|
||||
if instance and hasattr(instance, 'cleanup'):
|
||||
try:
|
||||
instance.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
del self._tools[name]
|
||||
del self._protocol_map[name]
|
||||
self._build_mcp_cache()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all_tools_info(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有工具的简要信息,用于系统提示词"""
|
||||
result = []
|
||||
for tool in self._tools.values():
|
||||
result.append({
|
||||
'name': tool['name'],
|
||||
'description': tool.get('description', ''),
|
||||
'keywords': tool.get('keywords', []),
|
||||
'protocol': tool.get('protocol', ''),
|
||||
})
|
||||
return result
|
||||
|
||||
def get_tool_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""根据名称获取工具(兼容 LiuginManager 接口)"""
|
||||
return self._tools.get(name)
|
||||
|
||||
def get_skills_system_prompt(self) -> str:
|
||||
"""获取所有技能的系统提示词"""
|
||||
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:
|
||||
"""获取工具使用提示词(兼容旧接口)"""
|
||||
prompts = []
|
||||
|
||||
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")
|
||||
|
||||
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 信息"""
|
||||
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 协议请求"""
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
"""xcli_core - 兼容层(旧插件依赖)"""
|
||||
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from colorama import Fore, Style
|
||||
|
||||
# 配置文件路径
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
|
||||
|
||||
|
||||
def load_config():
|
||||
"""加载配置文件"""
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
|
||||
except json.JSONDecodeError:
|
||||
return {"api": {"engines": {}}, "system": {}, "plugins": {}}
|
||||
|
||||
|
||||
def save_config(config):
|
||||
"""保存配置文件"""
|
||||
try:
|
||||
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"保存配置失败: {e}")
|
||||
|
||||
|
||||
def get_system_config(key, default=None):
|
||||
"""获取系统配置"""
|
||||
config = load_config()
|
||||
return config.get("system", {}).get(key, default)
|
||||
|
||||
|
||||
def set_system_config(key, value):
|
||||
"""设置系统配置"""
|
||||
config = load_config()
|
||||
if "system" not in config:
|
||||
config["system"] = {}
|
||||
config["system"][key] = value
|
||||
save_config(config)
|
||||
|
||||
|
||||
# ── 引擎配置档案管理 ──
|
||||
# 存储结构: config["engine_profiles"][engine_name] = [ {name, base_url, api_key, model}, ... ]
|
||||
|
||||
def get_profiles(engine_name):
|
||||
"""获取指定引擎的所有配置档案"""
|
||||
config = load_config()
|
||||
return config.get("engine_profiles", {}).get(engine_name, [])
|
||||
|
||||
|
||||
def save_profile(engine_name, profile):
|
||||
"""保存/更新一个配置档案(按 name 去重,已存在则覆盖)"""
|
||||
config = load_config()
|
||||
if "engine_profiles" not in config:
|
||||
config["engine_profiles"] = {}
|
||||
profiles = config["engine_profiles"].setdefault(engine_name, [])
|
||||
# 按 name 去重
|
||||
for i, p in enumerate(profiles):
|
||||
if p.get("name") == profile.get("name"):
|
||||
profiles[i] = profile
|
||||
break
|
||||
else:
|
||||
profiles.append(profile)
|
||||
config["engine_profiles"][engine_name] = profiles
|
||||
save_config(config)
|
||||
|
||||
|
||||
def delete_profile(engine_name, profile_name):
|
||||
"""删除指定引擎的一个配置档案"""
|
||||
config = load_config()
|
||||
profiles = config.get("engine_profiles", {}).get(engine_name, [])
|
||||
profiles = [p for p in profiles if p.get("name") != profile_name]
|
||||
if "engine_profiles" not in config:
|
||||
config["engine_profiles"] = {}
|
||||
config["engine_profiles"][engine_name] = profiles
|
||||
save_config(config)
|
||||
|
||||
|
||||
def apply_profile(engine_name, profile):
|
||||
"""将档案应用到 config.json 的 api.engines 节点(下次启动或重载时生效)"""
|
||||
config = load_config()
|
||||
if "api" not in config:
|
||||
config["api"] = {}
|
||||
if "engines" not in config["api"]:
|
||||
config["api"]["engines"] = {}
|
||||
engine_cfg = config["api"]["engines"].setdefault(engine_name, {})
|
||||
if "base_url" in profile:
|
||||
engine_cfg["base_url"] = profile["base_url"]
|
||||
if "api_key" in profile:
|
||||
engine_cfg["api_key"] = profile["api_key"]
|
||||
if "model" in profile:
|
||||
engine_cfg["model"] = profile["model"]
|
||||
save_config(config)
|
||||
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('xiaoli_cli.log', encoding='utf-8'),
|
||||
logging.StreamHandler() # 同时输出到控制台
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -0,0 +1,87 @@
|
||||
# 常量定义
|
||||
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 shutil
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from colorama import init, Fore, Style
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import socket
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from io import StringIO
|
||||
|
||||
# ── 第三方依赖(全部 try/except 保护) ──
|
||||
|
||||
try:
|
||||
import requests
|
||||
REQUESTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
requests = None
|
||||
REQUESTS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
OPENAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
OpenAI = None
|
||||
OPENAI_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
NUMPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
np = None
|
||||
NUMPY_AVAILABLE = False
|
||||
|
||||
# tkinter 仅在视频播放功能中使用,服务器环境可能不可用
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
TKINTER_AVAILABLE = True
|
||||
except ImportError:
|
||||
TKINTER_AVAILABLE = False
|
||||
|
||||
# cv2 仅在视频播放功能中使用
|
||||
try:
|
||||
import cv2
|
||||
CV2_AVAILABLE = True
|
||||
except ImportError:
|
||||
CV2_AVAILABLE = False
|
||||
|
||||
# 图像显示相关(可选)
|
||||
try:
|
||||
from PIL import Image
|
||||
PIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PIL_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from ascii_magic import AsciiArt
|
||||
ASCII_MAGIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ASCII_MAGIC_AVAILABLE = False
|
||||
|
||||
# 统一工具管理器 - 支持 Liugin 和 Skill 双协议
|
||||
try:
|
||||
from unified_tool_manager import UnifiedToolManager
|
||||
UNIFIED_TOOL_MANAGER_AVAILABLE = True
|
||||
except ImportError:
|
||||
UNIFIED_TOOL_MANAGER_AVAILABLE = False
|
||||
|
||||
# TUI 模式已移除,全面使用 CLI 模式
|
||||
@@ -0,0 +1,800 @@
|
||||
"""
|
||||
持久化记忆系统
|
||||
- MEMORY.md: 长期记忆(AI 自己维护的精华)
|
||||
- memory/YYYY-MM-DD.md: 每日日记/日志
|
||||
- chat_history/: 自动保存的聊天记录
|
||||
- 支持关键词检索、上下文压缩、AI 写日记
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import glob
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
"""持久化记忆管理器"""
|
||||
|
||||
# 记忆模式常量
|
||||
MODE_COMPANION = "companion" # 陪伴模式(长期记忆)
|
||||
MODE_WORK = "work" # 工作模式(短期记忆)
|
||||
|
||||
def __init__(self, project_dir: str):
|
||||
self.project_dir = project_dir
|
||||
self.memory_dir = os.path.join(project_dir, "memory")
|
||||
self.memory_file = os.path.join(project_dir, "MEMORY.md")
|
||||
self.chat_dir = os.path.join(project_dir, "chat_history")
|
||||
self.config_file = os.path.join(project_dir, "config.json")
|
||||
self.compress_threshold = 50 # 超过 50 条消息触发压缩
|
||||
self._ensure_dirs()
|
||||
self._memory_mode = self._load_memory_mode()
|
||||
|
||||
def _load_memory_mode(self) -> str:
|
||||
"""加载记忆模式配置"""
|
||||
try:
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
return config.get("system", {}).get("memory_mode", "")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _save_memory_mode(self, mode: str):
|
||||
"""保存记忆模式配置"""
|
||||
try:
|
||||
config = {}
|
||||
if os.path.exists(self.config_file):
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
if "system" not in config:
|
||||
config["system"] = {}
|
||||
config["system"]["memory_mode"] = mode
|
||||
with open(self.config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
self._memory_mode = mode
|
||||
except Exception as e:
|
||||
print(f"保存记忆模式配置失败: {e}")
|
||||
|
||||
def get_memory_mode(self) -> str:
|
||||
"""获取当前记忆模式"""
|
||||
return self._memory_mode
|
||||
|
||||
def is_mode_selected(self) -> bool:
|
||||
"""检查是否已选择记忆模式"""
|
||||
return self._memory_mode in [self.MODE_COMPANION, self.MODE_WORK]
|
||||
|
||||
def select_mode(self, mode: str) -> bool:
|
||||
"""选择记忆模式(只能选择一次)"""
|
||||
if self.is_mode_selected():
|
||||
return False # 已经选择过,不能更改
|
||||
if mode not in [self.MODE_COMPANION, self.MODE_WORK]:
|
||||
return False # 无效的模式
|
||||
self._save_memory_mode(mode)
|
||||
return True
|
||||
|
||||
def get_mode_description(self) -> str:
|
||||
"""获取当前模式的描述"""
|
||||
if self._memory_mode == self.MODE_COMPANION:
|
||||
return "陪伴模式(长期记忆):拥有成熟的记忆功能,记住用户的偏好、习惯、历史对话等"
|
||||
elif self._memory_mode == self.MODE_WORK:
|
||||
return "工作模式(短期记忆):每次对话是独立的短期记忆,可通过指令手动存储聊天记录"
|
||||
return "未选择模式"
|
||||
|
||||
def _ensure_dirs(self):
|
||||
"""确保目录存在"""
|
||||
os.makedirs(self.memory_dir, exist_ok=True)
|
||||
os.makedirs(self.chat_dir, exist_ok=True)
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# MEMORY.md — 长期记忆
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def load_memory(self) -> str:
|
||||
"""读取 MEMORY.md 内容"""
|
||||
if os.path.exists(self.memory_file):
|
||||
try:
|
||||
with open(self.memory_file, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def save_memory(self, content: str):
|
||||
"""保存 MEMORY.md"""
|
||||
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
def append_memory(self, section: str, content: str):
|
||||
"""向 MEMORY.md 的指定 section 追加内容"""
|
||||
existing = self.load_memory()
|
||||
marker = f"## {section}"
|
||||
if marker in existing:
|
||||
# 在该 section 末尾追加
|
||||
parts = existing.split(marker, 1)
|
||||
after = parts[1]
|
||||
# 找到下一个 ## 或文件末尾
|
||||
next_section = after.find("\n## ")
|
||||
if next_section == -1:
|
||||
new_content = parts[0] + marker + after.rstrip() + "\n" + content + "\n"
|
||||
else:
|
||||
new_content = (parts[0] + marker + after[:next_section].rstrip() +
|
||||
"\n" + content + "\n" + after[next_section:])
|
||||
self.save_memory(new_content)
|
||||
else:
|
||||
# 新增 section
|
||||
new_content = existing.rstrip() + f"\n\n{marker}\n{content}\n"
|
||||
self.save_memory(new_content)
|
||||
|
||||
def list_memory_sections(self) -> list:
|
||||
"""列出 MEMORY.md 中所有的 section 标题"""
|
||||
content = self.load_memory()
|
||||
if not content:
|
||||
return []
|
||||
sections = []
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("## "):
|
||||
sections.append(line[3:].strip())
|
||||
return sections
|
||||
|
||||
def read_memory_section(self, section: str) -> str:
|
||||
"""读取 MEMORY.md 中指定 section 的内容"""
|
||||
content = self.load_memory()
|
||||
if not content:
|
||||
return ""
|
||||
marker = f"## {section}"
|
||||
if marker not in content:
|
||||
return ""
|
||||
parts = content.split(marker, 1)
|
||||
after = parts[1]
|
||||
next_section = after.find("\n## ")
|
||||
if next_section == -1:
|
||||
return after.strip()
|
||||
return after[:next_section].strip()
|
||||
|
||||
def write_memory_section(self, section: str, content: str):
|
||||
"""写入/覆盖 MEMORY.md 中指定 section(如不存在则新建)"""
|
||||
existing = self.load_memory()
|
||||
marker = f"## {section}"
|
||||
if marker in existing:
|
||||
# 替换该 section 的内容
|
||||
parts = existing.split(marker, 1)
|
||||
after = parts[1]
|
||||
next_section = after.find("\n## ")
|
||||
if next_section == -1:
|
||||
new_content = parts[0] + marker + "\n" + content + "\n"
|
||||
else:
|
||||
new_content = (parts[0] + marker + "\n" + content + "\n" + after[next_section:])
|
||||
self.save_memory(new_content)
|
||||
else:
|
||||
# 新增 section
|
||||
new_content = existing.rstrip() + f"\n\n{marker}\n{content}\n"
|
||||
self.save_memory(new_content)
|
||||
|
||||
def delete_memory_section(self, section: str) -> bool:
|
||||
"""删除 MEMORY.md 中指定的 section"""
|
||||
existing = self.load_memory()
|
||||
if not existing:
|
||||
return False
|
||||
marker = f"## {section}"
|
||||
if marker not in existing:
|
||||
return False
|
||||
parts = existing.split(marker, 1)
|
||||
after = parts[1]
|
||||
next_section = after.find("\n## ")
|
||||
if next_section == -1:
|
||||
new_content = parts[0].rstrip() + "\n"
|
||||
else:
|
||||
new_content = parts[0] + after[next_section:]
|
||||
self.save_memory(new_content)
|
||||
return True
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 每日日记 — memory/YYYY-MM-DD.md
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def _today_file(self) -> str:
|
||||
"""今天的日记文件路径"""
|
||||
return os.path.join(self.memory_dir, f"{datetime.now().strftime('%Y-%m-%d')}.md")
|
||||
|
||||
def load_today(self) -> str:
|
||||
"""读取今天的日记"""
|
||||
path = self._today_file()
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def write_diary(self, content: str, tag: str = ""):
|
||||
"""写日记条目"""
|
||||
path = self._today_file()
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
tag_str = f" [{tag}]" if tag else ""
|
||||
entry = f"\n### {now}{tag_str}\n{content}\n"
|
||||
|
||||
existing = ""
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
existing = f.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not existing:
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
existing = f"# {date_str} 开发日志\n"
|
||||
|
||||
with open(path, 'a', encoding='utf-8') as f:
|
||||
f.write(entry)
|
||||
|
||||
def write_ai_diary(self, content: str):
|
||||
"""AI 写日记(自动标记为 AI 观察)"""
|
||||
self.write_diary(content, tag="AI观察")
|
||||
|
||||
def list_diary_files(self) -> List[str]:
|
||||
"""列出所有日记文件"""
|
||||
files = glob.glob(os.path.join(self.memory_dir, "*.md"))
|
||||
files.sort(reverse=True)
|
||||
return [os.path.basename(f) for f in files]
|
||||
|
||||
def load_diary(self, date_str: str) -> str:
|
||||
"""读取指定日期的日记"""
|
||||
path = os.path.join(self.memory_dir, f"{date_str}.md")
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 聊天记录 — 自动保存
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 聊天记录 — 单文件 JSON 存储
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
CHAT_STORE_FILE = "chats.json" # 单文件存储
|
||||
|
||||
def _chat_store_path(self) -> str:
|
||||
"""聊天记录单文件路径"""
|
||||
return os.path.join(self.chat_dir, self.CHAT_STORE_FILE)
|
||||
|
||||
def _load_chat_store(self) -> dict:
|
||||
"""加载聊天记录存储"""
|
||||
path = self._chat_store_path()
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {"chats": []}
|
||||
|
||||
def _save_chat_store(self, store: dict):
|
||||
"""保存聊天记录存储"""
|
||||
path = self._chat_store_path()
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(store, f, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def auto_save_chat(self, conversation: list, label: str = ""):
|
||||
"""保存聊天记录到单文件存储"""
|
||||
if not conversation:
|
||||
return
|
||||
store = self._load_chat_store()
|
||||
now = datetime.now()
|
||||
chat_name = label or now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
chat_entry = {
|
||||
"name": chat_name,
|
||||
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"label": label,
|
||||
"message_count": len(conversation),
|
||||
"conversation": conversation
|
||||
}
|
||||
|
||||
# 按名称去重:如果同名已存在则覆盖
|
||||
chats = store.get("chats", [])
|
||||
for i, c in enumerate(chats):
|
||||
if c.get("name") == chat_name:
|
||||
chats[i] = chat_entry
|
||||
break
|
||||
else:
|
||||
chats.append(chat_entry)
|
||||
|
||||
store["chats"] = chats
|
||||
self._save_chat_store(store)
|
||||
|
||||
def list_chats(self, limit: int = 20) -> List[Dict]:
|
||||
"""列出最近的聊天记录"""
|
||||
store = self._load_chat_store()
|
||||
chats = store.get("chats", [])
|
||||
# 按时间倒序
|
||||
chats_sorted = sorted(chats, key=lambda c: c.get("timestamp", ""), reverse=True)
|
||||
results = []
|
||||
for c in chats_sorted[:limit]:
|
||||
results.append({
|
||||
"name": c.get("name", ""),
|
||||
"time": c.get("timestamp", ""),
|
||||
"size_kb": round(len(json.dumps(c, ensure_ascii=False)) / 1024, 1),
|
||||
})
|
||||
return results
|
||||
|
||||
def load_chat(self, name: str) -> Optional[list]:
|
||||
"""加载指定的聊天记录(按名称查找)"""
|
||||
store = self._load_chat_store()
|
||||
for c in store.get("chats", []):
|
||||
if c.get("name") == name:
|
||||
return c.get("conversation", [])
|
||||
return None
|
||||
|
||||
def delete_chat(self, name: str) -> bool:
|
||||
"""删除指定的聊天记录"""
|
||||
store = self._load_chat_store()
|
||||
chats = store.get("chats", [])
|
||||
new_chats = [c for c in chats if c.get("name") != name]
|
||||
if len(new_chats) == len(chats):
|
||||
return False
|
||||
store["chats"] = new_chats
|
||||
self._save_chat_store(store)
|
||||
return True
|
||||
|
||||
def search_chats(self, keyword: str, limit: int = 10) -> List[Dict]:
|
||||
"""在聊天记录中搜索关键词"""
|
||||
keyword_lower = keyword.lower()
|
||||
store = self._load_chat_store()
|
||||
results = []
|
||||
for c in store.get("chats", []):
|
||||
for msg in c.get("conversation", []):
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and keyword_lower in content.lower():
|
||||
results.append({
|
||||
"name": c.get("name", ""),
|
||||
"time": c.get("timestamp", ""),
|
||||
"snippet": content[:120],
|
||||
"role": msg.get("role", ""),
|
||||
})
|
||||
break # 每条记录只匹配一次
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 关键词检索 — 全局记忆搜索
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def search_memory(self, keyword: str, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
在所有记忆文件中搜索关键词
|
||||
搜索范围: MEMORY.md + memory/*.md + chat_history/*.json
|
||||
"""
|
||||
keyword_lower = keyword.lower()
|
||||
results = []
|
||||
|
||||
# 搜索 MEMORY.md
|
||||
if os.path.exists(self.memory_file):
|
||||
results.extend(self._search_file(
|
||||
self.memory_file, keyword_lower, "MEMORY.md", limit - len(results)
|
||||
))
|
||||
|
||||
# 搜索日记文件
|
||||
for md_file in sorted(glob.glob(os.path.join(self.memory_dir, "*.md")), reverse=True):
|
||||
if len(results) >= limit:
|
||||
break
|
||||
name = f"memory/{os.path.basename(md_file)}"
|
||||
results.extend(self._search_file(
|
||||
md_file, keyword_lower, name, limit - len(results)
|
||||
))
|
||||
|
||||
# 搜索聊天记录(单文件存储)
|
||||
store = self._load_chat_store()
|
||||
for c in store.get("chats", []):
|
||||
if len(results) >= limit:
|
||||
break
|
||||
name = f"chat/{c.get('name', '')}"
|
||||
for msg in c.get("conversation", []):
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and keyword_lower in content.lower():
|
||||
results.append({
|
||||
"source": name,
|
||||
"line": 0,
|
||||
"snippet": content[:120],
|
||||
})
|
||||
break
|
||||
|
||||
return results[:limit]
|
||||
|
||||
def _search_file(self, path: str, keyword: str, source: str, limit: int) -> List[Dict]:
|
||||
"""在单个文件中搜索"""
|
||||
results = []
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
for i, line in enumerate(lines):
|
||||
if keyword in line.lower():
|
||||
snippet = line.strip()[:120]
|
||||
results.append({
|
||||
"source": source,
|
||||
"line": i + 1,
|
||||
"snippet": snippet,
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
def _search_json_file(self, path: str, keyword: str, source: str, limit: int) -> List[Dict]:
|
||||
"""在 JSON 聊天记录中搜索"""
|
||||
results = []
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for msg in data.get("conversation", []):
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and keyword in content.lower():
|
||||
results.append({
|
||||
"source": source,
|
||||
"role": msg.get("role", ""),
|
||||
"snippet": content[:120],
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 上下文压缩
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def should_compress(self, conversation: list) -> bool:
|
||||
"""判断是否需要压缩上下文"""
|
||||
return len(conversation) > self.compress_threshold
|
||||
|
||||
def compress_context(self, conversation: list, engine=None) -> list:
|
||||
"""
|
||||
压缩对话历史
|
||||
策略: 保留最近 10 条 + 用 AI 总结前面的内容
|
||||
"""
|
||||
if len(conversation) <= 10:
|
||||
return conversation
|
||||
|
||||
keep_recent = 10
|
||||
to_compress = conversation[:-keep_recent]
|
||||
recent = conversation[-keep_recent:]
|
||||
|
||||
# 尝试用 AI 生成摘要
|
||||
summary = self._ai_summarize(to_compress, engine)
|
||||
|
||||
# 构建压缩后的历史
|
||||
compressed = [{
|
||||
"role": "system",
|
||||
"content": f"[上下文压缩] 以下是之前 {len(to_compress)} 条对话的摘要:\n{summary}"
|
||||
}]
|
||||
compressed.extend(recent)
|
||||
return compressed
|
||||
|
||||
def _ai_summarize(self, messages: list, engine=None) -> str:
|
||||
"""用 AI 生成对话摘要"""
|
||||
if not engine:
|
||||
return self._simple_summarize(messages)
|
||||
|
||||
# 构建摘要请求
|
||||
conversation_text = ""
|
||||
for msg in messages:
|
||||
role = msg.get("role", "unknown")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
conversation_text += f"[{role}]: {content[:200]}\n"
|
||||
|
||||
prompt = (
|
||||
"请用简洁的中文总结以下对话的关键信息(工具调用、代码修改、重要决策)。"
|
||||
"控制在 200 字以内。\n\n"
|
||||
f"{conversation_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
summary = engine.generate_response(
|
||||
prompt,
|
||||
system_prompt="你是摘要生成器。只输出摘要,不要多余内容。"
|
||||
)
|
||||
if summary and len(summary) > 10:
|
||||
return summary
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self._simple_summarize(messages)
|
||||
|
||||
def _simple_summarize(self, messages: list) -> str:
|
||||
"""简单摘要(不依赖 AI)"""
|
||||
topics = []
|
||||
tool_calls = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
role = msg.get("role", "")
|
||||
if role == "user" and isinstance(content, str):
|
||||
# 提取用户请求
|
||||
if len(content) < 100:
|
||||
topics.append(content)
|
||||
else:
|
||||
topics.append(content[:50] + "...")
|
||||
if role == "assistant" and isinstance(content, str):
|
||||
# 提取工具调用
|
||||
if '"action": "use_tool"' in content or '"tool":' in content:
|
||||
try:
|
||||
# 尝试解析工具名
|
||||
for part in content.split('"tool":'):
|
||||
if len(part) > 2:
|
||||
tool_name = part.split('"')[1] if '"' in part else ""
|
||||
if tool_name and tool_name not in tool_calls:
|
||||
tool_calls.append(tool_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
summary_parts = []
|
||||
if topics:
|
||||
summary_parts.append(f"讨论了 {len(topics)} 个话题: {'; '.join(topics[:5])}")
|
||||
if tool_calls:
|
||||
summary_parts.append(f"使用了工具: {', '.join(tool_calls[:8])}")
|
||||
summary_parts.append(f"共 {len(messages)} 条对话")
|
||||
|
||||
return "。".join(summary_parts)
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 构建记忆上下文(注入系统提示词)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def build_memory_context(self) -> str:
|
||||
"""构建记忆上下文,用于注入系统提示词"""
|
||||
parts = []
|
||||
|
||||
# 加载 MEMORY.md
|
||||
memory = self.load_memory()
|
||||
if memory:
|
||||
parts.append(f"【长期记忆 MEMORY.md】\n{memory[:2000]}")
|
||||
|
||||
# 加载今天的日记
|
||||
today = self.load_today()
|
||||
if today:
|
||||
parts.append(f"【今日日志】\n{today[:1000]}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return (
|
||||
"\n\n---\n"
|
||||
"以下是你的持久化记忆,帮助你了解之前的工作和上下文:\n\n"
|
||||
+ "\n\n".join(parts)
|
||||
+ "\n---\n"
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 工具接口(供 AI 调用)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def handle_tool(self, args: str) -> str:
|
||||
"""处理 memory 工具调用"""
|
||||
if not args or not args.strip():
|
||||
return self._tool_help()
|
||||
|
||||
parts = args.strip().split(None, 1)
|
||||
action = parts[0].lower()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
if action == "write":
|
||||
if not rest:
|
||||
return "用法: memory write <内容>"
|
||||
self.write_diary(rest)
|
||||
return f"日记已写入 {datetime.now().strftime('%Y-%m-%d')}"
|
||||
|
||||
elif action == "diary":
|
||||
if not rest:
|
||||
return "用法: memory diary <AI观察内容>"
|
||||
self.write_ai_diary(rest)
|
||||
return f"AI 日记已写入"
|
||||
|
||||
elif action == "remember":
|
||||
if not rest:
|
||||
return "用法: memory remember <要记住的内容>"
|
||||
self.append_memory("备忘", f"- {rest}")
|
||||
return f"已记录到 MEMORY.md"
|
||||
|
||||
elif action == "search":
|
||||
if not rest:
|
||||
return "用法: memory search <关键词>"
|
||||
results = self.search_memory(rest)
|
||||
if not results:
|
||||
return f"未找到与 '{rest}' 相关的记忆"
|
||||
lines = [f"搜索 '{rest}' 的结果:"]
|
||||
for r in results:
|
||||
source = r.get("source", "")
|
||||
snippet = r.get("snippet", "")
|
||||
role = r.get("role", "")
|
||||
prefix = f"[{role}] " if role else ""
|
||||
lines.append(f" 📄 {source}: {prefix}{snippet}")
|
||||
return "\n".join(lines)
|
||||
|
||||
elif action == "context":
|
||||
ctx = self.build_memory_context()
|
||||
return ctx if ctx else "当前没有记忆上下文"
|
||||
|
||||
elif action == "diary_list":
|
||||
files = self.list_diary_files()
|
||||
if not files:
|
||||
return "还没有日记文件"
|
||||
return "日记文件:\n" + "\n".join(f" 📅 {f}" for f in files[:20])
|
||||
|
||||
elif action == "diary_read":
|
||||
if not rest:
|
||||
return "用法: memory diary_read <日期,如 2026-04-27>"
|
||||
content = self.load_diary(rest)
|
||||
return content if content else f"没有找到 {rest} 的日记"
|
||||
|
||||
elif action == "chat_list":
|
||||
chats = self.list_chats()
|
||||
if not chats:
|
||||
return "没有保存的聊天记录"
|
||||
lines = ["最近的聊天记录:"]
|
||||
for c in chats:
|
||||
lines.append(f" 💬 {c['name']} ({c['time']}, {c['size_kb']}KB)")
|
||||
return "\n".join(lines)
|
||||
|
||||
elif action == "chat_search":
|
||||
if not rest:
|
||||
return "用法: memory chat_search <关键词>"
|
||||
results = self.search_chats(rest)
|
||||
if not results:
|
||||
return f"未在聊天记录中找到 '{rest}'"
|
||||
lines = [f"聊天记录搜索 '{rest}':"]
|
||||
for r in results:
|
||||
lines.append(f" 💬 {r['file']} [{r['role']}]: {r['snippet']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
else:
|
||||
return f"未知操作: {action}\n" + self._tool_help()
|
||||
|
||||
def _tool_help(self) -> str:
|
||||
return """memory 工具用法:
|
||||
memory write <内容> - 写日记
|
||||
memory diary <内容> - AI 写观察日记
|
||||
memory remember <内容> - 记录到长期记忆 (MEMORY.md)
|
||||
memory search <关键词> - 搜索所有记忆
|
||||
memory context - 查看当前记忆上下文
|
||||
memory diary_list - 列出所有日记
|
||||
memory diary_read <日期> - 读取指定日期日记
|
||||
memory chat_list - 列出聊天记录
|
||||
memory chat_search <关键词> - 在聊天记录中搜索"""
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# 记忆插件(注册到插件系统)
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
def save_conversation_by_mode(self, conversation: list, label: str = ""):
|
||||
"""根据模式保存对话"""
|
||||
if self._memory_mode == self.MODE_COMPANION:
|
||||
# 陪伴模式:自动保存到长期记忆
|
||||
self.auto_save_chat(conversation, label)
|
||||
# 提取关键信息保存到MEMORY.md
|
||||
self._extract_and_save_key_info(conversation)
|
||||
elif self._memory_mode == self.MODE_WORK:
|
||||
# 工作模式:自动保存聊天记录
|
||||
self.auto_save_chat(conversation, label)
|
||||
|
||||
|
||||
def _extract_and_save_key_info(self, conversation: list):
|
||||
"""从对话中提取关键信息保存到MEMORY.md(陪伴模式专用)"""
|
||||
# 这里可以添加AI提取关键信息的逻辑
|
||||
# 暂时简单实现:保存最后一条助手消息
|
||||
for msg in reversed(conversation):
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if content and len(content) > 20:
|
||||
self.append_memory("对话记录", f"- {content[:200]}")
|
||||
break
|
||||
|
||||
|
||||
def load_context_by_mode(self, conversation: list) -> list:
|
||||
"""根据模式加载上下文"""
|
||||
if self._memory_mode == self.MODE_COMPANION:
|
||||
# 陪伴模式:加载长期记忆作为上下文
|
||||
memory_content = self.load_memory()
|
||||
if memory_content:
|
||||
context_msg = {"role": "system", "content": f"长期记忆:{memory_content}"}
|
||||
return [context_msg] + conversation
|
||||
return conversation
|
||||
elif self._memory_mode == self.MODE_WORK:
|
||||
# 工作模式:只返回当前对话
|
||||
return conversation
|
||||
return conversation
|
||||
|
||||
class Liugin:
|
||||
"""记忆系统插件 — AI 可读写记忆、搜索历史"""
|
||||
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
self.memory = None
|
||||
self.usage = """记忆系统工具使用方法:
|
||||
|
||||
JSON格式示例:
|
||||
{"action": "use_tool", "tool": "memory", "args": "write 今天完成了v5.2的开发"} - 写日记
|
||||
{"action": "use_tool", "tool": "memory", "args": "diary 用户偏好使用中文交流"} - AI观察日记
|
||||
{"action": "use_tool", "tool": "memory", "args": "remember 项目使用MIT协议"} - 记到长期记忆
|
||||
{"action": "use_tool", "tool": "memory", "args": "search 定时任务"} - 搜索所有记忆
|
||||
{"action": "use_tool", "tool": "memory", "args": "context"} - 查看记忆上下文
|
||||
{"action": "use_tool", "tool": "memory", "args": "diary_list"} - 列出日记
|
||||
{"action": "use_tool", "tool": "memory", "args": "diary_read 2026-04-27"} - 读日记
|
||||
{"action": "use_tool", "tool": "memory", "args": "chat_list"} - 列出聊天记录
|
||||
{"action": "use_tool", "tool": "memory", "args": "chat_search 关键词"} - 搜聊天记录
|
||||
|
||||
功能说明:
|
||||
- write: 写当日日记
|
||||
- diary: AI 自己写观察日记
|
||||
- remember: 写入 MEMORY.md 长期记忆
|
||||
- search: 全局关键词搜索(日记+记忆+聊天记录)
|
||||
- context: 查看当前注入的上下文
|
||||
- diary_list / diary_read: 管理日记
|
||||
- chat_list / chat_search: 管理聊天记录"""
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
self.memory = MemoryManager(project_dir)
|
||||
# 挂载到 cli 实例上供其他模块使用
|
||||
cli.memory_manager = self.memory
|
||||
# 注册命令
|
||||
self.cli.register_liugin_command('memory', self.command_handler)
|
||||
|
||||
def command_handler(self, args):
|
||||
return self.handle(args)
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "memory",
|
||||
"description": "持久化记忆系统。AI 可写日记、记录长期记忆、搜索历史记忆和聊天记录。支持关键词检索。",
|
||||
"keywords": ["记忆", "memory", "日记", "diary", "搜索", "search", "历史", "history", "remember"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
if not self.memory:
|
||||
return "记忆系统未初始化"
|
||||
return self.memory.handle_tool(args)
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "memory",
|
||||
"description": "持久化记忆系统",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["write", "diary", "remember", "search", "context",
|
||||
"diary_list", "diary_read", "chat_list", "chat_search"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "内容/关键词"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
}
|
||||
# ══════════════════════════════════════════════
|
||||
# 模式特定的方法
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
跨平台系统通知模块
|
||||
当 AI 完成任务时,发送系统通知提醒用户
|
||||
支持 Windows / macOS / Linux
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""跨平台系统通知管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = True
|
||||
self._platform = self._detect_platform()
|
||||
self._last_title = ""
|
||||
self._last_message = ""
|
||||
self._cooldown_seconds = 2 # 防止重复通知的冷却时间
|
||||
self._last_notify_time = 0
|
||||
|
||||
@staticmethod
|
||||
def _detect_platform() -> str:
|
||||
"""检测当前平台"""
|
||||
if os.name == 'nt':
|
||||
return 'windows'
|
||||
elif sys.platform == 'darwin':
|
||||
return 'macos'
|
||||
else:
|
||||
return 'linux'
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
"""启用/禁用通知"""
|
||||
self.enabled = enabled
|
||||
|
||||
def notify(self, title: str, message: str, force: bool = False):
|
||||
"""
|
||||
发送系统通知
|
||||
|
||||
Args:
|
||||
title: 通知标题
|
||||
message: 通知内容
|
||||
force: 是否忽略冷却时间强制发送
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
# 防止重复通知
|
||||
import time
|
||||
now = time.time()
|
||||
if (not force and
|
||||
title == self._last_title and
|
||||
message == self._last_message and
|
||||
now - self._last_notify_time < self._cooldown_seconds):
|
||||
return
|
||||
|
||||
self._last_title = title
|
||||
self._last_message = message
|
||||
self._last_notify_time = now
|
||||
|
||||
# 在后台线程发送,不阻塞主流程
|
||||
thread = threading.Thread(
|
||||
target=self._send_notification,
|
||||
args=(title, message),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def notify_task_complete(self, task_summary: str = ""):
|
||||
"""通知任务完成的快捷方法"""
|
||||
title = "小狸 Pro-CLI"
|
||||
if task_summary:
|
||||
# 截断过长的摘要
|
||||
if len(task_summary) > 80:
|
||||
task_summary = task_summary[:77] + "..."
|
||||
message = f"任务已完成: {task_summary}"
|
||||
else:
|
||||
message = "任务已完成 ✓"
|
||||
|
||||
self.notify(title, message)
|
||||
|
||||
def notify_tool_complete(self, tool_name: str, result_summary: str = ""):
|
||||
"""通知工具执行完成"""
|
||||
title = "小狸 Pro-CLI"
|
||||
if result_summary:
|
||||
if len(result_summary) > 60:
|
||||
result_summary = result_summary[:57] + "..."
|
||||
message = f"{tool_name}: {result_summary}"
|
||||
else:
|
||||
message = f"{tool_name} 执行完成 ✓"
|
||||
|
||||
self.notify(title, message)
|
||||
|
||||
# ── 平台实现 ──
|
||||
|
||||
def _send_notification(self, title: str, message: str):
|
||||
"""根据平台发送通知"""
|
||||
try:
|
||||
if self._platform == 'windows':
|
||||
self._notify_windows(title, message)
|
||||
elif self._platform == 'macos':
|
||||
self._notify_macos(title, message)
|
||||
else:
|
||||
self._notify_linux(title, message)
|
||||
except Exception:
|
||||
# 通知失败不应影响正常流程
|
||||
pass
|
||||
|
||||
def _notify_windows(self, title: str, message: str):
|
||||
"""Windows 通知 — 使用 PowerShell(win10toast 在 Python 3.12+ 上不兼容)"""
|
||||
# 方法1: PowerShell BurntToast 模块
|
||||
try:
|
||||
ps_script = f'''
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
||||
$textNodes = $template.GetElementsByTagName("text")
|
||||
$textNodes.Item(0).AppendChild($template.CreateTextNode("{title}")) > $null
|
||||
$textNodes.Item(1).AppendChild($template.CreateTextNode("{message}")) > $null
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($template)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("{title}").Show($toast)
|
||||
'''
|
||||
subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-Command", ps_script],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方法2: PowerShell 简单弹窗 (最后降级)
|
||||
try:
|
||||
escaped_msg = message.replace("'", "''")
|
||||
escaped_title = title.replace("'", "''")
|
||||
ps_cmd = (
|
||||
f"Add-Type -AssemblyName System.Windows.Forms; "
|
||||
f"[System.Windows.Forms.MessageBox]::Show('{escaped_msg}', '{escaped_title}')"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-Command", ps_cmd],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _notify_macos(self, title: str, message: str):
|
||||
"""macOS 通知 — 使用 osascript"""
|
||||
escaped_title = title.replace('"', '\\"')
|
||||
escaped_message = message.replace('"', '\\"')
|
||||
script = f'display notification "{escaped_message}" with title "{escaped_title}"'
|
||||
subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
|
||||
def _notify_linux(self, title: str, message: str):
|
||||
"""Linux 通知 — 优先 notify-send,降级到 D-Bus"""
|
||||
# 方法1: notify-send (libnotify)
|
||||
try:
|
||||
subprocess.run(
|
||||
["notify-send", title, message, "-i", "dialog-information", "-t", "3000"],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# 方法2: D-Bus 直接调用
|
||||
try:
|
||||
dbus_script = f'''
|
||||
import dbus
|
||||
bus = dbus.SessionBus()
|
||||
notify_obj = bus.get_object('org.freedesktop.Notifications', '/org/freedesktop/Notifications')
|
||||
notify_iface = dbus.Interface(notify_obj, 'org.freedesktop.Notifications')
|
||||
notify_iface.Notify('xiaoli-cli', 0, 'dialog-information', '{title}', '{message}', [], {{}}, 3000)
|
||||
'''
|
||||
subprocess.run(
|
||||
[sys.executable, "-c", dbus_script],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 全局单例 ──
|
||||
_notification_manager = None
|
||||
|
||||
|
||||
def get_notification_manager() -> NotificationManager:
|
||||
"""获取全局通知管理器单例"""
|
||||
global _notification_manager
|
||||
if _notification_manager is None:
|
||||
_notification_manager = NotificationManager()
|
||||
return _notification_manager
|
||||
|
||||
|
||||
def notify_task_complete(task_summary: str = ""):
|
||||
"""快捷函数:通知任务完成"""
|
||||
get_notification_manager().notify_task_complete(task_summary)
|
||||
|
||||
|
||||
def notify_tool_complete(tool_name: str, result_summary: str = ""):
|
||||
"""快捷函数:通知工具执行完成"""
|
||||
get_notification_manager().notify_tool_complete(tool_name, result_summary)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
统一工具返回格式 - ToolResult
|
||||
|
||||
所有插件和工具管理器使用统一的返回格式,
|
||||
替代原来混乱的字符串/Dict 返回。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
# 错误码常量
|
||||
class ErrorCode:
|
||||
"""工具错误码"""
|
||||
SUCCESS = 0
|
||||
# 参数错误 (1xx)
|
||||
MISSING_ARGS = 100
|
||||
INVALID_ARGS = 101
|
||||
UNSUPPORTED_OP = 102
|
||||
# 资源错误 (2xx)
|
||||
NOT_FOUND = 200
|
||||
FILE_NOT_FOUND = 201
|
||||
PERMISSION_DENIED = 202
|
||||
# 执行错误 (3xx)
|
||||
EXEC_FAILED = 300
|
||||
TIMEOUT = 301
|
||||
DECODE_ERROR = 302
|
||||
# 系统错误 (4xx)
|
||||
INTERNAL_ERROR = 400
|
||||
NOT_IMPLEMENTED = 401
|
||||
|
||||
_NAMES = {
|
||||
0: "成功",
|
||||
100: "缺少参数",
|
||||
101: "参数无效",
|
||||
102: "不支持的操作",
|
||||
200: "未找到",
|
||||
201: "文件不存在",
|
||||
202: "权限不足",
|
||||
300: "执行失败",
|
||||
301: "超时",
|
||||
302: "解码错误",
|
||||
400: "内部错误",
|
||||
401: "未实现",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def name(cls, code: int) -> str:
|
||||
return cls._NAMES.get(code, f"未知({code})")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResult:
|
||||
"""统一工具返回格式"""
|
||||
|
||||
success: bool
|
||||
data: Any = None
|
||||
error_code: int = ErrorCode.SUCCESS
|
||||
error_message: str = ""
|
||||
|
||||
# 可选元数据
|
||||
tool_name: str = ""
|
||||
execution_time: float = 0.0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.success:
|
||||
return str(self.data) if self.data is not None else "执行完成"
|
||||
return f"[{ErrorCode.name(self.error_code)}] {self.error_message}"
|
||||
|
||||
# ── 便捷构造方法 ──
|
||||
|
||||
@classmethod
|
||||
def ok(cls, data: Any = None, **kwargs) -> "ToolResult":
|
||||
"""成功"""
|
||||
return cls(success=True, data=data, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def fail(cls, message: str, code: int = ErrorCode.EXEC_FAILED, **kwargs) -> "ToolResult":
|
||||
"""失败"""
|
||||
return cls(success=False, error_code=code, error_message=message, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def missing(cls, what: str = "参数", **kwargs) -> "ToolResult":
|
||||
"""缺少参数"""
|
||||
return cls.fail(f"缺少{what}", ErrorCode.MISSING_ARGS, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def not_found(cls, what: str = "资源", **kwargs) -> "ToolResult":
|
||||
"""未找到"""
|
||||
return cls.fail(f"{what}不存在", ErrorCode.NOT_FOUND, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def timeout(cls, seconds: int = 0, **kwargs) -> "ToolResult":
|
||||
"""超时"""
|
||||
msg = f"执行超时"
|
||||
if seconds:
|
||||
msg += f" ({seconds}秒)"
|
||||
return cls.fail(msg, ErrorCode.TIMEOUT, **kwargs)
|
||||
|
||||
# ── 格式转换 ──
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转为普通 dict(兼容旧接口)"""
|
||||
if self.success:
|
||||
return {"result": self.data if self.data is not None else "执行完成"}
|
||||
return {"result": str(self)}
|
||||
|
||||
def to_mcp(self) -> dict:
|
||||
"""转为 MCP 协议格式"""
|
||||
text = str(self)
|
||||
return {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"isError": not self.success,
|
||||
}
|
||||
|
||||
def to_string(self) -> str:
|
||||
"""转为字符串(兼容旧插件 handle() 返回值)"""
|
||||
return str(self)
|
||||
Reference in new issue
Block a user