22 Commits
Author SHA1 Message Date
OpenClaw bfefbef475 release: v5.2.0 - 插件内存管理 + TUI 修复 2026-05-08 00:47:38 +08:00
OpenClaw fb41c79b14 feat: 插件内存管理 - TTL自动淘汰 + 懒加载
新增 plugin_memory_manager.py:
- 后台线程定期扫描空闲插件,超过TTL自动从内存卸载
- AI调用已卸载插件时自动重新加载(懒加载)
- 核心插件(tool_search)可钉住永不淘汰
- 可配置: TTL(300s), 最大空闲数(10), 扫描间隔(60s), 钉住列表

改造 unified_tool_manager.py:
- 集成 PluginMemoryManager
- execute/mcp_call/search_tools/get_tool 统一走 _ensure_loaded
- search_tools 可搜索已卸载插件(使用缓存信息)
- 新增 get_all_tools_info/get_tool_by_name/memory_manager 属性

改造 tui.py:
- /status 命令显示内存管理详细状态
- 侧栏显示已加载/已卸载工具数
2026-05-08 00:40:01 +08:00
OpenClaw 038fbe24c8 fix(tui): 修复set_timer(0)导致的ZeroDivisionError
_render_welcome_animated中delay=i*0.1在i=0时为0.0,
Textual 8.x的set_timer(0,...)内部interval为0导致除零崩溃。
使用max(0.01, delay)确保最小延迟。
2026-05-08 00:24:26 +08:00
OpenClaw fe79f0e5d4 fix(tui): 修复消息分类、侧栏切换、动画失效、TextArea导入
- _write_raw: 空字符串'' in msg永远为True导致所有消息归类为tool-ok,
  替换为正确的emoji标记(✅/❌/🤖)
- action_toggle_sidebar: Textual无.visible属性,改用has_class/addClass
- CSS .hidden: display:none阻止transition动画,改用width+opacity方案
- constants.py: 添加TextArea、Rule到Textual可用性检查导入列表
2026-05-08 00:17:36 +08:00
OpenClaw Bot af9fffc4a6 docs: rewrite README for Gitee compliance 2026-05-03 23:37:31 +08:00
OpenClaw Bot 7ada90ac27 Add project logo: anime-style face icon 2026-05-03 23:33:48 +08:00
xiaoli-cli bot 407ecc0f47 docs: README 添加插件仓库引用
- 插件列表章节添加 xiaoli-cli-plugins 链接
- 链接区添加插件仓库地址
2026-04-30 03:08:07 +08:00
shuiyu1123 78b74d1bb1 feat: 新增 7 个实用插件
- json_toolkit: JSON 工具箱(格式化/压缩/校验/路径查询/差异比较/Schema推断)
- snippet_manager: 代码片段管理器(保存/搜索/分类/标签/导入导出)
- regex_tester: 正则表达式测试器(匹配/提取/替换/解释/常用模式库/性能测试)
- id_generator: UUID/ID 生成器(UUID v1/v4/v5/NanoID/短ID/雪花ID/ULID/自定义模板)
- color_picker: 颜色工具(HEX/RGB/HSL/HSV/CMYK转换/WCAG对比度/调色板/色盲模拟)
- bookmark_manager: 书签管理器(分类/标签/搜索/导入导出/可访问性检查)
- markdown_tool: Markdown 工具(TOC/统计/表格生成/待办提取/语法检查/字数统计)

所有插件均支持 Plugin/Liugin 双协议和 MCP 定义。
2026-04-30 03:01:11 +08:00
xiaoli-cli bot bef3fe560d feat: 注册表添加 onebot 插件 2026-04-30 02:50:37 +08:00
xiaoli-cli bot 0b19ec64a4 fix: 修复 Gitee 下载支持,通过 API + token 下载插件
- Gitee raw URL 返回 403,自动转为 API 调用
- 从 GITEE_TOKEN 环境变量读取访问令牌
- 修复 Fore.DIM → Style.DIM
- 测试通过: install / list / remove 全流程正常
2026-04-30 02:42:52 +08:00
xiaoli-cli bot 6f13d41c11 feat: 添加 /plugin 插件市场命令
功能:
- /plugin list - 列出可用插件(自动同步远程源)
- /plugin search <关键词> - 搜索插件
- /plugin info <插件码> - 查看插件详情
- /plugin install <插件码|URL> - 安装插件(自动下载、启用、动态加载)
- /plugin remove <名称> - 卸载插件
- /plugin update <插件码> - 更新插件
- /plugin add-source <URL> - 添加远程插件源

文件:
- xcli_core/plugin_market.py - PluginMarketMixin 核心逻辑
- plugin_registry.json - 本地插件码注册表
- xcli_core/cli_core.py - 集成 PluginMarketMixin
- HELP.md - 添加插件市场文档
2026-04-30 02:37:05 +08:00
xiaoli-cli-bot 0d27f2b774 feat: add /manual command + fix cross-engine command routing
- /manual → switch to manual engine (CLI + TUI)
- /manual status → run manual engine sub-commands
- /manual restart → restart manual terminal
- /engine.manual status → now works from any engine (not just manual)
- Fixed handle_engine_command to route to correct engine by name
2026-04-29 20:04:40 +08:00
xiaoli-cli-bot 03685ab5e7 fix: WebUI engine switch now updates UI and shows errors 2026-04-29 19:57:17 +08:00
xiaoli-cli-bot 7a72a5c69e fix: Ollama model validation, TUI CSS, WebUI overhaul
1. Ollama: validate model is configured before API call, show helpful error
2. TUI: remove unsupported scroll-behavior CSS property
3. WebUI server: add full tool calling loop (FC + JSON action)
4. WebUI HTML: complete redesign
   - Modern dark theme with better typography
   - Markdown rendering via marked.js
   - Tool call/result visualization with colored cards
   - Dynamic sidebar (engines + tools from server)
   - Better input area with gradient send button
   - Responsive layout
2026-04-29 19:51:42 +08:00
xiaoli-cli-bot baa2cbc49a feat: diff display mode - popup or inline (SSH compatible)
- Startup prompt asks user to choose popup or inline diff mode
- Added inline diff display in main terminal with colored output
- Green for added lines, red for deleted lines (both modes)
- TUI mode uses Rich markup, CLI mode uses ANSI colors
- New commands: diff_popup popup / diff_popup inline
- Default: inline mode (SSH compatible)
2026-04-29 19:40:07 +08:00
xiaoli-cli-bot f41d5d5a7c fix: remove unsupported @keyframes from Textual CSS
Textual CSS parser does not support @keyframes syntax (it's a Web CSS feature).
These keyframes were unused - all animations are implemented via Python's
set_interval() and animate() methods. Removing them fixes the stylesheet
parse error that prevented TUI mode from starting.
2026-04-29 19:28:53 +08:00
shuiyu1123 8865084905 !4 chore: 清理仓库,删除不需要的文件
Merge pull request !4 from shuiyu1123/cleanup-v5.1.4
2026-04-29 10:36:31 +00:00
shuiyu1123 da9cfb6c9d chore: 清理仓库,删除不需要的文件
删除内容:
- xiaoli-cli-v5.1.2.zip(旧版本构建产物)
- compare.html / webui-analysis.html(AI编程助手横评页面)
- scheduler_tasks.json(空运行时数据)
- skills/file-manager/scripts/test.txt / test_multi.txt(测试数据)
- .gitee/pipeline.yml / .github/workflows/ci.yml(CI配置)
2026-04-29 18:34:20 +08:00
shuiyu1123 f142e3bae8 删除文件 scheduler_tasks.json 2026-04-29 10:26:01 +00:00
shuiyu1123 e3b2d56686 删除文件 webui-analysis.html 2026-04-29 10:25:47 +00:00
shuiyu1123 aa37ec0e44 删除文件 compare.html 2026-04-29 10:25:33 +00:00
shuiyu1123 530a255091 !3 feat: 新增桌面GUI自动化插件 (gui_auto) - Windows UIA 元素识别与操控
Merge pull request !3 from shuiyu1123/feature/gui-auto-plugin
2026-04-29 07:42:34 +00:00
33 changed files with 5357 additions and 2948 deletions

No files matched your search

-117
View File
@@ -1,117 +0,0 @@
# Gitee Go CI 流水线
triggers:
push:
branches:
- master
- main
pull_request:
branches:
- master
- main
stages:
- lint
- test
- structure
jobs:
py-compile:
name: 语法检查
stage: lint
image: python:3.12
script:
- python -c "
import py_compile, os, sys;
errors = 0;
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in {'.git', '__pycache__', 'node_modules', 'embedded_python'}];
for f in files:
if f.endswith('.py'):
path = os.path.join(root, f);
try:
py_compile.compile(path, doraise=True);
except py_compile.PyCompileError as e:
print(f'ERROR: {e}');
errors += 1;
if errors:
print(f'{errors} 个文件有语法错误');
sys.exit(1);
print('所有文件语法正确')
"
ruff-lint:
name: Ruff lint
stage: lint
image: python:3.12
script:
- pip install ruff -q
- ruff check . --select E,F,W --ignore E501,E402 --exclude .git,__pycache__,embedded_python
pytest-310:
name: 插件测试 (Python 3.10)
stage: test
image: python:3.10
script:
- pip install pytest colorama -q
- pytest tests/ -v --tb=short
pytest-311:
name: 插件测试 (Python 3.11)
stage: test
image: python:3.11
script:
- pip install pytest colorama -q
- pytest tests/ -v --tb=short
pytest-312:
name: 插件测试 (Python 3.12)
stage: test
image: python:3.12
script:
- pip install pytest colorama -q
- pytest tests/ -v --tb=short
structure:
name: 项目结构检查
stage: structure
image: python:3.12
script:
- echo "检查项目结构..."
- |
files=(
"ai_cli.py"
"launcher.py"
"xcli_core/cli_core.py"
"xcli_core/cli_base.py"
"xcli_core/config.py"
"xcli_core/constants.py"
"ai_engines/ollama_engine.py"
"ai_engines/openai_engine.py"
"plugins/code_editor.py"
"plugins/cmd_executor.py"
"plugins/file_manager.py"
"plugins/auto_engineer.py"
)
missing=0
for f in "${files[@]}"; do
if [ -f "$f" ]; then
echo " ✓ $f"
else
echo " ✗ $f (缺失!)"
missing=$((missing + 1))
fi
done
if [ $missing -gt 0 ]; then
echo "${missing} 个文件缺失"
exit 1
fi
echo "项目结构完整"
- |
engines=$(ls ai_engines/*_engine.py 2>/dev/null | wc -l)
echo "AI 引擎数量: $engines"
if [ "$engines" -gt 2 ]; then
echo "警告: 引擎数量超过 2 个"
exit 1
fi
echo "引擎数量正常"
-148
View File
@@ -1,148 +0,0 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
lint:
name: 代码检查
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: 安装依赖
run: pip install ruff
- name: 语法检查 (py_compile)
run: |
python -c "
import py_compile, os, sys
errors = 0
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in {'.git', '__pycache__', 'node_modules', 'embedded_python'}]
for f in files:
if f.endswith('.py'):
path = os.path.join(root, f)
try:
py_compile.compile(path, doraise=True)
except py_compile.PyCompileError as e:
print(f'ERROR: {e}')
errors += 1
if errors:
print(f'\n{errors} 个文件有语法错误')
sys.exit(1)
print('所有文件语法正确')
"
- name: Ruff lint
run: ruff check . --select E,F,W --ignore E501,E402,W503 --exclude .git,__pycache__,embedded_python
test:
name: 插件测试
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: 安装 Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: 安装测试依赖
run: pip install pytest colorama
- name: 运行测试
run: pytest tests/ -v --tb=short
structure:
name: 项目结构检查
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: 检查必要文件
run: |
echo "检查项目结构..."
files=(
"ai_cli.py"
"launcher.py"
"xcli_core/cli_core.py"
"xcli_core/cli_base.py"
"xcli_core/config.py"
"xcli_core/constants.py"
"ai_engines/ollama_engine.py"
"ai_engines/openai_engine.py"
"plugins/code_editor.py"
"plugins/git_tools.py"
"plugins/cmd_executor.py"
"plugins/file_manager.py"
"plugins/auto_engineer.py"
)
missing=0
for f in "${files[@]}"; do
if [ -f "$f" ]; then
echo " $f"
else
echo " $f (缺失!)"
missing=$((missing + 1))
fi
done
if [ $missing -gt 0 ]; then
echo "\n${missing} 个文件缺失"
exit 1
fi
echo "\n项目结构完整"
- name: 检查旧文件已清理
run: |
old_files=(
"ai_engines/mimo_engine.py"
"ai_engines/GLM_http_engine.py"
"ai_engines/qwq_engine.py"
"ai_engines/xfyun_spark_engine.py"
"ai_engines/iflow_api_engine.py"
"ai_engines/maas_http_engine.py"
"ai_engines/SDK_openAI.py"
"plugins/git_workflow.py"
"plugins/code_search.py"
"plugins/project_analyzer.py"
"plugins/scheduler.py"
)
found=0
for f in "${old_files[@]}"; do
if [ -f "$f" ]; then
echo " 旧文件未清理: $f"
found=$((found + 1))
fi
done
if [ $found -gt 0 ]; then
echo "\n${found} 个旧文件未清理"
exit 1
fi
echo "旧文件已全部清理"
- name: 检查无重复引擎
run: |
engines=$(ls ai_engines/*_engine.py 2>/dev/null | wc -l)
echo "AI 引擎数量: $engines"
if [ "$engines" -gt 2 ]; then
echo "警告: 引擎数量超过 2 个 (ollama + openai)"
exit 1
fi
echo "引擎数量正常"
+28
View File
@@ -4,6 +4,34 @@
---
## v5.2.0 (2026-05-08)
### ✨ 新增功能
- **插件内存管理器** (`plugin_memory_manager.py`) — TTL 自动淘汰 + 懒加载机制
- 后台线程定期扫描空闲插件,超过 TTL(默认 5 分钟)自动从内存卸载,释放资源
- AI 调用已卸载的插件时自动重新加载(懒加载),对用户完全透明
- 核心插件(`tool_search`)可钉住永不淘汰,保障基础功能可用
- 可配置参数:TTL 时间、最大空闲插件数、扫描间隔、钉住列表
- `/status` 命令和 TUI 侧栏实时显示内存管理状态(已加载/已卸载/统计)
- `search_tools` 支持搜索已卸载插件(使用缓存的元数据,无需加载实例)
### TUI 修复
- **修复消息分类失效** — `_write_raw` 中空字符串 `'' in msg` 永远为 True,导致所有消息显示为工具成功样式;替换为正确的 emoji 标记(✅/❌/🤖)
- **修复侧栏切换失效** — Textual 无 `.visible` 布尔属性,改用 `has_class("hidden")` + CSS 类控制显隐
- **修复侧栏隐藏动画** — CSS `display: none` 阻止过渡动画,改用 `width: 0` + `opacity: 0` 方案
- **修复 TextArea 导入缺失** — `constants.py` 可用性检查未导入 `TextArea`、`Rule`
- **修复 `set_timer(0)` 崩溃** — 欢迎动画首行延迟为 0.0 导致 Textual 8.x 除零错误(`ZeroDivisionError`),使用 `max(0.01, delay)` 修复
### 改进
- `UnifiedToolManager` 重构:集成 `PluginMemoryManager`,所有工具访问统一走 `_ensure_loaded` 路径
- 新增 `get_all_tools_info()` 方法,系统提示词可列出所有注册工具(含已卸载)
- 新增 `get_tool_by_name()` / `memory_manager` 属性,兼容旧 `LiuginManager` 接口
---
## v5.1.4 (2026-04-29)
### 新增功能
+48
View File
@@ -222,6 +222,54 @@ tool_search 浏览器 # 搜索浏览器相关工具
---
## 插件市场
通过 `/plugin` 命令管理插件,支持从远程仓库一键安装。
### 查看可用插件
```
/plugin list # 列出所有可用插件(自动同步远程源)
/plugin search 郊狼 # 搜索关键词
/plugin info dglab # 查看插件详情
```
### 安装插件
```
/plugin install dglab # 通过插件码安装
/plugin install https://example.com/my_plugin.py # 从 URL 安装
```
安装后自动:
- 下载插件文件到 `plugins/` 目录
- 在 `plugins_config.py` 中启用
- 尝试动态加载(无需重启)
### 卸载插件
```
/plugin remove dglab_ws # 卸载指定插件
```
### 更新插件
```
/plugin update dglab # 重新下载更新
```
### 添加插件源
```
/plugin add-source https://example.com/registry.json # 同步远程插件源
```
### 插件码注册表
插件码是插件的唯一标识,映射到下载地址。注册表文件:`plugin_registry.json`
---
## 聊天记录
```
+1 -1
View File
@@ -5,7 +5,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-5.1.2-blue" alt="version">
<img src="https://img.shields.io/badge/version-5.2.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-113%20passed-brightgreen" alt="tests">
+78 -78
View File
@@ -1,36 +1,42 @@
# 小狸 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-5.1.4-blue" alt="version">
<img src="https://img.shields.io/badge/version-5.2.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-19-blueviolet" alt="plugins">
<img src="https://img.shields.io/badge/MCP-supported-brightgreen" alt="mcp">
</p>
---
## 简介
小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。
## 特性
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索、**自动语法检查**
- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索、自动语法检查
- **Git 版本控制** — 完整 Git 操作 + 工作流自动化(smart-commit、changelog)
- **浏览器自动化** — Playwright 驱动,导航/交互/截图/JS 执行/PDF
- **浏览器自动化** — Playwright 驱动,导航、交互、截图、JS 执行、PDF 导出
- **子 Agent 系统** — 多 Agent 并行协作,独立任务分配与结果回收
- **统一安全层** — AI 风险识别 + 三模式切换(普通/人工/无限制)
- **图像生成** — 基于 ComfyUI 在线服务,自然语言/Tag 双模式,支持中文描述
- **记忆系统** — 长期记忆 + 每日日记 + AI 写日记 + 全局搜索 + 自动保存对话
- **Clawli 远程模式** — PC-手机 WebSocket 双向通信,支持内网穿透代理
- **远程通信** — PC 与手机 WebSocket 双向通信
- **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI
- **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux)
- **MCP 协议** — 19 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
- **19 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆、桌面GUI自动化等
- **三模式界面** — TUI 图形化 + 传统命令行 + **Web UI 浏览器界面**
- **19 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
- **三模式界面** — TUI 图形化 + 传统命令行 + Web UI 浏览器界面
- **150+ 个自动化测试** — pytest 覆盖全部插件和核心模块
## 快速开始
@@ -52,7 +58,7 @@ python ai_cli.py
# 指定引擎
python ai_cli.py --engine openai
# Windows 一键启动(无需 Python 环境)
# Windows 一键启动
launcher.exe
```
@@ -89,13 +95,15 @@ launcher.exe
## 插件列表 (19 个)
> 更多社区插件请访问 [xiaoli-cli-plugins](https://gitee.com/shuiyu1123/xiaoli-cli-plugins),使用 `/plugin install <插件码>` 一键安装。
### 核心生产力
| 插件 | 操作数 | 说明 |
|------|--------|------|
| `code_editor` | 24 | 代码编辑+搜索。精准替换、批量编辑、find/regex/symbols/imports/callers/todo/structure + 自动语法检查 + diff 弹窗 |
| `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 命令执行。跨平台,超时控制,危险命令拦截 |
| `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 持久化 |
@@ -106,12 +114,12 @@ launcher.exe
|------|--------|------|
| `sub_agent` | 8 | 子 Agent 系统。spawn/list/status/result/send/kill,多 Agent 并行协作 |
| `browser_auto` | 28 | 浏览器自动化。导航/点击/填写/截图/JS执行/PDF/cookies(基于 Playwright) |
| `gui_auto` | 20 | **桌面GUI自动化**。基于 Windows UIA,屏幕元素识别/点击/输入/截图/元素树遍历(仅Windows) |
| `ai_image` | — | **AI生图**。基于 ai.2x.nz 自然语言生图,异步生成 + WebSocket 进度推送 |
| `image_generator` | 7 | **图像生成**。基于 ComfyUI 在线服务,自然语言/Tag 双模式,28 个角色工作流 |
| `memory_plugin` | 11 | **记忆系统**。AI 写日记/搜索记忆/管理聊天记录/长期记忆 MEMORY.md |
| `scheduler` | 4 | **定时任务**。支持相对时间/绝对时间/重复任务,到期自动激活 AI |
| `tool_search` | — | 工具搜索。按关键词发现可用插件(供 AI 按需调用) |
| `gui_auto` | 20 | 桌面 GUI 自动化。基于 Windows UIA,屏幕元素识别/点击/输入/截图(仅 Windows) |
| `ai_image` | — | AI 生图。基于 ai.2x.nz 自然语言生图,异步生成 + WebSocket 进度推送 |
| `image_generator` | 7 | 图像生成。基于 ComfyUI 在线服务,自然语言/Tag 双模式,28 个角色工作流 |
| `memory_plugin` | 11 | 记忆系统。AI 写日记/搜索记忆/管理聊天记录/长期记忆 |
| `scheduler` | 4 | 定时任务。支持相对时间/绝对时间/重复任务,到期自动激活 AI |
| `tool_search` | — | 工具搜索。按关键词发现可用插件 |
### 辅助工具
@@ -122,7 +130,7 @@ launcher.exe
| `frontend_tester` | 前端测试。HTML/CSS/JS 语法检查、响应式验证 |
| `speech_recognition` | 语音识别。录音 + 语音转文字(Whisper) |
| `audio_player` | 音频播放 |
| `send_image` | Clawli 模式下发图片到手机 |
| `send_image` | 远程模式下发图片到手机 |
## 记忆系统
@@ -144,13 +152,12 @@ memory_plugin remember 用户偏好深色主题
memory_plugin chat_search 图像生成
```
## Clawli 远程模式
## 远程通信
通过 WebSocket 实现 PC-手机双向通信:
通过 WebSocket 实现 PC 与手机双向通信:
```bash
/remote start 9079 mypassword # 启动本地服务
/remote proxy relay.example.com # 通过中继服务器穿透内网
/remote stop # 停止服务
/remote status # 查看状态
```
@@ -159,13 +166,12 @@ memory_plugin chat_search 图像生成
## 安全机制
### 三模式切换 (`/safe`)
### 模式切换 (`/safe`)
| 模式 | 图标 | 说明 |
|------|------|------|
| **普通模式** (默认) | | AI 识别风险,有风险才请求确认 |
| **人工确认** | | 所有指令都需要用户确认 |
| **无限制** | | 直接执行,不检查 |
| 模式 | 说明 |
|------|------|
| **普通模式** (默认) | AI 识别风险,有风险才请求确认 |
| **人工确认** | 所有指令都需要用户确认 |
### 检查流程
@@ -173,16 +179,16 @@ memory_plugin chat_search 图像生成
工具调用 → safety.check()
├─ 快速预检(读取/查询类)→ 直接放行
├─ 普通模式 → AI 独立分析风险
│ ├─ safe=true → 放行
│ └─ safe=false → 展示风险等级+影响,y/n 确认
│ ├─ safe → 放行
│ └─ unsafe → 展示风险等级+影响,y/n 确认
└─ 特殊指令(rm/delete/chmod)→ 始终确认
```
### 代码沙箱
### 代码执行
独立的安全代码执行环境:
- AST 级危险代码检测
- 模块白名单(阻断 os/subprocess/socket 等 25 个模块)
- 模块白名单(阻断 25 个高危模块)
- 子进程隔离执行
- 内存限制
@@ -196,20 +202,18 @@ memory_plugin chat_search 图像生成
| **FC** | OpenAI Function Calling 原生调用 |
| **JSON** | 传统 `{"action": "use_tool", ...}` 格式 |
`fc_tools.py` 自动将 MCP 定义转换为 OpenAI FC 格式,支持混合响应解析。
## 命令列表
| 命令 | 说明 |
|------|------|
| `/help` | 显示帮助 |
| `/safe` | 切换安全模式(普通→人工→无限制) |
| `/safe` | 切换安全模式 |
| `/engine list` | 列出可用引擎 |
| `/engine switch <引擎>` | 切换引擎 |
| `/engine.openai models` | 列出 OpenAI 引擎模型 |
| `/chat save/list/open` | 聊天记录管理 |
| `/file.read <路径>` | 读取文件 |
| `/remote` | Clawli 远程连接帮助 |
| `/remote` | 远程连接帮助 |
| `/notify` | 切换任务完成通知 |
| `/scheduler` / `/remind` | 管理定时任务 |
| `/memory` | 管理记忆系统 |
@@ -225,7 +229,7 @@ WebSocket 实时交互界面,支持完整的 AI 对话体验。
python webui_server.py --port 8080
# 浏览器访问 http://localhost:8080
# 方式 2: 直接打开静态页面(功能有限)
# 方式 2: 直接打开静态页面
start webui.html # Windows
open webui.html # macOS
xdg-open webui.html # Linux
@@ -236,58 +240,54 @@ xdg-open webui.html # Linux
```
xiaoli-cli/
├── ai_cli.py # 主入口
├── webui.html # Web UI 界面 (WebSocket 实时交互)
├── webui_server.py # Web UI 后端 (WebSocket + 静态文件服务)
├── launcher.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 # 工具调用执行(支持 MCP/FC/JSON)
│ ├── cli_clawli.py # Clawli 远程模式
│ ├── cli_code_exec.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 # 记忆系统(长期记忆+日记+搜索+压缩)
│ ├── memory.py # 记忆系统
│ ├── notification.py # 跨平台系统通知
│ ├── plugin_manager.py # 插件管理器(Liugin/Plugin 双协议)
│ ├── safety.py # 统一安全层(AI 风险分析)
│ ├── sandbox.py # 代码安全沙箱(AST 级检测)
│ └── tui.py # TUI 界面(Textual + 动画)
│
│ ├── plugin_manager.py # 插件管理器
│ ├── safety.py # 统一安全层
│ ├── sandbox.py # 代码安全沙箱
│ └── tui.py # TUI 界面
├── ai_engines/ # AI 引擎
│ ├── ollama_engine.py # Ollama 本地模型
│ └── openai_engine.py # OpenAI 兼容格式
│
│ ├── ollama_engine.py
│ └── openai_engine.py
├── plugins/ # 插件 (19 个)
│ ├── code_editor.py # 代码编辑+搜索 (24 操作)
│ ├── git_tools.py # Git 版本控制 (17 操作)
│ ├── cmd_executor.py # Shell 命令执行
│ ├── file_manager.py # 文件管理 (10 操作)
│ ├── auto_engineer.py # 工程化自动化 (12 操作)
│ ├── task_manager.py # 任务管理
│ ├── sub_agent.py # 子 Agent 系统 (8 操作)
│ ├── browser_auto.py # 浏览器自动化 (28 操作)
│ ├── gui_auto.py # 桌面GUI自动化 (20 操作) ⭐ NEW
│ ├── ai_image.py # AI生图 (ai.2x.nz) ⭐ NEW
│ ├── image_generator.py # 图像生成 (7 操作) ⭐ NEW
│ ├── memory_plugin.py # 记忆系统 (11 操作) ⭐ NEW
│ ├── scheduler.py # 定时任务
│ ├── tool_search.py # 工具搜索
│ ├── network_tools.py # 网络工具
│ ├── ai_search.py # AI 搜索
│ ├── frontend_tester.py # 前端测试
│ ├── speech_recognition.py # 语音识别
│ └── send_image.py # 图片发送
│
├── skills/ # 技能 (Skill 协议)
├── image_engine/ # 图像识别引擎(Ollama 视觉模型)
├── tests/ # 测试套件 (113 用例)
│ ├── 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
│ └── send_image.py
├── skills/ # 技能
├── image_engine/ # 图像识别引擎
├── tests/ # 测试套件 (150+ 用例)
├── love.txt # 彩蛋情话
└── about.txt # 关于信息
```
@@ -295,7 +295,7 @@ xiaoli-cli/
## 测试
```bash
# 运行全部测试 (113 用例)
# 运行全部测试
python -m pytest tests/ -v
# 语法检查
@@ -307,7 +307,7 @@ python -c "import py_compile; py_compile.compile('ai_cli.py', doraise=True)"
小狸支持 `Liugin`(自研协议)和 `Plugin`(通用协议)两种类名,均可被插件管理器自动识别:
```python
class Plugin: # 或 class Liugin
class Plugin:
"""我的插件"""
def __init__(self):
@@ -348,6 +348,6 @@ class Plugin: # 或 class Liugin
## 链接
- Gitee: https://gitee.com/shuiyu1123/xiaoli-cli
- 插件仓库: https://gitee.com/shuiyu1123/xiaoli-cli-plugins
- [更新日志](CHANGELOG.md)
- [使用指南](HELP.md)
- [AI 编程助手横评](compare.html)
+7
View File
@@ -122,6 +122,13 @@ Ollama AI引擎插件帮助信息
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', '无结果')}"
-1161
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+30
View File
@@ -0,0 +1,30 @@
{
"version": 1,
"description": "小狸 CLI 插件码注册表 - 通过 /plugin install <插件码> 安装",
"plugins": {
"dglab": {
"name": "DG-Lab 郊狼 WebSocket 控制",
"description": "基于 DG-LAB-OPENSOURCE 官方 Socket V2 协议,支持郊狼 3.0 脉冲主机远程控制",
"author": "xiaoli-cli-plugins",
"version": "1.0.0",
"file": "dglab_ws.py",
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/dglab_ws.py",
"docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/README.md",
"deps": ["websocket-client"],
"optional_deps": ["qrcode"],
"keywords": ["dg-lab", "dglab", "郊狼", "coyote", "websocket", "脉冲"]
},
"onebot": {
"name": "OneBot v11 QQ 机器人",
"description": "让 xiaoli-cli 成为 QQ 机器人,连接 go-cqhttp/Lagrange/LLOneBot 等 OneBot 实现",
"author": "xiaoli-cli-plugins",
"version": "1.0.0",
"file": "onebot.py",
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/onebot.py",
"docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/README.md",
"deps": ["websocket-client"],
"optional_deps": [],
"keywords": ["onebot", "qq", "机器人", "bot", "go-cqhttp", "lagrange", "群聊", "私聊"]
}
}
}
+489
View File
@@ -0,0 +1,489 @@
"""
书签管理器插件 - 管理开发相关链接和资源
支持分类、标签、搜索、导入导出,JSON 持久化
"""
import json
import os
import hashlib
import time
from datetime import datetime
from typing import List, Optional
class Plugin:
"""书签管理器 - 开发资源和链接管理"""
def __init__(self):
self.usage = """书签管理器
操作:
add <URL> -n <名称> [-c 分类] [-t 标签1,标签2] [-d 描述]
- 添加书签
list [-c 分类] [-t 标签] - 列出书签
open <名称或ID> - 获取书签 URL
search <关键词> - 搜索书签
delete <名称或ID> - 删除书签
edit <名称或ID> [-n 名称] [-d 描述] [-c 分类] [-t 标签]
- 编辑书签
categories - 列出所有分类
tags - 列出所有标签
export [文件] - 导出书签
import <文件> - 导入书签
stats - 统计信息
recent [-n 数量] - 最近添加的书签
check - 检查书签是否可访问
favicon <URL> - 获取网站 favicon URL
示例:
bookmark_manager add https://github.com -n GitHub -c 开发 -t 代码,托管 -d 代码托管平台
bookmark_manager list -c 开发
bookmark_manager search github
bookmark_manager open GitHub
bookmark_manager categories
"""
self.cli = None
self.bookmarks_file = "bookmarks.json"
self.bookmarks = []
self._load_bookmarks()
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "bookmark_manager",
"description": "书签管理器 - 管理开发相关链接和资源,支持分类、标签、搜索",
"keywords": ["书签", "链接", "收藏", "bookmark", "网址", "资源", "网站", "URL"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "bookmark_manager",
"description": "书签管理器,支持添加、搜索、获取、删除书签",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["add", "list", "open", "search", "delete",
"edit", "categories", "tags", "export",
"import", "stats", "recent", "check"],
"description": "操作类型"
},
"url": {"type": "string", "description": "URL"},
"name": {"type": "string", "description": "书签名称"},
"category": {"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", "")
return f"{op} {name}".strip()
def _load_bookmarks(self):
try:
if os.path.exists(self.bookmarks_file):
with open(self.bookmarks_file, 'r', encoding='utf-8') as f:
self.bookmarks = json.load(f)
except Exception:
self.bookmarks = []
def _save_bookmarks(self):
try:
with open(self.bookmarks_file, 'w', encoding='utf-8') as f:
json.dump(self.bookmarks, f, ensure_ascii=False, indent=2)
return None
except Exception as e:
return f"保存失败: {e}"
def _gen_id(self, url: str) -> str:
return hashlib.md5(f"{url}{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,
"open": self._op_open,
"search": self._op_search,
"delete": self._op_delete,
"edit": self._op_edit,
"categories": self._op_categories,
"tags": self._op_tags,
"export": self._op_export,
"import": self._op_import,
"stats": self._op_stats,
"recent": self._op_recent,
"check": self._op_check,
"favicon": self._op_favicon,
}
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 "错误:请提供 URL"
url = args[0]
name, category, tags, desc = "", "", [], ""
i = 1
while i < len(args):
if args[i] == "-n" and i + 1 < len(args):
name = args[i + 1]; i += 1
elif args[i] == "-c" and i + 1 < len(args):
category = 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 name:
# 从 URL 推断名称
from urllib.parse import urlparse
parsed = urlparse(url if '://' in url else 'https://' + url)
name = parsed.netloc.replace('www.', '').split('.')[0].capitalize()
url = parsed.geturl()
# 检查重复
for b in self.bookmarks:
if b["url"] == url:
return f"错误:URL 已存在 (书签: {b['name']})"
bookmark = {
"id": self._gen_id(url),
"name": name,
"url": url,
"category": category,
"tags": tags,
"description": desc,
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"access_count": 0
}
self.bookmarks.append(bookmark)
err = self._save_bookmarks()
if err:
return err
return f"✅ 已添加书签 '{name}'\n URL: {url}\n 分类: {category or '未分类'}"
def _op_list(self, args: List[str]) -> str:
cat_filter, tag_filter = None, None
i = 0
while i < len(args):
if args[i] == "-c" and i + 1 < len(args):
cat_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.bookmarks
if cat_filter:
filtered = [b for b in filtered if b.get("category", "").lower() == cat_filter]
if tag_filter:
filtered = [b for b in filtered if any(tag_filter in t.lower() for t in b.get("tags", []))]
if not filtered:
return "📭 没有找到书签"
# 按分类分组
grouped = {}
for b in filtered:
cat = b.get("category", "未分类")
grouped.setdefault(cat, []).append(b)
result = f"\n 书签列表 (共 {len(filtered)} 个)\n"
result += "═" * 55 + "\n"
for cat, items in sorted(grouped.items()):
result += f"\n {cat} ({len(items)} 个)\n"
result += "─" * 45 + "\n"
for b in items:
tags = ", ".join(b.get("tags", [])) if b.get("tags") else ""
result += f" [{b['id']}] {b['name']}\n"
result += f" {b['url']}\n"
if tags:
result += f" ️ {tags}\n"
if b.get("description"):
result += f" {b['description'][:50]}\n"
result += "\n"
return result.rstrip()
def _op_open(self, args: List[str]) -> str:
if not args:
return "错误:请提供书签名称或 ID"
key = args[0]
bookmark = self._find_bookmark(key)
if not bookmark:
return f"❌ 未找到书签: {key}"
bookmark["access_count"] = bookmark.get("access_count", 0) + 1
self._save_bookmarks()
return (f" {bookmark['name']}\n"
f" URL: {bookmark['url']}\n"
f" 访问次数: {bookmark.get('access_count', 0)}")
def _op_search(self, args: List[str]) -> str:
if not args:
return "错误:请提供搜索关键词"
keyword = " ".join(args).lower()
results = []
for b in self.bookmarks:
searchable = f"{b['name']} {b['url']} {b.get('description', '')} {' '.join(b.get('tags', []))} {b.get('category', '')}".lower()
if keyword in searchable:
results.append(b)
if not results:
return f" 未找到匹配 '{keyword}' 的书签"
output = f" 搜索结果 ({len(results)} 个):\n"
output += "─" * 50 + "\n"
for b in results:
output += f" [{b['id']}] {b['name']} - {b['url']}\n"
if b.get("description"):
output += f" {b['description'][:40]}\n"
return output.rstrip()
def _op_delete(self, args: List[str]) -> str:
if not args:
return "错误:请提供书签名称或 ID"
key = args[0]
for i, b in enumerate(self.bookmarks):
if b["name"].lower() == key.lower() or b["id"] == key:
removed = self.bookmarks.pop(i)
self._save_bookmarks()
return f" ️ 已删除书签: {removed['name']} ({removed['url']})"
return f"❌ 未找到书签: {key}"
def _op_edit(self, args: List[str]) -> str:
if not args:
return "错误:请提供书签名称或 ID"
key = args[0]
bookmark = self._find_bookmark(key)
if not bookmark:
return f"❌ 未找到书签: {key}"
i = 1
while i < len(args):
if args[i] == "-n" and i + 1 < len(args):
bookmark["name"] = args[i + 1]; i += 1
elif args[i] == "-d" and i + 1 < len(args):
bookmark["description"] = args[i + 1]; i += 1
elif args[i] == "-c" and i + 1 < len(args):
bookmark["category"] = args[i + 1]; i += 1
elif args[i] == "-t" and i + 1 < len(args):
bookmark["tags"] = [t.strip() for t in args[i + 1].split(",") if t.strip()]; i += 1
i += 1
bookmark["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._save_bookmarks()
return f"✅ 已更新书签: {bookmark['name']}"
def _op_categories(self, args: List[str]) -> str:
cat_count = {}
for b in self.bookmarks:
cat = b.get("category", "未分类")
cat_count[cat] = cat_count.get(cat, 0) + 1
if not cat_count:
return " 暂无分类"
result = " 分类列表:\n"
for cat, count in sorted(cat_count.items(), key=lambda x: -x[1]):
bar = "█" * min(count, 20)
result += f" {cat:15s} {bar} ({count})\n"
return result.rstrip()
def _op_tags(self, args: List[str]) -> str:
tag_count = {}
for b in self.bookmarks:
for t in b.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_export(self, args: List[str]) -> str:
output_file = args[0] if args else "bookmarks_export.json"
try:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(self.bookmarks, f, ensure_ascii=False, indent=2)
return f"✅ 已导出 {len(self.bookmarks)} 个书签到: {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_urls = {b["url"] for b in self.bookmarks}
added = 0
for b in imported:
if isinstance(b, dict) and b.get("url") and b["url"] not in existing_urls:
b["id"] = self._gen_id(b["url"])
self.bookmarks.append(b)
existing_urls.add(b["url"])
added += 1
self._save_bookmarks()
return f"✅ 已导入 {added} 个书签 (跳过 {len(imported) - added} 个重复)"
except Exception as e:
return f"❌ 导入失败: {e}"
def _op_stats(self, args: List[str]) -> str:
total = len(self.bookmarks)
if not total:
return "📊 暂无书签"
cat_count = {}
total_access = 0
for b in self.bookmarks:
cat = b.get("category", "未分类")
cat_count[cat] = cat_count.get(cat, 0) + 1
total_access += b.get("access_count", 0)
most_accessed = max(self.bookmarks, key=lambda b: b.get("access_count", 0))
result = f"📊 书签统计\n"
result += "─" * 35 + "\n"
result += f" 总数: {total}\n"
result += f" 总访问: {total_access} 次\n"
result += f" 分类数: {len(cat_count)}\n"
result += f" 最常访问: {most_accessed['name']} ({most_accessed.get('access_count', 0)} 次)\n"
result += f"\n 分类分布:\n"
for cat, count in sorted(cat_count.items(), key=lambda x: -x[1]):
result += f" {cat}: {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.bookmarks, key=lambda b: b.get("created_at", ""), reverse=True)[:count]
if not recent:
return "📭 暂无书签"
result = f"⏰ 最近 {len(recent)} 个书签:\n"
for b in recent:
result += f" [{b['id']}] {b['name']} - {b.get('created_at', '')}\n"
return result.rstrip()
def _op_check(self, args: List[str]) -> str:
import socket
from urllib.parse import urlparse
bookmarks = self.bookmarks
if args:
# 检查指定书签
bookmark = self._find_bookmark(args[0])
if not bookmark:
return f"❌ 未找到书签: {args[0]}"
bookmarks = [bookmark]
result = f" ️ 书签可访问性检查 ({len(bookmarks)} 个):\n"
result += "─" * 50 + "\n"
for b in bookmarks[:20]:
url = b["url"]
try:
parsed = urlparse(url)
host = parsed.netloc or parsed.path.split('/')[0]
socket.create_connection((host, 80), timeout=3)
result += f" {b['name']} ({host})\n"
except (socket.timeout, socket.error, OSError):
result += f" {b['name']} ({host}) - 不可达\n"
if len(bookmarks) > 20:
result += f" ... 仅检查前 20 个\n"
return result.rstrip()
def _op_favicon(self, args: List[str]) -> str:
if not args:
return "错误:请提供 URL"
url = args[0]
if '://' not in url:
url = 'https://' + url
from urllib.parse import urlparse
parsed = urlparse(url)
base = f"{parsed.scheme}://{parsed.netloc}"
favicon_url = f"{base}/favicon.ico"
return (f" Favicon:\n"
f" 网站: {base}\n"
f" Favicon: {favicon_url}\n"
f" Google: https://www.google.com/s2/favicons?domain={parsed.netloc}&sz=64")
def _find_bookmark(self, key: str) -> Optional[dict]:
for b in self.bookmarks:
if b["name"].lower() == key.lower() or b["id"] == key:
return b
return None
def test_plugin():
plugin = Plugin()
print("书签管理器测试:")
print(plugin.handle('add https://github.com -n GitHub -c 开发 -t 代码,托管 -d 代码托管平台'))
print(plugin.handle('add https://stackoverflow.com -n StackOverflow -c 学习 -t 问答,编程'))
print(plugin.handle('list'))
print(plugin.handle('search github'))
if __name__ == "__main__":
test_plugin()
+137 -25
View File
@@ -37,9 +37,10 @@ class Plugin:
编辑操作后自动执行,无需手动调用
Diff 弹窗:
diff_popup [on|off] - 开关 diff 弹窗 (别名: popup)
编辑操作后弹出新窗口显示修改前后对比(默认开启)
on/开/enable → 开启 | off/关/disable → 关闭 | 无参数 → 切换
diff_popup [on|off|popup|inline] - 开关/切换 diff 显示模式 (别名: popup)
popup/弹窗 → 弹窗模式(新终端窗口)
inline/ssh → 主终端内显示(SSH 兼容)
on/开 → 开启 | off/关 → 关闭 | 无参数 → 切换
查看操作:
read_range <文件> <起始行> [结束行] - 读取指定行范围
@@ -59,7 +60,8 @@ Diff 弹窗:
stats <目录> - 代码统计
"""
self.cli = None
self.diff_popup_enabled = True # diff 弹窗开关,默认开启
self.diff_popup_enabled = True # diff 弹窗开关
self.diff_popup_mode = None # None=启动时询问, True=弹窗, False=主终端内显示
def set_cli(self, cli):
self.cli = cli
@@ -611,20 +613,26 @@ else:
def _spawn_diff_popup(self, filepath: str, before: str, after: str):
"""
在新终端窗口中显示修改前后的对比。
使用独立线程避免阻塞主流程。
显示修改前后的对比。
根据 diff_popup_mode 决定弹窗还是主终端内显示。
"""
if not self.diff_popup_enabled:
return
if before == after:
return # 内容无变化,不弹窗
# 启动时未选择模式,默认为主终端内显示(SSH 兼容)
popup = self.diff_popup_mode if self.diff_popup_mode is not None else False
if popup:
thread = threading.Thread(
target=self._do_spawn_diff_popup,
args=(filepath, before, after),
daemon=True
)
thread.start()
else:
self._show_inline_diff(filepath, before, after)
def _do_spawn_diff_popup(self, filepath: str, before: str, after: str):
"""实际执行弹窗创建(在子线程中运行)"""
@@ -642,7 +650,6 @@ else:
tofile=f"修改后: {basename}",
lineterm=''
))
diff_text = '\n'.join(diff_lines)
# 统计变更
added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++'))
@@ -655,7 +662,7 @@ else:
display = self._build_diff_display(
basename, filepath,
before_numbered, after_numbered,
diff_text, added, removed,
diff_lines, added, removed,
before_count, after_count
)
@@ -670,38 +677,62 @@ else:
def _build_diff_display(self, basename, filepath,
before_numbered, after_numbered,
diff_text, added, removed,
diff_lines, added, removed,
before_count, after_count):
"""构建完整的 diff 显示文本"""
"""构建完整的 diff 显示文本(带 ANSI 颜色)"""
sep = '═' * 70
thin_sep = '─' * 70
# ANSI 颜色
R = '\033[31m' # 红
G = '\033[32m' # 绿
C = '\033[36m' # 青
B = '\033[1m' # 粗体
D = '\033[2m' # 暗
W = '\033[37m' # 白
N = '\033[0m' # 重置
# 构建带颜色的 diff
diff_colored_lines = []
for line in diff_lines:
if line.startswith('@@'):
diff_colored_lines.append(f"{C}{line}{N}")
elif line.startswith('+++') or line.startswith('---'):
diff_colored_lines.append(f"{B}{line}{N}")
elif line.startswith('+'):
diff_colored_lines.append(f"{G}{line}{N}")
elif line.startswith('-'):
diff_colored_lines.append(f"{R}{line}{N}")
else:
diff_colored_lines.append(line)
diff_colored = '\n'.join(diff_colored_lines) if diff_colored_lines else ' (无差异)'
display = f"""
{sep}
文件变更对比: {basename}
路径: {filepath}
{sep}
{W}{sep}{N}
{B} 文件变更对比: {basename}{N}
{D} 路径: {filepath}{N}
{W}{sep}{N}
统计: {before_count} 行 → {after_count} 行 | +{added} 新增 -{removed} 删除
统计: {before_count} 行 → {after_count} 行 | {G}+{added} 新增{N} {R}-{removed} 删除{N}
{thin_sep}
{W}{thin_sep}{N}
修改前 ({before_count} 行):
{thin_sep}
{W}{thin_sep}{N}
{before_numbered}
{thin_sep}
{W}{thin_sep}{N}
修改后 ({after_count} 行):
{thin_sep}
{W}{thin_sep}{N}
{after_numbered}
{thin_sep}
{W}{thin_sep}{N}
差异 (Diff):
{thin_sep}
{diff_text if diff_text else ' (无差异)'}
{W}{thin_sep}{N}
{diff_colored}
{sep}
{W}{sep}{N}
按任意键关闭此窗口...
{sep}
{W}{sep}{N}
"""
return display
@@ -775,8 +806,81 @@ else:
except Exception as e:
print(f"[Diff 弹窗] 创建失败: {e}")
def _show_inline_diff(self, filepath: str, before: str, after: str):
"""在主终端内显示 diff(SSH 兼容模式),带颜色"""
basename = os.path.basename(filepath)
is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
# 生成 diff
diff_lines = list(difflib.unified_diff(
before.splitlines(keepends=True),
after.splitlines(keepends=True),
fromfile=f"修改前: {basename}",
tofile=f"修改后: {basename}",
lineterm=''
))
# 统计
added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++'))
removed = sum(1 for l in diff_lines if l.startswith('-') and not l.startswith('---'))
before_count = len(before.splitlines())
after_count = len(after.splitlines())
sep = '═' * 60
thin = '─' * 60
if is_tui:
# TUI 模式:使用 Rich 标记
output = self.cli._output
output(f"[dim]{sep}[/]")
output(f"[bold cyan] 文件变更: {basename}[/] [dim]{filepath}[/]")
output(f"[dim]{sep}[/]")
output(f"[bold] 统计: {before_count} 行 → {after_count} 行 | "
f"[green]+{added} 新增[/] [red]-{removed} 删除[/]")
output(f"[dim]{thin}[/]")
for line in diff_lines:
if line.startswith('@@'):
output(f"[cyan]{line}[/]")
elif line.startswith('+++') or line.startswith('---'):
output(f"[bold]{line}[/]")
elif line.startswith('+'):
output(f"[green]{line}[/]")
elif line.startswith('-'):
output(f"[red]{line}[/]")
else:
output(line)
output(f"[dim]{sep}[/]")
else:
# CLI 模式:使用 ANSI 颜色
from colorama import Fore, Style
_out = print
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
_out(f"{Fore.CYAN} 文件变更: {basename}{Style.RESET_ALL} {filepath}")
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
_out(f" 统计: {before_count} 行 → {after_count} 行 | "
f"{Fore.GREEN}+{added} 新增{Style.RESET_ALL} "
f"{Fore.RED}-{removed} 删除{Style.RESET_ALL}")
_out(f"{Fore.WHITE}{thin}{Style.RESET_ALL}")
for line in diff_lines:
if line.startswith('@@'):
_out(f"{Fore.CYAN}{line}{Style.RESET_ALL}")
elif line.startswith('+++') or line.startswith('---'):
_out(f"{Style.BRIGHT}{line}{Style.RESET_ALL}")
elif line.startswith('+'):
_out(f"{Fore.GREEN}{line}{Style.RESET_ALL}")
elif line.startswith('-'):
_out(f"{Fore.RED}{line}{Style.RESET_ALL}")
else:
_out(line)
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
def _op_diff_popup(self, path: str, rest: str) -> str:
"""切换 diff 弹窗开关"""
"""切换 diff 弹窗开关 / 设置显示模式"""
arg = (path + ' ' + rest).strip().lower()
if arg in ('on', '开', '开启', 'enable', '1', 'true'):
self.diff_popup_enabled = True
@@ -784,6 +888,14 @@ else:
elif arg in ('off', '关', '关闭', 'disable', '0', 'false'):
self.diff_popup_enabled = False
return " Diff 弹窗已关闭"
elif arg in ('popup', '弹窗', '弹窗模式'):
self.diff_popup_enabled = True
self.diff_popup_mode = True
return " Diff 模式: 弹窗(新终端窗口)"
elif arg in ('inline', '内联', 'ssh', '终端', '主终端'):
self.diff_popup_enabled = True
self.diff_popup_mode = False
return " Diff 模式: 主终端内显示(SSH 兼容)"
else:
# 无参数:切换状态
self.diff_popup_enabled = not self.diff_popup_enabled
+459
View File
@@ -0,0 +1,459 @@
"""
颜色工具插件 - 颜色格式转换、调色板、对比度检查
支持 HEX/RGB/HSL/HSV/CMYK 格式互转,WCAG 对比度检查
"""
import re
import colorsys
from typing import List, Tuple
class Plugin:
"""颜色工具 - 格式转换、调色板、对比度检查"""
def __init__(self):
self.usage = """颜色工具
操作:
convert <颜色> - 自动检测并转换为所有格式
hex <RGB或HSL> - 转换为 HEX
rgb <HEX或HSL> - 转换为 RGB
hsl <HEX或RGB> - 转换为 HSL
hsv <HEX或RGB> - 转换为 HSV
cmyk <HEX或RGB> - 转换为 CMYK
contrast <颜色1> <颜色2> - WCAG 对比度检查
palette <颜色> [-n 数量] - 生成调色板(明暗变体)
complementary <颜色> - 互补色
analogous <颜色> - 类似色
triadic <颜色> - 三色组
random [-n 数量] - 随机颜色
mix <颜色1> <颜色2> [-w 权重] - 混合两种颜色
blind <颜色> <类型> - 色盲模拟
named - 列出 CSS 命名颜色
颜色格式:
HEX: #FF6600, #F60, FF6600
RGB: rgb(255,102,0) 或 255,102,0
HSL: hsl(24,100%,50%)
HSV: hsv(24,100%,100%)
CMYK: cmyk(0,60,100,0)
示例:
color_picker convert "#3498db"
color_picker rgb "hsl(207,62%,54%)"
color_picker contrast "#FFFFFF" "#000000"
color_picker palette "#e74c3c" -n 9
color_picker complementary "#3498db"
"""
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "color_picker",
"description": "颜色工具 - HEX/RGB/HSL/HSV/CMYK 转换、对比度检查、调色板生成",
"keywords": ["颜色", "color", "HEX", "RGB", "HSL", "调色板", "对比度", "色盲", "转换"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "color_picker",
"description": "颜色工具,支持格式转换、对比度检查、调色板生成",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["convert", "hex", "rgb", "hsl", "hsv", "cmyk",
"contrast", "palette", "complementary", "analogous",
"triadic", "random", "mix", "blind"],
"description": "操作类型"
},
"color": {"type": "string", "description": "颜色值"},
"color2": {"type": "string", "description": "第二个颜色值"},
"count": {"type": "integer", "description": "数量"},
"weight": {"type": "number", "description": "混合权重 (0-1)"},
"blind_type": {"type": "string", "description": "色盲类型"}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
color = arguments.get("color", "")
return f"{op} {color}".strip()
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return self._op_random(["1"])
operation = parts[0].lower()
handlers = {
"convert": self._op_convert,
"hex": self._op_hex,
"rgb": self._op_rgb,
"hsl": self._op_hsl,
"hsv": self._op_hsv,
"cmyk": self._op_cmyk,
"contrast": self._op_contrast,
"palette": self._op_palette,
"complementary": self._op_complementary,
"analogous": self._op_analogous,
"triadic": self._op_triadic,
"random": self._op_random,
"mix": self._op_mix,
"blind": self._op_blind,
}
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_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 _parse_color(self, color_str: str) -> Tuple[int, int, int]:
"""解析颜色字符串为 RGB (0-255)"""
s = color_str.strip().lower()
# HEX
hex_match = re.match(r'^#?([0-9a-f]{3,8})$', s)
if hex_match:
h = hex_match.group(1)
if len(h) == 3:
h = h[0]*2 + h[1]*2 + h[2]*2
elif len(h) == 8:
h = h[:6] # ignore alpha
if len(h) == 6:
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
# RGB
rgb_match = re.match(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)', s)
if rgb_match:
return (int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)))
# plain r,g,b
plain_match = re.match(r'^(\d+)\s*,\s*(\d+)\s*,\s*(\d+)$', s)
if plain_match:
return (int(plain_match.group(1)), int(plain_match.group(2)), int(plain_match.group(3)))
# HSL
hsl_match = re.match(r'hsl\s*\(\s*(\d+)\s*,\s*(\d+)%,\s*(\d+)%\s*\)', s)
if hsl_match:
h = int(hsl_match.group(1)) / 360
sl = int(hsl_match.group(2)) / 100
l = int(hsl_match.group(3)) / 100
r, g, b = colorsys.hls_to_rgb(h, l, sl)
return (int(r * 255), int(g * 255), int(b * 255))
# HSV/HSB
hsv_match = re.match(r'hsv\s*\(\s*(\d+)\s*,\s*(\d+)%,\s*(\d+)%\s*\)', s)
if hsv_match:
h = int(hsv_match.group(1)) / 360
sv = int(hsv_match.group(2)) / 100
v = int(hsv_match.group(3)) / 100
r, g, b = colorsys.hsv_to_rgb(h, sv, v)
return (int(r * 255), int(g * 255), int(b * 255))
# CMYK
cmyk_match = re.match(r'cmyk\s*\(\s*(\d+)%?\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)', s)
if cmyk_match:
c = int(cmyk_match.group(1)) / 100
m = int(cmyk_match.group(2)) / 100
y = int(cmyk_match.group(3)) / 100
k = int(cmyk_match.group(4)) / 100
r = int(255 * (1 - c) * (1 - k))
g = int(255 * (1 - m) * (1 - k))
b = int(255 * (1 - y) * (1 - k))
return (r, g, b)
raise ValueError(f"无法解析颜色: {color_str}")
def _rgb_to_hex(self, r: int, g: int, b: int) -> str:
return f"#{r:02X}{g:02X}{b:02X}"
def _rgb_to_hsl(self, r: int, g: int, b: int) -> Tuple[int, int, int]:
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
return (int(h * 360), int(s * 100), int(l * 100))
def _rgb_to_hsv(self, r: int, g: int, b: int) -> Tuple[int, int, int]:
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
return (int(h * 360), int(s * 100), int(v * 100))
def _rgb_to_cmyk(self, r: int, g: int, b: int) -> Tuple[int, int, int, int]:
if r == g == b == 0:
return (0, 0, 0, 100)
c = 1 - r / 255
m = 1 - g / 255
y = 1 - b / 255
k = min(c, m, y)
c = int((c - k) / (1 - k) * 100)
m = int((m - k) / (1 - k) * 100)
y = int((y - k) / (1 - k) * 100)
k = int(k * 100)
return (c, m, y, k)
def _luminance(self, r: int, g: int, b: int) -> float:
"""计算相对亮度 (WCAG 2.0)"""
def linearize(c):
c = c / 255
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
def _format_color_block(self, r: int, g: int, b: int) -> str:
"""生成颜色预览块"""
return f" ■ RGB({r}, {g}, {b})"
def _op_convert(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色值"
r, g, b = self._parse_color(args[0])
h, s, l = self._rgb_to_hsl(r, g, b)
hh, sv, vv = self._rgb_to_hsv(r, g, b)
c, m, y, k = self._rgb_to_cmyk(r, g, b)
result = f" 颜色转换: {args[0]}\n"
result += self._format_color_block(r, g, b) + "\n"
result += "─" * 35 + "\n"
result += f" HEX: {self._rgb_to_hex(r, g, b)}\n"
result += f" RGB: rgb({r}, {g}, {b})\n"
result += f" HSL: hsl({h}, {s}%, {l}%)\n"
result += f" HSV: hsv({hh}, {sv}%, {vv}%)\n"
result += f" CMYK: cmyk({c}%, {m}%, {y}%, {k}%)\n"
return result.rstrip()
def _op_hex(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色值"
r, g, b = self._parse_color(args[0])
return f" HEX: {self._rgb_to_hex(r, g, b)}"
def _op_rgb(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色值"
r, g, b = self._parse_color(args[0])
return f" RGB: rgb({r}, {g}, {b})"
def _op_hsl(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色值"
r, g, b = self._parse_color(args[0])
h, s, l = self._rgb_to_hsl(r, g, b)
return f" HSL: hsl({h}, {s}%, {l}%)"
def _op_hsv(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色值"
r, g, b = self._parse_color(args[0])
h, s, v = self._rgb_to_hsv(r, g, b)
return f" HSV: hsv({h}, {s}%, {v}%)"
def _op_cmyk(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色值"
r, g, b = self._parse_color(args[0])
c, m, y, k = self._rgb_to_cmyk(r, g, b)
return f" CMYK: cmyk({c}%, {m}%, {y}%, {k}%)"
def _op_contrast(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:请提供两种颜色"
r1, g1, b1 = self._parse_color(args[0])
r2, g2, b2 = self._parse_color(args[1])
l1 = self._luminance(r1, g1, b1)
l2 = self._luminance(r2, g2, b2)
lighter = max(l1, l2)
darker = min(l1, l2)
ratio = (lighter + 0.05) / (darker + 0.05)
aa_large = ratio >= 3
aa_normal = ratio >= 4.5
aaa_large = ratio >= 4.5
aaa_normal = ratio >= 7
result = f" WCAG 对比度检查\n"
result += "─" * 40 + "\n"
result += f" 颜色1: {self._rgb_to_hex(r1, g1, b1)} {self._format_color_block(r1, g1, b1)}\n"
result += f" 颜色2: {self._rgb_to_hex(r2, g2, b2)} {self._format_color_block(r2, g2, b1)}\n"
result += f" 对比度: {ratio:.2f}:1\n"
result += "─" * 40 + "\n"
result += f" {' ' if aa_normal else '❌'} AA 正文 (≥4.5:1)\n"
result += f" {' ' if aa_large else '❌'} AA 大字 (≥3:1)\n"
result += f" {' ' if aaa_normal else '❌'} AAA 正文 (≥7:1)\n"
result += f" {' ' if aaa_large else '❌'} AAA 大字 (≥4.5:1)\n"
return result.rstrip()
def _op_palette(self, args: List[str]) -> str:
if not args:
return "错误:请提供基础颜色"
r, g, b = self._parse_color(args[0])
opts = self._parse_opts(args[1:])
count = int(opts.get("n", 9))
count = max(3, min(count, 15))
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
result = f" 调色板 (基于 {args[0]}):\n"
result += "─" * 50 + "\n"
steps = count
for i in range(steps):
# 从暗到亮
new_l = 0.1 + (0.8 * i / (steps - 1))
nr, ng, nb = colorsys.hls_to_rgb(h, new_l, s)
nr, ng, nb = int(nr * 255), int(ng * 255), int(nb * 255)
hex_val = self._rgb_to_hex(nr, ng, nb)
bar = "█" * int(new_l * 20)
result += f" {hex_val} {bar} L:{int(new_l*100)}%\n"
return result.rstrip()
def _op_complementary(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色"
r, g, b = self._parse_color(args[0])
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
# 互补色: 色相旋转180°
comp_h = (h + 0.5) % 1.0
cr, cg, cb = colorsys.hls_to_rgb(comp_h, l, s)
cr, cg, cb = int(cr * 255), int(cg * 255), int(cb * 255)
result = f" 互补色:\n"
result += f" 原色: {self._rgb_to_hex(r, g, b)}\n"
result += f" 互补: {self._rgb_to_hex(cr, cg, cb)}\n"
return result
def _op_analogous(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色"
r, g, b = self._parse_color(args[0])
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
result = f" 类似色:\n"
for offset in [-30, -15, 0, 15, 30]:
ah = (h + offset / 360) % 1.0
ar, ag, ab = colorsys.hls_to_rgb(ah, l, s)
ar, ag, ab = int(ar * 255), int(ag * 255), int(ab * 255)
marker = " ◀" if offset == 0 else ""
result += f" {self._rgb_to_hex(ar, ag, ab)} ({offset:+d}°){marker}\n"
return result.rstrip()
def _op_triadic(self, args: List[str]) -> str:
if not args:
return "错误:请提供颜色"
r, g, b = self._parse_color(args[0])
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
result = f" 三色组:\n"
for i, offset in enumerate([0, 120, 240]):
th = (h + offset / 360) % 1.0
tr, tg, tb = colorsys.hls_to_rgb(th, l, s)
tr, tg, tb = int(tr * 255), int(tg * 255), int(tb * 255)
result += f" {i+1}. {self._rgb_to_hex(tr, tg, tb)} ({offset}°)\n"
return result.rstrip()
def _op_random(self, args: List[str]) -> str:
opts = self._parse_opts(args)
count = int(opts.get("n", 5))
count = min(count, 20)
result = f" 随机颜色 ({count} 个):\n"
for _ in range(count):
r, g, b = __import__('random').randint(0, 255), __import__('random').randint(0, 255), __import__('random').randint(0, 255)
result += f" {self._rgb_to_hex(r, g, b)} rgb({r}, {g}, {b})\n"
return result.rstrip()
def _op_mix(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:请提供两种颜色"
r1, g1, b1 = self._parse_color(args[0])
r2, g2, b2 = self._parse_color(args[1])
opts = self._parse_opts(args[2:])
w = float(opts.get("w", 0.5))
w = max(0, min(1, w))
mr = int(r1 * (1 - w) + r2 * w)
mg = int(g1 * (1 - w) + g2 * w)
mb = int(b1 * (1 - w) + b2 * w)
result = f" 颜色混合 (权重 {w}):\n"
result += f" 颜色1: {self._rgb_to_hex(r1, g1, b1)}\n"
result += f" 颜色2: {self._rgb_to_hex(r2, g2, b2)}\n"
result += f" 混合: {self._rgb_to_hex(mr, mg, mb)}\n"
return result
def _op_blind(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:请提供颜色和色盲类型\n类型: protanopia, deuteranopia, tritanopia"
r, g, b = self._parse_color(args[0])
blind_type = args[1].lower()
# 简化的色盲模拟矩阵
matrices = {
"protanopia": [[0.567, 0.433, 0.0], [0.558, 0.442, 0.0], [0.0, 0.242, 0.758]],
"deuteranopia": [[0.625, 0.375, 0.0], [0.7, 0.3, 0.0], [0.0, 0.3, 0.7]],
"tritanopia": [[0.95, 0.05, 0.0], [0.0, 0.433, 0.567], [0.0, 0.475, 0.525]],
}
matrix = matrices.get(blind_type)
if not matrix:
return f"错误:未知类型 '{blind_type}'\n支持: protanopia, deuteranopia, tritanopia"
nr = int(matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b)
ng = int(matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b)
nb = int(matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b)
nr, ng, nb = max(0, min(255, nr)), max(0, min(255, ng)), max(0, min(255, nb))
result = f" 色盲模拟 ({blind_type}):\n"
result += f" 原色: {self._rgb_to_hex(r, g, b)}\n"
result += f" 模拟: {self._rgb_to_hex(nr, ng, nb)}\n"
return result
def test_plugin():
plugin = Plugin()
print("颜色工具测试:")
print(plugin.handle('convert "#3498db"'))
print(plugin.handle('contrast "#FFFFFF" "#000000"'))
print(plugin.handle('palette "#e74c3c" -n 7'))
print(plugin.handle('complementary "#3498db"'))
if __name__ == "__main__":
test_plugin()
+383
View File
@@ -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 Plugin:
"""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 = Plugin()
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()
+386
View File
@@ -0,0 +1,386 @@
"""
JSON 工具箱插件 - JSON 格式化、校验、查询、转换
提供 JSON 美化、压缩、验证、路径查询、类型转换等功能
"""
import json
import os
import re
from typing import List
class Plugin:
"""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 = Plugin()
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()
+540
View File
@@ -0,0 +1,540 @@
"""
Markdown 工具插件 - Markdown 渲染、目录生成、格式转换
支持 TOC 生成、标题提取、表格生成、格式化、统计
"""
import re
import json
import os
from typing import List, Tuple
class Plugin:
"""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 = Plugin()
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()
+442
View File
@@ -0,0 +1,442 @@
"""
正则表达式测试器插件 - 测试、调试、管理正则表达式
支持匹配测试、提取、替换、常用模式库
"""
import re
import json
import os
from typing import List
class Plugin:
"""正则表达式测试器 - 测试、调试、管理正则"""
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 = Plugin()
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()
+428
View File
@@ -0,0 +1,428 @@
"""
代码片段管理器插件 - 保存、搜索、复用常用代码片段
支持分类、标签、搜索、导出,JSON 持久化存储
"""
import json
import os
import time
import hashlib
from datetime import datetime
from typing import List, Optional
class Plugin:
"""代码片段管理器 - 保存和复用代码片段"""
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"],
"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", "")
return f"{op} {name}".strip()
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
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)
self._save_snippets()
return f" ️ 已删除片段: {removed['name']}"
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")
self._save_snippets()
return f"✅ 已更新片段: {snippet['name']}"
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
self._save_snippets()
return f"✅ 已导入 {added} 个片段 (跳过 {len(imported) - added} 个重复)"
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
self._save_snippets()
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 = Plugin()
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()
+2
View File
@@ -16,3 +16,5 @@ ENABLE_TOOL_SEARCH = True
ENABLE_AUTO_ENGINEER = True
ENABLE_GIT_WORKFLOW = True
ENABLE_PROJECT_ANALYZER = True
ENABLE_DGLAB_WS = False
-3
View File
@@ -1,3 +0,0 @@
Line 1
MODIFIED LINE
Line 3
@@ -1,5 +0,0 @@
Line 1
REPLACED LINE 2
Line 3
REPLACED LINE 4
Line 5
+339 -26
View File
@@ -1,14 +1,19 @@
"""
统一工具管理器 - 协调 Liugin、Skill 和 MCP 三套协议
自动判断并路由到正确的处理器
集成插件内存管理: TTL 自动淘汰 + 懒加载
"""
import os
import json
import logging
import threading
from typing import Dict, List, Any, Optional, Union, Callable
from colorama import Fore, Style
logger = logging.getLogger(__name__)
class UnifiedToolManager:
"""
@@ -24,9 +29,24 @@ class UnifiedToolManager:
- 回退到 Liugin 协议
- 支持强制指定协议
- MCP 格式调用统一入口
内存管理:
- 空闲插件自动淘汰(TTL 机制)
- 调用时自动懒加载回来
- 核心插件可钉住不淘汰
"""
def __init__(self, cli_instance=None):
# 内存管理默认配置
DEFAULT_PLUGIN_TTL = 300 # 5分钟
DEFAULT_MAX_IDLE_PLUGINS = 10 # 最多保留10个空闲插件
DEFAULT_CHECK_INTERVAL = 60 # 60秒检查一次
DEFAULT_PINNED_PLUGINS = ['tool_search'] # 默认钉住的插件
def __init__(self, cli_instance=None,
plugin_ttl: int = None,
max_idle_plugins: int = None,
check_interval: int = None,
pinned_plugins: list = None):
self.cli = cli_instance
self._lock = threading.RLock()
@@ -43,6 +63,23 @@ class UnifiedToolManager:
# MCP 工具定义缓存
self._mcp_tools_cache: Optional[List[Dict]] = None
# ── 原始文件路径缓存(用于懒加载) ──
self._plugin_paths: Dict[str, str] = {} # name -> file_path
self._skill_dirs: Dict[str, str] = {} # name -> skill_dir
# ── 插件内存管理器 ──
from xcli_core.plugin_memory_manager import PluginMemoryManager
self._memory_mgr = PluginMemoryManager(
plugin_ttl=plugin_ttl or self.DEFAULT_PLUGIN_TTL,
max_idle_plugins=max_idle_plugins or self.DEFAULT_MAX_IDLE_PLUGINS,
check_interval=check_interval or self.DEFAULT_CHECK_INTERVAL,
pinned_plugins=pinned_plugins or self.DEFAULT_PINNED_PLUGINS,
)
self._memory_mgr.set_callbacks(
load_fn=self._lazy_load_plugin,
unload_fn=self._unload_plugin,
)
def initialize(self, plugins_dir: str = "plugins", skills_dir: str = "skills") -> None:
"""
初始化并加载所有工具
@@ -52,6 +89,8 @@ class UnifiedToolManager:
skills_dir: Skill 目录
"""
# 加载 Liugins
self._plugins_dir = plugins_dir
self._skills_dir = skills_dir
self._load_plugins(plugins_dir)
# 加载 Skills
@@ -60,12 +99,20 @@ class UnifiedToolManager:
# 构建缓存
self._build_mcp_cache()
# 启动内存管理器
self._memory_mgr.start()
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')}")
# 打印内存管理状态
status = self._memory_mgr.get_status()
print(f" - 内存管理: TTL={status['ttl']}s, 最大空闲={status['max_idle']}, "
f"钉住={status['pinned']}")
def _load_plugins(self, plugins_dir: str) -> None:
"""加载 Liugin 协议工具"""
"""加载 Liugin 协议工具(并注册到内存管理器)"""
import importlib.util
if not os.path.exists(plugins_dir):
@@ -126,6 +173,22 @@ class UnifiedToolManager:
}
self._protocol_map[tool_name] = 'liugin'
# 记录路径(用于懒加载)
self._plugin_paths[tool_name] = plugin_path
# 注册到内存管理器
self._memory_mgr.register(
name=tool_name,
path=plugin_path,
protocol='liugin',
info_cache={
'name': tool_name,
'description': tool_info.get('description', ''),
'keywords': tool_info.get('keywords', []),
'usage': tool_info.get('usage', ''),
}
)
except Exception as e:
print(f"{Fore.RED}加载 Liugin '{plugin_name}' 失败: {e}{Style.RESET_ALL}")
@@ -185,6 +248,24 @@ class UnifiedToolManager:
'mcp_definition': tool_def # Skill 的 OpenAI 格式就是 MCP 格式
}
self._protocol_map[skill_name] = 'skill'
# 记录路径(用于懒加载)
skill_path = os.path.join(skills_dir, skill_name)
self._skill_dirs[skill_name] = skill_path
# 注册到内存管理器
self._memory_mgr.register(
name=skill_name,
path=skill_path,
protocol='skill',
info_cache={
'name': skill_name,
'description': func_def.get('description', ''),
'keywords': getattr(skill, 'tags', []),
'format': skill_format,
}
)
print(f"{Fore.GREEN}已加载 Skill ({skill_format}): {skill_name}{Style.RESET_ALL}")
except ImportError as e:
@@ -239,6 +320,167 @@ class UnifiedToolManager:
return handler
# ==================== 懒加载/卸载 ====================
def _lazy_load_plugin(self, name: str) -> Optional[Dict[str, Any]]:
"""
懒加载单个插件(由内存管理器回调)
Args:
name: 插件名
Returns:
tool_dict 如果成功,None 如果失败
"""
import importlib.util
# ── 尝试 Liugin 插件 ──
plugin_path = self._plugin_paths.get(name)
if plugin_path and os.path.exists(plugin_path):
return self._load_single_plugin(name, plugin_path)
# ── 尝试 Skill ──
skill_dir = self._skill_dirs.get(name)
if skill_dir and os.path.exists(skill_dir):
return self._load_single_skill(name, skill_dir)
return None
def _load_single_plugin(self, name: str, plugin_path: str) -> Optional[Dict[str, Any]]:
"""加载单个 Liugin 插件"""
import importlib.util
try:
plugin_name = os.path.splitext(os.path.basename(plugin_path))[0]
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 = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if not plugin_class:
return None
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', name)
mcp_definition = None
if hasattr(plugin_instance, 'get_mcp_definition'):
mcp_definition = plugin_instance.get_mcp_definition()
tool_dict = {
'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
}
with self._lock:
self._tools[tool_name] = tool_dict
self._protocol_map[tool_name] = 'liugin'
return tool_dict
except Exception as e:
logger.error(f"懒加载插件 '{name}' 失败: {e}")
return None
def _load_single_skill(self, name: str, skill_dir: str) -> Optional[Dict[str, Any]]:
"""加载单个 Skill"""
try:
from skills.base import SkillLoader, MarkdownSkill, Skill
loader = SkillLoader(os.path.dirname(skill_dir))
skill = loader.load_skill(skill_dir)
if not skill:
return None
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', {})
is_markdown = isinstance(skill, MarkdownSkill)
skill_format = "markdown" if is_markdown else "python"
tool_dict = {
'name': 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
}
with self._lock:
self._tools[name] = tool_dict
self._protocol_map[name] = 'skill'
return tool_dict
except Exception as e:
logger.error(f"懒加载 Skill '{name}' 失败: {e}")
return None
def _unload_plugin(self, name: str) -> None:
"""
卸载插件实例(由内存管理器回调)
从工具表中移除,释放实例引用,但保留路径缓存以便重新加载
"""
with self._lock:
if name in self._tools:
tool = self._tools[name]
# 清理实例引用
instance = tool.get('instance')
if instance and hasattr(instance, 'cleanup'):
try:
instance.cleanup()
except Exception:
pass
del self._tools[name]
# 不删除 _protocol_map 和路径缓存,懒加载时需要
# 重建 MCP 缓存
self._build_mcp_cache()
def _ensure_loaded(self, name: str) -> Optional[Dict[str, Any]]:
"""
确保插件已加载(调用入口统一使用)
如果插件已加载,更新使用时间戳并返回 tool_dict
如果插件已卸载,自动懒加载后返回 tool_dict
如果插件不存在,返回 None
"""
# 先检查是否在工具表中
with self._lock:
tool = self._tools.get(name)
if tool:
self._memory_mgr.mark_used(name)
return tool
# 不在工具表中,可能是已卸载,尝试懒加载
result = self._memory_mgr.ensure_loaded(name)
if result:
self._memory_mgr.mark_used(name)
return result
return None
def _get_enabled_plugins(self, plugins_dir: str) -> Optional[List[str]]:
"""获取启用的插件列表"""
# 检查环境变量
@@ -349,8 +591,8 @@ class UnifiedToolManager:
return list(self._tools.values())
def get_tool(self, name: str) -> Optional[Dict[str, Any]]:
"""获取指定工具"""
return self._tools.get(name)
"""获取指定工具(支持懒加载)"""
return self._ensure_loaded(name)
def get_protocol(self, name: str) -> Optional[str]:
"""获取工具使用的协议"""
@@ -358,7 +600,7 @@ class UnifiedToolManager:
def execute(self, tool_name: str, args: str = "", **kwargs) -> Dict[str, Any]:
"""
执行工具调用
执行工具调用(支持懒加载)
Args:
tool_name: 工具名称
@@ -368,7 +610,7 @@ class UnifiedToolManager:
Returns:
{"result": "..."} 格式的结果
"""
tool = self._tools.get(tool_name)
tool = self._ensure_loaded(tool_name)
if not tool:
return {"result": f"未找到工具: {tool_name}"}
@@ -418,7 +660,7 @@ class UnifiedToolManager:
def mcp_call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""
MCP 格式的工具调用入口
MCP 格式的工具调用入口(支持懒加载)
这是 MCP 协议的主要调用接口,接收结构化参数并返回结果
@@ -438,7 +680,7 @@ class UnifiedToolManager:
"isError": false
}
"""
tool = self._tools.get(tool_name)
tool = self._ensure_loaded(tool_name)
if not tool:
return self._mcp_error(f"未找到工具: {tool_name}")
@@ -607,7 +849,7 @@ class UnifiedToolManager:
def search_tools(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
"""
搜索工具
搜索工具(包括已卸载的插件,使用缓存信息)
Args:
query: 搜索关键词
@@ -621,11 +863,50 @@ class UnifiedToolManager:
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
})
# 搜索已卸载但注册的插件(使用缓存信息)
for name in self._memory_mgr.get_unloaded_names():
# 跳过已加载的(上面已搜索)
if name in self._tools:
continue
info = self._memory_mgr.get_info_cache(name)
if info:
score = self._score_tool_match(info, query_lower, query_normalized)
if score > 0:
matches.append({
'name': info.get('name', name),
'description': info.get('description', ''),
'usage': info.get('usage', ''),
'keywords': info.get('keywords', []),
'protocol': info.get('protocol', 'liugin'),
'instance': None,
'score': score,
'_unloaded': True, # 标记为已卸载
})
# 按分数排序
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['name'].lower()
name = tool.get('name', '').lower()
name_normalized = name.replace('-', '').replace('_', '')
desc = tool['description'].lower()
desc = tool.get('description', '').lower()
keywords = [kw.lower() for kw in tool.get('keywords', [])]
# 名称完全匹配(忽略分隔符差异)
@@ -643,21 +924,7 @@ class UnifiedToolManager:
if query_lower in kw or query_normalized in kw_normalized:
score += 20
break
if score > 0:
matches.append({
'name': tool['name'],
'description': tool['description'],
'usage': tool.get('usage', ''),
'keywords': keywords,
'protocol': tool['protocol'],
'instance': tool.get('instance'),
'score': score
})
# 按分数排序
matches.sort(key=lambda x: x['score'], reverse=True)
return matches[:limit]
return score
def register_tool(self, name: str, handler: callable, description: str = "",
protocol: str = "custom", mcp_definition: Dict = None, **kwargs) -> None:
@@ -695,11 +962,57 @@ class UnifiedToolManager:
if name in self._tools:
del self._tools[name]
del self._protocol_map[name]
# 同时从内存管理器注销
if name in self._plugin_paths:
del self._plugin_paths[name]
if name in self._skill_dirs:
del self._skill_dirs[name]
# 重建缓存
self._build_mcp_cache()
return True
return False
def get_all_tools_info(self) -> List[Dict[str, Any]]:
"""
获取所有工具的简要信息(包括已卸载的)
用于系统提示词,让 AI 知道有哪些工具可用
已卸载的工具只有基本信息,调用时会自动加载
"""
result = []
# 已加载的
for tool in self._tools.values():
result.append({
'name': tool['name'],
'description': tool.get('description', ''),
'keywords': tool.get('keywords', []),
'protocol': tool.get('protocol', ''),
'loaded': True,
})
# 已卸载的(从内存管理器缓存读取)
for name in self._memory_mgr.get_unloaded_names():
if name in self._tools:
continue
info = self._memory_mgr.get_info_cache(name)
if info:
result.append({
'name': info.get('name', name),
'description': info.get('description', ''),
'keywords': info.get('keywords', []),
'protocol': info.get('protocol', 'liugin'),
'loaded': False,
})
return result
def get_tool_by_name(self, name: str) -> Optional[Dict[str, Any]]:
"""根据名称获取工具(兼容 LiuginManager 接口,支持懒加载)"""
return self._ensure_loaded(name)
@property
def memory_manager(self):
"""获取内存管理器实例"""
return self._memory_mgr
def get_skills_system_prompt(self) -> str:
"""
获取所有技能的系统提示词
-381
View File
@@ -1,381 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小狸 Pro-CLI v5.1.2 · 竞品实力对比</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#06080d;--bg2:#0d1117;--bg3:#161b22;--border:#21262d;--txt:#e6edf3;--dim:#8b949e;--mute:#484f58;--blue:#58a6ff;--purple:#bc8cff;--cyan:#39d2c0;--green:#3fb950;--red:#f85149;--yellow:#d29922;--orange:#f0883e}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;line-height:1.7}
/* ── Hero ── */
.hero{text-align:center;padding:80px 20px 50px;background:linear-gradient(180deg,#0d1117 0%,#06080d 100%);position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:0;left:50%;width:600px;height:600px;transform:translateX(-50%);background:radial-gradient(circle,rgba(88,166,255,.08) 0%,transparent 70%);pointer-events:none}
.hero h1{font-size:2.6em;font-weight:800;background:linear-gradient(135deg,#58a6ff,#bc8cff,#39d2c0);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px}
.hero .sub{color:var(--dim);font-size:1.1em;margin-bottom:24px}
.hero .tags{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
.tag{padding:5px 14px;border-radius:20px;font-size:.82em;font-weight:600;border:1px solid}
.tag-b{background:rgba(88,166,255,.1);color:var(--blue);border-color:rgba(88,166,255,.25)}
.tag-p{background:rgba(188,140,255,.1);color:var(--purple);border-color:rgba(188,140,255,.25)}
.tag-g{background:rgba(63,185,80,.1);color:var(--green);border-color:rgba(63,185,80,.25)}
.tag-c{background:rgba(57,210,192,.1);color:var(--cyan);border-color:rgba(57,210,192,.25)}
.tag-y{background:rgba(210,153,34,.1);color:var(--yellow);border-color:rgba(210,153,34,.25)}
.container{max-width:1100px;margin:0 auto;padding:0 24px}
section{padding:56px 0;border-bottom:1px solid rgba(33,38,45,.5)}
section:last-child{border:none}
h2{font-size:1.7em;margin-bottom:6px;background:linear-gradient(90deg,var(--blue),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
h2 .em{-webkit-text-fill-color:initial}
.sec-desc{color:var(--dim);margin-bottom:32px;font-size:1.02em}
/* ── What's New ── */
.new-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.new-card{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:24px;transition:.3s;position:relative;overflow:hidden}
.new-card:hover{border-color:var(--blue);box-shadow:0 0 24px rgba(88,166,255,.1);transform:translateY(-2px)}
.new-card .icon{font-size:1.8em;margin-bottom:8px}
.new-card h3{font-size:1.08em;margin-bottom:8px;color:#f0f6fc}
.new-card p{color:var(--dim);font-size:.93em}
.new-card .badge-new{position:absolute;top:12px;right:12px;background:var(--green);color:#000;font-size:.72em;font-weight:700;padding:2px 8px;border-radius:10px}
/* ── Compare Table ── */
.tbl-wrap{overflow-x:auto;border-radius:12px;border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:.9em}
thead th{background:var(--bg3);color:#f0f6fc;padding:13px 16px;text-align:left;font-weight:700;position:sticky;top:0;border-bottom:1px solid var(--border)}
tbody td{padding:12px 16px;border-bottom:1px solid rgba(33,38,45,.5)}
tbody tr:hover{background:rgba(88,166,255,.04)}
tbody td:first-child{font-weight:600;color:var(--dim);white-space:nowrap}
.y{color:var(--green);font-weight:700}
.n{color:var(--red);opacity:.5}
.p{color:var(--yellow)}
.hl{background:rgba(88,166,255,.06);font-weight:600}
/* ── Score Cards ── */
.score-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin-top:20px}
.score{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:22px;text-align:center;transition:.3s}
.score:hover{border-color:var(--blue);box-shadow:0 0 20px rgba(88,166,255,.08)}
.score .nm{font-size:1.05em;font-weight:700;margin-bottom:4px}
.score .tot{font-size:2.6em;font-weight:800;margin:6px 0}
.score .sub{font-size:.82em;color:var(--dim)}
.score .bars{text-align:left;margin-top:14px;font-size:.86em;color:var(--dim)}
.score .bars .row{display:flex;justify-content:space-between;margin-bottom:3px}
.score .bars .bw{background:rgba(255,255,255,.05);border-radius:4px;height:6px;margin-bottom:8px;overflow:hidden}
.score .bars .bd{height:100%;border-radius:4px}
/* ── Radar ── */
.radar-wrap{display:flex;justify-content:center;margin:30px 0}
.radar{position:relative;width:400px;height:400px}
.radar svg{width:100%;height:100%}
/* ── SWOT ── */
.swot{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.sc{border-radius:12px;padding:24px;border:1px solid}
.sc h3{font-size:1.08em;margin-bottom:12px}
.sc ul{list-style:none;padding:0}
.sc li{padding:5px 0 5px 22px;position:relative;color:var(--dim);font-size:.93em}
.sc li::before{content:'→';position:absolute;left:0;font-weight:700}
.sc-s{background:rgba(63,185,80,.04);border-color:rgba(63,185,80,.2)}.sc-s h3{color:var(--green)}.sc-s li::before{color:var(--green)}
.sc-w{background:rgba(248,81,73,.04);border-color:rgba(248,81,73,.2)}.sc-w h3{color:var(--red)}.sc-w li::before{color:var(--red)}
.sc-o{background:rgba(88,166,255,.04);border-color:rgba(88,166,255,.2)}.sc-o h3{color:var(--blue)}.sc-o li::before{color:var(--blue)}
.sc-t{background:rgba(240,136,62,.04);border-color:rgba(240,136,62,.2)}.sc-t h3{color:var(--orange)}.sc-t li::before{color:var(--orange)}
/* ── Verdict ── */
.verdict{background:linear-gradient(135deg,rgba(88,166,255,.06),rgba(188,140,255,.06));border:1px solid rgba(88,166,255,.2);border-radius:14px;padding:36px;text-align:center;margin-top:28px}
.verdict h3{font-size:1.4em;margin-bottom:10px;color:#f0f6fc}
.verdict p{color:var(--dim);max-width:680px;margin:0 auto;font-size:1.02em}
footer{text-align:center;padding:36px 20px;color:var(--mute);font-size:.86em;border-top:1px solid rgba(33,38,45,.5)}
footer a{color:var(--blue);text-decoration:none}
@media(max-width:768px){.hero h1{font-size:1.7em}.swot{grid-template-columns:1fr}table{font-size:.8em}}
</style>
</head>
<body>
<div class="hero">
<h1>小狸 Pro-CLI v5.1.2</h1>
<p class="sub">新增自动语法检查 · Diff 弹窗 · Web UI 后,与同类产品的实力对比</p>
<div class="tags">
<span class="tag tag-g"> 新增语法检查 · 11 种语言</span>
<span class="tag tag-b"> 新增 Diff 弹窗 · 可开关</span>
<span class="tag tag-p"> 新增 Web UI · 纯静态</span>
<span class="tag tag-c">v5.1.2 · 2026-04-27</span>
<span class="tag tag-y">16 插件 · 142 操作 · 约 19K 行</span>
</div>
</div>
<!-- ════════════════ 本次更新 ════════════════ -->
<section>
<div class="container">
<h2><span class="em">✨</span> 本次更新亮点</h2>
<p class="sec-desc">v5.1.2 新增 3 大功能,补齐了编辑后自动验证和可视化界面的短板。</p>
<div class="new-grid">
<div class="new-card">
<span class="badge-new">NEW</span>
<div class="icon"> </div>
<h3>自动语法检查</h3>
<p>编辑操作完成后自动触发,覆盖 <strong>11 种语言</strong>:Python (ast.parse + py_compile 双重验证)、JavaScript、TypeScript、JSON、YAML、TOML、HTML/XML、CSS、Shell (bash -n)、SQL。通过显示 ✓,失败显示精确的行号/列号/错误描述。AI 也可主动调用 <code>syntax_check</code>。</p>
</div>
<div class="new-card">
<span class="badge-new">NEW</span>
<div class="icon"> </div>
<h3>Diff 弹窗</h3>
<p>每次编辑后自动弹出新终端窗口,显示修改前/后的完整内容(带行号)、unified diff 差异、+N/-M 行数统计。兼容 <strong>Windows (cmd) / macOS (Terminal) / Linux (gnome-terminal 等 6 种)</strong>。异步不阻塞,<code>diff_popup on/off</code> 一键开关。</p>
</div>
<div class="new-card">
<span class="badge-new">NEW</span>
<div class="icon"> </div>
<h3>Web UI</h3>
<p>纯静态 HTML 界面,<strong>零依赖、零后端</strong>,浏览器直接打开。三栏布局:左侧文件树、中间对话/编辑器(Tab 切换)、右侧状态面板。暗色主题、动画效果、快捷芯片、行号编辑器、Ctrl+S 保存。</p>
</div>
</div>
</div>
</section>
<!-- ════════════════ 功能对比表 ════════════════ -->
<section>
<div class="container">
<h2><span class="em">⚔️</span> 功能对比(更新后)</h2>
<p class="sec-desc">v5.1.2 新增功能后,小狸在语法验证和界面维度补齐了关键差距。绿色高亮 = 本次新增优势。</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>维度</th><th>小狸 v5.1.2</th><th>Claude Code</th><th>OpenClaw</th><th>Aider</th><th>Cursor</th></tr>
</thead>
<tbody>
<tr><td>开源</td><td class="y">✅ MIT</td><td class="n">❌</td><td class="y">✅</td><td class="y">✅ Apache</td><td class="n">❌</td></tr>
<tr><td>本地模型</td><td class="y">✅ Ollama 原生</td><td class="n">❌</td><td class="p">⚠️ 有限</td><td class="p">⚠️ 需配置</td><td class="n">❌</td></tr>
<tr><td>代码编辑</td><td class="y">✅ 24 操作</td><td class="y">✅</td><td class="y">✅</td><td class="y">✅</td><td class="y">✅</td></tr>
<tr class="hl"><td> 自动语法检查</td><td class="y">✅ 11 语言 · 自动+主动</td><td class="n">❌</td><td class="n">❌</td><td class="n">❌</td><td class="p">⚠️ 有限</td></tr>
<tr class="hl"><td> Diff 弹窗</td><td class="y">✅ 自动弹窗 · 可开关</td><td class="n">❌</td><td class="n">❌</td><td class="n">❌</td><td class="p">⚠️ 内置 diff</td></tr>
<tr class="hl"><td> Web UI</td><td class="y">✅ 纯静态 HTML</td><td class="n">❌</td><td class="p">⚠️ Canvas</td><td class="n">❌</td><td class="y">✅ GUI IDE</td></tr>
<tr><td>Git 集成</td><td class="y">✅ 20 操作</td><td class="y">✅</td><td class="y">✅ GitHub</td><td class="y">✅</td><td class="y">✅</td></tr>
<tr><td>浏览器自动化</td><td class="y">✅ 31 操作</td><td class="n">❌</td><td class="y">✅</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>子 Agent</td><td class="y">✅ 多 Agent 并行</td><td class="n">❌</td><td class="y">✅ 强大</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>安全沙箱</td><td class="y">✅ AST+子进程+三模式</td><td class="p">⚠️ 权限</td><td class="y">✅ 多层</td><td class="p">⚠️ 基础</td><td class="p">⚠️ 沙箱</td></tr>
<tr><td>持久化记忆</td><td class="y">✅ MEMORY+日记+检索</td><td class="p">⚠️ CLAUDE.md</td><td class="y">✅ 完整</td><td class="p">⚠️ 有限</td><td class="p">⚠️ 有限</td></tr>
<tr><td>TUI 界面</td><td class="y">✅ Textual 动画</td><td class="n">❌ 纯 CLI</td><td class="p">⚠️</td><td class="n">❌</td><td class="y">✅ GUI</td></tr>
<tr><td>工程化工具</td><td class="y">✅ lint/format/test/build</td><td class="p">⚠️</td><td class="p">⚠️</td><td class="p">⚠️</td><td class="y">✅</td></tr>
<tr><td>语音识别</td><td class="y">✅ STT</td><td class="n">❌</td><td class="p">⚠️ TTS</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>远程控制</td><td class="y">✅ Clawli+WebSocket</td><td class="n">❌</td><td class="y">✅ 多渠道</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>测试覆盖</td><td class="y">✅ 113 用例</td><td class="n">❌</td><td class="p">⚠️</td><td class="y">✅</td><td class="n">❌</td></tr>
<tr><td>中文原生</td><td class="y">✅</td><td class="p">⚠️</td><td class="y">✅</td><td class="p">⚠️</td><td class="p">⚠️</td></tr>
<tr><td>价格</td><td class="y">免费</td><td>$20/月</td><td class="y">免费</td><td>$20/月</td><td>$20/月</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ════════════════ 评分对比 ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> 多维度评分(v5.1.2 更新后)</h2>
<p class="sec-desc">满分 10 分,基于代码审查和功能实测。</p>
<div class="score-row">
<div class="score" style="border-color:rgba(88,166,255,.35)">
<div class="nm" style="color:var(--blue)">小狸 v5.1.2</div>
<div class="tot" style="color:var(--blue)">7.3</div>
<div class="sub">↑ 0.5 · 语法检查 + Diff弹窗 + Web UI</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--blue)"></div></div>
<div class="row"><span>代码质量</span><span>6.5</span></div>
<div class="bw"><div class="bd" style="width:65%;background:var(--blue)"></div></div>
<div class="row"><span>架构设计</span><span>6.5</span></div>
<div class="bw"><div class="bd" style="width:65%;background:var(--blue)"></div></div>
<div class="row"><span>安全机制</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--blue)"></div></div>
<div class="row"><span>用户体验</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--blue)"></div></div>
<div class="row"><span>生态与社区</span><span>4.0</span></div>
<div class="bw"><div class="bd" style="width:40%;background:var(--blue)"></div></div>
<div class="row"><span>文档完整度</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--blue)"></div></div>
<div class="row"><span>工程化水平</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--blue)"></div></div>
</div>
</div>
<div class="score" style="border-color:rgba(240,136,62,.35)">
<div class="nm" style="color:var(--orange)">Claude Code</div>
<div class="tot" style="color:var(--orange)">8.5</div>
<div class="sub">模型能力碾压,但无语法检查/Diff弹窗</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--orange)"></div></div>
<div class="row"><span>代码质量</span><span>9.5</span></div>
<div class="bw"><div class="bd" style="width:95%;background:var(--orange)"></div></div>
<div class="row"><span>架构设计</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
<div class="row"><span>安全机制</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--orange)"></div></div>
<div class="row"><span>用户体验</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
<div class="row"><span>生态与社区</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--orange)"></div></div>
<div class="row"><span>文档完整度</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
<div class="row"><span>工程化水平</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
</div>
</div>
<div class="score" style="border-color:rgba(63,185,80,.35)">
<div class="nm" style="color:var(--green)">OpenClaw</div>
<div class="tot" style="color:var(--green)">7.8</div>
<div class="sub">Agent 平台标杆,多渠道生态</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--green)"></div></div>
<div class="row"><span>代码质量</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--green)"></div></div>
<div class="row"><span>架构设计</span><span>8.5</span></div>
<div class="bw"><div class="bd" style="width:85%;background:var(--green)"></div></div>
<div class="row"><span>安全机制</span><span>8.5</span></div>
<div class="bw"><div class="bd" style="width:85%;background:var(--green)"></div></div>
<div class="row"><span>用户体验</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--green)"></div></div>
<div class="row"><span>生态与社区</span><span>7.0</span></div>
<div class="bw"><div class="bd" style="width:70%;background:var(--green)"></div></div>
<div class="row"><span>文档完整度</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--green)"></div></div>
<div class="row"><span>工程化水平</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--green)"></div></div>
</div>
</div>
<div class="score" style="border-color:rgba(188,140,255,.35)">
<div class="nm" style="color:var(--purple)">Aider</div>
<div class="tot" style="color:var(--purple)">7.2</div>
<div class="sub">专注代码编辑,简洁高效</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>6.5</span></div>
<div class="bw"><div class="bd" style="width:65%;background:var(--purple)"></div></div>
<div class="row"><span>代码质量</span><span>8.5</span></div>
<div class="bw"><div class="bd" style="width:85%;background:var(--purple)"></div></div>
<div class="row"><span>架构设计</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--purple)"></div></div>
<div class="row"><span>安全机制</span><span>5.0</span></div>
<div class="bw"><div class="bd" style="width:50%;background:var(--purple)"></div></div>
<div class="row"><span>用户体验</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--purple)"></div></div>
<div class="row"><span>生态与社区</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--purple)"></div></div>
<div class="row"><span>文档完整度</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--purple)"></div></div>
<div class="row"><span>工程化水平</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--purple)"></div></div>
</div>
</div>
</div>
</div>
</section>
<!-- ════════════════ 差异化优势 ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> v5.1.2 新增的独有优势</h2>
<p class="sec-desc">这三项功能在同类产品中均无直接竞品。</p>
<div class="new-grid">
<div class="new-card" style="border-left:3px solid var(--green)">
<h3> 自动语法检查 — 行业空白</h3>
<p><strong>Claude Code</strong>:不做语法检查,依赖模型自身判断。<br>
<strong>Aider</strong>:不做语法检查。<br>
<strong>Cursor</strong>:有 lint 集成但非自动触发。<br><br>
<strong>小狸</strong>:编辑后 <strong>自动</strong> 触发,11 种语言,AST 级精确错误定位。AI 修改代码后立刻知道对不对,不用等运行时才发现。<strong>这是编程 CLI 赛道的首创。</strong></p>
</div>
<div class="new-card" style="border-left:3px solid var(--blue)">
<h3> Diff 弹窗 — 所见即所得</h3>
<p><strong>Claude Code</strong>:终端内显示 diff 文本。<br>
<strong>Aider</strong>:终端内显示 diff。<br>
<strong>Cursor</strong>:IDE 内置 diff 视图。<br><br>
<strong>小狸</strong>:每次编辑后 <strong>弹出独立窗口</strong>,完整显示修改前后内容 + 行号 + unified diff + 统计。不挤在终端里,一目了然。<code>diff_popup off</code> 一键关闭。</p>
</div>
<div class="new-card" style="border-left:3px solid var(--purple)">
<h3> Web UI — 零门槛</h3>
<p><strong>Claude Code</strong>:纯 CLI,无 UI。<br>
<strong>Aider</strong>:纯 CLI,无 UI。<br>
<strong>OpenClaw</strong>:Canvas 组件,非完整 UI。<br><br>
<strong>小狸</strong>:纯静态 HTML 文件,<strong>双击打开</strong>。暗色主题三栏布局,对话 + 编辑器 + 文件树 + 状态面板。不需要 npm、不需要 Flask、不需要任何后端。</p>
</div>
</div>
</div>
</section>
<!-- ════════════════ SWOT ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> SWOT 分析(更新后)</h2>
<div class="swot">
<div class="sc sc-s">
<h3> Strengths</h3>
<ul>
<li><strong>自动语法检查</strong> — 行业首创,11 语言,编辑后即时反馈</li>
<li><strong>Diff 弹窗</strong> — 独立窗口对比,所见即所得</li>
<li><strong>三模式界面</strong> — CLI + TUI + Web UI,覆盖所有场景</li>
<li><strong>功能密度极高</strong> — 16 插件 142 操作,2 个月一人完成</li>
<li><strong>安全纵深</strong> — AST 沙箱 + 子进程隔离 + 三模式</li>
<li><strong>本地模型原生</strong> — Ollama 开箱即用,完全离线</li>
<li><strong>中文原生 + MIT 开源</strong> — 国内开发者零门槛</li>
</ul>
</div>
<div class="sc sc-w">
<h3> Weaknesses</h3>
<ul>
<li><strong>模型能力差距</strong> — 本地模型 vs Claude/GPT-4 鸿沟仍在</li>
<li><strong>单人维护</strong> — 长期迭代速度和可持续性存疑</li>
<li><strong>社区生态空白</strong> — 无插件市场、无活跃贡献者</li>
<li><strong>架构复杂度</strong> — cli_core.py 对话循环待拆分</li>
<li><strong>Web UI 是静态</strong> — 无后端,不能真正执行操作</li>
</ul>
</div>
<div class="sc sc-o">
<h3> Opportunities</h3>
<ul>
<li><strong>国产替代</strong> — 中文原生 + 本地模型是巨大优势</li>
<li><strong>本地 AI 爆发</strong> — Ollama/llama.cpp 快速发展</li>
<li><strong>语法检查可产品化</strong> — 独立为 CI/CD 工具或 IDE 插件</li>
<li><strong>教育市场</strong> — 免费 + 中文 + 有趣(彩蛋情书)</li>
<li><strong>Web UI 可进化</strong> — 加后端即可变为完整 Web IDE</li>
</ul>
</div>
<div class="sc sc-t">
<h3>⚠️ Threats</h3>
<ul>
<li><strong>大厂碾压</strong> — Claude Code / Cursor 资金和模型优势</li>
<li><strong>开源竞品</strong> — Aider、Cline、Continue.dev 社区更活跃</li>
<li><strong>AI 工具快速迭代</strong> — 功能可能被快速复制</li>
<li><strong>维护可持续性</strong> — 单人项目最大风险</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ════════════════ 结论 ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> 结论</h2>
<div class="verdict">
<h3>能不能抗衡?</h3>
<p><strong>功能维度:能。</strong>16 插件 142 操作 + 自动语法检查 + Diff 弹窗 + 三模式界面,功能广度已经超过 Claude Code 和 Aider,在语法验证维度甚至领先全行业。</p>
<p style="margin-top:14px"><strong>体验维度:接近。</strong>Web UI 补齐了可视化短板,TUI 动画体验独特。但底层模型能力差距决定了对话质量和代码生成精度仍有代差。</p>
<p style="margin-top:14px"><strong>生态维度:差距大。</strong>社区、插件市场、贡献者体系是最大的结构性劣势。功能做得再好,一个人扛不住长期迭代。</p>
<p style="margin-top:18px;color:var(--blue);font-weight:600">一句话:功能够打,体验能看,生态要追。v5.1.2 是一次关键的能力补齐。</p>
</div>
</div>
</section>
<footer>
<p>基于 <a href="https://gitee.com/shuiyu1123/xiaoli-cli">xiaoli-cli v5.1.2</a> 全部源代码逐行审查生成 · 2026-04-27</p>
</footer>
</body>
</html>
+293 -931
View File
File diff suppressed because it is too large. Load diff
+118 -24
View File
@@ -97,36 +97,103 @@ class AIEngineBridge:
}
def chat(self, message: str, callback=None):
"""发送消息到 AI 并获取回复"""
"""发送消息到 AI 并获取回复(支持工具调用循环)"""
if not self.cli:
return "❌ 小狸核心未初始化,请检查配置"
return {"type": "error", "message": "小狸核心未初始化,请检查配置"}
results = [] # 收集所有事件
try:
# 使用小狸的核心对话处理
import io
import contextlib
# 添加用户消息到历史
self.cli.shared_conversation_history.append({
"role": "user", "content": message
})
# 捕获输出
captured = io.StringIO()
with contextlib.redirect_stdout(captured):
# 构建系统提示词
liugin_prompts = self.cli.get_liugin_usage_prompts()
system_prompt = self.cli._build_system_prompt(liugin_prompts)
# 生成响应
if self.cli.current_engine:
response = self.cli.current_engine.generate_response(
message,
system_prompt=system_prompt
)
else:
response = "❌ 没有可用的 AI 引擎"
loop_count = 0
max_loops = 15
current_input = message
output = captured.getvalue()
return response or "(无响应)"
while loop_count < max_loops:
# 生成 AI 响应
if not self.cli.current_engine:
return {"type": "error", "message": "没有可用的 AI 引擎"}
response = self.cli.current_engine.generate_response(
current_input, system_prompt=system_prompt
)
if not response:
return {"type": "reply", "message": "(无响应)"}
# 检查 FC 响应(Ollama Function Calling)
fc_result = self.cli._handle_fc_response(response, message)
if fc_result:
# FC 工具已执行,结果作为下一轮输入
results.append({"type": "fc_tool_done", "message": fc_result[:200]})
current_input = fc_result
loop_count += 1
continue
# 解析混合响应(文本 + JSON 工具调用)
try:
text_content, json_data = self.cli._parse_mixed_response(response)
except Exception:
text_content, json_data = response, None
if json_data and isinstance(json_data, dict):
if json_data.get('action') == 'use_tool':
tool_name = json_data.get('tool', '')
tool_args = json_data.get('args', '')
results.append({"type": "tool_call", "name": tool_name, "args": tool_args[:200]})
# 执行工具
try:
tool_result = self.cli._execute_tool_by_name(tool_name, tool_args)
except Exception as e:
tool_result = f"工具执行错误: {e}"
results.append({"type": "tool_result", "name": tool_name, "result": str(tool_result)[:500]})
# 把工具结果加入历史并继续循环
self.cli.shared_conversation_history.append({
"role": "tool", "content": str(tool_result),
"name": tool_name
})
current_input = f"工具 {tool_name} 的执行结果:\n{tool_result}"
loop_count += 1
continue
elif json_data.get('continue') or json_data.get('need_continue'):
msg = json_data.get('message', text_content or '')
if msg:
results.append({"type": "partial_reply", "message": msg})
self.cli.shared_conversation_history.append({"role": "assistant", "content": msg})
current_input = "继续"
loop_count += 1
continue
elif json_data.get('message'):
final = json_data.get('message')
self.cli.shared_conversation_history.append({"role": "assistant", "content": final})
results.append({"type": "reply", "message": final})
break
# 纯文本响应
display = text_content if text_content else response
self.cli.shared_conversation_history.append({"role": "assistant", "content": display})
results.append({"type": "reply", "message": display})
break
if not results:
results.append({"type": "reply", "message": response or "(无响应)"})
return {"type": "multi", "events": results}
except Exception as e:
return f"❌ 错误: {e}"
return {"type": "error", "message": f"错误: {e}"}
def execute_tool(self, tool_name: str, tool_args: str):
"""直接执行工具"""
@@ -195,14 +262,29 @@ async def ws_handler(websocket):
# 在线程中执行 AI 调用(避免阻塞事件循环)
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
result = await loop.run_in_executor(
None, bridge.chat, user_msg
)
# 发送回复
# 处理返回结果
if isinstance(result, dict):
if result.get("type") == "multi":
# 多事件:逐个发送
for event in result.get("events", []):
await websocket.send(json.dumps({
"type": event.get("type", "reply"),
"data": event,
}, ensure_ascii=False))
else:
await websocket.send(json.dumps({
"type": result.get("type", "reply"),
"data": result,
}, ensure_ascii=False))
else:
# 兼容旧格式
await websocket.send(json.dumps({
"type": "reply",
"data": {"message": response},
"data": {"message": str(result)},
}, ensure_ascii=False))
elif msg_type == "tool":
@@ -244,12 +326,24 @@ async def ws_handler(websocket):
elif msg_type == "switch_engine":
# 切换引擎
engine_name = msg_data.get("engine", "")
if bridge.cli and engine_name in bridge.cli.engines:
if bridge.cli:
if engine_name in bridge.cli.engines:
bridge.cli.switch_engine(engine_name)
# 发送切换成功 + 更新后的状态
await websocket.send(json.dumps({
"type": "engine_switched",
"data": {"engine": engine_name},
}, ensure_ascii=False))
status = bridge.get_status()
await websocket.send(json.dumps({
"type": "status",
"data": status,
}, ensure_ascii=False))
else:
await websocket.send(json.dumps({
"type": "error",
"data": {"message": f"未找到引擎: {engine_name}"},
}, ensure_ascii=False))
elif msg_type == "switch_safety":
# 切换安全模式
+13 -1
View File
@@ -355,7 +355,19 @@ class BaseAICLI:
engine.max_history = self.max_history
def handle_engine_command(self, command):
"""处理引擎特定命令"""
"""处理引擎特定命令(支持跨引擎调用,如 /engine.manual status)"""
# 先检查是否指定了引擎名(如 "manual status" → 引擎=manual, 命令=status)
parts = command.split(' ', 1)
target_name = parts[0]
rest = parts[1] if len(parts) > 1 else ""
# 如果目标名是某个已加载的引擎,直接调用该引擎
if target_name in self.engines:
target_engine = self.engines[target_name]
if hasattr(target_engine, 'handle_command'):
return target_engine.handle_command(rest)
# 否则当作当前引擎的命令
if not hasattr(self.current_engine, 'handle_command'):
return False
engine_name = getattr(self.current_engine, 'name', '')
+69 -4
View File
@@ -18,10 +18,11 @@ from .cli_tools import ToolMixin
from .cli_code_exec import CodeExecMixin
from .cli_display import DisplayMixin
from .cli_history import HistoryMixin
from .plugin_market import PluginMarketMixin
from .notification import notify_task_complete, get_notification_manager
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin):
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin, PluginMarketMixin):
"""AI CLI主程序 - 组合所有 Mixin"""
# ── 深度思考处理 ──
@@ -174,9 +175,10 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
- 如果语法检查失败,根据错误信息修复代码后再次编辑
【Diff 弹窗 — 自动弹出修改对比】
- 编辑操作完成后自动弹出新终端窗口,显示修改前后的完整内容和行号对比
- 包含 diff 差异、新增/删除行数统计
- 开关: diff_popup on(开启)/ diff_popup off(关闭)/ diff_popup(切换)
- 编辑操作完成后自动显示修改前后的完整内容和行号对比
- 新增行用绿色标记,删除行用红色标记
- 支持两种模式: 弹窗模式(新终端窗口)/ 主终端内显示(SSH 兼容)
- 开关: diff_popup on / diff_popup off / diff_popup popup / diff_popup inline
- 默认开启,无需手动调用
【code_editor 编辑格式】
@@ -1017,14 +1019,54 @@ multi 操作支持一次修改多处:
print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/plugin' 管理插件市场 (安装/卸载/搜索){Style.RESET_ALL}")
current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置'
print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}")
loaded_engines = list(self.engines.keys())
if len(loaded_engines) > 1:
print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}")
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
# 询问 diff 弹窗模式
self._ask_diff_popup_mode()
self._run_cli_loop()
def _ask_diff_popup_mode(self):
"""启动时询问 diff 显示模式"""
# 找到 code_editor 插件
code_editor = None
for tool in self.liugin_manager.tools:
if tool.get('name') == 'code_editor':
code_editor = tool.get('handler')
break
if code_editor is None:
return
# 找到插件实例(通过 handler 的 __self__)
plugin_instance = getattr(code_editor, '__self__', None)
if plugin_instance is None:
return
print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}")
print(f" {Fore.WHITE}1{Style.RESET_ALL} - 弹窗模式(新终端窗口显示)")
print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)")
try:
choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2): {Style.RESET_ALL}").strip()
except (EOFError, KeyboardInterrupt):
choice = '2'
if choice == '1':
plugin_instance.diff_popup_mode = True
print(f"{Fore.GREEN} ✓ 已选择: 弹窗模式{Style.RESET_ALL}")
else:
plugin_instance.diff_popup_mode = False
print(f"{Fore.GREEN} ✓ 已选择: 主终端内显示{Style.RESET_ALL}")
print()
def _run_cli_loop(self):
"""运行 CLI 主循环"""
while True:
@@ -1062,6 +1104,22 @@ multi 操作支持一次修改多处:
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
continue
# /manual 快捷命令 — 切换到 manual 引擎或执行 manual 子命令
if user_input == '/manual' or user_input.startswith('/manual '):
args = user_input[8:].strip() if user_input.startswith('/manual ') else ''
if 'manual' not in self.engines:
print(f"{Fore.RED}manual 引擎未加载{Style.RESET_ALL}")
elif not args:
# 无参数:切换到 manual 引擎
self.switch_engine('manual')
print(f"{Fore.GREEN}已切换到 manual 引擎{Style.RESET_ALL}")
else:
# 有参数:执行 manual 引擎命令(如 status, restart)
result = self.engines['manual'].handle_command(args)
if result:
print(f"{Fore.GREEN}{result}{Style.RESET_ALL}")
continue
if user_input.startswith('/file.read '):
args = user_input[11:].strip()
parts = args.split(' ', 2)
@@ -1106,6 +1164,13 @@ multi 操作支持一次修改多处:
print(f"{Fore.RED}未知的聊天命令.可用命令: save, list, open{Style.RESET_ALL}")
continue
if user_input.startswith('/plugin'):
plugin_args = user_input[7:].strip()
result = self.handle_plugin_command(plugin_args)
if result:
print(result)
continue
if user_input.startswith('/remote'):
remote_command = user_input[7:].strip()
self._handle_remote_command(remote_command)
+1 -1
View File
@@ -102,7 +102,7 @@ except ImportError:
try:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree
from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree, TextArea, Rule
from textual.widgets.tree import TreeNode
from textual.binding import Binding
from textual.events import Mount
+4
View File
@@ -130,3 +130,7 @@ class LiuginManager:
if tool.get('name', '').lower() == tool_name.lower():
return tool
return None
def get_tool_info_only(self, tool_name):
"""根据名称获取工具信息(不含 handler,用于搜索已卸载插件)"""
return self.get_tool_by_name(tool_name)
+652
View File
@@ -0,0 +1,652 @@
"""
插件市场 Mixin — 提供 /plugin 命令
支持: install, remove, list, search, info
"""
import os
import sys
import json
import base64
import importlib.util
from colorama import Fore, Style
try:
from urllib.request import urlopen, Request
HAS_URLLIB = True
except ImportError:
HAS_URLLIB = False
class PluginMarketMixin:
"""插件市场功能"""
def _get_project_dir(self):
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _get_plugins_dir(self):
return os.path.join(self._get_project_dir(), "plugins")
def _get_registry_path(self):
return os.path.join(self._get_project_dir(), "plugin_registry.json")
def _get_config_path(self):
return os.path.join(self._get_project_dir(), "plugins_config.py")
# ── 注册表操作 ──
def _load_registry(self):
"""加载本地注册表"""
path = self._get_registry_path()
if not os.path.exists(path):
return {"version": 1, "plugins": {}}
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"{Fore.YELLOW}⚠ 读取注册表失败: {e},使用空注册表{Style.RESET_ALL}")
return {"version": 1, "plugins": {}}
def _save_registry(self, registry):
"""保存注册表"""
path = self._get_registry_path()
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(registry, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"{Fore.RED}❌ 保存注册表失败: {e}{Style.RESET_ALL}")
def _fetch_remote_registry(self):
"""拉取远程注册表并合并"""
remote_urls = [
"https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/plugin_registry.json",
]
registry = self._load_registry()
merged_count = 0
for url in remote_urls:
try:
data = None
# 尝试直接获取
try:
req = Request(url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=10)
data = resp.read()
except Exception:
pass
# Gitee raw 失败,尝试 API
if data is None and "gitee.com" in url and "/raw/" in url:
try:
parts = url.split("gitee.com/")[1]
repo_path = parts.split("/raw/")[0]
rest = parts.split("/raw/", 1)[1]
rest_parts = rest.split("/", 1)
branch = rest_parts[0]
file_path = rest_parts[1] if len(rest_parts) > 1 else rest
api_url = f"https://gitee.com/api/v5/repos/{repo_path}/contents/{file_path}?ref={branch}"
token = os.environ.get("GITEE_TOKEN", "")
if token:
api_url += f"&access_token={token}"
req = Request(api_url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=10)
json_data = json.loads(resp.read())
if "content" in json_data:
import base64
data = base64.b64decode(json_data["content"])
except Exception:
pass
if data is None:
continue
remote = json.loads(data.decode('utf-8'))
remote_plugins = remote.get("plugins", {})
for code, info in remote_plugins.items():
if code not in registry.get("plugins", {}):
registry.setdefault("plugins", {})[code] = info
merged_count += 1
break # 成功就不再尝试其他源
except Exception:
continue
if merged_count > 0:
self._save_registry(registry)
print(f"{Style.DIM} (从远程同步了 {merged_count} 个插件){Style.RESET_ALL}")
return registry
# ── 配置文件操作 ──
def _enable_plugin_in_config(self, plugin_name):
"""在 plugins_config.py 中启用插件"""
config_path = self._get_config_path()
enable_key = f"ENABLE_{plugin_name.upper()}"
if not os.path.exists(config_path):
with open(config_path, 'w', encoding='utf-8') as f:
f.write(f"# 小狸插件配置\n\n{enable_key} = True\n")
return True
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# 检查是否已存在
if enable_key in content:
# 替换为 True
lines = content.split('\n')
new_lines = []
for line in lines:
if line.strip().startswith(enable_key):
new_lines.append(f"{enable_key} = True")
else:
new_lines.append(line)
with open(config_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
else:
# 追加
with open(config_path, 'a', encoding='utf-8') as f:
f.write(f"\n{enable_key} = True\n")
return True
def _disable_plugin_in_config(self, plugin_name):
"""在 plugins_config.py 中禁用插件"""
config_path = self._get_config_path()
enable_key = f"ENABLE_{plugin_name.upper()}"
if not os.path.exists(config_path):
return False
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
if enable_key not in content:
return False
lines = content.split('\n')
new_lines = []
for line in lines:
if line.strip().startswith(enable_key):
new_lines.append(f"{enable_key} = False")
else:
new_lines.append(line)
with open(config_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
return True
# ── 下载安装 ──
def _download_file(self, url, dest_path):
"""下载文件到指定路径,支持 Gitee/GitHub raw 和 API"""
if not HAS_URLLIB:
return False, "缺少 urllib 模块"
import base64
# 尝试的 URL 列表(按优先级)
urls_to_try = [url]
# Gitee raw URL → 转为 API
if "gitee.com" in url and "/raw/" in url:
try:
parts = url.split("gitee.com/")[1]
repo_path = parts.split("/raw/")[0]
rest = parts.split("/raw/", 1)[1]
# rest = "master/dglab_ws/dglab_ws.py" → 分离 branch 和 path
rest_parts = rest.split("/", 1)
branch = rest_parts[0]
file_path = rest_parts[1] if len(rest_parts) > 1 else rest
api_url = f"https://gitee.com/api/v5/repos/{repo_path}/contents/{file_path}?ref={branch}"
token = os.environ.get("GITEE_TOKEN", "")
if token:
api_url += f"&access_token={token}"
urls_to_try.insert(0, api_url)
except Exception:
pass
# GitHub raw URL → 转为 API
if "github.com" in url and "/raw/" in url:
try:
parts = url.split("github.com/")[1]
repo_path = parts.split("/raw/")[0]
file_path = parts.split("/raw/", 1)[1]
api_url = f"https://api.github.com/repos/{repo_path}/contents/{file_path}"
urls_to_try.insert(0, api_url)
except Exception:
pass
last_err = ""
for try_url in urls_to_try:
try:
req = Request(try_url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=30)
data = resp.read()
# 检查是否是 JSON(API 响应)
try:
json_data = json.loads(data)
if isinstance(json_data, dict) and "content" in json_data:
data = base64.b64decode(json_data["content"])
except (json.JSONDecodeError, ValueError):
pass # 不是 JSON,直接用原始数据
with open(dest_path, 'wb') as f:
f.write(data)
return True, f"下载完成 ({len(data)} 字节)"
except Exception as e:
last_err = str(e)
continue
return False, last_err
def _install_deps(self, deps, optional=False):
"""提示安装依赖(不自动安装,遵守安全规则)"""
if not deps:
return
label = "可选依赖" if optional else "依赖"
print(f"{Fore.YELLOW} 📦 {label}:{', '.join(deps)}{Style.RESET_ALL}")
print(f"{Style.DIM} 运行: pip install {' '.join(deps)}{Style.RESET_ALL}")
# ── /plugin 命令入口 ──
def handle_plugin_command(self, args):
"""处理 /plugin 命令"""
args = args.strip()
if not args:
return self._plugin_help()
parts = args.split(maxsplit=1)
sub = parts[0].lower()
rest = parts[1] if len(parts) > 1 else ""
if sub == "install":
return self._plugin_install(rest.strip())
elif sub in ("remove", "uninstall"):
return self._plugin_remove(rest.strip())
elif sub == "list":
return self._plugin_list()
elif sub == "search":
return self._plugin_search(rest.strip())
elif sub == "info":
return self._plugin_info(rest.strip())
elif sub == "update":
return self._plugin_update(rest.strip())
elif sub == "add-source":
return self._plugin_add_source(rest.strip())
else:
return f"{Fore.RED}❌ 未知子命令: {sub}\n{self._plugin_help()}"
def _plugin_help(self):
"""插件市场帮助"""
return f"""{Fore.CYAN}🔌 插件市场{Style.RESET_ALL}
{Fore.WHITE}命令:{Style.RESET_ALL}
/plugin list 列出所有可用插件
/plugin search <关键词> 搜索插件
/plugin info <插件码> 查看插件详情
/plugin install <插件码> 安装插件
/plugin install <URL> 从 URL 安装插件
/plugin remove <插件名> 卸载插件
/plugin update <插件码> 更新已安装的插件
/plugin add-source <URL> 添加远程插件源
{Fore.WHITE}示例:{Style.RESET_ALL}
/plugin list 查看可用插件
/plugin install dglab 安装 DG-Lab 插件
/plugin search 郊狼 搜索相关插件
/plugin remove dglab_ws 卸载插件"""
def _plugin_list(self):
"""列出所有可用插件"""
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
if not plugins:
return f"{Fore.YELLOW}📭 插件注册表为空,运行 /plugin install <URL> 从 URL 安装{Style.RESET_ALL}"
# 检查已安装状态
plugins_dir = self._get_plugins_dir()
installed = set()
if os.path.exists(plugins_dir):
for f in os.listdir(plugins_dir):
if f.endswith('.py') and f != '__init__.py':
installed.add(f[:-3])
lines = [f"{Fore.CYAN}🔌 可用插件列表:{Style.RESET_ALL}\n"]
for code, info in sorted(plugins.items()):
name = info.get("name", code)
desc = info.get("description", "")
version = info.get("version", "")
target_file = info.get("file", f"{code}.py")
target_name = target_file[:-3] if target_file.endswith('.py') else target_file
if target_name in installed:
status = f"{Fore.GREEN}✓ 已安装{Style.RESET_ALL}"
else:
status = f"{Style.DIM}○ 未安装{Style.RESET_ALL}"
lines.append(f" {Fore.WHITE}{code:<15}{Style.RESET_ALL} {status} {name}")
if desc:
lines.append(f" {Style.DIM}{'':15} {desc[:50]}{Style.RESET_ALL}")
if version:
lines.append(f" {Style.DIM}{'':15} v{version}{Style.RESET_ALL}")
lines.append(f"\n{Style.DIM}使用 /plugin install <插件码> 安装{Style.RESET_ALL}")
return "\n".join(lines)
def _plugin_search(self, keyword):
"""搜索插件"""
if not keyword:
return f"{Fore.YELLOW}请提供搜索关键词: /plugin search <关键词>{Style.RESET_ALL}"
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
keyword_lower = keyword.lower()
matches = []
for code, info in plugins.items():
score = 0
name = info.get("name", "").lower()
desc = info.get("description", "").lower()
keywords = [k.lower() for k in info.get("keywords", [])]
if keyword_lower in code.lower():
score += 100
if keyword_lower in name:
score += 50
if keyword_lower in desc:
score += 30
for kw in keywords:
if keyword_lower in kw:
score += 20
if score > 0:
matches.append((score, code, info))
matches.sort(key=lambda x: x[0], reverse=True)
if not matches:
return f"{Fore.YELLOW}🔍 未找到与 '{keyword}' 相关的插件{Style.RESET_ALL}"
lines = [f"{Fore.CYAN}🔍 搜索 '{keyword}' 的结果:{Style.RESET_ALL}\n"]
for _, code, info in matches:
name = info.get("name", code)
desc = info.get("description", "")
lines.append(f" {Fore.WHITE}{code:<15}{Style.RESET_ALL} {name}")
if desc:
lines.append(f" {Style.DIM}{'':15} {desc[:60]}{Style.RESET_ALL}")
return "\n".join(lines)
def _plugin_info(self, code):
"""查看插件详情"""
if not code:
return f"{Fore.YELLOW}请提供插件码: /plugin info <插件码>{Style.RESET_ALL}"
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
if code not in plugins:
return f"{Fore.RED}❌ 未找到插件 '{code}',运行 /plugin list 查看可用插件{Style.RESET_ALL}"
info = plugins[code]
name = info.get("name", code)
desc = info.get("description", "")
author = info.get("author", "未知")
version = info.get("version", "未知")
deps = info.get("deps", [])
opt_deps = info.get("optional_deps", [])
url = info.get("url", "")
docs = info.get("docs", "")
keywords = info.get("keywords", [])
lines = [
f"{Fore.CYAN}📦 插件详情: {code}{Style.RESET_ALL}\n",
f" 名称: {name}",
f" 版本: {version}",
f" 作者: {author}",
]
if desc:
lines.append(f" 描述: {desc}")
if keywords:
lines.append(f" 关键词: {', '.join(keywords)}")
if deps:
lines.append(f" 依赖: {', '.join(deps)}")
if opt_deps:
lines.append(f" 可选依赖: {', '.join(opt_deps)}")
if url:
lines.append(f" 下载地址: {url}")
if docs:
lines.append(f" 文档: {docs}")
lines.append(f"\n {Fore.GREEN}/plugin install {code}{Style.RESET_ALL} 安装此插件")
return "\n".join(lines)
def _plugin_install(self, code):
"""安装插件"""
if not code:
return f"{Fore.YELLOW}请提供插件码或 URL: /plugin install <插件码|URL>{Style.RESET_ALL}"
plugins_dir = self._get_plugins_dir()
os.makedirs(plugins_dir, exist_ok=True)
# 判断是 URL 还是插件码
if code.startswith("http://") or code.startswith("https://"):
return self._install_from_url(code)
# 从注册表查找
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
if code not in plugins:
return (
f"{Fore.RED}❌ 未找到插件 '{code}'\n"
f"{Fore.YELLOW}运行 /plugin list 查看可用插件\n"
f"或使用 /plugin install <URL> 从 URL 安装{Style.RESET_ALL}"
)
info = plugins[code]
name = info.get("name", code)
url = info.get("url", "")
target_file = info.get("file", f"{code}.py")
deps = info.get("deps", [])
opt_deps = info.get("optional_deps", [])
if not url:
return f"{Fore.RED}❌ 插件 '{code}' 没有下载地址{Style.RESET_ALL}"
dest = os.path.join(plugins_dir, target_file)
# 检查是否已安装
if os.path.exists(dest):
print(f"{Fore.YELLOW}⚠ 插件 '{name}' 已存在,将覆盖更新{Style.RESET_ALL}")
print(f"{Fore.CYAN}📥 正在安装: {name}{Style.RESET_ALL}")
print(f"{Style.DIM} 下载: {url}{Style.RESET_ALL}")
ok, msg = self._download_file(url, dest)
if not ok:
return f"{Fore.RED}❌ 下载失败: {msg}{Style.RESET_ALL}"
print(f"{Fore.GREEN} ✓ 文件已保存到: {dest}{Style.RESET_ALL}")
# 下载文档
docs_url = info.get("docs", "")
if docs_url:
docs_dest = os.path.join(plugins_dir, target_file.replace('.py', '_README.md'))
ok_docs, _ = self._download_file(docs_url, docs_dest)
if ok_docs:
print(f"{Fore.GREEN} ✓ 文档已保存到: {docs_dest}{Style.RESET_ALL}")
# 启用插件
plugin_module_name = target_file[:-3] if target_file.endswith('.py') else target_file
self._enable_plugin_in_config(plugin_module_name)
print(f"{Fore.GREEN} ✓ 已在配置中启用: ENABLE_{plugin_module_name.upper()} = True{Style.RESET_ALL}")
# 提示依赖
self._install_deps(deps, optional=False)
self._install_deps(opt_deps, optional=True)
# 尝试动态加载
print(f"{Fore.CYAN} 🔄 尝试动态加载插件...{Style.RESET_ALL}")
try:
spec = importlib.util.spec_from_file_location(plugin_module_name, dest)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if plugin_class:
instance = plugin_class()
tool_info = instance.get_tool_info()
tool_info['handler'] = instance.handle
if hasattr(instance, 'set_cli'):
instance.set_cli(self)
self.liugin_manager.tools.append(tool_info)
print(f"{Fore.GREEN} ✓ 插件已加载: {tool_info.get('name', plugin_module_name)}{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW} ⚠ 未找到 Liugin/Plugin 类,重启后自动加载{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW} ⚠ 动态加载失败: {e},重启后自动加载{Style.RESET_ALL}")
return f"\n{Fore.GREEN}🎉 插件 '{name}' 安装完成!{Style.RESET_ALL}"
def _install_from_url(self, url):
"""从 URL 直接安装"""
plugins_dir = self._get_plugins_dir()
os.makedirs(plugins_dir, exist_ok=True)
# 从 URL 提取文件名
filename = url.split('/')[-1].split('?')[0]
if not filename.endswith('.py'):
return f"{Fore.RED}❌ URL 必须指向 .py 文件{Style.RESET_ALL}"
dest = os.path.join(plugins_dir, filename)
print(f"{Fore.CYAN}📥 正在从 URL 安装: {url}{Style.RESET_ALL}")
ok, msg = self._download_file(url, dest)
if not ok:
return f"{Fore.RED}❌ 下载失败: {msg}{Style.RESET_ALL}"
plugin_name = filename[:-3]
self._enable_plugin_in_config(plugin_name)
print(f"{Fore.GREEN} ✓ 已保存到: {dest}{Style.RESET_ALL}")
print(f"{Fore.GREEN} ✓ 已启用: ENABLE_{plugin_name.upper()}{Style.RESET_ALL}")
# 尝试动态加载
try:
spec = importlib.util.spec_from_file_location(plugin_name, dest)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if plugin_class:
instance = plugin_class()
tool_info = instance.get_tool_info()
tool_info['handler'] = instance.handle
if hasattr(instance, 'set_cli'):
instance.set_cli(self)
self.liugin_manager.tools.append(tool_info)
print(f"{Fore.GREEN} ✓ 插件已加载: {tool_info.get('name', plugin_name)}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW} ⚠ 动态加载失败: {e},重启后自动加载{Style.RESET_ALL}")
return f"\n{Fore.GREEN}🎉 插件安装完成!{Style.RESET_ALL}"
def _plugin_remove(self, name):
"""卸载插件"""
if not name:
return f"{Fore.YELLOW}请提供插件名: /plugin remove <插件名>{Style.RESET_ALL}"
plugins_dir = self._get_plugins_dir()
# 查找插件文件
target = None
for f in os.listdir(plugins_dir):
if f.endswith('.py') and f != '__init__.py':
module_name = f[:-3]
if module_name == name or f == name:
target = os.path.join(plugins_dir, f)
break
if not target:
return f"{Fore.RED}❌ 未找到已安装的插件 '{name}'{Style.RESET_ALL}"
module_name = os.path.basename(target)[:-3]
# 删除文件
try:
os.remove(target)
print(f"{Fore.GREEN} ✓ 已删除: {target}{Style.RESET_ALL}")
except Exception as e:
return f"{Fore.RED}❌ 删除失败: {e}{Style.RESET_ALL}"
# 删除关联文档
docs_file = target.replace('.py', '_README.md')
if os.path.exists(docs_file):
os.remove(docs_file)
# 禁用插件
self._disable_plugin_in_config(module_name)
print(f"{Fore.GREEN} ✓ 已禁用: ENABLE_{module_name.upper()} = False{Style.RESET_ALL}")
# 从内存中移除
self.liugin_manager.tools = [
t for t in self.liugin_manager.tools
if t.get('name') != module_name
]
return f"\n{Fore.GREEN}🗑️ 插件 '{name}' 已卸载{Style.RESET_ALL}"
def _plugin_update(self, code):
"""更新插件(重新下载)"""
if not code:
return f"{Fore.YELLOW}请提供插件码: /plugin update <插件码>{Style.RESET_ALL}"
registry = self._load_registry()
plugins = registry.get("plugins", {})
if code not in plugins:
return f"{Fore.RED}❌ 注册表中未找到 '{code}',无法更新{Style.RESET_ALL}"
# 检查是否已安装
info = plugins[code]
target_file = info.get("file", f"{code}.py")
dest = os.path.join(self._get_plugins_dir(), target_file)
if not os.path.exists(dest):
return f"{Fore.YELLOW}⚠ 插件 '{code}' 未安装,使用 /plugin install {code} 安装{Style.RESET_ALL}"
print(f"{Fore.CYAN}🔄 更新插件: {code}{Style.RESET_ALL}")
return self._plugin_install(code)
def _plugin_add_source(self, url):
"""添加远程插件源"""
if not url:
return f"{Fore.YELLOW}请提供注册表 URL: /plugin add-source <URL>{Style.RESET_ALL}"
print(f"{Fore.CYAN}📡 正在同步插件源: {url}{Style.RESET_ALL}")
try:
req = Request(url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=15)
remote = json.loads(resp.read().decode('utf-8'))
except Exception as e:
return f"{Fore.RED}❌ 拉取失败: {e}{Style.RESET_ALL}"
registry = self._load_registry()
remote_plugins = remote.get("plugins", {})
added = 0
updated = 0
for code, info in remote_plugins.items():
if code in registry.get("plugins", {}):
registry["plugins"][code] = info
updated += 1
else:
registry.setdefault("plugins", {})[code] = info
added += 1
self._save_registry(registry)
return (
f"{Fore.GREEN}✓ 插件源同步完成: 新增 {added} 个,更新 {updated} 个{Style.RESET_ALL}"
)
+355
View File
@@ -0,0 +1,355 @@
"""
插件内存管理器 - TTL 自动淘汰 + 懒加载
机制:
1. 每个插件记录 last_used 时间戳
2. 后台线程定期扫描,超过 TTL 未使用的插件从内存卸载
3. AI 调用已卸载的插件时,自动重新加载
4. 核心插件(tool_search 等)可钉住,永不淘汰
配置:
- plugin_ttl: 插件空闲多久后淘汰(秒),默认 300(5分钟)
- max_idle_plugins: 最多保留多少个空闲插件,超出后淘汰最久未用的,默认 10
- check_interval: 后台扫描间隔(秒),默认 60
- pinned_plugins: 钉住的插件名列表,永不淘汰
"""
import os
import time
import threading
import importlib.util
from typing import Dict, Optional, List, Any, Callable
from colorama import Fore, Style
from .config import logger
class PluginMemoryManager:
"""插件内存管理器 - 自动淘汰空闲插件,按需懒加载"""
# 默认钉住的插件(核心工具,不淘汰)
DEFAULT_PINNED = {'tool_search'}
def __init__(self,
plugin_ttl: int = 300,
max_idle_plugins: int = 10,
check_interval: int = 60,
pinned_plugins: Optional[List[str]] = None):
self.plugin_ttl = plugin_ttl
self.max_idle_plugins = max_idle_plugins
self.check_interval = check_interval
self.pinned = set(pinned_plugins or self.DEFAULT_PINNED)
# ── 插件元数据 ──
# name -> {path, protocol, last_used, info_cache, ...}
self._registry: Dict[str, Dict[str, Any]] = {}
self._lock = threading.RLock()
# ── 已加载的实例引用(由 UnifiedToolManager 提供注册/注销回调) ──
self._load_callback: Optional[Callable] = None # (name) -> tool_dict or None
self._unload_callback: Optional[Callable] = None # (name) -> None
# ── 后台清理线程 ──
self._cleanup_thread: Optional[threading.Thread] = None
self._running = False
# ── 统计 ──
self.stats = {
'evictions': 0,
'lazy_loads': 0,
'pins_hit': 0,
}
# ==================== 生命周期 ====================
def start(self) -> None:
"""启动后台清理线程"""
if self._running:
return
self._running = True
self._cleanup_thread = threading.Thread(
target=self._cleanup_loop, daemon=True, name="plugin-gc"
)
self._cleanup_thread.start()
print(f"{Fore.CYAN}[插件内存管理] 已启动 (TTL={self.plugin_ttl}s, "
f"最大空闲={self.max_idle_plugins}, 检查间隔={self.check_interval}s){Style.RESET_ALL}")
def stop(self) -> None:
"""停止后台清理线程"""
self._running = False
if self._cleanup_thread:
self._cleanup_thread.join(timeout=5)
self._cleanup_thread = None
# ==================== 回调注册 ====================
def set_callbacks(self,
load_fn: Callable[[str], Optional[Dict]],
unload_fn: Callable[[str], None]) -> None:
"""
注册加载/卸载回调(由 UnifiedToolManager 提供)
Args:
load_fn: 加载插件函数,接收插件名,返回 tool_dict 或 None
unload_fn: 卸载插件函数,接收插件名
"""
self._load_callback = load_fn
self._unload_callback = unload_fn
# ==================== 插件注册 ====================
def register(self, name: str, path: str, protocol: str = 'liugin',
info_cache: Optional[Dict] = None) -> None:
"""
注册一个插件的元数据(加载时调用)
Args:
name: 插件名
path: 插件文件路径
protocol: 协议类型 (liugin/skill)
info_cache: 缓存的工具信息(name, description, keywords 等)
"""
with self._lock:
self._registry[name] = {
'path': path,
'protocol': protocol,
'last_used': time.time(),
'loaded': True,
'info_cache': info_cache or {},
}
def mark_used(self, name: str) -> None:
"""标记插件被使用(每次工具调用时)"""
with self._lock:
if name in self._registry:
self._registry[name]['last_used'] = time.time()
def mark_loaded(self, name: str) -> None:
"""标记插件已加载"""
with self._lock:
if name in self._registry:
self._registry[name]['loaded'] = True
self._registry[name]['last_used'] = time.time()
def mark_unloaded(self, name: str) -> None:
"""标记插件已卸载"""
with self._lock:
if name in self._registry:
self._registry[name]['loaded'] = False
# ==================== 懒加载 ====================
def ensure_loaded(self, name: str) -> Optional[Dict]:
"""
确保插件已加载,如果已卸载则自动重新加载
Args:
name: 插件名
Returns:
tool_dict 如果加载成功,None 如果失败
"""
with self._lock:
entry = self._registry.get(name)
if not entry:
return None # 未注册的插件,不管
if entry['loaded']:
# 已加载,更新时间戳
entry['last_used'] = time.time()
return None # 返回 None 表示不需要重新加载
# 需要重新加载(锁外执行,避免死锁)
print(f"{Fore.CYAN}[插件内存管理] 懒加载插件: {name}{Style.RESET_ALL}")
result = self._do_load(name)
if result:
self.stats['lazy_loads'] += 1
print(f"{Fore.GREEN}[插件内存管理] 懒加载成功: {name}{Style.RESET_ALL}")
else:
print(f"{Fore.RED}[插件内存管理] 懒加载失败: {name}{Style.RESET_ALL}")
return result
def _do_load(self, name: str) -> Optional[Dict]:
"""执行实际的插件加载"""
if not self._load_callback:
return None
with self._lock:
entry = self._registry.get(name)
if not entry:
return None
try:
result = self._load_callback(name)
if result:
with self._lock:
if name in self._registry:
self._registry[name]['loaded'] = True
self._registry[name]['last_used'] = time.time()
return result
except Exception as e:
logger.error(f"懒加载插件 '{name}' 失败: {e}")
return None
# ==================== 淘汰 ====================
def evict(self, name: str) -> bool:
"""
淘汰指定插件
Args:
name: 插件名
Returns:
是否成功淘汰
"""
if name in self.pinned:
self.stats['pins_hit'] += 1
return False
with self._lock:
entry = self._registry.get(name)
if not entry or not entry['loaded']:
return False
# 执行卸载(锁外)
if self._unload_callback:
try:
self._unload_callback(name)
except Exception as e:
logger.error(f"卸载插件 '{name}' 失败: {e}")
return False
with self._lock:
if name in self._registry:
self._registry[name]['loaded'] = False
self.stats['evictions'] += 1
print(f"{Fore.YELLOW}[插件内存管理] 已淘汰: {name} "
f"(空闲 {self._idle_time(name):.0f}s){Style.RESET_ALL}")
return True
def _idle_time(self, name: str) -> float:
"""获取插件空闲时间"""
with self._lock:
entry = self._registry.get(name)
if not entry:
return 0
return time.time() - entry['last_used']
# ==================== 后台清理 ====================
def _cleanup_loop(self) -> None:
"""后台清理循环"""
while self._running:
try:
time.sleep(self.check_interval)
if not self._running:
break
self._cleanup_once()
except Exception as e:
logger.error(f"插件清理线程异常: {e}")
def _cleanup_once(self) -> None:
"""执行一次清理"""
with self._lock:
# 收集所有已加载且未钉住的插件
candidates = []
for name, entry in self._registry.items():
if entry['loaded'] and name not in self.pinned:
idle = time.time() - entry['last_used']
if idle >= self.plugin_ttl:
candidates.append((name, idle))
if not candidates:
return
# 按空闲时间排序,最久未用的优先淘汰
candidates.sort(key=lambda x: x[1], reverse=True)
# 也检查总数限制
with self._lock:
loaded_count = sum(1 for e in self._registry.values()
if e['loaded'] and e.get('name') not in self.pinned)
evicted = 0
for name, idle in candidates:
# 如果已超过最大空闲数限制 或 超过 TTL
if loaded_count - evicted > self.max_idle_plugins or idle >= self.plugin_ttl:
if self.evict(name):
evicted += 1
if evicted:
print(f"{Fore.CYAN}[插件内存管理] 本轮淘汰 {evicted} 个插件, "
f"累计淘汰 {self.stats['evictions']} 次, "
f"懒加载 {self.stats['lazy_loads']} 次{Style.RESET_ALL}")
# ==================== 查询接口 ====================
def is_loaded(self, name: str) -> bool:
"""检查插件是否已加载"""
with self._lock:
entry = self._registry.get(name)
return entry['loaded'] if entry else False
def is_registered(self, name: str) -> bool:
"""检查插件是否已注册"""
with self._lock:
return name in self._registry
def get_info_cache(self, name: str) -> Optional[Dict]:
"""获取插件的缓存信息(不需要加载实例)"""
with self._lock:
entry = self._registry.get(name)
return entry.get('info_cache') if entry else None
def get_all_registered(self) -> List[str]:
"""获取所有已注册的插件名"""
with self._lock:
return list(self._registry.keys())
def get_loaded_names(self) -> List[str]:
"""获取当前已加载的插件名"""
with self._lock:
return [n for n, e in self._registry.items() if e['loaded']]
def get_unloaded_names(self) -> List[str]:
"""获取当前已卸载的插件名"""
with self._lock:
return [n for n, e in self._registry.items() if not e['loaded']]
def get_status(self) -> Dict[str, Any]:
"""获取内存管理状态"""
with self._lock:
loaded = sum(1 for e in self._registry.values() if e['loaded'])
unloaded = sum(1 for e in self._registry.values() if not e['loaded'])
return {
'total': len(self._registry),
'loaded': loaded,
'unloaded': unloaded,
'pinned': list(self.pinned),
'ttl': self.plugin_ttl,
'max_idle': self.max_idle_plugins,
'check_interval': self.check_interval,
'stats': dict(self.stats),
}
def format_status(self) -> str:
"""格式化状态信息(用于 /status 命令)"""
s = self.get_status()
lines = [
f" 插件内存管理:",
f" 总注册: {s['total']} 已加载: {s['loaded']} 已卸载: {s['unloaded']}",
f" TTL: {s['ttl']}s 最大空闲: {s['max_idle']} 扫描间隔: {s['check_interval']}s",
f" 钉住: {', '.join(s['pinned']) or '无'}",
f" 统计: 淘汰 {s['stats']['evictions']} 次, "
f"懒加载 {s['stats']['lazy_loads']} 次, "
f"钉住保护 {s['stats']['pins_hit']} 次",
]
# 显示已卸载的插件
unloaded = self.get_unloaded_names()
if unloaded:
lines.append(f" 已卸载: {', '.join(unloaded)}")
return '\n'.join(lines)
+55 -42
View File
@@ -62,8 +62,7 @@ if TEXTUAL_AVAILABLE:
scrollbar-color: {_Theme.BORDER};
scrollbar-color-hover: {_Theme.TEXT_MUTED};
padding: 0 1;
/* 平滑滚动 */
scroll-behavior: smooth;
}}
/* ── 输入区域 ── */
@@ -144,10 +143,11 @@ if TEXTUAL_AVAILABLE:
}}
#sidebar.hidden {{
display: none;
width: 0;
min-width: 0;
opacity: 0;
border: none;
overflow: hidden;
}}
.sidebar-header {{
@@ -297,28 +297,6 @@ if TEXTUAL_AVAILABLE:
background: {_Theme.BG};
}}
/* ── 闪光动画 keyframes ── */
@keyframes pulse-glow {{
0% {{ color: {_Theme.THINKING}; }}
50% {{ color: {_Theme.ACCENT}; }}
100% {{ color: {_Theme.THINKING}; }}
}}
@keyframes fade-in {{
from {{ opacity: 0; }}
to {{ opacity: 1; }}
}}
@keyframes slide-in-right {{
from {{ offset-x: 100%; }}
to {{ offset-x: 0; }}
}}
@keyframes bounce-in {{
0% {{ opacity: 0; offset-y: 5%; }}
60% {{ opacity: 1; offset-y: -1%; }}
100% {{ opacity: 1; offset-y: 0; }}
}}
"""
class _TUIBridge:
@@ -502,7 +480,7 @@ if TEXTUAL_AVAILABLE:
"",
" ╔══════════════════════════════════════════════╗",
" ║ ║",
" ║ 小狸 Pro-CLI v5.1 ║",
" ║ 小狸 Pro-CLI v5.2 ║",
" ║ 智能编程助手 · 动画增强版 ║",
" ║ ║",
" ╚══════════════════════════════════════════════╝",
@@ -531,12 +509,12 @@ if TEXTUAL_AVAILABLE:
widget = Static(line, classes=cls)
scroll.mount(widget)
# 延迟显示每一行
delay = i * 0.1
# 延迟显示每一行(避免 delay=0 导致 Textual 除零错误)
delay = max(0.01, i * 0.1)
self.set_timer(delay, lambda w=widget: w.add_class("visible"))
# 最后滚动到底部
self.set_timer(len(welcome_lines) * 0.1 + 0.1,
self.set_timer(max(0.01, len(welcome_lines) * 0.1 + 0.1),
lambda: scroll.scroll_end(animate=True, duration=0.3))
def _render_welcome(self):
@@ -546,7 +524,7 @@ if TEXTUAL_AVAILABLE:
("", "msg-dim"),
(" ╔══════════════════════════════════════════════╗", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ║ 小狸 Pro-CLI v5.1 ║", "msg-welcome"),
(" ║ 小狸 Pro-CLI v5.2 ║", "msg-welcome"),
(" ║ 智能编程助手 · 动画增强版 ║", "msg-welcome"),
(" ║ ║", "msg-welcome"),
(" ╚══════════════════════════════════════════════╝", "msg-welcome"),
@@ -584,6 +562,14 @@ if TEXTUAL_AVAILABLE:
status_container.mount(Static(f" 工具: {len(self.bridge.tools())} 个", classes="tool-item"))
status_container.mount(Static(f" 引擎: {len(self.bridge.engines())} 个", classes="tool-item"))
# 内存管理状态
if hasattr(self.cli, 'liugin_manager') and hasattr(self.cli.liugin_manager, 'memory_manager'):
mgr = self.cli.liugin_manager.memory_manager
s = mgr.get_status()
status_container.mount(Static(f" 已加载: {s['loaded']}", classes="tool-item"))
if s['unloaded'] > 0:
status_container.mount(Static(f" 已卸载: {s['unloaded']}", classes="tool-item"))
def _update_status(self, text: str):
bar = self.query_one("#status-bar")
engine = self.bridge.current_engine()
@@ -737,11 +723,12 @@ if TEXTUAL_AVAILABLE:
'cls': lambda: self.action_clear(),
'model': lambda: self._switch_model(args),
'engine': lambda: self._switch_model(args),
'about': lambda: self._system(" 小狸 Pro-CLI v5.1 - 智能编程助手"),
'about': lambda: self._system(" 小狸 Pro-CLI v5.2 - 智能编程助手"),
'status': lambda: self._show_status(),
'tools': lambda: self._show_tools(),
'engines': lambda: self._show_engines(),
'tui': lambda: self._system("已在 TUI 模式中"),
'manual': lambda: self._handle_manual(args),
}
handler = cmds.get(name)
@@ -776,6 +763,24 @@ if TEXTUAL_AVAILABLE:
Enter 换行 Tab 缩进"""
self._system(help_text)
def _handle_manual(self, args):
"""处理 /manual 命令"""
if 'manual' not in self.cli.engines:
self._error("manual 引擎未加载")
return
if not args:
# 切换到 manual 引擎
if self.bridge.switch_engine('manual'):
self._system("已切换到 manual 引擎")
self._update_sidebar()
else:
self._error("切换失败")
else:
# 执行 manual 引擎子命令
result = self.cli.engines['manual'].handle_command(args)
if result:
self._system(result)
def _switch_cli(self):
self._system("切换到命令行模式...")
self.cli.tui_output_callback = None
@@ -810,11 +815,18 @@ if TEXTUAL_AVAILABLE:
self._system('\n'.join(lines))
def _show_status(self):
self._system(f""" 系统状态:
引擎: {self.bridge.current_engine()}
引擎数: {len(self.bridge.engines())}
工具数: {len(self.bridge.tools())}
对话数: {len(self.bridge.history())}""")
status_lines = [
f" 系统状态:",
f" 引擎: {self.bridge.current_engine()}",
f" 引擎数: {len(self.bridge.engines())}",
f" 工具数: {len(self.bridge.tools())}",
f" 对话数: {len(self.bridge.history())}",
]
# 内存管理状态
if hasattr(self.cli, 'liugin_manager') and hasattr(self.cli.liugin_manager, 'memory_manager'):
mgr = self.cli.liugin_manager.memory_manager
status_lines.append(mgr.format_status())
self._system('\n'.join(status_lines))
# ── AI 生成 ──
@@ -849,11 +861,11 @@ if TEXTUAL_AVAILABLE:
self.call_after_refresh(self._update_sidebar)
def _write_raw(self, msg):
if '' in msg or 'OK 工具' in msg:
if '✅' in msg or 'OK 工具' in msg:
self._add(f" {msg}", "msg-tool-ok")
elif '' in msg or 'X 工具' in msg or '错误' in msg:
elif '❌' in msg or 'X 工具' in msg or '错误' in msg:
self._add(f" {msg}", "msg-tool-err")
elif '' in msg:
elif '🤖' in msg:
self._add(f" {msg}", "msg-ai")
elif '工具' in msg and ('调用' in msg or '执行' in msg):
self._add(f" {msg}", "msg-tool-ok")
@@ -874,12 +886,13 @@ if TEXTUAL_AVAILABLE:
def action_toggle_sidebar(self):
sidebar = self.query_one("#sidebar")
sidebar.visible = not sidebar.visible
# 动画:侧栏切换时平滑过渡
if sidebar.visible:
# 通过 CSS 类控制侧栏显隐
if sidebar.has_class("hidden"):
sidebar.remove_class("hidden")
self.sidebar_visible = True
else:
sidebar.add_class("hidden")
self.sidebar_visible = False
def action_cancel(self):
if self.is_generating:
Binary file not shown.