18 Commits
Author SHA1 Message Date
shuiyu1123 de15b54b19 release: v6.1.0 - TUI 体验全面升级 + 背景自定义
TUI 界面改进:
- 全面中文化: CommandPalette/HelpPanel/Footer/Chatbox 边框标题等所有用户可见英文汉化
- 辅助中心 (Ctrl+P/F9): 原"设置"重命名, 含 6 项可切换设置
- 独立帮助浮层 (? 键): 重写帮助页面逻辑, 全中文, 带滑入淡入动画
- 背景切换: 辅助中心 [5] 预设背景, [6] 自定义背景; 新增 /bg 命令支持图片提取主色调
- 换行修复: Ctrl+J 作为主推换行键 (Windows 终端不区分 Shift+Enter)
- 引擎切换修复: 解决 call_from_thread 在主线程崩溃的 RuntimeError

技术改进:
- 文本工具调用解析: 检测模型用 <tool_call> 标签返回的工具调用
- 网络工具优化: network_tools.py 和 ai_search.py 改进请求头/重试/编码
- 流式输出修复: append_chunk 的 refresh 改为 layout=True
- 取消响应修复: 引擎层 _cancelled 标志 + TUI 层 worker 身份跟踪

融合 v6.0.0 工作:
- 保留 terminal 模拟器/operit 桥接/深度思考/工作模式/记忆模式等 v6.0.0 全部功能
- 在此基础上叠加本会话的 TUI 改进
2026-06-29 13:00:19 +08:00
shuiyu1123 b5f638cffc fix: 修复TUI界面多个问题,添加记忆模块模式选择功能
- 修复ToolResult对象的get方法错误
- 修复TUI版本号显示错误(v5.4.1 -> v6.0)
- 修复Shift+Enter换行功能
- 修复AI回复颜色显示问题
- 添加记忆模块模式选择功能(陪伴模式/工作模式)
- 清理项目文件,准备上传到Gitee
2026-06-26 22:26:13 +08:00
shuiyu1123 329854866b test: 新增 android_api_test.py — 12项 Android API 转译测试全部通过
测试覆盖: Intent, Uri, Bundle, File, JSONObject, ArrayList/HashMap,
Environment, Log, Settings, Bitmap/Canvas/Color, StringBuilder, ContentValues
2026-06-24 14:27:17 +00:00
shuiyu1123 a49c360867 refactor: 删除 operit 全部旧代码,新增 operit_loader.py 单文件转译层
- 删除 operit_py/ (47个脚本)、operit_runtime.py、android_compat.py
- 新增 operit_loader.py — 通过 Android-pyApi 仓库提供 Android API 转译
- operit 脚本工具现通过 Android-pyApi (212模块/175K行) 无损运行
- 支持 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker
- 支持 OkHttp/console/complete/exports 全套 Operit API
- 跨平台运行: Windows/macOS/Linux
- 新增 operit_scripts/ 目录用于存放 operit 插件脚本
2026-06-24 14:22:30 +00:00
shuiyu1123 b245af9063 feat: 全面 Android API → Python 兼容层 (android_compat.py)
新增 android_compat.py (3552行):
全面映射 Android Java API 到 Python 跨平台实现,覆盖 16 个领域、108 个 API 对象:

android.content: Intent(20+actions), Context, ContentResolver, ContentValues,
                 BroadcastReceiver, IntentFilter, SharedPreferences
android.net: Uri (parse/fromFile/withAppendedPath)
android.os: Build, Environment, PowerManager, BatteryManager, VibrationEffect,
             Bundle, Handler, Looper, ParcelFileDescriptor
android.graphics: Bitmap (PIL后端), Canvas, Color, Paint, Rect, Matrix,
                  Typeface, BitmapFactory
android.graphics.pdf: PdfRenderer (PyMuPDF/pdf2image后端)
android.app: ActivityManager, PackageManager(包查询), NotificationManager,
             AlarmManager, DownloadManager
android.view: View, TextView, EditText, Button, ImageView, ScrollView,
              ViewGroup, LinearLayout, FrameLayout, RelativeLayout
android.view.accessibility: AccessibilityNodeInfo (UI树+pyautogui点击),
                            AccessibilityPage, UINode
android.hardware: SensorManager, CameraManager, BiometricPrompt
android.location: LocationManager(模拟GPS), Geocoder, Location(Haversine)
android.media: MediaPlayer, AudioRecord, AudioManager
android.telephony: TelephonyManager
android.provider: Settings, MediaStore, CalendarContract, ContactsContract
android.net.wifi: WifiManager, WifiInfo + ConnectivityManager
android.os.storage: StorageManager, StorageVolume
java.io: File, FileInputStream/OutputStream, BufferedReader, BufferedWriter

Java桥接: Java.type() 支持直调真实Java类(jpype)或返回存根
系统服务: 10+ 模拟服务 (ActivityManager/PowerManager/etc)
路径映射: /sdcard → ~/Android-compat/sdcard
UI自动化: UINode + pyautogui 桌面降级
绘图: Bitmap/Canvas → PIL/Pillow 后端

集成到 operit_runtime.py: 自动检测并注入108个Android API到脚本命名空间

测试结果:
  - 语法检查: android_compat.py ✅, operit_runtime.py ✅
  - 路径映射: /sdcard → /root/Android-compat/sdcard ✅
  - Intent/Uri/Context/Bitmap/File 全API ✅
  - 脚本加载: 38脚本 280工具 ✅
2026-06-23 12:43:38 +00:00
shuiyu1123 dc77dfd304 feat: 将 47 个 Operit TypeScript 脚本全面翻译为 Python
- 新增 operit_runtime.py: 跨平台 Python 运行时
  - 实现 Tools.System/Files/Net/UI/Memory/FFmpeg/Workflow/Chat/SoftwareSettings/Tasker
  - 实现 OkHttp 链式 HTTP 客户端兼容层
  - 实现 complete()/exports/console 全局对象
  - 实现 OperitScriptLoader 脚本加载器
  - 支持 Windows/macOS/Linux 三平台

- 新增 operit_py/ 目录: 47 个 Python 翻译脚本
  - 搜索类: duckduckgo, google_search, tavily, zhipu_search, various_search, crossref
  - 绘图类: openai_draw, qwen_draw, xai_draw, minimax_draw, siliconflow_draw, zhipu_draw, nanobanana_draw
  - 媒体类: ffmpeg, file_converter, douyin_download, bilibili_tools, jmcomic
  - 文件类: extended_file_tools, hex_editor, reader, beeimg_image_uploader
  - 网络类: extended_http_tools, network_test
  - 自动化类: browser, automatic_ui_base, automatic_ui_subagent, automatic_bilibili_assistant, automatic_xiaohongshu_assistant, automatic_baidu_map_assistant
  - 系统类: system_tools, super_admin, daily_life, operit_editor
  - 对话类: ai_chat, extended_chat, qq_intelligent
  - 工作流类: workflow, vflow_trigger, tasker
  - 工具类: time, code_runner, quick_start, extended_memory_tools, baidu_map, 12306, pdf_vision_parser

- 测试结果: 47/47 语法检查通过, 38 脚本加载成功, 280 个工具注册
- Android 专属功能已降级处理(保留接口,返回不支持提示)
2026-06-23 12:27:18 +00:00
shuiyu1123 6fa759665d CHANGELOG v6.0.0: 移除 operit_bridge 条目 2026-06-23 10:56:38 +00:00
shuiyu1123 ef8de43693 移除 operit 相关插件: 删除 operit_bridge 和 plugins/operit/ 脚本
- 删除 plugins/operit/ 目录(47 个 Operit 脚本)
- 删除 plugins/operit_bridge.py
- 删除 tests/test_operit_bridge.py
- plugins_config.py: 移除 ENABLE_OPERIT_BRIDGE
- plugin_registry.json: 移除 operit_bridge 注册
- README.md: 更新插件计数和项目结构
- CHANGELOG.md: 移除 operit 相关条目
2026-06-23 10:51:21 +00:00
xiaoli-cli bot 88ddd18ded release: v6.0.0 - 插件生态 + 深度思考 + 工作模式
重大更新:
- 新增 terminal/operit_bridge/image_reader 三个插件
- 深度思考: 原生API适配(DeepSeek/MiMo/OpenAI/Claude/Grok) + 提示词兜底
- 工作模式: 普通/人工/无限制/计划模式
- 计划模式: 全自动+审查+重试+引擎故障切换
- 系统提示词兜底策略: 插件仓库→网上搜索→自己写插件
- 插件整理: 删除图像类,主仓库25个生产力插件
2026-06-23 10:24:31 +00:00
xiaoli-cli bot 810a057d25 feat: 工作模式系统 - 整合安全模式 + 计划模式
工作模式 (/mode 或 /safe 切换):
- 普通: AI识别风险,有风险才确认 (原安全模式)
- 人工确认: 所有指令都需确认 (原安全模式)
- 无限制: 直接执行 (原安全模式)
- 计划模式: 全自动 + 审查 + 重试 + 引擎切换 (新增)

计划模式行为:
- 全自动执行,不询问用户
- 引擎错误10次自动切换到其他引擎
- 无其他引擎时继续重试,但不把引擎错误加入上下文
- 任务完成后自动审查结果
- 审查发现问题自动重试

实现:
- safety.py: SafetyLayer → WorkMode,新增 MODE_PLAN
- cli_core.py: 新增 _plan_generate / _plan_review
- /safe 和 /mode 命令均可切换
2026-06-23 10:11:06 +00:00
xiaoli-cli bot 3f7df28423 feat: 通用深度思考适配 - 原生API + 提示词兜底
原生支持 (API参数):
- DeepSeek/MiMo: enable_thinking + reasoning_effort
- OpenAI o-series: reasoning.effort
- Claude: thinking.type + budget_tokens (按等级分配2k/8k/32k)
- Grok: thinking.enabled
- Gemini thinking: 模型名自带

不支持原生的模型 (提示词注入):
- low: 无额外提示词
- medium: 逐步思考提示 (分析→因素→方案→选择)
- high: 工程级深度推理框架 (问题分解→多角度分析→方案设计→验证优化)

所有模型统一 /engine.openai think [on|off|low|medium|high] 控制
2026-06-23 09:59:08 +00:00
xiaoli-cli bot 75194a9da7 feat: OpenAI 引擎支持 MiMo 深度思考
MiMo 使用 DeepSeek 兼容格式:
- enable_thinking: true (v2.5-pro 默认开启)
- reasoning_content 返回思维链
- reasoning_effort: low/medium/high
2026-06-23 09:50:26 +00:00
xiaoli-cli bot a6f6962184 feat: OpenAI 引擎支持深度思考开关
新增:
- /engine.openai think [on|off|low|medium|high] 命令
- deep_think 配置项 (config.json)
- reasoning_effort 参数 (low/medium/high)

支持模型:
- DeepSeek: enable_thinking + reasoning_content
- OpenAI o-series: reasoning.effort
- 通用: reasoning_effort 参数

深度思考开启后:
- 自动注入思考参数到请求体
- 返回的思维链(reasoning_content)显示在终端
- info 命令显示当前思考状态
2026-06-23 09:37:34 +00:00
xiaoli-cli bot 346f5fdcb4 feat: 系统提示词增加兜底策略
遇到能力不足时的三级解决路径:
1. 查插件仓库 (/plugin search → install)
2. 网上搜索 (ai_search)
3. 自己写插件 (Liugin 类)

原则: 放弃是最后的选择。能装就装,能搜就搜,能写就写。
2026-06-23 09:33:49 +00:00
xiaoli-cli bot d336b04b61 feat: 新增 image_reader 插件 - 图片读取与 OCR 文字识别
功能:
- ocr: 从图片提取文字(支持 Tesseract / Windows PowerShell)
- info: 获取图片信息(尺寸/格式/大小/模式)
- read: 综合读取(信息 + OCR)
- ascii: 图片转 ASCII 字符画(终端显示)
- base64: 转 Base64 编码
- hash: 计算 MD5/SHA256

纯标准库实现,Tesseract 为可选依赖。
2026-06-23 09:29:28 +00:00
xiaoli-cli bot 125e00a485 chore: 整理插件分类 - 删除图像类,移动娱乐类
删除(图像类):
- ai_image.py (AI生图)
- image_generator.py (ComfyUI生图)
- send_image.py (远程发图)

移至插件仓库(娱乐/可选):
- color_picker.py
- bookmark_manager.py
- audio_player.py

保留24个必备生产力插件:
核心: code_editor/git_tools/file_manager/cmd_executor/terminal
工程: auto_engineer/frontend_tester/workflow/sub_agent
搜索: ai_search/tool_search/network_tools
自动化: browser_auto/gui_auto
数据: json_toolkit/regex_tester/markdown_tool/id_generator/snippet_manager
系统: scheduler/task_manager/memory_plugin/operit_bridge/speech_recognition

更新 README.md/plugin_registry.json/plugins_config.py
2026-06-23 09:25:25 +00:00
xiaoli-cli bot 5ea19b75ae refactor: terminal 插件重写 v2 - 极简接口
新设计: AI 的虚拟键盘和屏幕
- open: 打开终端
- type: 打字
- key: 按键 (enter/tab/ctrl+c/方向键/f1-f12等)
- read: 看整个屏幕
- last [N]: 只看最近N行(默认20,避免上下文爆炸)
- wait: 等待输出

从600行精简到250行,接口从12个减到8个
2026-06-23 09:14:15 +00:00
xiaoli-cli bot dfe21298b9 release: v5.4.2 - terminal模拟器 + Operit桥接
新增插件:
- terminal: 持久交互式终端,纯标准库,全平台,13个测试
- operit_bridge: Operit AI工具格式桥接,47个脚本288个工具,12个测试

其他:
- plugins/operit/: 47个Operit官方脚本
- 更新plugin_registry.json/plugins_config.py
- 更新about.txt/CHANGELOG.md至v5.4.2
2026-06-23 08:50:15 +00:00
53 changed files with 6876 additions and 3315 deletions

No files matched your search

+93
View File
@@ -1,5 +1,98 @@
# 更新日志 # 更新日志
## v6.1.0 (2026-06-28)
**TUI 体验全面升级 + 背景自定义**
### TUI 界面改进
- **全面中文化** — CommandPalette(命令搜索面板)子选项、placeholder、系统命令名全部汉化;HelpPanel 帮助页面、Footer、Chatbox 边框标题、错误前缀、辅助中心面板等所有用户可见英文汉化
- **辅助中心**(Ctrl+P / F9)— 原"设置"页面重命名为"辅助中心",含 6 项可切换设置(主题/引擎/历史/安全/预设背景/自定义背景)
- **独立帮助浮层**(? 键)— 重写帮助页面逻辑,独立浮层替代原 Textual HelpPanel 侧边栏,全中文内容,带滑入淡入动画
- **背景切换** — 辅助中心 [5] 切换 4 种预设背景色(墨黑/深蓝灰/深紫/深蓝),[6] 自定义背景;新增 `/bg` 命令支持用图片提取主色调作为背景
- **换行修复** — Ctrl+J 作为主推换行键(Windows 终端不区分 Shift+Enter),同时保留 shift+enter/alt+enter 兼容
- **引擎切换修复** — 解决 `call_from_thread` 在主线程崩溃的 RuntimeError,主线程直接调用,工作线程才走 call_from_thread
### 技术改进
- **文本工具调用解析** — 新增 `_handle_text_tool_call` 方法,检测模型在文本响应中用 `<tool_call>` 标签返回的工具调用(非标准 Function Calling API),解析并执行
- **网络工具优化** — network_tools.py 和 ai_search.py 改进请求头、重试机制、编码处理
- **流式输出修复** — Chatbox.append_chunk 和 ThinkingBox.append_chunk 的 refresh 改为 layout=True,修复流式只显示首行
- **取消响应修复** — 引擎层增加 `_cancelled` 标志和 cancel() 方法,TUI 层跟踪 worker 身份,action_cancel 直接调用 _finish_cancel
---
## v6.0.0 (2026-06-23)
**重大更新:插件生态 + 深度思考 + 工作模式**
### 新增插件
- **terminal** (终端模拟器 v2):AI 的虚拟键盘和屏幕
- 极简接口:open / type / key / read / last / wait
- 持久 Shell 会话,环境变量跨命令保持
- Python REPL 交互,多会话并行
- `last [N]` 只看最近 N 行,避免上下文爆炸
- 纯标准库,零外部依赖,全平台
- **image_reader** (图片读取):从图片中提取文字
- OCR 文字识别(Tesseract / Windows PowerShell)
- ASCII 字符画、图片信息、Base64、哈希
### 深度思考
- OpenAI 引擎支持 `/engine.openai think [on|off|low|medium|high]`
- 原生支持:DeepSeek、MiMo、OpenAI o-series、Claude、Grok、Gemini thinking
- 提示词兜底:不支持原生的模型自动注入思考提示词
- low: 无额外提示词
- medium: 逐步思考提示
- high: 工程级深度推理框架
### 工作模式
- `/mode` 或 `/safe` 切换四种工作模式
- 普通:AI 识别风险,有风险才确认
- 人工确认:所有指令都需确认
- 无限制:直接执行
- **计划模式(新增)**:全自动 + 审查 + 重试 + 引擎切换
- 引擎错误 10 次自动切换引擎
- 无其他引擎继续重试,错误不入上下文
- 任务完成后自动审查,发现问题自动重试
### 系统提示词
- 新增兜底策略:插件仓库 → 网上搜索 → 自己写插件
- 放弃是最后的选择
### 插件整理
- 删除图像类插件:ai_image、image_generator、send_image
- 移至插件仓库:audio_player、bookmark_manager、color_picker
- 主仓库保留 24 个必备生产力插件
### 配置更新
- config.json.example 新增 deep_think / reasoning_effort 配置
- plugins_config.py 重写,统一启用所有核心插件
- plugin_registry.json 同步更新
---
## v5.4.2 (2026-06-23)
**新增插件:**
- **terminal** (终端模拟器):纯标准库实现的持久交互式终端
- 持久 Shell 会话,环境变量/目录跨命令保持
- Python REPL 交互 (`-i` 模式,`PYTHONUNBUFFERED`)
- 多会话管理(open/switch/list/close)
- 特殊按键支持(Ctrl+C/D/Z、Tab、方向键等 18 种)
- 文件哨兵机制(避免 stdout 提示符污染)
- Windows/Linux/macOS 全平台,零外部依赖
- 13 个单元测试
**其他变更:**
所有重要更改都记录在此文件中。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。 所有重要更改都记录在此文件中。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。
--- ---
+20 -17
View File
@@ -9,11 +9,11 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version"> <img src="https://img.shields.io/badge/version-6.0.0-blue" alt="version">
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python"> <img src="https://img.shields.io/badge/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/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/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/plugins-24-blueviolet" alt="plugins">
</p> </p>
--- ---
@@ -34,9 +34,9 @@
- **远程通信** — PC 与手机 WebSocket 双向通信 - **远程通信** — PC 与手机 WebSocket 双向通信
- **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI - **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI
- **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux) - **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux)
- **MCP 协议** — 19 个插件全部实现 MCP 定义,支持 Function Calling 原生调用 - **MCP 协议** — 18 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
- **进程保护** — 跨平台单实例保护 + 进程优先级提升 + Windows PPL 风格保护 + 看门狗监控 + 优雅关闭 - **进程保护** — 跨平台单实例保护 + 进程优先级提升 + Windows PPL 风格保护 + 看门狗监控 + 优雅关闭
- **19 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等 - **18 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
- **三模式界面** — TUI 图形化 + 传统命令行 + Web UI 浏览器界面 - **三模式界面** — TUI 图形化 + 传统命令行 + Web UI 浏览器界面
- **150+ 个自动化测试** — pytest 覆盖全部插件和核心模块 - **150+ 个自动化测试** — pytest 覆盖全部插件和核心模块
@@ -94,7 +94,7 @@ launcher.exe
} }
``` ```
## 插件列表 (19 个) ## 插件列表 (23 个)
> 更多社区插件请访问 [xiaoli-cli-plugins](https://gitee.com/shuiyu1123/xiaoli-cli-plugins),使用 `/plugin install <插件码>` 一键安装。 > 更多社区插件请访问 [xiaoli-cli-plugins](https://gitee.com/shuiyu1123/xiaoli-cli-plugins),使用 `/plugin install <插件码>` 一键安装。
@@ -114,24 +114,27 @@ launcher.exe
| 插件 | 操作数 | 说明 | | 插件 | 操作数 | 说明 |
|------|--------|------| |------|--------|------|
| `sub_agent` | 8 | 子 Agent 系统。spawn/list/status/result/send/kill,多 Agent 并行协作 | | `sub_agent` | 8 | 子 Agent 系统。spawn/list/status/result/send/kill,多 Agent 并行协作 |
| `browser_auto` | 28 | 浏览器自动化。导航/点击/填写/截图/JS执行/PDF/cookies(基于 Playwright) | | `terminal` | 8 | 终端模拟器。AI 的虚拟键盘和屏幕,打字/按键/看屏幕,支持 Python REPL |
| `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` | — | 工具搜索。按关键词发现可用插件 |
### 辅助工具 ### 辅助工具
| 插件 | 说明 | | 插件 | 说明 |
|------|------| |------|------|
| `browser_auto` | 浏览器自动化。导航/点击/填写/截图/JS执行(基于 Playwright) |
| `gui_auto` | 桌面 GUI 自动化。Windows UIA 元素识别/点击/输入(仅 Windows) |
| `memory_plugin` | 记忆系统。AI 写日记/搜索记忆/管理聊天记录 |
| `scheduler` | 定时任务。支持相对/绝对/重复任务,到期自动激活 AI |
| `tool_search` | 工具搜索。按关键词发现可用插件 |
| `workflow` | 工作流引擎。YAML 多步骤自动化,变量传递,条件分支 |
| `network_tools` | 网络工具。ping/get/status/headers/ip | | `network_tools` | 网络工具。ping/get/status/headers/ip |
| `ai_search` | AI 搜索。jina.ai 智能搜索 + 网页内容提取 | | `ai_search` | AI 搜索。jina.ai 智能搜索 + 网页内容提取 |
| `frontend_tester` | 前端测试。HTML/CSS/JS 语法检查、响应式验证 | | `frontend_tester` | 前端测试。HTML/CSS/JS 语法检查、响应式验证 |
| `speech_recognition` | 语音识别。录音 + 语音转文字(Whisper) | | `speech_recognition` | 语音识别。录音 + 语音转文字(Whisper) |
| `audio_player` | 音频播放 | | `json_toolkit` | JSON 工具箱。格式化/压缩/校验/路径查询/差异比较 |
| `send_image` | 远程模式下发图片到手机 | | `regex_tester` | 正则表达式测试器。匹配/提取/替换/解释 |
| `snippet_manager` | 代码片段管理器。保存/搜索/复用常用代码 |
| `markdown_tool` | Markdown 工具。TOC/统计/表格生成/待办提取 |
| `id_generator` | ID 生成器。UUID/NanoID/短ID/雪花ID/ULID |
## 记忆系统 ## 记忆系统
@@ -195,7 +198,7 @@ memory_plugin chat_search 图像生成
## MCP 协议支持 ## MCP 协议支持
所有 19 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议: 所有 18 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议:
| 协议 | 说明 | | 协议 | 说明 |
|------|------| |------|------|
@@ -266,7 +269,7 @@ xiaoli-cli/
├── ai_engines/ # AI 引擎 ├── ai_engines/ # AI 引擎
│ ├── ollama_engine.py │ ├── ollama_engine.py
│ └── openai_engine.py │ └── openai_engine.py
├── plugins/ # 插件 (19 个) ├── plugins/ # 插件 (18 个)
│ ├── code_editor.py │ ├── code_editor.py
│ ├── git_tools.py │ ├── git_tools.py
│ ├── cmd_executor.py │ ├── cmd_executor.py
@@ -285,7 +288,7 @@ xiaoli-cli/
│ ├── ai_search.py │ ├── ai_search.py
│ ├── frontend_tester.py │ ├── frontend_tester.py
│ ├── speech_recognition.py │ ├── speech_recognition.py
│ └── send_image.py │ └── terminal.py
├── skills/ # 技能 ├── skills/ # 技能
├── image_engine/ # 图像识别引擎 ├── image_engine/ # 图像识别引擎
├── tests/ # 测试套件 (150+ 用例) ├── tests/ # 测试套件 (150+ 用例)
+2 -2
View File
@@ -1,7 +1,7 @@
**产品标识** **产品标识**
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI) - **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
- **内部版本号**:20260428 - **内部版本号**:20260628
- **发布版本**:发行版5.1.3 - **发布版本**:发行版6.1.0
**技术规格** **技术规格**
- **主要语言**:简体中文(UTF-8) - **主要语言**:简体中文(UTF-8)
+110 -14
View File
@@ -22,6 +22,26 @@ project_root = os.path.dirname(current_dir)
sys.path.insert(0, project_root) sys.path.insert(0, project_root)
def _safe_get(obj, key, default=None):
"""兼容 dict 和 pydantic 对象的安全取值(新版 ollama 返回 pydantic 模型,不支持 .get())"""
if obj is None:
return default
# 1. 先尝试 dict 访问
try:
return obj[key]
except (KeyError, TypeError):
pass
# 2. 尝试属性访问(pydantic 模型)
val = getattr(obj, key, None)
if val is not None:
return val
# 3. 尝试 model_dump 后的 dict 访问
try:
return obj.model_dump()[key]
except (AttributeError, KeyError, TypeError):
return default
class OllamaAI: class OllamaAI:
"""基于Ollama的本地AI引擎""" """基于Ollama的本地AI引擎"""
requires_api_key = False # Ollama是本地引擎,不需要API密钥 requires_api_key = False # Ollama是本地引擎,不需要API密钥
@@ -81,6 +101,9 @@ Ollama AI引擎插件帮助信息
# 共享对话历史引用(由CLI设置) # 共享对话历史引用(由CLI设置)
self.shared_conversation_history = None self.shared_conversation_history = None
# 取消标志
self._cancelled = False
# 直接启用思考标记,由主程序自动处理深度思考模型的响应 # 直接启用思考标记,由主程序自动处理深度思考模型的响应
self.thinking_start_marker = "<thinking>" self.thinking_start_marker = "<thinking>"
self.thinking_end_marker = "</thinking>" self.thinking_end_marker = "</thinking>"
@@ -93,6 +116,33 @@ Ollama AI引擎插件帮助信息
else: else:
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}") print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
def cancel(self):
"""取消正在进行的请求"""
self._cancelled = True
def apply_config(self, base_url=None, api_key=None, model=None):
"""应用新配置到当前引擎实例(内存),并持久化到 config.json"""
if base_url is not None:
self.base_url = base_url.rstrip('/')
if api_key is not None:
self.api_key = api_key
if model is not None:
self.model = model
# 持久化到 config.json
try:
import os, json
config_file = os.path.join(project_root, "config.json")
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
engine_cfg = config.setdefault("api", {}).setdefault("engines", {}).setdefault("ollama", {})
engine_cfg["base_url"] = self.base_url
engine_cfg["api_key"] = self.api_key
engine_cfg["model"] = self.model
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"持久化引擎配置失败: {e}")
def check_ollama_service(self): def check_ollama_service(self):
"""检测Ollama服务是否运行""" """检测Ollama服务是否运行"""
# 检查 ollama 库是否可用 # 检查 ollama 库是否可用
@@ -107,7 +157,7 @@ Ollama AI引擎插件帮助信息
# 如果出现异常,说明服务未运行 # 如果出现异常,说明服务未运行
return False return False
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None): def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None, stream_start_event=None):
""" """
生成AI响应 - 使用Ollama API 生成AI响应 - 使用Ollama API
@@ -137,10 +187,20 @@ Ollama AI引擎插件帮助信息
messages = self._build_messages_with_history(system_prompt, user_input) messages = self._build_messages_with_history(system_prompt, user_input)
# 构建调用参数 # 构建调用参数
# 重置取消标志
self._cancelled = False
# 获取流式回调
stream_cb = None
thinking_cb = None
if self.cli and hasattr(self.cli, 'tui_stream_callback'):
stream_cb = self.cli.tui_stream_callback
if self.cli and hasattr(self.cli, 'tui_thinking_callback'):
thinking_cb = self.cli.tui_thinking_callback
kwargs = { kwargs = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"stream": False "stream": True if stream_cb else False
} }
# ── Function Calling: 注入工具定义 ── # ── Function Calling: 注入工具定义 ──
@@ -150,16 +210,45 @@ Ollama AI引擎插件帮助信息
# 使用Ollama Python库调用API # 使用Ollama Python库调用API
response = ollama_client.chat(**kwargs) response = ollama_client.chat(**kwargs)
# ── 流式模式:逐块读取 ──
if stream_cb:
full_content = ""
reasoning_content = ""
for chunk in response:
if self._cancelled:
break
msg = _safe_get(chunk, "message", {})
# 检查 tool_calls
tc_raw = _safe_get(msg, "tool_calls")
if tc_raw:
# 工具调用 — 走原逻辑
message = msg
tool_calls_raw = tc_raw
break
# 深度思考(Ollama thinking 字段)
rc = _safe_get(msg, "thinking", "")
if rc and thinking_cb:
reasoning_content += rc
thinking_cb(rc)
content = _safe_get(msg, "content", "")
if content:
full_content += content
stream_cb(content)
else:
# 流式完成,无工具调用
return full_content.encode('utf-8', 'replace').decode('utf-8')
else:
message = _safe_get(response, "message", {})
tool_calls_raw = _safe_get(message, "tool_calls")
# ── Function Calling: 检查 tool_calls ── # ── Function Calling: 检查 tool_calls ──
message = response.get("message", {})
tool_calls_raw = message.get("tool_calls")
if tool_calls_raw: if tool_calls_raw:
# Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}] # Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}]
fc_calls = [] fc_calls = []
for i, tc in enumerate(tool_calls_raw): for i, tc in enumerate(tool_calls_raw):
func = tc.get("function", {}) func = _safe_get(tc, "function", {})
name = func.get("name", "") name = _safe_get(func, "name", "")
args = func.get("arguments", {}) args = _safe_get(func, "arguments", {})
call_id = f"call_{name}_{i}" call_id = f"call_{name}_{i}"
# 统一为小狸内部格式 # 统一为小狸内部格式
@@ -189,7 +278,7 @@ Ollama AI引擎插件帮助信息
}, ensure_ascii=False) }, ensure_ascii=False)
# 普通文本响应 # 普通文本响应
content = message.get("content", "") content = _safe_get(message, "content", "")
# 清理无效的 UTF-8 代理字符 # 清理无效的 UTF-8 代理字符
content = content.encode('utf-8', 'replace').decode('utf-8') content = content.encode('utf-8', 'replace').decode('utf-8')
return content return content
@@ -309,8 +398,13 @@ Ollama AI引擎插件帮助信息
try: try:
response = ollama_client.list() response = ollama_client.list()
models = [model["name"] for model in response["models"]] models_list = _safe_get(response, "models", [])
return models result = []
for model in models_list:
name = _safe_get(model, "model", "") or _safe_get(model, "name", "")
if name:
result.append(name)
return result if result else ["未找到本地模型"]
except Exception as e: except Exception as e:
return [f"获取模型列表失败: {str(e)}"] return [f"获取模型列表失败: {str(e)}"]
@@ -392,12 +486,14 @@ Ollama AI引擎插件帮助信息
# 逐块处理响应 # 逐块处理响应
for part in response: for part in response:
if 'status' in part: status = _safe_get(part, "status", None)
status = part['status'] if status:
print(f"{Fore.YELLOW}{status}{Style.RESET_ALL}") print(f"{Fore.YELLOW}{status}{Style.RESET_ALL}")
# 检查是否完成 # 检查是否完成
if 'completed' in part and 'total' in part: completed = _safe_get(part, "completed", None)
if part['completed'] == part['total']: total = _safe_get(part, "total", None)
if completed is not None and total is not None:
if completed == total:
print(f"{Fore.GREEN}模型 {model_name} 拉取完成!{Style.RESET_ALL}") print(f"{Fore.GREEN}模型 {model_name} 拉取完成!{Style.RESET_ALL}")
print(f"{Fore.GREEN}模型 {model_name} 拉取成功!{Style.RESET_ALL}") print(f"{Fore.GREEN}模型 {model_name} 拉取成功!{Style.RESET_ALL}")
+141 -5
View File
@@ -57,6 +57,10 @@ OpenAI 兼容格式引擎帮助信息
/engine.openai models - 列出可用模型(需要服务端支持) /engine.openai models - 列出可用模型(需要服务端支持)
/engine.openai set <模型名> - 切换模型 /engine.openai set <模型名> - 切换模型
/engine.openai info - 显示当前配置信息 /engine.openai info - 显示当前配置信息
/engine.openai think - 查看深度思考状态
/engine.openai think on - 开启深度思考
/engine.openai think off - 关闭深度思考
/engine.openai think high - 开启并设置推理强度 (low/medium/high)
使用说明: 使用说明:
1. 在 config.json 中配置 api_key、base_url、model 1. 在 config.json 中配置 api_key、base_url、model
@@ -84,12 +88,17 @@ OpenAI 兼容格式引擎帮助信息
self.base_url = engine_config.get("base_url", "").rstrip('/') self.base_url = engine_config.get("base_url", "").rstrip('/')
self.model = engine_config.get("model", "") self.model = engine_config.get("model", "")
self.max_history = config.get("system", {}).get("max_history", 10) self.max_history = config.get("system", {}).get("max_history", 10)
# 深度思考配置
self.deep_think = engine_config.get("deep_think", False)
self.reasoning_effort = engine_config.get("reasoning_effort", "medium")
except Exception as e: except Exception as e:
logger.warning(f"读取配置文件失败: {e}") logger.warning(f"读取配置文件失败: {e}")
self.api_key = "" self.api_key = ""
self.base_url = "" self.base_url = ""
self.model = "" self.model = ""
self.max_history = 10 self.max_history = 10
self.deep_think = False
self.reasoning_effort = "medium"
# 初始化对话历史 # 初始化对话历史
self.conversation_history = [] self.conversation_history = []
@@ -153,6 +162,21 @@ OpenAI 兼容格式引擎帮助信息
"stream": False "stream": False
} }
# ── 深度思考模式 ──
self._inject_thinking_prompt = None # 清除标记
if self.deep_think:
body = self._apply_deep_think(body)
# 不支持原生思考的模型,注入提示词
if self._inject_thinking_prompt:
thinking_hint = self._get_thinking_prompt(self._inject_thinking_prompt)
if thinking_hint and messages:
# 追加到系统提示词末尾
if messages[0].get("role") == "system":
messages[0]["content"] += thinking_hint
else:
messages.insert(0, {"role": "system", "content": thinking_hint})
body["messages"] = messages
# ── Function Calling: 注入工具定义 ── # ── Function Calling: 注入工具定义 ──
if tools: if tools:
body["tools"] = tools body["tools"] = tools
@@ -188,11 +212,16 @@ OpenAI 兼容格式引擎帮助信息
# 普通文本响应 # 普通文本响应
ai_response = message.get("content", "") ai_response = message.get("content", "")
reasoning = message.get("reasoning_content", "")
if ai_response: if ai_response:
# 清理无效的 UTF-8 代理字符 # 清理无效的 UTF-8 代理字符
ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8') ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8')
# 处理思考内容 # 处理思考内容(从 <think> 标签中提取)
ai_response = self._process_thinking_content(ai_response) ai_response = self._process_thinking_content(ai_response)
# 显示思维链
if reasoning:
reasoning = reasoning.encode('utf-8', 'replace').decode('utf-8')
print(f"{Fore.LIGHTBLACK_EX}[深度思考: {reasoning[:500]}{'...' if len(reasoning) > 500 else ''}]{Style.RESET_ALL}")
return ai_response or "" return ai_response or ""
else: else:
return f"API 返回格式错误: {response_data}" return f"API 返回格式错误: {response_data}"
@@ -337,6 +366,93 @@ OpenAI 兼容格式引擎帮助信息
return response return response
def _apply_deep_think(self, body: dict) -> dict:
"""
通用深度思考适配。
原生支持的模型用 API 参数,不支持的用提示词模拟。
"""
model = (self.model or "").lower()
effort = self.reasoning_effort # low / medium / high
# ── 原生支持:API 参数 ──
# DeepSeek / MiMo
if "deepseek" in model or "mimo" in model:
body["enable_thinking"] = True
body.pop("temperature", None)
if effort:
body["reasoning_effort"] = effort
return body
# OpenAI o-series (o1, o3, o4-mini 等)
if model.startswith("o") and (model[1:2].isdigit() or model[1:3] in ("1-", "3-", "4-")):
body["reasoning"] = {"effort": effort}
return body
# Anthropic Claude (通过 OpenAI 兼容层)
if "claude" in model or "anthropic" in model:
body["thinking"] = {"type": "enabled", "budget_tokens": self._thinking_budget(effort)}
return body
# Grok
if "grok" in model:
body["thinking"] = {"enabled": True}
return body
# Gemini thinking 模型
if "gemini" in model and "thinking" in model:
return body # 模型名自带 thinking,不需要额外参数
# ── 不支持原生思考:用提示词模拟 ──
self._inject_thinking_prompt = effort
return body
@staticmethod
def _thinking_budget(effort: str) -> int:
"""思考 token 预算"""
return {"low": 2048, "medium": 8192, "high": 32768}.get(effort, 8192)
def _get_thinking_prompt(self, effort: str) -> str:
"""根据思考等级生成提示词(注入系统提示词末尾)"""
if effort == "low":
return ""
if effort == "medium":
return (
"\n\n【思考模式:中等】\n"
"回答前请先逐步思考:\n"
"1. 分析用户的核心需求\n"
"2. 列出关键因素和约束\n"
"3. 考虑可能的方案\n"
"4. 选择最优方案并解释理由\n"
"然后再给出最终回答。"
)
# high
return (
"\n\n【思考模式:深度推理】\n"
"你必须使用结构化深度推理框架回答问题。严格遵循以下步骤:\n\n"
"## 第一步:问题分解\n"
"- 将问题拆解为子问题\n"
"- 识别关键约束和边界条件\n"
"- 明确输入输出要求\n\n"
"## 第二步:多角度分析\n"
"- 从至少 3 个不同角度分析问题\n"
"- 评估每种方案的优缺点\n"
"- 考虑边界情况和异常场景\n\n"
"## 第三步:方案设计\n"
"- 选择最优方案并详细说明理由\n"
"- 给出具体的实现步骤\n"
"- 预判可能的问题并给出预防措施\n\n"
"## 第四步:验证与优化\n"
"- 检验方案是否满足所有约束\n"
"- 考虑是否有更优解\n"
"- 给出最终答案和注意事项\n\n"
"## 输出格式\n"
"先输出推理过程(用 [思考] 标记),再输出最终答案。\n"
"推理过程要详细但不啰嗦,最终答案要简洁明确。"
)
def set_model(self, model_name): def set_model(self, model_name):
"""设置模型""" """设置模型"""
self.model = model_name self.model = model_name
@@ -392,11 +508,31 @@ OpenAI 兼容格式引擎帮助信息
elif command == "info": elif command == "info":
print(f"{Fore.GREEN}OpenAI 兼容引擎配置:{Style.RESET_ALL}") print(f"{Fore.GREEN}OpenAI 兼容引擎配置:{Style.RESET_ALL}")
print(f" base_url: {self.base_url}") print(f" base_url: {self.base_url}")
print(f" model: {self.model}") print(f" model: {self.model}")
print(f" api_key: {'已设置' if self.api_key else '未设置'}") print(f" api_key: {'已设置' if self.api_key else '未设置'}")
print(f" deep_think: {'开启' if self.deep_think else '关闭'}")
if self.deep_think:
print(f" reasoning: {self.reasoning_effort}")
return True
elif command.startswith("think"):
# /engine.openai think [on|off|low|medium|high]
arg = command.replace("think", "").strip().lower()
if arg in ("off", "0", "false"):
self.deep_think = False
print(f"{Fore.YELLOW}深度思考已关闭{Style.RESET_ALL}")
elif arg in ("on", "1", "true", ""):
self.deep_think = True
print(f"{Fore.GREEN}深度思考已开启 (reasoning_effort: {self.reasoning_effort}){Style.RESET_ALL}")
elif arg in ("low", "medium", "high"):
self.deep_think = True
self.reasoning_effort = arg
print(f"{Fore.GREEN}深度思考已开启 (reasoning_effort: {arg}){Style.RESET_ALL}")
else:
print(f"{Fore.RED}用法: /engine.openai think [on|off|low|medium|high]{Style.RESET_ALL}")
return True return True
else: else:
print(f"{Fore.RED}未知命令. 可用命令: models, set, info{Style.RESET_ALL}") print(f"{Fore.RED}未知命令. 可用: models, set, info, think{Style.RESET_ALL}")
return False return False
+3 -1
View File
@@ -9,7 +9,9 @@
"openai": { "openai": {
"api_key": "your-api-key-here", "api_key": "your-api-key-here",
"base_url": "https://api.deepseek.com/v1", "base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat" "model": "deepseek-chat",
"deep_think": false,
"reasoning_effort": "medium"
} }
} }
}, },
+1 -1
View File
@@ -106,7 +106,7 @@ class ImageEngineManager:
for filename in os.listdir(engine_dir): for filename in os.listdir(engine_dir):
if filename.endswith('_engine.py') and filename != '__init__.py': if filename.endswith('_engine.py') and filename != '__init__.py':
engine_name = filename[:-11] # 移除 _engine.py engine_name = filename[:-10] # 移除 _engine.py
try: try:
# 动态导入模块 # 动态导入模块
module_path = os.path.join(engine_dir, filename) module_path = os.path.join(engine_dir, filename)
+11 -3
View File
@@ -14,6 +14,14 @@ except ImportError:
OLLAMA_AVAILABLE = False OLLAMA_AVAILABLE = False
def _safe_get(obj, key, default=None):
"""兼容 dict 和 pydantic 对象的安全取值"""
try:
return obj[key]
except (KeyError, AttributeError, TypeError):
return default
class OllamaImageEngine(BaseImageEngine): class OllamaImageEngine(BaseImageEngine):
"""Ollama 视觉引擎""" """Ollama 视觉引擎"""
@@ -69,7 +77,7 @@ class OllamaImageEngine(BaseImageEngine):
try: try:
result = ollama.list() result = ollama.list()
self.installed_models = [m['model'] for m in result.get('models', [])] self.installed_models = [m['model'] for m in _safe_get(result, 'models', [])]
except: except:
self.installed_models = [] self.installed_models = []
@@ -137,7 +145,7 @@ class OllamaImageEngine(BaseImageEngine):
'images': [image_path] 'images': [image_path]
}] }]
) )
return response.get('message', {}).get('content', '') return _safe_get(_safe_get(response, 'message', {}), 'content', '')
except Exception as e: except Exception as e:
raise Exception(f"Ollama识别失败: {str(e)}") raise Exception(f"Ollama识别失败: {str(e)}")
@@ -168,7 +176,7 @@ class OllamaImageEngine(BaseImageEngine):
stream=False stream=False
) )
content = response.get('message', {}).get('content', '') content = _safe_get(_safe_get(response, 'message', {}), 'content', '')
if content: if content:
yield content yield content
else: else:
+1627
View File
File diff suppressed because it is too large. Load diff
+415
View File
@@ -0,0 +1,415 @@
# METADATA
# {
# "name": "android_api_test",
# "display_name": {"zh": "Android API 转译测试", "en": "Android API Translation Test"},
# "description": {"zh": "测试 Android-pyApi 能否成功转译调用安卓 API 的脚本", "en": "Test if Android-pyApi can translate Android API scripts"},
# "enabledByDefault": true,
# "category": "Test",
# "tools": [
# {"name": "test_intent", "description": {"zh": "测试 Intent API", "en": "Test Intent API"}, "parameters": []},
# {"name": "test_uri", "description": {"zh": "测试 Uri API", "en": "Test Uri API"}, "parameters": []},
# {"name": "test_bundle", "description": {"zh": "测试 Bundle API", "en": "Test Bundle API"}, "parameters": []},
# {"name": "test_file_io", "description": {"zh": "测试 java.io.File API", "en": "Test java.io.File API"}, "parameters": []},
# {"name": "test_json", "description": {"zh": "测试 JSONObject API", "en": "Test JSONObject API"}, "parameters": []},
# {"name": "test_collections", "description": {"zh": "测试 ArrayList/HashMap API", "en": "Test ArrayList/HashMap API"}, "parameters": []},
# {"name": "test_environment", "description": {"zh": "测试 Environment API", "en": "Test Environment API"}, "parameters": []},
# {"name": "test_log", "description": {"zh": "测试 Log API", "en": "Test Log API"}, "parameters": []},
# {"name": "test_settings", "description": {"zh": "测试 Settings API", "en": "Test Settings API"}, "parameters": []},
# {"name": "test_bitmap", "description": {"zh": "测试 Bitmap/Canvas/Color API", "en": "Test Bitmap/Canvas/Color API"}, "parameters": []},
# {"name": "test_string_builder", "description": {"zh": "测试 StringBuilder API", "en": "Test StringBuilder API"}, "parameters": []},
# {"name": "test_content_values", "description": {"zh": "测试 ContentValues API", "en": "Test ContentValues API"}, "parameters": []},
# {"name": "test_run_all", "description": {"zh": "运行全部测试", "en": "Run all tests"}, "parameters": []}
# ]
# }
import os
import sys
import json
import tempfile
async def test_intent(params):
"""测试 Intent — Android 最核心的 API"""
results = []
# 创建 Intent
intent = Intent("android.intent.action.VIEW")
results.append(f"Intent action: {intent.getAction()}")
# 设置 Data Uri
intent.setData(Uri.parse("https://www.example.com/path?key=value"))
results.append(f"Intent data: {intent.getData()}")
# 设置 Package
intent.setPackage("com.example.app")
results.append(f"Intent package: {intent.getPackage()}")
# 添加 Category
intent.addCategory("android.intent.category.DEFAULT")
results.append(f"Intent categories: {intent.getCategories()}")
# 设置 Flag
intent.setFlags(0x10000000) # FLAG_ACTIVITY_NEW_TASK
results.append(f"Intent flags: {hex(intent.getFlags())}")
# 添加 Extra
intent.putExtra("extra_key", "extra_value")
intent.putExtra("extra_int", 42)
results.append(f"Intent extra string: {intent.getStringExtra('extra_key')}")
results.append(f"Intent extra int: {intent.getIntExtra('extra_int', 0)}")
return {"success": True, "results": results}
async def test_uri(params):
"""测试 Uri — Android URI 解析"""
results = []
uri = Uri.parse("content://com.example.provider/items/42?sort=desc#fragment")
results.append(f"scheme: {uri.getScheme()}")
results.append(f"authority: {uri.getAuthority()}")
results.append(f"host: {uri.getHost()}")
results.append(f"path: {uri.getPath()}")
results.append(f"query: {uri.getQuery()}")
results.append(f"fragment: {uri.getFragment()}")
results.append(f"lastPathSegment: {uri.getLastPathSegment()}")
# 测试 Uri.Builder
builder = Uri.Builder()
builder.scheme("https")
builder.authority("api.example.com")
builder.appendPath("v1")
builder.appendPath("users")
builder.appendQueryParameter("limit", "10")
built_uri = builder.build()
results.append(f"built uri: {built_uri.toString() if hasattr(built_uri, 'toString') else str(built_uri)}")
return {"success": True, "results": results}
async def test_bundle(params):
"""测试 Bundle — Android 数据传递"""
results = []
bundle = Bundle()
bundle.putString("name", "Test User")
bundle.putInt("age", 25)
bundle.putBoolean("active", True)
bundle.putDouble("score", 95.5)
results.append(f"name: {bundle.getString('name')}")
results.append(f"age: {bundle.getInt('age')}")
results.append(f"active: {bundle.getBoolean('active')}")
results.append(f"score: {bundle.getDouble('score')}")
# 测试 keySet
keys = bundle.keySet()
results.append(f"keys: {keys}")
# 测试 containsKey
results.append(f"has 'name': {bundle.containsKey('name')}")
results.append(f"has 'missing': {bundle.containsKey('missing')}")
return {"success": True, "results": results}
async def test_file_io(params):
"""测试 java.io.File — 文件操作"""
results = []
# 创建 File 对象
f = File(tempfile.gettempdir(), "operit_test.txt")
results.append(f"path: {f.getPath()}")
results.append(f"absolutePath: {f.getAbsolutePath()}")
results.append(f"name: {f.getName()}")
results.append(f"parent: {f.getParent()}")
results.append(f"exists before: {f.exists()}")
# 创建文件
f.createNewFile()
results.append(f"exists after create: {f.exists()}")
# 写入内容 (用 Python open)
with open(f.getAbsolutePath(), "w") as fh:
fh.write("Hello from Android-pyApi!")
# 读取
with open(f.getAbsolutePath(), "r") as fh:
content = fh.read()
results.append(f"content: {content}")
# 文件大小
results.append(f"length: {f.length()}")
# 删除
f.delete()
results.append(f"exists after delete: {f.exists()}")
# 测试目录
d = File(tempfile.gettempdir(), "operit_test_dir")
d.mkdirs()
results.append(f"dir exists: {d.exists()}")
results.append(f"isDirectory: {d.isDirectory()}")
d.delete()
return {"success": True, "results": results}
async def test_json(params):
"""测试 JSONObject/JSONArray — JSON 处理"""
results = []
# 创建 JSONObject
obj = JSONObject()
obj.put("name", "Test")
obj.put("version", 3)
obj.put("active", True)
# 创建 JSONArray
arr = JSONArray()
arr.put("item1")
arr.put("item2")
arr.put("item3")
obj.put("items", arr)
# 读取
results.append(f"name: {obj.getString('name')}")
results.append(f"version: {obj.getInt('version')}")
results.append(f"active: {obj.getBoolean('active')}")
results.append(f"items length: {obj.getJSONArray('items').length()}")
results.append(f"items[0]: {obj.getJSONArray('items').getString(0)}")
# toString
json_str = obj.toString()
results.append(f"toString: {json_str}")
# 从字符串解析
parsed = JSONObject('{"key":"value","num":123}')
results.append(f"parsed key: {parsed.getString('key')}")
results.append(f"parsed num: {parsed.getInt('num')}")
return {"success": True, "results": results}
async def test_collections(params):
"""测试 ArrayList/HashMap — Java 集合"""
results = []
# ArrayList
lst = ArrayList()
lst.add("first")
lst.add("second")
lst.add("third")
results.append(f"ArrayList size: {lst.size()}")
results.append(f"ArrayList[0]: {lst.get(0)}")
results.append(f"ArrayList contains 'second': {lst.contains('second')}")
lst.remove("second")
results.append(f"After remove, size: {lst.size()}")
# HashMap
m = HashMap()
m.put("key1", "value1")
m.put("key2", "value2")
m.put("key3", "value3")
results.append(f"HashMap size: {m.size()}")
results.append(f"HashMap[key1]: {m.get('key1')}")
results.append(f"HashMap containsKey 'key2': {m.containsKey('key2')}")
m.remove("key2")
results.append(f"After remove, size: {m.size()}")
# HashSet
s = HashSet()
s.add("a")
s.add("b")
s.add("a") # 重复
results.append(f"HashSet size: {s.size()}")
results.append(f"HashSet contains 'a': {s.contains('a')}")
return {"success": True, "results": results}
async def test_environment(params):
"""测试 Environment — Android 存储目录"""
results = []
# 外部存储目录
external = Environment.getExternalStorageDirectory()
results.append(f"externalStorage: {external.getPath() if hasattr(external, 'getPath') else str(external)}")
# 数据目录
data_dir = Environment.getDataDirectory()
results.append(f"dataDirectory: {data_dir.getPath() if hasattr(data_dir, 'getPath') else str(data_dir)}")
# 缓存目录
cache_dir = Environment.getDownloadCacheDirectory()
results.append(f"downloadCacheDirectory: {cache_dir.getPath() if hasattr(cache_dir, 'getPath') else str(cache_dir)}")
# 状态检查
results.append(f"externalStorageState: {Environment.getExternalStorageState()}")
return {"success": True, "results": results}
async def test_log(params):
"""测试 Log — Android 日志"""
results = []
# 各种日志级别
Log.v("TestTag", "Verbose message")
Log.d("TestTag", "Debug message")
Log.i("TestTag", "Info message")
Log.w("TestTag", "Warning message")
Log.e("TestTag", "Error message")
results.append("All log levels executed (check stderr above)")
# 测试 getStackTraceString
try:
raise Exception("Test exception")
except Exception as e:
stack = Log.getStackTraceString(e)
results.append(f"stackTrace length: {len(stack)}")
return {"success": True, "results": results}
async def test_settings(params):
"""测试 Settings — Android 系统设置"""
results = []
# Settings.System 常量
results.append(f"Settings.System.SCREEN_BRIGHTNESS: {getattr(Settings.System, 'SCREEN_BRIGHTNESS', 'N/A') if hasattr(Settings, 'System') else 'Settings.System not available'}")
# Settings.Global 常量
results.append(f"Settings.Global.AIRPLANE_MODE_ON: {getattr(Settings.Global, 'AIRPLANE_MODE_ON', 'N/A') if hasattr(Settings, 'Global') else 'Settings.Global not available'}")
# Settings.Secure 常量
results.append(f"Settings.Secure.ADB_ENABLED: {getattr(Settings.Secure, 'ADB_ENABLED', 'N/A') if hasattr(Settings, 'Secure') else 'Settings.Secure not available'}")
return {"success": True, "results": results}
async def test_bitmap(params):
"""测试 Bitmap/Canvas/Color — Android 图形"""
results = []
# Color 常量
results.append(f"Color.RED: {hex(Color.RED)}")
results.append(f"Color.GREEN: {hex(Color.GREEN)}")
results.append(f"Color.BLUE: {hex(Color.BLUE)}")
results.append(f"Color.BLACK: {hex(Color.BLACK)}")
results.append(f"Color.WHITE: {hex(Color.WHITE)}")
# Color 方法
results.append(f"Color.rgb(255,128,0): {hex(Color.rgb(255, 128, 0))}")
results.append(f"Color.red(0xFF8000): {Color.red(0xFF8000)}")
results.append(f"Color.green(0xFF8000): {Color.green(0xFF8000)}")
results.append(f"Color.blue(0xFF8000): {Color.blue(0xFF8000)}")
# Paint
paint = Paint()
paint.setColor(Color.RED)
paint.setTextSize(24)
paint.setAntiAlias(True)
results.append(f"Paint color: {hex(paint.getColor())}")
results.append(f"Paint textSize: {paint.getTextSize()}")
results.append(f"Paint antiAlias: {paint.isAntiAlias()}")
# Rect
rect = Rect(0, 0, 100, 200)
results.append(f"Rect width: {rect.width()}")
results.append(f"Rect height: {rect.height()}")
results.append(f"Rect contains(50,50): {rect.contains(50, 50)}")
return {"success": True, "results": results}
async def test_string_builder(params):
"""测试 StringBuilder — Java 字符串构建"""
results = []
sb = StringBuilder()
sb.append("Hello")
sb.append(" ")
sb.append("World")
sb.append("!")
results.append(f"toString: {sb.toString()}")
results.append(f"length: {sb.length()}")
sb.insert(5, ",")
results.append(f"after insert: {sb.toString()}")
sb.delete(5, 6)
results.append(f"after delete: {sb.toString()}")
sb.reverse()
results.append(f"after reverse: {sb.toString()}")
return {"success": True, "results": results}
async def test_content_values(params):
"""测试 ContentValues — Android 数据库操作"""
results = []
cv = ContentValues()
cv.put("name", "Test Item")
cv.put("quantity", 10)
cv.put("price", 9.99)
cv.put("active", True)
cv.putNull("description")
results.append(f"name: {cv.get('name')}")
results.append(f"quantity: {cv.get('quantity')}")
results.append(f"price: {cv.get('price')}")
results.append(f"active: {cv.get('active')}")
results.append(f"description: {cv.get('description')}")
results.append(f"size: {cv.size()}")
results.append(f"keySet: {cv.keySet()}")
return {"success": True, "results": results}
async def test_run_all(params):
"""运行全部测试"""
all_results = {}
tests = [
("test_intent", test_intent),
("test_uri", test_uri),
("test_bundle", test_bundle),
("test_file_io", test_file_io),
("test_json", test_json),
("test_collections", test_collections),
("test_environment", test_environment),
("test_log", test_log),
("test_settings", test_settings),
("test_bitmap", test_bitmap),
("test_string_builder", test_string_builder),
("test_content_values", test_content_values),
]
passed = 0
failed = 0
for name, func in tests:
try:
result = await func({})
if result.get("success"):
passed += 1
all_results[name] = "✅ PASS"
else:
failed += 1
all_results[name] = f"❌ FAIL: {result.get('message', 'unknown')}"
except Exception as e:
failed += 1
all_results[name] = f"❌ ERROR: {e}"
return {
"success": failed == 0,
"passed": passed,
"failed": failed,
"total": len(tests),
"details": all_results
}
# 注册工具
exports["test_intent"] = test_intent
exports["test_uri"] = test_uri
exports["test_bundle"] = test_bundle
exports["test_file_io"] = test_file_io
exports["test_json"] = test_json
exports["test_collections"] = test_collections
exports["test_environment"] = test_environment
exports["test_log"] = test_log
exports["test_settings"] = test_settings
exports["test_bitmap"] = test_bitmap
exports["test_string_builder"] = test_string_builder
exports["test_content_values"] = test_content_values
exports["test_run_all"] = test_run_all
+28
View File
@@ -0,0 +1,28 @@
# METADATA
# {
# "name": "test_tool",
# "display_name": {"zh": "测试工具", "en": "Test Tool"},
# "description": {"zh": "测试 operit_loader 转译层", "en": "Test operit_loader translation layer"},
# "enabledByDefault": true,
# "category": "Utility",
# "tools": [
# {"name": "echo", "description": {"zh": "回显消息", "en": "Echo message"}, "parameters": [
# {"name": "message", "description": {"zh": "消息内容", "en": "Message content"}, "type": "string", "required": true}
# ]},
# {"name": "get_device_info", "description": {"zh": "获取设备信息", "en": "Get device info"}, "parameters": []}
# ]
# }
import os
import json
async def echo(params):
message = params.get("message", "hello")
return {"success": True, "message": f"Echo: {message}"}
async def get_device_info(params):
result = await Tools.System.getDeviceInfo()
return result
exports["echo"] = echo
exports["get_device_info"] = get_device_info
+28 -5
View File
@@ -10,10 +10,22 @@
"file": "dglab_ws.py", "file": "dglab_ws.py",
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/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", "docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/README.md",
"deps": ["websocket-client"], "deps": [
"optional_deps": ["qrcode"], "websocket-client"
"keywords": ["dg-lab", "dglab", "郊狼", "coyote", "websocket", "脉冲"] ],
"optional_deps": [
"qrcode"
],
"keywords": [
"dg-lab",
"dglab",
"郊狼",
"coyote",
"websocket",
"脉冲"
]
}, },
"onebot": { "onebot": {
"name": "OneBot v11 QQ 机器人", "name": "OneBot v11 QQ 机器人",
"description": "让 xiaoli-cli 成为 QQ 机器人,连接 go-cqhttp/Lagrange/LLOneBot 等 OneBot 实现", "description": "让 xiaoli-cli 成为 QQ 机器人,连接 go-cqhttp/Lagrange/LLOneBot 等 OneBot 实现",
@@ -22,9 +34,20 @@
"file": "onebot.py", "file": "onebot.py",
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/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", "docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/README.md",
"deps": ["websocket-client"], "deps": [
"websocket-client"
],
"optional_deps": [], "optional_deps": [],
"keywords": ["onebot", "qq", "机器人", "bot", "go-cqhttp", "lagrange", "群聊", "私聊"] "keywords": [
"onebot",
"qq",
"机器人",
"bot",
"go-cqhttp",
"lagrange",
"群聊",
"私聊"
]
} }
} }
} }
-589
View File
@@ -1,589 +0,0 @@
"""
AI 生图插件 - 基于 ai.2x.nz 自然语言生图接口
支持自然语言描述、直接 Tag、工作流切换、画风选择等
"""
import os
import json
import time
import threading
import hashlib
import re
from datetime import datetime
from urllib.parse import quote
try:
import requests
REQUESTS_AVAILABLE = True
except ImportError:
REQUESTS_AVAILABLE = False
try:
import websocket
WEBSOCKET_AVAILABLE = True
except ImportError:
WEBSOCKET_AVAILABLE = False
class Liugin:
"""AI 生图插件 - 调用 ai.2x.nz 接口生成图片"""
BASE_URL = "https://ai.2x.nz"
WS_URL = "wss://ai.2x.nz/ws/run"
API_URL = "https://ai.2x.nz/api"
# 保存目录
DEFAULT_SAVE_DIR = "generated_images"
def __init__(self):
self.usage = """AI 生图工具 - 基于自然语言描述生成图片
基本用法:
generate <描述> - 用自然语言描述生成图片
tag <直接Tag> - 用 Danbooru Tag 生成图片
both <Tag> <<<>>> <描述> - 同时使用 Tag 和自然语言
高级用法:
workflows - 列出所有可用工作流
switch <工作流名> - 切换工作流
width <数值> - 设置图片宽度 (最大1344)
height <数值> - 设置图片高度 (最大1344)
size <宽> <高> - 同时设置宽高
rewrite on|off - 改写模式开关
override on|off - 覆写模式开关
status - 查看当前状态和配置
示例:
ai_image generate 一个可爱的猫耳少女,樱花背景
ai_image tag 1girl, cat_ears, cherry_blossoms, smile
ai_image both 1girl, cat_ears <<<>>> 一个可爱的猫耳少女在樱花树下微笑
ai_image workflows
ai_image switch WAI - 37.json
ai_image size 1024 1024
ai_image rewrite on
"""
self.cli = None
self.save_dir = self.DEFAULT_SAVE_DIR
# 配置状态
self.current_workflow = None
self.direct_prompt = ""
self.nl_prompt = ""
self.rewrite = False
self.override = False
self.width = None
self.height = None
self.workflows_cache = None
self.is_generating = False
def set_cli(self, cli):
"""设置 CLI 实例引用"""
self.cli = cli
# 注册插件命令
if hasattr(cli, 'register_liugin_command'):
cli.register_liugin_command('ai_image', self.command_handler)
def get_tool_info(self):
return {
"name": "ai_image",
"description": "AI 生图工具 — 基于 ai.2x.nz 接口,支持自然语言描述和 Danbooru Tag 生成图片。当用户想要生成图片、画图、AI绘画、生图时使用此工具。",
"keywords": [
"生图", "画图", "图片", "AI绘画", "生成图片", "image", "generate",
"draw", "绘画", "插画", "二次元", "动漫", "Danbooru", "tag",
"工作流", "workflow", "ComfyUI"
],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "ai_image",
"description": "AI 生图工具,基于 ai.2x.nz 接口生成图片",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["generate", "tag", "both", "workflows", "switch", "size", "status"],
"description": "操作类型"
},
"prompt": {
"type": "string",
"description": "生成提示词"
}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
prompt = arguments.get("prompt", "")
return f"{op} {prompt}"
def command_handler(self, args):
"""处理 /ai_image 命令"""
return self.handle(args)
def handle(self, args):
"""处理 AI 生图请求"""
if not REQUESTS_AVAILABLE:
return "❌ 缺少 requests 库,请运行: pip install requests"
args = args.strip()
if not args:
return self.usage
# 解析操作
parts = args.split(maxsplit=1)
operation = parts[0].lower()
remaining = parts[1] if len(parts) > 1 else ""
try:
if operation in ["generate", "gen", "g"]:
return self._cmd_generate(remaining)
elif operation in ["tag", "t", "direct", "d"]:
return self._cmd_tag(remaining)
elif operation in ["both", "b"]:
return self._cmd_both(remaining)
elif operation in ["workflows", "wf", "list"]:
return self._cmd_workflows()
elif operation in ["switch", "sw"]:
return self._cmd_switch(remaining)
elif operation in ["width", "w"]:
return self._cmd_set_width(remaining)
elif operation in ["height", "h"]:
return self._cmd_set_height(remaining)
elif operation in ["size", "s"]:
return self._cmd_set_size(remaining)
elif operation in ["rewrite", "rw"]:
return self._cmd_set_rewrite(remaining)
elif operation in ["override", "ov"]:
return self._cmd_set_override(remaining)
elif operation in ["status", "st"]:
return self._cmd_status()
elif operation in ["help", "?"]:
return self.usage
else:
# 尝试当作自然语言描述处理
return self._cmd_generate(args)
except Exception as e:
return f"❌ 操作失败: {str(e)}"
# ── 命令实现 ──
def _cmd_generate(self, prompt):
"""自然语言生成图片"""
if not prompt.strip():
return "❌ 请提供描述内容。示例: ai_image generate 一个可爱的猫耳少女"
self.nl_prompt = prompt.strip()
self.direct_prompt = ""
self.override = False
return self._run_generation()
def _cmd_tag(self, tags):
"""使用直接 Tag 生成图片"""
if not tags.strip():
return "❌ 请提供 Tag 内容。示例: ai_image tag 1girl, cat_ears, smile"
self.direct_prompt = tags.strip()
self.nl_prompt = ""
self.override = True
return self._run_generation()
def _cmd_both(self, args):
"""同时使用 Tag 和自然语言"""
if "<<<>>>" in args:
parts = args.split("<<<>>>", 1)
self.direct_prompt = parts[0].strip()
self.nl_prompt = parts[1].strip()
else:
# 尝试用 || 分隔
if "||" in args:
parts = args.split("||", 1)
self.direct_prompt = parts[0].strip()
self.nl_prompt = parts[1].strip()
else:
return "❌ 格式错误。用法: ai_image both <Tag> <<<>>> <描述>"
self.override = False
return self._run_generation()
def _cmd_workflows(self):
"""列出所有工作流"""
workflows = self._fetch_workflows()
if not workflows:
return "❌ 获取工作流列表失败"
result = f"📂 共 {len(workflows)} 个工作流:\n\n"
for i, wf in enumerate(workflows, 1):
name = wf.get("path", "未知")
current = " ← [当前]" if name == self.current_workflow else ""
result += f" {i:2d}. {name}{current}\n"
result += f"\n💡 使用 'ai_image switch <工作流名>' 切换工作流"
return result
def _cmd_switch(self, name):
"""切换工作流"""
if not name.strip():
return "❌ 请提供工作流名称。先用 'ai_image workflows' 查看列表"
# 模糊匹配
workflows = self._fetch_workflows()
if not workflows:
return "❌ 获取工作流列表失败"
matched = None
name_lower = name.strip().lower()
for wf in workflows:
wf_path = wf.get("path", "")
if name_lower == wf_path.lower():
matched = wf_path
break
if name_lower in wf_path.lower():
matched = wf_path
if not matched:
# 尝试数字索引
try:
idx = int(name.strip()) - 1
if 0 <= idx < len(workflows):
matched = workflows[idx].get("path", "")
except ValueError:
pass
if not matched:
return f"❌ 未找到匹配的工作流: {name}\n💡 先用 'ai_image workflows' 查看列表"
self.current_workflow = matched
return f"✅ 已切换工作流: {matched}"
def _cmd_set_width(self, val):
"""设置宽度"""
try:
w = int(val.strip())
if w < 64 or w > 1344:
return "❌ 宽度范围: 64 ~ 1344"
self.width = w
return f"✅ 宽度已设置为: {w}"
except ValueError:
return "❌ 请输入有效数字"
def _cmd_set_height(self, val):
"""设置高度"""
try:
h = int(val.strip())
if h < 64 or h > 1344:
return "❌ 高度范围: 64 ~ 1344"
self.height = h
return f"✅ 高度已设置为: {h}"
except ValueError:
return "❌ 请输入有效数字"
def _cmd_set_size(self, val):
"""同时设置宽高"""
parts = val.strip().split()
if len(parts) < 2:
return "❌ 用法: ai_image size <宽> <高>"
try:
w, h = int(parts[0]), int(parts[1])
if w < 64 or w > 1344 or h < 64 or h > 1344:
return "❌ 宽高范围: 64 ~ 1344"
self.width = w
self.height = h
return f"✅ 尺寸已设置为: {w} x {h}"
except ValueError:
return "❌ 请输入有效数字"
def _cmd_set_rewrite(self, val):
"""设置改写模式"""
val = val.strip().lower()
if val in ["on", "开", "1", "true", "yes"]:
self.rewrite = True
return "✅ 改写模式已开启 — LLM 将智能重写 prompt"
elif val in ["off", "关", "0", "false", "no"]:
self.rewrite = False
return "✅ 改写模式已关闭 — 普通翻译/追加模式"
else:
self.rewrite = not self.rewrite
status = "开启" if self.rewrite else "关闭"
return f"✅ 改写模式已{status}"
def _cmd_set_override(self, val):
"""设置覆写模式"""
val = val.strip().lower()
if val in ["on", "开", "1", "true", "yes"]:
self.override = True
return "✅ 覆写模式已开启 — 忽略工作流内置 prompt"
elif val in ["off", "关", "0", "false", "no"]:
self.override = False
return "✅ 覆写模式已关闭 — 保留工作流内置 prompt"
else:
self.override = not self.override
status = "开启" if self.override else "关闭"
return f"✅ 覆写模式已{status}"
def _cmd_status(self):
"""查看当前状态"""
result = " AI 生图状态:\n\n"
result += f" 工作流: {self.current_workflow or '(默认)'}\n"
result += f" 直接 Tag: {self.direct_prompt or '(未设置)'}\n"
result += f" 自然语言: {self.nl_prompt or '(未设置)'}\n"
result += f" 改写模式: {'✅ 开启' if self.rewrite else '❌ 关闭'}\n"
result += f" 覆写模式: {'✅ 开启' if self.override else '❌ 关闭'}\n"
result += f" 宽度: {self.width or '(默认)'}\n"
result += f" 高度: {self.height or '(默认)'}\n"
result += f" 保存目录: {self.save_dir}\n"
result += f" 生成中: {'是' if self.is_generating else '否'}\n"
return result
# ── 核心生图逻辑 ──
def _fetch_workflows(self):
"""获取工作流列表"""
if self.workflows_cache:
return self.workflows_cache
try:
resp = requests.get(f"{self.API_URL}/workflows", timeout=15)
if resp.status_code == 200:
data = resp.json()
self.workflows_cache = data.get("workflows", [])
return self.workflows_cache
except Exception as e:
pass
return []
def _run_generation(self):
"""执行图片生成"""
if self.is_generating:
return "⚠️ 已有生成任务在进行中,请等待完成"
if not self.direct_prompt and not self.nl_prompt:
return "❌ 请提供至少一种 prompt(直接 Tag 或自然语言描述)"
self.is_generating = True
result_lines = []
image_paths = []
try:
# 获取默认工作流(如果未指定则使用列表中第一个)
workflow = self.current_workflow
if not workflow:
workflows = self._fetch_workflows()
if workflows:
# 优先选 "无Lora" 工作流,否则用第一个
for wf in workflows:
if "无lora" in wf.get("path", "").lower():
workflow = wf["path"]
break
if not workflow:
workflow = workflows[0].get("path", "")
# 构建 payload
payload = {
"workflow_path": workflow or "",
"inline_workflow": None,
"direct_prompt": self.direct_prompt,
"nl_prompt": self.nl_prompt,
"rewrite": self.rewrite,
"override": self.override,
"width": self.width,
"height": self.height,
}
result_lines.append(" 正在连接 ai.2x.nz ...")
result_lines.append(f" 直接 Tag: {self.direct_prompt[:80] or '(无)'}")
result_lines.append(f" 自然语言: {self.nl_prompt[:80] or '(无)'}")
result_lines.append(f" 工作流: {workflow or '(默认)'}")
result_lines.append("")
# 使用同步方式通过 REST + 轮询获取结果
# 由于 websocket-client 可能未安装,提供降级方案
if WEBSOCKET_AVAILABLE:
images = self._generate_via_ws(payload, result_lines)
else:
# 降级:尝试使用 requests + SSE 或提示安装
result_lines.append("⚠️ websocket-client 未安装,尝试安装...")
images = self._generate_via_ws_fallback(payload, result_lines)
if images:
image_paths = images
result_lines.append(f"\n✅ 生成完成!共 {len(images)} 张图片")
for i, path in enumerate(images, 1):
result_lines.append(f" {i}. {path}")
else:
result_lines.append("\n❌ 未生成任何图片")
except Exception as e:
result_lines.append(f"\n❌ 生成失败: {str(e)}")
finally:
self.is_generating = False
return "\n".join(result_lines)
def _generate_via_ws(self, payload, result_lines):
"""通过 WebSocket 生成图片"""
images = []
final_prompt = ""
error_msg = None
ws_result = {"images": [], "error": None, "final_prompt": "", "done": False}
def on_message(ws, message):
try:
m = json.loads(message)
msg_type = m.get("type", "")
if msg_type == "log":
result_lines.append(f" {m.get('message', '')}")
elif msg_type == "llm_start":
result_lines.append(" LLM 正在处理 prompt...")
elif msg_type == "llm_chunk":
pass # 流式输出,不逐块显示
elif msg_type == "llm_done":
result_lines.append(" LLM 处理完成")
elif msg_type == "progress":
node = m.get("node", "")
value = m.get("value", 0)
max_val = m.get("max", 0)
if max_val and max_val > 1:
pct = int(value * 100 / max_val)
result_lines.append(f"⏳ 进度: {node} {value}/{max_val} ({pct}%)")
else:
result_lines.append(f"⏳ 执行: {node}")
elif msg_type == "prompt_id":
fp = m.get("final_prompt", "")
if fp:
ws_result["final_prompt"] = fp
result_lines.append(f"\n 最终 Prompt:\n{fp[:300]}{'...' if len(fp) > 300 else ''}\n")
elif msg_type == "image":
url = m.get("url", "")
filename = m.get("filename", "")
if url:
# 下载图片
saved = self._download_image(url, filename)
if saved:
ws_result["images"].append(saved)
result_lines.append(f" 收到图片: {filename}")
elif msg_type == "done":
count = m.get("count", 0)
result_lines.append(f"✅ 服务端完成,共 {count} 张")
ws_result["done"] = True
ws.close()
elif msg_type == "error":
ws_result["error"] = m.get("message", "未知错误")
result_lines.append(f"❌ 错误: {ws_result['error']}")
ws.close()
except Exception as e:
result_lines.append(f"⚠️ 解析消息出错: {e}")
def on_error(ws, error):
ws_result["error"] = str(error)
result_lines.append(f"❌ WebSocket 错误: {error}")
def on_close(ws, close_status_code, close_msg):
ws_result["done"] = True
def on_open(ws):
ws.send(json.dumps(payload))
try:
ws = websocket.WebSocketApp(
self.WS_URL,
on_message=on_message,
on_error=on_error,
on_close=on_close,
on_open=on_open
)
# 在独立线程中运行 WebSocket
ws_thread = threading.Thread(target=ws.run_forever, kwargs={"ping_timeout": 10})
ws_thread.daemon = True
ws_thread.start()
# 等待完成(最多 5 分钟)
timeout = 300
start = time.time()
while not ws_result["done"] and (time.time() - start) < timeout:
time.sleep(1)
if ws_result["error"]:
raise Exception(ws_result["error"])
if not ws_result["done"]:
ws.close()
raise Exception("生成超时(超过 5 分钟)")
return ws_result["images"]
except Exception as e:
raise Exception(f"WebSocket 连接失败: {e}")
def _generate_via_ws_fallback(self, payload, result_lines):
"""降级方案:尝试自动安装 websocket-client"""
try:
import subprocess
result_lines.append(" 正在安装 websocket-client ...")
subprocess.run(
["pip", "install", "websocket-client"],
capture_output=True, timeout=60
)
import importlib
global websocket
websocket = importlib.import_module("websocket")
global WEBSOCKET_AVAILABLE
WEBSOCKET_AVAILABLE = True
result_lines.append("✅ websocket-client 安装成功")
return self._generate_via_ws(payload, result_lines)
except Exception as e:
result_lines.append(f"❌ 无法安装 websocket-client: {e}")
result_lines.append("💡 请手动运行: pip install websocket-client")
return []
def _download_image(self, url, filename):
"""下载图片到本地"""
try:
# 确保 URL 是完整的
if url.startswith("/"):
url = f"{self.BASE_URL}{url}"
elif not url.startswith("http"):
url = f"{self.BASE_URL}/api/output/file?path={quote(url)}"
# 创建保存目录
os.makedirs(self.save_dir, exist_ok=True)
# 生成文件名
if not filename:
filename = f"img_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
# 清理文件名
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
save_path = os.path.join(self.save_dir, filename)
# 如果文件已存在,添加序号
if os.path.exists(save_path):
name, ext = os.path.splitext(filename)
for i in range(1, 100):
new_path = os.path.join(self.save_dir, f"{name}_{i}{ext}")
if not os.path.exists(new_path):
save_path = new_path
break
# 下载
resp = requests.get(url, timeout=60, stream=True)
if resp.status_code == 200:
with open(save_path, 'wb') as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
return save_path
else:
return None
except Exception as e:
return None
# 兼容 Liugin 类名
+51 -25
View File
@@ -1,5 +1,6 @@
import subprocess import subprocess
import json import json
import time
from urllib.parse import quote from urllib.parse import quote
import requests import requests
@@ -7,6 +8,14 @@ import requests
class Liugin: class Liugin:
"""AI搜索插件 - 使用jina.ai进行智能搜索""" """AI搜索插件 - 使用jina.ai进行智能搜索"""
# 统一 User-Agent(模拟浏览器,避免被反爬拦截)
_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
_HEADERS = {
'User-Agent': _UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
def __init__(self): def __init__(self):
self.usage = """AI搜索工具使用方法:/r self.usage = """AI搜索工具使用方法:/r
ai_search <操作> <参数>/r ai_search <操作> <参数>/r
@@ -27,6 +36,7 @@ ai_search <操作> <参数>/r
""" """
self.cli = None # CLI实例引用 self.cli = None # CLI实例引用
self.timeout = 30 # 请求超时时间(秒) self.timeout = 30 # 请求超时时间(秒)
self.max_retries = 2 # 最大重试次数
def set_cli(self, cli): def set_cli(self, cli):
"""设置CLI实例引用""" """设置CLI实例引用"""
@@ -162,34 +172,50 @@ JSON格式示例:
return f"网络搜索错误: {str(e)}" return f"网络搜索错误: {str(e)}"
def _execute_curl(self, url): def _execute_curl(self, url):
"""使用 requests 库获取内容(替代 curl 命令)""" """使用 requests 库获取内容(带重试、UA、编码处理)"""
try: last_error = None
# 使用 requests 库发送请求 for attempt in range(self.max_retries + 1):
response = requests.get( try:
url, response = requests.get(
timeout=self.timeout, url,
headers={'User-Agent': 'Mozilla/5.0 (compatible; AI-Search-Bot)'} timeout=self.timeout,
) headers=self._HEADERS,
allow_redirects=True
)
# 检查状态码 # 检查状态码
if response.status_code != 200: if response.status_code != 200:
return f"错误:HTTP请求失败,状态码: {response.status_code}" return f"错误:HTTP请求失败,状态码: {response.status_code}"
# 获取内容 # 显式编码处理:避免 ISO-8859-1 默认导致中文乱码
content = response.text.strip() if not response.encoding or response.encoding.lower() == 'iso-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
if not content: # 获取内容
return "错误:未获取到任何内容" content = response.text.strip()
# 限制输出长度以避免过长输出 if not content:
if len(content) > 5000: return "错误:未获取到任何内容"
content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)"
return content # 限制输出长度以避免过长输出
if len(content) > 5000:
content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)"
except requests.Timeout: return content
return f"错误:请求超时 (>{self.timeout}秒)"
except requests.ConnectionError: except requests.exceptions.Timeout:
return "错误:网络连接失败,请检查网络" last_error = f"请求超时 (>{self.timeout}秒)"
except Exception as e: if attempt < self.max_retries:
return f"错误:请求时发生错误: {str(e)}" time.sleep(1)
except requests.exceptions.ConnectionError:
last_error = "网络连接失败,请检查网络"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.RequestException as e:
last_error = str(e)
break
except Exception as e:
last_error = str(e)
break
return f"错误:请求时发生错误: {last_error}"
-169
View File
@@ -1,169 +0,0 @@
import os
import threading
from colorama import Fore, Style
class Liugin:
"""音频播放插件"""
def __init__(self):
self.usage = """音频播放工具使用方法:
audio_player play - 播放音频文件
例如:
- audio_player play - 播放Ciallo~(∠・ω- )⌒-.mp3音频文件
使用工具的JSON格式示例:
{"action": "use_tool", "tool": "audio_player", "args": "play"} - 播放音频文件
直接命令:
- /ciallo - 播放音频文件
"""
self.cli = None # CLI实例引用
self.audio_file = None # 音频文件路径将在set_cli中设置
def set_cli(self, cli):
"""设置CLI实例引用"""
self.cli = cli
# 获取主程序目录路径,通过CLI实例
import os
# 使用CLI实例中的项目目录信息
self.audio_file = os.path.join(os.path.dirname(cli.chat_history_dir), "Ciallo~(∠・ω- )⌒-.mp3")
# 注册插件命令
self.cli.register_liugin_command('ciallo', self.play_command_handler)
def get_tool_info(self):
return {
"name": "audio_player",
"description": "音频播放工具,播放指定的音频文件",
"keywords": ["音频", "播放", "audio", "play", "音乐", "声音"],
"usage": """audio_player 工具使用说明:
JSON格式示例:
{"action": "use_tool", "tool": "audio_player", "args": "play"} - 播放音频文件
直接命令:/ciallo - 播放音频文件"""
}
def get_mcp_definition(self):
return {
"name": "audio_player",
"description": "音频播放工具",
"inputSchema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"description": "音频文件路径(可选)"
}
}
}
}
def convert_mcp_args(self, arguments):
return arguments.get("file", "")
def handle(self, args):
"""处理音频播放请求"""
try:
# 解析参数
parts = args.strip().split()
if len(parts) < 1:
return "错误:请提供操作类型。支持的操作:play"
operation = parts[0].lower()
if operation == "play":
return self._play_audio()
else:
return f"错误:不支持的操作 '{operation}'。支持的操作:play"
except Exception as e:
return f"音频播放错误: {str(e)}"
def play_command_handler(self, args):
"""处理 /ciallo 命令"""
# 在新线程中播放音频,避免阻塞主程序
thread = threading.Thread(target=self._play_audio_internal)
thread.daemon = True
thread.start()
return f"正在播放音频文件: {self.audio_file}"
def _play_audio(self):
"""播放音频文件"""
try:
# 检查音频文件是否存在
if not os.path.exists(self.audio_file):
return f"错误:音频文件 '{self.audio_file}' 不存在。"
# 使用多种方式尝试播放音频
success = self._try_play_audio()
if success:
return f"音频文件 '{self.audio_file}' 播放成功!"
else:
return f"音频播放失败。请确保已安装相应的音频播放库。"
except Exception as e:
return f"播放音频时发生错误: {str(e)}"
def _play_audio_internal(self):
"""在新线程中播放音频"""
try:
# 检查音频文件是否存在
if not os.path.exists(self.audio_file):
print(f"{Fore.RED}错误:音频文件 '{self.audio_file}' 不存在。{Style.RESET_ALL}")
return
# 使用多种方式尝试播放音频
self._try_play_audio()
except Exception as e:
print(f"{Fore.RED}播放音频时发生错误: {str(e)}{Style.RESET_ALL}")
def _try_play_audio(self):
"""尝试多种方式播放音频"""
# 首先尝试使用pygame(如果已安装)
try:
import pygame
pygame.mixer.init()
pygame.mixer.music.load(self.audio_file)
pygame.mixer.music.play()
# 等待音频播放完成
while pygame.mixer.music.get_busy():
pygame.time.Clock().tick(10)
pygame.mixer.quit()
return True
except ImportError:
pass # pygame未安装,尝试其他方式
except Exception:
pass # 其他pygame相关错误,尝试其他方式
# 尝试使用playsound库(如果已安装)
try:
from playsound import playsound
playsound(self.audio_file)
return True
except ImportError:
pass # playsound未安装,尝试其他方式
except Exception:
pass # 其他playsound相关错误,尝试其他方式
# 尝试使用winsound(仅Windows)
try:
import winsound
# 对于MP3文件,先尝试使用高级API
import subprocess
subprocess.run(["start", "", self.audio_file], shell=True)
return True
except Exception:
pass # winsound相关错误,尝试其他方式
# 尝试使用os.startfile(仅Windows)
try:
os.startfile(self.audio_file) # 这会使用默认程序打开文件
return True
except Exception:
pass # os.startfile相关错误
# 如果所有方式都失败,返回False
return False
+10 -2
View File
@@ -368,8 +368,16 @@ class Liugin:
if not test_files and len(py_files) > 5: if not test_files and len(py_files) > 5:
suggestions.append(" 添加单元测试") suggestions.append(" 添加单元测试")
uses_print = sum(1 for f in py_files if 'print(' in open(f, errors='ignore').read()) uses_print = 0
uses_log = sum(1 for f in py_files if 'logging' in open(f, errors='ignore').read()) uses_log = 0
for f in py_files:
with open(f, errors='ignore') as fh:
content = fh.read()
if 'print(' in content:
uses_print += 1
if 'logging' in content:
uses_log += 1
if uses_print > uses_log and uses_print > 3: if uses_print > uses_log and uses_print > 3:
suggestions.append(" 考虑用 logging 替代 print") suggestions.append(" 考虑用 logging 替代 print")
-489
View File
@@ -1,489 +0,0 @@
"""
书签管理器插件 - 管理开发相关链接和资源
支持分类、标签、搜索、导入导出,JSON 持久化
"""
import json
import os
import hashlib
import time
from datetime import datetime
from typing import List, Optional
class Liugin:
"""书签管理器 - 开发资源和链接管理"""
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 = Liugin()
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()
+3 -1
View File
@@ -1158,8 +1158,10 @@ else:
def _op_diff(self, path: str, rest: str) -> str: def _op_diff(self, path: str, rest: str) -> str:
if not path: if not path:
return "错误:请提供第一个文件路径" return "错误:请提供第一个文件路径"
if not rest.strip():
return "错误:请提供第二个文件路径"
f1 = self._norm(path) f1 = self._norm(path)
f2 = self._norm(rest.split()[0] if rest.strip() else "") f2 = self._norm(rest.split()[0])
if not os.path.exists(f1): if not os.path.exists(f1):
return f"错误:文件不存在: {f1}" return f"错误:文件不存在: {f1}"
if not os.path.exists(f2): if not os.path.exists(f2):
-459
View File
@@ -1,459 +0,0 @@
"""
颜色工具插件 - 颜色格式转换、调色板、对比度检查
支持 HEX/RGB/HSL/HSV/CMYK 格式互转,WCAG 对比度检查
"""
import re
import colorsys
from typing import List, Tuple
class Liugin:
"""颜色工具 - 格式转换、调色板、对比度检查"""
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 = Liugin()
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()
+568
View File
@@ -0,0 +1,568 @@
"""
编译验证插件 - 批量语法/编译检查
对项目内 Python 文件做 ast.parse + py_compile 双重验证,
支持按目录、单文件或全项目扫描,并发执行加速。
"""
import os
import sys
import ast
import time
import shutil
import py_compile
import concurrent.futures
from pathlib import Path
from typing import List, Tuple, Optional
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.tool_result import ToolResult, ErrorCode
try:
from colorama import Fore, Style, init as _colorama_init
_colorama_init(autoreset=False)
_HAS_COLOR = True
except Exception:
_HAS_COLOR = False
class _Stub:
def __getattr__(self, _): return ""
Fore = Style = _Stub()
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKIP_DIRS = {
"__pycache__", ".git", ".venv", "venv", "env", ".env",
"node_modules", ".idea", ".vscode", "dist", "build",
".mypy_cache", ".pytest_cache", ".ruff_cache", "site-packages",
}
SKIP_FILES = {
"setup.py",
}
class Liugin:
"""编译验证 - 批量语法检查 / 编译验证 / 清理 __pycache__"""
def __init__(self):
self.usage = """编译验证工具
操作:
all - 验证项目内全部 Python 文件
core - 仅验证 xcli_core/ 目录
engines - 仅验证 ai_engines/ 目录
plugins - 仅验证 plugins/ 目录
dir <目录> - 验证指定目录
check <文件> - 验证单个文件 (可多次指定)
clean [目录] - 清理 __pycache__ (默认项目根)
stats - 统计各目录 Python 文件数量
选项:
-v / --verbose - 显示每个文件的结果
-j / --jobs <N> - 并发线程数 (默认 8)
-q / --quiet - 仅在结束时打印汇总
输出:
✅ 绿色 = 通过
❌ 红色 = 失败
汇总: 总数/通过/失败 + 失败文件详情
示例:
check all -v
check core
check plugins/compile_check.py
check dir ai_engines -j 16
check clean
"""
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "compile_check",
"description": "编译验证工具 — 批量语法/编译检查(ast.parse + py_compile),支持目录、单文件、全项目扫描,并发加速,清理 __pycache__",
"keywords": ["编译", "验证", "语法", "检查", "compile", "syntax",
"check", "lint", "py_compile", "ast", "批量",
"validate", "verify", "clean", "pycache"],
"usage": self.usage,
}
def get_mcp_definition(self):
return {
"name": "compile_check",
"description": "编译验证工具",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["all", "core", "engines", "plugins",
"dir", "check", "clean", "stats"],
"description": "操作类型"
},
"path": {"type": "string",
"description": "目录或文件路径"},
"paths": {"type": "array",
"items": {"type": "string"},
"description": "多文件路径列表"},
"verbose": {"type": "boolean", "default": False},
"jobs": {"type": "integer", "default": 8},
"quiet": {"type": "boolean", "default": False},
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
path = arguments.get("path", "")
return f"{op} {path}".strip()
def handle(self, args: str):
try:
parts = self._parse_args(args)
if not parts:
return self._print_usage()
operation = parts[0].lower()
opts = self._extract_options(parts[1:])
if operation in ("help", "-h", "--help"):
return self._print_usage()
routes = {
"all": self._op_all,
"core": self._op_core,
"engines": self._op_engines,
"plugins": self._op_plugins,
"dir": self._op_dir,
"check": self._op_check,
"clean": self._op_clean,
"stats": self._op_stats,
}
handler = routes.get(operation)
if not handler:
return ToolResult.fail(
f"不支持的操作 '{operation}'",
ErrorCode.UNSUPPORTED_OP,
tool_name="compile_check",
)
return handler(opts)
except Exception as e:
return ToolResult.fail(
f"编译验证错误: {e}",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _print_usage(self):
self._emit(self.usage)
return ToolResult.ok("usage_printed", tool_name="compile_check")
def _out(self, msg: str):
"""统一输出,适配 TUI/CLI"""
if self.cli is not None and hasattr(self.cli, "_output"):
self.cli._output(msg)
else:
print(msg)
def _emit(self, msg: str, end: str = "\n"):
"""统一输出,自动适配 TUI/CLI
- TUI 模式: 一行一行输出到 _output,忽略 end(TUI 有自己的状态指示器)
- CLI 模式: 尊重 end,支持 end="" 的进度条刷新
- TUI 模式下 end != "\\n" 的进度条输出直接跳过(避免污染对话区)
"""
if self.cli is not None and hasattr(self.cli, "_output"):
if end != "\n":
return # TUI 模式跳过进度条类输出
self.cli._output(msg)
else:
print(msg, end=end)
def _ok(self, msg: str) -> str:
return f"{Fore.GREEN}{msg}{Style.RESET_ALL}"
def _err(self, msg: str) -> str:
return f"{Fore.RED}{msg}{Style.RESET_ALL}"
def _warn(self, msg: str) -> str:
return f"{Fore.YELLOW}{msg}{Style.RESET_ALL}"
def _dim(self, msg: str) -> str:
return f"{Fore.CYAN}{msg}{Style.RESET_ALL}"
def _banner(self, title: str) -> str:
bar = "═" * max(20, len(title) + 4)
return f"{Fore.CYAN}{bar}\n {title}\n{bar}{Style.RESET_ALL}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == " " and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _extract_options(self, args: List[str]) -> dict:
opts = {
"positional": [],
"verbose": False,
"quiet": False,
"jobs": 8,
}
i = 0
while i < len(args):
a = args[i]
if a in ("-v", "--verbose"):
opts["verbose"] = True
elif a in ("-q", "--quiet"):
opts["quiet"] = True
elif a in ("-j", "--jobs") and i + 1 < len(args):
try:
opts["jobs"] = max(1, int(args[i + 1]))
except ValueError:
pass
i += 1
elif not a.startswith("-"):
opts["positional"].append(a)
i += 1
return opts
def _collect_py_files(self, root: str) -> List[str]:
root = os.path.abspath(root)
if not os.path.isdir(root):
return []
result: List[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
if not fn.endswith(".py"):
continue
if fn in SKIP_FILES:
continue
result.append(os.path.join(dirpath, fn))
result.sort()
return result
def _verify_one(self, filepath: str) -> Tuple[str, bool, str]:
try:
with open(filepath, "r", encoding="utf-8") as f:
src = f.read()
except UnicodeDecodeError:
try:
with open(filepath, "r", encoding="gbk") as f:
src = f.read()
except Exception as e:
return filepath, False, f"读取失败: {e}"
except Exception as e:
return filepath, False, f"读取失败: {e}"
try:
ast.parse(src, filename=filepath)
except SyntaxError as e:
line = getattr(e, "lineno", "?")
col = getattr(e, "offset", "?")
msg = getattr(e, "msg", str(e))
return filepath, False, f"SyntaxError 第{line}行第{col}列: {msg}"
try:
py_compile.compile(filepath, doraise=True, quiet=2)
except py_compile.PyCompileError as e:
return filepath, False, f"PyCompileError: {e.msg if hasattr(e, 'msg') else e}"
except Exception as e:
return filepath, False, f"编译失败: {e}"
return filepath, True, ""
def _run_batch(
self,
files: List[str],
title: str,
verbose: bool = False,
quiet: bool = False,
jobs: int = 8,
) -> Tuple[int, int, List[Tuple[str, str]]]:
total = len(files)
if total == 0:
self._emit(self._warn(f"{title}: 没有可验证的 Python 文件"))
return 0, 0, []
if not quiet:
self._emit(self._banner(f"{title} ({total} 个文件, {jobs} 线程)"))
self._emit("")
passed = 0
failed: List[Tuple[str, str]] = []
start = time.time()
results: List[Tuple[str, bool, str]] = []
if jobs == 1:
for f in files:
results.append(self._verify_one(f))
else:
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as ex:
futures = {ex.submit(self._verify_one, f): f for f in files}
done = 0
for fut in concurrent.futures.as_completed(futures):
results.append(fut.result())
done += 1
if not quiet and not verbose and total > 0:
pct = done * 100 // total
rel = os.path.relpath(futures[fut], PROJECT_ROOT)
self._emit(f"\r 进度: [{done}/{total}] {pct:3d}% {rel[:40]:<40}", end="")
if not quiet and not verbose:
self._emit("")
results.sort(key=lambda x: x[0])
if verbose and not quiet:
self._emit("")
for filepath, ok, err in results:
rel = os.path.relpath(filepath, PROJECT_ROOT)
if ok:
passed += 1
if verbose and not quiet:
self._emit(f" {self._ok('✓')} {rel}")
else:
failed.append((rel, err))
if not quiet:
self._emit(f" {self._err('✗')} {rel}")
self._emit(f" {self._err(err)}")
elapsed = time.time() - start
self._emit("")
self._emit("─" * 60)
if failed:
self._emit(f" {self._err('✗ 失败:')} {len(failed)}/{total}")
self._emit(f" {self._ok('✓ 通过:')} {passed}/{total}")
self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}")
self._emit("")
self._emit(self._err("失败列表:"))
for rel, err in failed:
self._emit(f" • {self._err(rel)}")
first_line = err.split("\n")[0][:120]
self._emit(f" {first_line}")
else:
self._emit(f" {self._ok(f'✓ 全部通过:')} {passed}/{total} 个文件")
self._emit(f" {self._dim(f'耗时: {elapsed:.2f}s')}")
self._emit("─" * 60)
return passed, len(failed), failed
def _op_all(self, opts: dict):
files = self._collect_py_files(PROJECT_ROOT)
passed, failed, _ = self._run_batch(
files,
"全项目编译验证",
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"全部通过 ({passed}/{passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_core(self, opts: dict):
return self._scan_subdir("xcli_core", "xcli_core 编译验证", opts)
def _op_engines(self, opts: dict):
return self._scan_subdir("ai_engines", "ai_engines 编译验证", opts)
def _op_plugins(self, opts: dict):
return self._scan_subdir("plugins", "plugins 编译验证", opts)
def _scan_subdir(self, sub: str, title: str, opts: dict):
target = os.path.join(PROJECT_ROOT, sub)
if not os.path.isdir(target):
return ToolResult.not_found(f"目录: {sub}", tool_name="compile_check")
files = self._collect_py_files(target)
passed, failed, _ = self._run_batch(
files, title,
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"{sub} 全部通过 ({passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"{sub} 有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_dir(self, opts: dict):
if not opts["positional"]:
return ToolResult.missing("目录路径", tool_name="compile_check")
target = os.path.abspath(opts["positional"][0])
if not os.path.isdir(target):
return ToolResult.not_found(f"目录: {target}", tool_name="compile_check")
files = self._collect_py_files(target)
passed, failed, _ = self._run_batch(
files,
f"目录验证: {os.path.relpath(target, PROJECT_ROOT)}",
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"目录全部通过 ({passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"目录有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_check(self, opts: dict):
if not opts["positional"]:
return ToolResult.missing("文件路径", tool_name="compile_check")
targets = []
for p in opts["positional"]:
fp = os.path.abspath(p)
if not os.path.isfile(fp):
return ToolResult.not_found(
f"文件: {p}", tool_name="compile_check",
)
targets.append(fp)
if len(targets) == 1:
self._emit(self._banner(
f"单文件验证: {os.path.relpath(targets[0], PROJECT_ROOT)}"
))
filepath, ok, err = self._verify_one(targets[0])
rel = os.path.relpath(filepath, PROJECT_ROOT)
self._emit("")
if ok:
self._emit(f" {self._ok('✓ 通过')} {rel}")
self._emit("─" * 60)
return ToolResult.ok(f"通过: {rel}", tool_name="compile_check")
else:
self._emit(f" {self._err('✗ 失败')} {rel}")
self._emit(f" {self._err(err)}")
self._emit("─" * 60)
return ToolResult.fail(
f"编译失败: {rel}",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
passed, failed, _ = self._run_batch(
targets,
f"批量验证 ({len(targets)} 个文件)",
verbose=opts["verbose"],
quiet=opts["quiet"],
jobs=opts["jobs"],
)
if failed == 0:
return ToolResult.ok(
f"全部通过 ({passed})",
tool_name="compile_check",
)
return ToolResult.fail(
f"有 {failed} 个文件编译失败",
ErrorCode.EXEC_FAILED,
tool_name="compile_check",
)
def _op_clean(self, opts: dict):
target = PROJECT_ROOT
if opts["positional"]:
target = os.path.abspath(opts["positional"][0])
if not os.path.isdir(target):
return ToolResult.not_found(
f"目录: {target}", tool_name="compile_check",
)
self._emit(self._banner(f"清理 __pycache__: {os.path.relpath(target, PROJECT_ROOT)}"))
removed = 0
for dirpath, dirnames, _ in os.walk(target):
for d in list(dirnames):
if d == "__pycache__":
full = os.path.join(dirpath, d)
try:
shutil.rmtree(full)
removed += 1
rel = os.path.relpath(full, PROJECT_ROOT)
if not opts["quiet"]:
self._emit(f" {self._ok('✓ 已删除')} {rel}")
except Exception as e:
rel = os.path.relpath(full, PROJECT_ROOT)
self._emit(f" {self._err('✗ 失败')} {rel}: {e}")
self._emit("")
self._emit("─" * 60)
if removed:
self._emit(f" {self._ok(f'✓ 已清理')} {removed} 个 __pycache__")
else:
self._emit(f" {self._dim('没有可清理的 __pycache__')}")
self._emit("─" * 60)
return ToolResult.ok(
f"清理完成 ({removed} 个目录)",
tool_name="compile_check",
)
def _op_stats(self, opts: dict):
self._emit(self._banner("项目 Python 文件统计"))
subdirs = [
("xcli_core", "xcli_core"),
("ai_engines", "ai_engines"),
("plugins", "plugins"),
]
total = 0
rows: List[Tuple[str, int]] = []
for sub, label in subdirs:
target = os.path.join(PROJECT_ROOT, sub)
if os.path.isdir(target):
count = len(self._collect_py_files(target))
rows.append((label, count))
total += count
other = len(self._collect_py_files(PROJECT_ROOT)) - total
rows.append(("其他(根目录等)", other))
total += other
self._emit("")
self._emit(f" {'目录':<20} {'文件数':>8}")
self._emit(" " + "─" * 30)
for label, count in rows:
bar = "█" * min(count, 40) if count else ""
color = Fore.GREEN if count else Fore.CYAN
self._emit(f" {label:<20} {count:>8} {color}{bar}{Style.RESET_ALL}")
self._emit(" " + "─" * 30)
self._emit(f" {'合计':<20} {total:>8}")
self._emit("")
self._emit("─" * 60)
return ToolResult.ok(
f"统计完成 (共 {total} 个文件)",
tool_name="compile_check",
)
+2 -1
View File
@@ -4,6 +4,7 @@
""" """
import os import os
import re
import subprocess import subprocess
from colorama import Fore, Style from colorama import Fore, Style
@@ -317,7 +318,7 @@ JSON格式示例:
"message": "使用了var声明,建议使用let或const" "message": "使用了var声明,建议使用let或const"
}) })
if '==' in content and '!==' not in content: if re.search(r'(?<![=!])==(?!=)', content):
issues.append({ issues.append({
"level": "warning", "level": "warning",
"message": "使用了==比较,建议使用===严格相等" "message": "使用了==比较,建议使用===严格相等"
+1 -1
View File
@@ -354,7 +354,7 @@ class Liugin:
def _auto_commit_message(self, status: str) -> str: def _auto_commit_message(self, status: str) -> str:
lines = status.strip().split('\n') lines = status.strip().split('\n')
added = len([l for l in lines if l.startswith('A ') or l.startswith('A') and l[1] == ' ']) added = len([l for l in lines if l.startswith('A ') or (len(l) > 1 and l.startswith('A') and l[1] == ' ')])
modified = len([l for l in lines if l.startswith('M ') or (len(l) > 1 and l[1] == 'M')]) modified = len([l for l in lines if l.startswith('M ') or (len(l) > 1 and l[1] == 'M')])
deleted = len([l for l in lines if l.startswith('D ') or (len(l) > 1 and l[1] == 'D')]) deleted = len([l for l in lines if l.startswith('D ') or (len(l) > 1 and l[1] == 'D')])
+2 -2
View File
@@ -324,7 +324,7 @@ class Liugin:
lines.append(f"{indent}{prefix}{type_part} {name_part}{id_part}{bounds_part}{state_str}") lines.append(f"{indent}{prefix}{type_part} {name_part}{id_part}{bounds_part}{state_str}")
children = element.FindAll( children = element.FindAll(
self._uia.CreatePropertyCondition(30000, 0), # TreeScope_Children 2, # TreeScope_Children
self._uia.CreateTrueCondition() self._uia.CreateTrueCondition()
) )
if children: if children:
@@ -570,7 +570,7 @@ class Liugin:
try: try:
children = self._root.FindAll( children = self._root.FindAll(
self._uia.CreatePropertyCondition(30000, 2), 2, # TreeScope_Children
self._uia.CreateTrueCondition() self._uia.CreateTrueCondition()
) )
+1 -1
View File
@@ -355,7 +355,7 @@ class Liugin:
if output: if output:
# 提取纯ID保存到文件 # 提取纯ID保存到文件
ids = [line.strip().lstrip(' ') for line in result.split('\n') ids = [line.strip().lstrip(' ') for line in result.split('\n')
if line.strip() and not any(c in line for c in [' ', '️', '', '❄️', ''])] if line.strip() and not any(c in line for c in [' ', '️', '❄️'])]
try: try:
with open(output, 'w', encoding='utf-8') as f: with open(output, 'w', encoding='utf-8') as f:
f.write('\n'.join(ids)) f.write('\n'.join(ids))
-370
View File
@@ -1,370 +0,0 @@
"""
图像生成插件 - 基于 ai.2x.nz 的 ComfyUI 在线服务
支持自然语言描述和英文 Tag 两种模式
"""
import os
import json
import asyncio
import urllib.request
import time
from typing import Optional
class Liugin:
"""图像生成工具 — 自然语言/Tag 生图,基于 ComfyUI 在线服务"""
def __init__(self):
self.usage = """图像生成工具 — 基于 ai.2x.nz 的 ComfyUI 在线服务
操作:
generate <描述> - 自然语言描述生成图片(中文/英文均可)
tag <英文tags> - 直接 Tag 模式(逗号分隔的英文标签)
list - 列出可用的工作流/角色
status - 查看 GPU 状态
示例:
image_generator generate 一个白发蓝眼的猫耳少女在写代码
image_generator tag masterpiece, 1girl, white hair, blue eyes, cat ears, coding
image_generator generate 画一个赛博朋克风格的城市夜景 --width 1024 --height 768
说明:
- 自然语言模式会经过 LLM 翻译为 Tag,效果更好
- 直接 Tag 模式适合熟悉 Danbooru 标签的用户
- 默认分辨率 1024x1344(WAI 推荐)
- 最大分辨率 1344x1344
- 生成的图片保存在 screenshots/ 目录
"""
self.cli = None
self.api_base = "https://ai.2x.nz"
self.ws_url = "wss://ai.2x.nz/ws/run"
self.default_workflow = "WAI - 无Lora.json"
self.output_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "screenshots")
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "image_generator",
"description": "图像生成 — 自然语言/Tag 生图,基于 ComfyUI 在线服务(ai.2x.nz),支持中文描述",
"keywords": ["图像", "生成", "画图", "绘图", "AI画", "生图", "image", "generate", "draw", "paint",
"diffusion", "comfyui", "stable", "sd", "文生图", "text2img"],
"usage": self.usage
}
def handle(self, args: str) -> str:
try:
parts = args.strip().split(maxsplit=1)
if not parts:
return "错误:请提供操作类型\n\n" + self.usage
op = parts[0].lower()
rest = parts[1] if len(parts) > 1 else ""
handlers = {
"generate": self._generate,
"tag": self._generate_tag,
"gen": self._generate,
"g": self._generate,
"list": self._list_workflows,
"ls": self._list_workflows,
"status": self._gpu_status,
}
handler = handlers.get(op)
if not handler:
# 如果没有指定操作,把整个 args 当作自然语言描述
return self._generate(args)
return handler(rest)
except Exception as e:
return f"图像生成错误: {e}"
def _generate(self, args: str) -> str:
"""自然语言模式生成图片"""
# 解析 --width 和 --height 参数
width, height = 1024, 1344
prompt = args.strip()
if "--width" in prompt or "--height" in prompt or "--w" in prompt or "--h" in prompt:
import re
w_match = re.search(r'--w(?:idth)?\s+(\d+)', prompt)
h_match = re.search(r'--h(?:eight)?\s+(\d+)', prompt)
if w_match:
width = int(w_match.group(1))
prompt = prompt[:w_match.start()] + prompt[w_match.end():]
if h_match:
height = int(h_match.group(1))
prompt = prompt[:h_match.start()] + prompt[h_match.end():]
prompt = prompt.strip()
if not prompt:
return "错误:请提供图片描述\n示例: image_generator generate 一个白发猫耳少女"
# 限制分辨率
width = min(width, 1344)
height = min(height, 1344)
return self._run_generation(prompt, "", width, height, rewrite=True, override=False)
def _generate_tag(self, args: str) -> str:
"""直接 Tag 模式"""
width, height = 1024, 1344
prompt = args.strip()
if "--width" in prompt or "--height" in prompt or "--w" in prompt or "--h" in prompt:
import re
w_match = re.search(r'--w(?:idth)?\s+(\d+)', prompt)
h_match = re.search(r'--h(?:eight)?\s+(\d+)', prompt)
if w_match:
width = int(w_match.group(1))
prompt = prompt[:w_match.start()] + prompt[w_match.end():]
if h_match:
height = int(h_match.group(1))
prompt = prompt[:h_match.start()] + prompt[h_match.end():]
prompt = prompt.strip()
if not prompt:
return "错误:请提供 Tag\n示例: image_generator tag masterpiece, 1girl, white hair, blue eyes"
width = min(width, 1344)
height = min(height, 1344)
return self._run_generation("", prompt, width, height, rewrite=False, override=False)
def _run_generation(self, nl_prompt: str, direct_prompt: str, width: int, height: int,
rewrite: bool = False, override: bool = False) -> str:
"""执行图像生成(同步包装)"""
try:
import websocket as ws_lib
except ImportError:
# 回退:用 urllib 的方式不行,WebSocket 必须用专用库
# 尝试用 subprocess 调用 python 脚本
return self._run_generation_subprocess(nl_prompt, direct_prompt, width, height, rewrite, override)
try:
start_time = time.time()
ws = ws_lib.create_connection(self.ws_url, timeout=180)
payload = {
"workflow_path": self.default_workflow,
"inline_workflow": None,
"direct_prompt": direct_prompt,
"nl_prompt": nl_prompt,
"rewrite": rewrite,
"override": override,
"width": width,
"height": height,
}
ws.send(json.dumps(payload))
image_path = None
final_prompt = ""
error_msg = ""
while True:
try:
raw = ws.recv()
data = json.loads(raw)
msg_type = data.get("type", "")
if msg_type == "image":
url = data.get("url", "")
filename = data.get("filename", "")
if url:
image_path = self._download_image(url, filename)
elif msg_type == "final_prompt":
final_prompt = data.get("final_prompt", "") if isinstance(data.get("final_prompt"), str) else data.get("prompt", "")
elif msg_type == "done":
break
elif msg_type == "error":
error_msg = data.get("message", "未知错误")
break
except Exception as e:
error_msg = str(e)
break
ws.close()
elapsed = time.time() - start_time
if error_msg:
return f" 生成失败: {error_msg}"
if image_path:
size = os.path.getsize(image_path)
size_str = f"{size/1024:.0f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB"
result = f" 图片生成成功!\n"
result += f" 路径: {image_path}\n"
result += f" 大小: {size_str}\n"
result += f" 分辨率: {width}x{height}\n"
result += f"⏱ 耗时: {elapsed:.1f}s\n"
if final_prompt:
result += f" Prompt: {final_prompt[:120]}...\n"
result += f"\n💡 提示: 用 'send_image {image_path}' 可以推送到手机"
return result
else:
return " 生成完成但未收到图片数据"
except Exception as e:
return f" 连接失败: {e}\n💡 请检查网络连接或稍后重试"
def _run_generation_subprocess(self, nl_prompt: str, direct_prompt: str, width: int, height: int,
rewrite: bool, override: bool) -> str:
"""用子进程执行 WebSocket 生成(无需 websocket 库)"""
import subprocess
import tempfile
script = f'''
import json, asyncio, urllib.request, os, sys
async def main():
try:
import websockets
except ImportError:
print("ERROR:需要安装 websockets: pip install websockets")
sys.exit(1)
uri = "wss://ai.2x.nz/ws/run"
async with websockets.connect(uri) as ws:
payload = {{
"workflow_path": "{self.default_workflow}",
"inline_workflow": None,
"direct_prompt": {json.dumps(direct_prompt)},
"nl_prompt": {json.dumps(nl_prompt)},
"rewrite": {"true" if rewrite else "false"},
"override": {"true" if override else "false"},
"width": {width},
"height": {height},
}}
await ws.send(json.dumps(payload))
image_url = None
while True:
try:
msg = await asyncio.wait_for(ws.recv(), timeout=180)
data = json.loads(msg)
t = data.get("type", "")
if t == "image":
image_url = data.get("url", "")
elif t == "done":
break
elif t == "error":
print(f"ERROR:{{data.get('message','')}}")
break
except asyncio.TimeoutError:
print("ERROR:超时")
break
if image_url:
full_url = f"https://ai.2x.nz{{image_url}}"
out_path = os.path.join("{self.output_dir}", f"generated_{{os.path.basename(image_url)}}")
os.makedirs(os.path.dirname(out_path), exist_ok=True)
urllib.request.urlretrieve(full_url, out_path)
print(f"OK:{{out_path}}")
asyncio.run(main())
'''
script_path = os.path.join(self.output_dir, "_gen_temp.py")
os.makedirs(self.output_dir, exist_ok=True)
with open(script_path, 'w') as f:
f.write(script)
try:
result = subprocess.run(
["python3", script_path],
capture_output=True, text=True, timeout=200
)
output = result.stdout.strip()
if output.startswith("OK:"):
image_path = output[3:]
size = os.path.getsize(image_path)
size_str = f"{size/1024:.0f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB"
res = f" 图片生成成功!\n"
res += f" 路径: {image_path}\n"
res += f" 大小: {size_str}\n"
res += f" 分辨率: {width}x{height}\n"
res += f"\n💡 提示: 用 'send_image {image_path}' 可以推送到手机"
return res
elif output.startswith("ERROR:"):
return f" {output[6:]}"
else:
return f" 生成异常:\n{result.stdout}\n{result.stderr}"
except subprocess.TimeoutExpired:
return " 生成超时(>200s),请稍后重试"
finally:
try:
os.remove(script_path)
except:
pass
def _download_image(self, url: str, filename: str) -> str:
"""下载生成的图片"""
full_url = f"{self.api_base}{url}"
os.makedirs(self.output_dir, exist_ok=True)
out_path = os.path.join(self.output_dir, f"gen_{filename}")
urllib.request.urlretrieve(full_url, out_path)
return out_path
def _list_workflows(self, args: str) -> str:
"""列出可用的工作流"""
try:
import urllib.request
req = urllib.request.Request(f"{self.api_base}/api/workflows")
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
workflows = data.get("workflows", [])
result = f" 可用工作流 ({len(workflows)} 个):\n\n"
lora_workflows = []
no_lora = []
for w in workflows:
name = w.get("path", "")
if "无Lora" in name or "no" in name.lower():
no_lora.append(name)
else:
lora_workflows.append(name)
if no_lora:
result += " 通用(无角色绑定):\n"
for w in no_lora:
result += f" • {w}\n"
if lora_workflows:
result += "\n 角色工作流:\n"
for w in lora_workflows:
result += f" • {w}\n"
result += "\n💡 使用 'image_generator generate <描述>' 自然语言生图"
result += "\n💡 使用 'image_generator tag <tags>' 直接 Tag 生图"
return result
except Exception as e:
return f" 获取工作流列表失败: {e}"
def _gpu_status(self, args: str) -> str:
"""查看 GPU 状态"""
try:
import urllib.request
req = urllib.request.Request(f"{self.api_base}/api/gpu")
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
result = " 服务器 GPU 状态:\n"
for gpu in data.get("gpus", []):
name = gpu.get("name", "未知")
util = gpu.get("utilization", 0)
mem_used = gpu.get("memory_used", 0)
mem_total = gpu.get("memory_total", 0)
temp = gpu.get("temperature", 0)
result += f" {name}\n"
result += f" GPU 负载: {util}%\n"
result += f" 显存: {mem_used}/{mem_total} MiB\n"
result += f" 温度: {temp}°C\n"
return result
except Exception as e:
return f" 获取 GPU 状态失败: {e}"
+293
View File
@@ -0,0 +1,293 @@
"""
图片读取插件 — 从图片中提取文字,发送给 AI
=============================================
功能:读取图片 → OCR 提取文字 → 返回给 AI
支持:Tesseract OCR / Windows PowerShell OCR / 纯 Python 元数据
"""
import os
import sys
import json
import base64
import hashlib
import subprocess
import platform
import tempfile
from pathlib import Path
_IS_WIN = platform.system() == "Windows"
class Liugin:
"""图片读取 — 从图片中提取文字"""
def __init__(self):
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "image_reader",
"description": "图片读取工具 — 从图片中提取文字(OCR),获取图片信息,"
"将图片内容转为文本发送给 AI",
"keywords": [
"图片", "image", "OCR", "文字识别", "读图", "截图",
"提取文字", "图片转文字", "识别", "tesseract",
],
"usage": (
"image_reader <操作> <路径>\n\n"
"操作:\n"
" ocr <图片路径> - 从图片中提取文字\n"
" info <图片路径> - 获取图片信息(尺寸/格式/大小)\n"
" read <图片路径> - 综合读取(信息 + OCR)\n"
" ascii <图片路径> [宽] - 转 ASCII 字符画(终端显示)\n"
" base64 <图片路径> - 转 Base64 编码\n"
" hash <图片路径> - 计算图片哈希值\n\n"
"示例:\n"
" image_reader ocr screenshot.png - 识别截图中的文字\n"
" image_reader read photo.jpg - 综合读取图片\n"
" image_reader ascii logo.png 40 - 转40列宽ASCII画\n"
),
}
def get_mcp_definition(self):
return {
"name": "image_reader",
"description": "图片读取工具 — 从图片中提取文字(OCR)",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ocr", "info", "read", "ascii", "base64", "hash"],
"description": "操作类型",
},
"path": {"type": "string", "description": "图片文件路径"},
"width": {"type": "integer", "description": "ASCII 画宽度"},
},
"required": ["action", "path"],
},
}
def convert_mcp_args(self, arguments):
action = arguments.get("action", "")
path = arguments.get("path", "")
width = arguments.get("width", "")
parts = [action, path]
if width:
parts.append(str(width))
return " ".join(parts)
def handle(self, args: str) -> str:
parts = args.strip().split(maxsplit=2)
if not parts:
return "请提供操作和图片路径"
action = parts[0].lower()
path = parts[1] if len(parts) > 1 else ""
extra = parts[2] if len(parts) > 2 else ""
if not path:
return "请提供图片路径"
# 展开 ~ 和环境变量
path = os.path.expanduser(os.path.expandvars(path))
if not os.path.isfile(path):
return f"文件不存在: {path}"
handlers = {
"ocr": self._ocr,
"info": self._info,
"read": self._read,
"ascii": self._ascii,
"base64": self._b64,
"hash": self._hash,
}
h = handlers.get(action)
if not h:
return f"不支持的操作 '{action}'"
try:
return h(path, extra)
except Exception as e:
return f"错误: {e}"
# ── 图片信息 ──
def _info(self, path: str, _: str) -> str:
size = os.path.getsize(path)
ext = os.path.splitext(path)[1].lower()
lines = [
f"图片: {os.path.basename(path)}",
f"格式: {ext}",
f"大小: {self._human_size(size)}",
]
# 尝试用 PIL 获取尺寸
try:
from PIL import Image
with Image.open(path) as img:
lines.append(f"尺寸: {img.width} x {img.height}")
lines.append(f"模式: {img.mode}")
if hasattr(img, 'info'):
dpi = img.info.get('dpi')
if dpi:
lines.append(f"DPI: {dpi}")
except ImportError:
pass
except Exception as e:
lines.append(f"读取详情失败: {e}")
return "\n".join(lines)
# ── OCR 文字识别 ──
def _ocr(self, path: str, _: str) -> str:
# 方案1: Tesseract
result = self._ocr_tesseract(path)
if result:
return result
# 方案2: Windows PowerShell OCR
if _IS_WIN:
result = self._ocr_windows(path)
if result:
return result
# 方案3: 用 PIL 读取图片元数据,至少告诉用户图片是什么
return (
"未找到 OCR 引擎。请安装以下任一工具:\n"
" - Tesseract: https://github.com/tesseract-ocr/tesseract\n"
" - Ubuntu: sudo apt install tesseract-ocr tesseract-ocr-chi-sim\n"
" - macOS: brew install tesseract\n"
" - Windows: 下载 UB-Mannheim installer\n\n"
f"图片信息:\n{self._info(path, '')}"
)
def _ocr_tesseract(self, path: str) -> str:
"""用 Tesseract OCR 提取文字"""
# 检查 tesseract 是否可用
try:
subprocess.run(["tesseract", "--version"],
capture_output=True, timeout=5)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
# 执行 OCR
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
tmp_path = tmp.name
try:
# 检测语言:中文+英文
cmd = ["tesseract", path, tmp_path[:-4], "-l", "chi_sim+eng"]
subprocess.run(cmd, capture_output=True, timeout=30)
if os.path.exists(tmp_path):
with open(tmp_path, "r", encoding="utf-8") as f:
text = f.read().strip()
if text:
return f"OCR 识别结果:\n{text}"
return "OCR 未识别到文字"
except subprocess.TimeoutExpired:
return "OCR 超时"
except Exception as e:
return f"OCR 失败: {e}"
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
return ""
def _ocr_windows(self, path: str) -> str:
"""用 Windows 内置 OCR (PowerShell + Windows.Media.Ocr)"""
try:
ps_script = f'''
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$bitmap = [Windows.Graphics.Imaging.SoftwareBitmap, Windows.Graphics.Imaging, ContentType=WindowsRuntime]
$ocrEngine = [Windows.Media.Ocr.OcrEngine, Windows.Media.Ocr, ContentType=WindowsRuntime]
$ocr = $ocrEngine.TryCreateFromUserProfileLanguages()
$file = Get-Item "{path}"
$stream = [System.IO.File]::OpenRead($file.FullName)
$decoder = [Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream).GetAwaiter().GetResult()
$bitmap = $decoder.GetSoftwareBitmapAsync().GetAwaiter().GetResult()
$result = $ocr.RecognizeAsync($bitmap).GetAwaiter().GetResult()
$result.Text
'''
result = subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and result.stdout.strip():
return f"OCR 识别结果:\n{result.stdout.strip()}"
except Exception:
pass
return ""
# ── 综合读取 ──
def _read(self, path: str, _: str) -> str:
"""信息 + OCR"""
info = self._info(path, "")
ocr = self._ocr(path, "")
return f"{info}\n\n{ocr}"
# ── ASCII 字符画 ──
def _ascii(self, path: str, extra: str) -> str:
"""将图片转为 ASCII 字符画"""
width = int(extra) if extra.strip().isdigit() else 60
try:
from PIL import Image
except ImportError:
return "需要 Pillow 库: pip install Pillow"
chars = " .:-=+*#%@"
with Image.open(path) as img:
# 转灰度
img = img.convert("L")
# 缩放
ratio = img.height / img.width
new_height = int(width * ratio * 0.5) # 字符高宽比约 2:1
img = img.resize((width, new_height))
lines = []
for y in range(new_height):
row = ""
for x in range(width):
pixel = img.getpixel((x, y))
idx = pixel * (len(chars) - 1) // 255
row += chars[idx]
lines.append(row)
return "\n".join(lines)
# ── Base64 编码 ──
def _b64(self, path: str, _: str) -> str:
with open(path, "rb") as f:
data = f.read()
ext = os.path.splitext(path)[1].lower().lstrip(".")
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp"
}.get(ext, "image/png")
b64 = base64.b64encode(data).decode("ascii")
return f"data:{mime};base64,{b64[:100]}... ({len(b64)} 字符)"
# ── 哈希 ──
def _hash(self, path: str, _: str) -> str:
with open(path, "rb") as f:
data = f.read()
md5 = hashlib.md5(data).hexdigest()
sha = hashlib.sha256(data).hexdigest()
return f"MD5: {md5}\nSHA256: {sha}"
# ── 工具 ──
@staticmethod
def _human_size(size: int) -> str:
for unit in ["B", "KB", "MB", "GB"]:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
+96 -78
View File
@@ -9,6 +9,14 @@ from colorama import Fore, Style
class Liugin: class Liugin:
"""网络工具插件""" """网络工具插件"""
# 统一 User-Agent(模拟浏览器,避免被反爬拦截)
_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
_HEADERS = {
'User-Agent': _UA,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
def __init__(self): def __init__(self):
self.usage = """网络工具使用方法:/r self.usage = """网络工具使用方法:/r
network_tools <操作> <参数>/r network_tools <操作> <参数>/r
@@ -35,6 +43,7 @@ network_tools <操作> <参数>/r
""" """
self.cli = None # CLI实例引用 self.cli = None # CLI实例引用
self.timeout = 10 # 请求超时时间(秒) self.timeout = 10 # 请求超时时间(秒)
self.max_retries = 2 # 最大重试次数
def set_cli(self, cli): def set_cli(self, cli):
"""设置CLI实例引用""" """设置CLI实例引用"""
@@ -161,8 +170,35 @@ JSON格式示例:
except Exception as e: except Exception as e:
return f"网络工具操作错误: {str(e)}" return f"网络工具操作错误: {str(e)}"
def _request(self, url, method='GET', timeout=None):
"""统一请求方法 — 带重试、UA、编码处理"""
timeout = timeout or self.timeout
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = requests.request(
method, url, timeout=timeout, headers=self._HEADERS,
allow_redirects=True
)
# 显式编码处理:优先用响应头声明的编码,兜底 UTF-8
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
return response
except requests.exceptions.Timeout:
last_error = f"请求超时 (>{timeout}秒)"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.ConnectionError:
last_error = "连接失败 (无法连接到服务器)"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.RequestException as e:
last_error = str(e)
break
return last_error
def _ping(self, target): def _ping(self, target):
"""测试网站连通性和延迟""" """测试网站连通性和延迟(单次请求,不重试,保证延迟测量准确)"""
try: try:
# 确保URL格式正确 # 确保URL格式正确
if not target.startswith(('http://', 'https://')): if not target.startswith(('http://', 'https://')):
@@ -170,12 +206,19 @@ JSON格式示例:
else: else:
test_url = target test_url = target
# 添加协议如果缺失 # 单次请求测量延迟(不使用重试,避免重试干扰延迟数值)
if not test_url.startswith(('http://', 'https://')):
test_url = 'http://' + test_url
start_time = time.time() start_time = time.time()
response = requests.get(test_url, timeout=self.timeout) try:
response = requests.get(
test_url, timeout=self.timeout,
headers=self._HEADERS, allow_redirects=True
)
except requests.exceptions.Timeout:
return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败"
except requests.exceptions.ConnectionError:
return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败"
except requests.exceptions.RequestException as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
end_time = time.time() end_time = time.time()
response_time = (end_time - start_time) * 1000 # 转换为毫秒 response_time = (end_time - start_time) * 1000 # 转换为毫秒
@@ -191,98 +234,73 @@ JSON格式示例:
return result return result
except requests.exceptions.Timeout:
return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败"
except requests.exceptions.ConnectionError:
return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败"
except requests.exceptions.RequestException as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
except Exception as e: except Exception as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败" return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
def _get_content(self, url): def _get_content(self, url):
"""获取网页内容""" """获取网页内容"""
try: # 确保URL格式正确
# 确保URL格式正确 if not url.startswith(('http://', 'https://')):
if not url.startswith(('http://', 'https://')): url = 'https://' + url
url = 'https://' + url
response = requests.get(url, timeout=self.timeout) response = self._request(url)
# _request 返回字符串表示错误
if isinstance(response, str):
return f"获取网页内容失败: {response}"
if response.status_code == 200: if response.status_code == 200:
content = response.text content = response.text
# 限制内容长度以避免过长输出 # 限制内容长度以避免过长输出
if len(content) > 2000: if len(content) > 2000:
content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)" content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)"
result = f"网页内容 (来自 {url}):\n" result = f"网页内容 (来自 {url}):\n"
result += content result += content
return result return result
else: else:
return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}" return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}"
except requests.exceptions.Timeout:
return f"获取网页内容超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"获取网页内容时发生错误: {str(e)}"
def _check_status(self, url): def _check_status(self, url):
"""检查网站状态码""" """检查网站状态码"""
try: # 确保URL格式正确
# 确保URL格式正确 if not url.startswith(('http://', 'https://')):
if not url.startswith(('http://', 'https://')): url = 'https://' + url
url = 'https://' + url
response = requests.get(url, timeout=self.timeout) response = self._request(url)
if isinstance(response, str):
return f"检查网站状态失败: {response}"
result = f"网站状态 (来自 {url}):\n" result = f"网站状态 (来自 {url}):\n"
result += f"HTTP状态码: {response.status_code}\n" result += f"HTTP状态码: {response.status_code}\n"
result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n" result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n"
result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n" result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n"
result += f"内容长度: {len(response.content)} 字节\n" result += f"内容长度: {len(response.content)} 字节\n"
result += f"服务器: {response.headers.get('Server', '未知')}\n" result += f"服务器: {response.headers.get('Server', '未知')}\n"
result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n" result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n"
return result return result
except requests.exceptions.Timeout:
return f"检查网站状态超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"检查网站状态时发生错误: {str(e)}"
def _get_headers(self, url): def _get_headers(self, url):
"""获取网站响应头""" """获取网站响应头(优先 HEAD 请求,失败则回退 GET)"""
try: # 确保URL格式正确
# 确保URL格式正确 if not url.startswith(('http://', 'https://')):
if not url.startswith(('http://', 'https://')): url = 'https://' + url
url = 'https://' + url
response = requests.get(url, timeout=self.timeout) # 优先用 HEAD(更高效),失败则回退到 GET
response = self._request(url, method='HEAD')
if isinstance(response, str):
# HEAD 失败(可能服务器不支持),回退到 GET
response = self._request(url, method='GET')
if isinstance(response, str):
return f"获取响应头失败: {response}"
headers = dict(response.headers) headers = dict(response.headers)
result = f"响应头 (来自 {url}):\n" result = f"响应头 (来自 {url}):\n"
for key, value in headers.items(): for key, value in headers.items():
result += f"{key}: {value}\n" result += f"{key}: {value}\n"
return result return result
except requests.exceptions.Timeout:
return f"获取响应头超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"获取响应头时发生错误: {str(e)}"
def _get_ip(self, domain): def _get_ip(self, domain):
"""获取域名对应的IP地址""" """获取域名对应的IP地址"""
+31 -11
View File
@@ -295,17 +295,37 @@ JSON格式示例:
except Exception: except Exception:
pass pass
# 打印醒目提醒 # 打印醒目提醒(TUI 兼容)
print() is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") is_generating = self.cli and getattr(self.cli, 'tui_stream_callback', None)
print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}") if is_tui and not is_generating:
print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}") # TUI 模式且未在生成中:通过回调输出
print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}") lines = [
if is_repeat: "",
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S") f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}",
print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}") f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}",
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}") f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}",
f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}",
f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}",
]
if is_repeat:
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
lines.append(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
lines.append(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
self.cli.tui_output_callback("\n".join(lines))
elif not is_generating:
# CLI 模式
print()
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
print(f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}{'─' * 50}{Style.RESET_ALL}")
print(f" {Fore.CYAN}任务:{Style.RESET_ALL} {message}")
print(f" {Fore.CYAN}时间:{Style.RESET_ALL} {now.strftime('%H:%M:%S')}")
if is_repeat:
next_str = (now + timedelta(seconds=repeat_seconds)).strftime("%Y-%m-%d %H:%M:%S")
print(f" {Fore.CYAN}下次:{Style.RESET_ALL} {next_str}")
print(f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}")
# 自动激活 AI — 将提醒注入对话 # 自动激活 AI — 将提醒注入对话
self._activate_ai(task_id, message) self._activate_ai(task_id, message)
-144
View File
@@ -1,144 +0,0 @@
"""
发送图片插件 - Clawli 模式下向手机端发送图片
支持独立进程服务器模式
"""
import os
import json
from typing import Dict, Any
def get_clawli_server():
"""获取 Clawli 服务器实例(优先使用独立进程服务器)"""
try:
import clawli_server
if clawli_server.is_running():
return clawli_server
except ImportError:
pass
# 回退到旧的 WebSocket 服务器
try:
from websocket_server import websocket_server
return websocket_server
except ImportError:
return None
class Liugin:
"""发送图片插件 - 在 Clawli 模式下发送图片到手机端"""
def __init__(self):
self.name = "send_image"
self.cli = None
def set_cli(self, cli_instance):
"""设置 CLI 引用"""
self.cli = cli_instance
def get_tool_info(self):
"""返回工具信息"""
return {
"name": "send_image",
"description": "在 Clawli 模式下发送图片到手机端,支持 JPG、PNG、GIF、BMP、WEBP 格式",
"keywords": ["图片", "发送", "手机", "clawli", "image", "send"],
"usage": "send_image {\"image_path\": \"图片路径\", \"caption\": \"说明文字\"}"
}
def handle(self, args):
"""处理工具调用
Args:
args: 可以是 JSON 字符串或字典
"""
# 解析参数
if isinstance(args, str):
try:
params = json.loads(args)
except json.JSONDecodeError:
params = {"image_path": args.strip()}
elif isinstance(args, dict):
params = args
else:
return "错误: 参数格式无效"
image_path = params.get("image_path", "")
caption = params.get("caption", "")
if not image_path:
return "错误: 请提供图片路径"
# 检查文件是否存在
if not os.path.isfile(image_path):
return f"错误: 图片文件不存在: {image_path}"
# 检查是否是图片文件
ext = image_path.lower().split('.')[-1] if '.' in image_path else ''
supported_formats = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
if ext not in supported_formats:
return f"错误: 不支持的图片格式: {ext},支持: {', '.join(supported_formats)}"
# 获取服务器
server = get_clawli_server()
if not server:
return "错误: Clawli 服务器未初始化"
# 尝试使用独立进程服务器
try:
import clawli_server
if clawli_server.is_running():
success, msg = clawli_server.send_image(image_path, caption)
if success:
file_size = os.path.getsize(image_path)
size_str = f"{file_size/1024:.1f}KB" if file_size < 1024*1024 else f"{file_size/1024/1024:.2f}MB"
return f"图片已发送到手机端: {os.path.basename(image_path)} ({size_str})"
else:
return f"错误: {msg}"
except ImportError:
pass
# 回退到旧的 WebSocket 服务器
if hasattr(server, 'running') and not server.running:
return "错误: Clawli 模式未启动,请先使用 /remote start 启动远程服务"
if hasattr(server, 'clients') and not server.clients:
return "错误: 没有连接的手机端,请确保手机已连接"
if hasattr(server, 'send_image_to_clients'):
success = server.send_image_to_clients(image_path, caption)
if success:
file_size = os.path.getsize(image_path)
size_str = f"{file_size/1024:.1f}KB" if file_size < 1024*1024 else f"{file_size/1024/1024:.2f}MB"
return f"图片已发送到手机端: {os.path.basename(image_path)} ({size_str})"
return "错误: 发送图片失败"
# 工具定义
TOOLS = [
{
"type": "function",
"function": {
"name": "send_image",
"description": "在 Clawli 模式下发送图片到手机端。支持 JPG、PNG、GIF、BMP、WEBP 格式。",
"parameters": {
"type": "object",
"properties": {
"image_path": {
"type": "string",
"description": "图片文件的绝对路径"
},
"caption": {
"type": "string",
"description": "图片说明文字(可选)"
}
},
"required": ["image_path"]
}
}
}
]
def get_tools():
"""返回工具定义列表"""
return TOOLS
+18 -1
View File
@@ -83,7 +83,24 @@ class Liugin:
def convert_mcp_args(self, arguments): def convert_mcp_args(self, arguments):
op = arguments.get("operation", "") op = arguments.get("operation", "")
name = arguments.get("name", "") name = arguments.get("name", "")
return f"{op} {name}".strip() parts = [op]
if name:
parts.append(name)
if arguments.get("keyword"):
parts.append(arguments["keyword"])
if arguments.get("file"):
parts.append(arguments["file"])
if arguments.get("count"):
parts.append(str(arguments["count"]))
if arguments.get("language"):
parts.extend(["-l", arguments["language"]])
if arguments.get("code"):
parts.extend(["-c", f'"{arguments["code"]}"'])
if arguments.get("tags"):
parts.extend(["-t", arguments["tags"]])
if arguments.get("description"):
parts.extend(["-d", f'"{arguments["description"]}"'])
return " ".join(parts)
def _load_snippets(self): def _load_snippets(self):
try: try:
+25 -29
View File
@@ -319,18 +319,29 @@ class Liugin:
return None return None
def _find_agent(self, agent_id: str):
"""线程安全地查找 Agent(精确匹配 + 模糊匹配)"""
with self._lock:
agent = self.agents.get(agent_id)
if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
return agent
def _list(self, args: str) -> str: def _list(self, args: str) -> str:
"""列出所有子 Agent""" """列出所有子 Agent"""
with self._lock: with self._lock:
if not self.agents: if not self.agents:
return "没有子 Agent" return "没有子 Agent"
agents_list = list(self.agents.values())
result = [f"子 Agent 列表 ({len(self.agents)} 个):\n"] result = [f"子 Agent 列表 ({len(agents_list)} 个):\n"]
# 按状态分组 # 按状态分组
running = [a for a in self.agents.values() if a.status == "running"] running = [a for a in agents_list if a.status == "running"]
done = [a for a in self.agents.values() if a.status == "done"] done = [a for a in agents_list if a.status == "done"]
failed = [a for a in self.agents.values() if a.status == "failed"] failed = [a for a in agents_list if a.status == "failed"]
if running: if running:
result.append(f" 运行中 ({len(running)}):") result.append(f" 运行中 ({len(running)}):")
@@ -355,14 +366,9 @@ class Liugin:
if not agent_id: if not agent_id:
return "错误:请提供 Agent ID" return "错误:请提供 Agent ID"
agent = self.agents.get(agent_id) agent = self._find_agent(agent_id)
if not agent: if not agent:
# 模糊匹配 return f"错误:未找到 Agent '{agent_id}'"
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
lines = [ lines = [
f"Agent: {agent.id}", f"Agent: {agent.id}",
@@ -386,13 +392,9 @@ class Liugin:
if not agent_id: if not agent_id:
return "错误:请提供 Agent ID" return "错误:请提供 Agent ID"
agent = self.agents.get(agent_id) agent = self._find_agent(agent_id)
if not agent: if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] return f"错误:未找到 Agent '{agent_id}'"
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
if agent.status == "running": if agent.status == "running":
return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps})" return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps})"
@@ -417,13 +419,9 @@ class Liugin:
agent_id = parts[0] agent_id = parts[0]
message = parts[1] message = parts[1]
agent = self.agents.get(agent_id) agent = self._find_agent(agent_id)
if not agent: if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] return f"错误:未找到 Agent '{agent_id}'"
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
if agent.status != "running": if agent.status != "running":
return f"Agent {agent.id} 未在运行 (状态: {agent.status})" return f"Agent {agent.id} 未在运行 (状态: {agent.status})"
@@ -467,17 +465,15 @@ class Liugin:
except Exception as e: except Exception as e:
agent.error = str(e) agent.error = str(e)
agent.status = "failed"
agent.finished_at = datetime.now().strftime("%H:%M:%S")
def _kill(self, args: str) -> str: def _kill(self, args: str) -> str:
"""终止子 Agent""" """终止子 Agent"""
agent_id = args.strip() agent_id = args.strip()
agent = self.agents.get(agent_id) agent = self._find_agent(agent_id)
if not agent: if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name] return f"错误:未找到 Agent '{agent_id}'"
if matches:
agent = matches[0]
else:
return f"错误:未找到 Agent '{agent_id}'"
agent.status = "failed" agent.status = "failed"
agent.error = "被主 Agent 终止" agent.error = "被主 Agent 终止"
+411
View File
@@ -0,0 +1,411 @@
"""
终端模拟器 v2 — AI 的虚拟键盘和屏幕
=====================================
设计理念:AI 面前有一个终端,它能做的事就是:
1. 打字(type)
2. 按键(key)
3. 看屏幕(read)
就这么简单。
"""
import os
import re
import sys
import time
import uuid
import json
import threading
import subprocess
import platform
from collections import deque
_IS_WIN = platform.system() == "Windows"
class Terminal:
"""一个终端 = 一个子进程 + 一个屏幕缓冲"""
def __init__(self, sid: str, shell: str = "", cwd: str = ""):
self.id = sid
self.shell = shell or self._default_shell()
self.cwd = cwd or os.getcwd()
self._proc = None
self._screen = deque(maxlen=5000) # 屏幕行
self._raw = "" # 未处理原始输出
self._lock = threading.Lock()
self._start()
@staticmethod
def _default_shell():
if _IS_WIN:
return os.environ.get("COMSPEC", "cmd.exe")
for s in ["/bin/bash", "/bin/sh"]:
if os.path.isfile(s):
return s
return "/bin/sh"
def _start(self):
env = {**os.environ, "TERM": "dumb", "NO_COLOR": "1",
"PYTHONUNBUFFERED": "1", "PROMPT_COMMAND": ""}
if not _IS_WIN:
base = os.path.basename(self.shell)
if "bash" in base:
env["PS1"] = ""
args = [self.shell, "--norc", "--noprofile"]
elif "python" in base:
args = [self.shell, "-i"]
else:
args = [self.shell]
flags = 0
else:
args = [self.shell, "/K", "chcp 65001 >nul"]
flags = subprocess.CREATE_NO_WINDOW
self._proc = subprocess.Popen(
args, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, bufsize=0, cwd=self.cwd,
env=env, creationflags=flags if _IS_WIN else 0,
)
threading.Thread(target=self._reader, daemon=True).start()
time.sleep(0.3)
def _reader(self):
fd = self._proc.stdout.fileno()
while self._proc.poll() is None:
try:
chunk = os.read(fd, 4096)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
with self._lock:
self._raw += text
for line in text.split("\n"):
self._screen.append(line)
except (OSError, ValueError):
break
def alive(self):
return self._proc and self._proc.poll() is None
# ── 核心操作:打字 ──
def type(self, text: str):
"""像人一样打字,每个字符发到 stdin"""
if not self.alive():
return
try:
self._proc.stdin.write(text.encode("utf-8"))
self._proc.stdin.flush()
except (OSError, BrokenPipeError):
pass
# ── 核心操作:按键 ──
_KEYS = {
"enter": "\n", "return": "\n",
"tab": "\t",
"backspace": "\x7f", "bs": "\x7f",
"esc": "\x1b", "escape": "\x1b",
"space": " ",
"delete": "\x1b[3~", "del": "\x1b[3~",
"insert": "\x1b[2~",
"up": "\x1b[A", "down": "\x1b[B",
"right": "\x1b[C", "left": "\x1b[D",
"home": "\x1b[H", "end": "\x1b[F",
"pageup": "\x1b[5~", "pagedown": "\x1b[6~",
"f1": "\x1bOP", "f2": "\x1bOQ", "f3": "\x1bOR", "f4": "\x1bOS",
"f5": "\x1b[15~", "f6": "\x1b[17~", "f7": "\x1b[18~", "f8": "\x1b[19~",
"f9": "\x1b[20~", "f10": "\x1b[21~", "f11": "\x1b[23~", "f12": "\x1b[24~",
}
# Ctrl+ 字母映射
_CTRL = {chr(i): chr(i - 64) for i in range(65, 91)} # A-Z → \x01-\x1a
def key(self, name: str):
"""
按一个键。
支持: enter, tab, esc, up, down, left, right,
ctrl+c, ctrl+d, ctrl+z, ctrl+l, ctrl+a, ctrl+e,
f1-f12, backspace, delete, home, end, space, ...
"""
if not self.alive():
return
name = name.lower().strip()
# Ctrl+ 组合
if name.startswith("ctrl+"):
letter = name[5:]
seq = self._CTRL.get(letter.upper())
if seq:
self._send_raw(seq)
return
# 普通按键
seq = self._KEYS.get(name)
if seq:
self._send_raw(seq)
elif len(name) == 1:
# 单个字符直接发
self._send_raw(name)
def combo(self, *keys):
"""
组合键,如 combo("ctrl", "shift", "t")
目前主要支持 ctrl+单字母
"""
parts = [k.lower() for k in keys]
if "ctrl" in parts and len(parts) == 2:
other = [k for k in parts if k != "ctrl"][0]
self.key(f"ctrl+{other}")
elif "alt" in parts:
# Alt+key → ESC + key
other = [k for k in parts if k != "alt"][0]
self._send_raw("\x1b" + other)
elif "shift" in parts:
other = [k for k in parts if k != "shift"][0]
self._send_raw(other.upper() if len(other) == 1 else other)
def _send_raw(self, data: str):
try:
self._proc.stdin.write(data.encode("utf-8"))
self._proc.stdin.flush()
except (OSError, BrokenPipeError):
pass
# ── 核心操作:看屏幕 ──
def read(self, lines: int = 0) -> str:
"""读取屏幕当前内容"""
with self._lock:
if lines > 0:
buf = list(self._screen)[-lines:]
else:
buf = list(self._screen)
return self._clean("\n".join(buf))
def read_new(self, timeout: float = 1) -> str:
"""等待并读取新输出"""
with self._lock:
self._raw = ""
time.sleep(timeout)
with self._lock:
out = self._raw
self._raw = ""
return self._clean(out)
def wait(self, seconds: float = 1):
"""等待,让终端输出完"""
time.sleep(seconds)
def _clean(self, text: str) -> str:
if not text:
return ""
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
text = re.sub(r'\x1b\][^\x07]*\x07', '', text)
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)
text = text.replace('\r\n', '\n').replace('\r', '\n')
text = re.sub(r'\n{4,}', '\n\n\n', text)
return text.strip()
def close(self):
try:
if self._proc and self._proc.poll() is None:
self._send_raw("exit\n")
time.sleep(0.2)
if self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
except Exception:
pass
self._proc = None
# ═══════════════════════════════════════════════════════════════
# Liugin 插件
# ═══════════════════════════════════════════════════════════════
class Liugin:
"""终端模拟器 v2 — AI 的虚拟键盘和屏幕"""
def __init__(self):
self.cli = None
self._terminals: dict = {}
self._current = "main"
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "terminal",
"description": "终端模拟器 — AI 的虚拟键盘和屏幕。"
"启动终端、打字、按键、看屏幕,像人一样操作。",
"keywords": [
"终端", "terminal", "shell", "键盘", "按键", "打字",
"屏幕", "交互", "REPL", "cmd", "bash", "python",
],
"usage": (
"terminal <操作> [参数]\n\n"
" open [shell] - 打开终端,返回初始画面\n"
" type <文字> - 打字(发送到终端)\n"
" key <按键名> - 按一个键(enter/tab/ctrl+c/方向键...)\n"
" read [行数] - 看屏幕内容\n"
" last [行数] - 看最近N行(默认20,避免上下文爆炸)\n"
" wait [秒] - 等待输出完毕\n"
" switch <ID> - 切换终端\n"
" list - 列出所有终端\n"
" close [ID] - 关闭终端\n\n"
"按键: enter tab esc space backspace delete\n"
" up down left right home end\n"
" ctrl+c ctrl+d ctrl+z ctrl+l ctrl+a ctrl+e\n"
" f1-f12\n\n"
"示例:\n"
" terminal open - 打开 bash\n"
" terminal open python3 - 打开 Python\n"
" terminal type ls -la - 打字\n"
" terminal key enter - 按回车\n"
" terminal read - 看屏幕\n"
" terminal key ctrl+c - 中断"
),
}
def get_mcp_definition(self):
return {
"name": "terminal",
"description": "终端模拟器 — AI 的虚拟键盘和屏幕",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["open", "type", "key", "read", "last", "wait",
"switch", "list", "close"],
},
"text": {"type": "string", "description": "要打的字 / shell路径"},
"key": {"type": "string", "description": "按键名"},
"last": {"type": "integer", "description": "最近N行"},
"seconds": {"type": "number", "description": "等待秒数"},
"session": {"type": "string", "description": "终端 ID"},
},
"required": ["action"],
},
}
def convert_mcp_args(self, arguments):
a = arguments.get("action", "")
parts = [a]
for k in ["text", "key", "lines", "seconds", "session"]:
v = arguments.get(k)
if v is not None:
parts.append(str(v))
return " ".join(parts)
def handle(self, args: str) -> str:
parts = args.strip().split(maxsplit=1)
if not parts:
return self._help()
action = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
# 支持 `read 20` 省写
if rest.isdigit() and action in ('read', 'last'):
action, rest = 'last', rest
h = {
"open": self._h_open, "type": self._h_type,
"key": self._h_key, "read": self._h_read,
"last": self._h_last, "wait": self._h_wait,
"switch": self._h_switch, "list": self._h_list,
"close": self._h_close,
}.get(action)
if not h:
return f"不支持 '{action}'\n{self._help()}"
try:
return h(rest)
except Exception as e:
return f"终端错误: {e}"
def _help(self):
return (
"terminal open [shell] - 打开终端\n"
"terminal type <文字> - 打字\n"
"terminal key <按键> - 按键\n"
"terminal read [行数] - 看屏幕\n"
"terminal wait [秒] - 等待\n"
"terminal list - 列出终端\n"
"terminal close - 关闭"
)
def _get(self, sid: str = "") -> Terminal:
sid = sid or self._current
if sid not in self._terminals:
self._terminals[sid] = Terminal(sid)
return self._terminals[sid]
def _h_open(self, rest: str) -> str:
sid = f"s{len(self._terminals) + 1}" if self._terminals else "main"
t = Terminal(sid, shell=rest.strip())
self._terminals[sid] = t
self._current = sid
screen = t.read()
return f"✓ 终端 {sid} 已打开\n{screen}"
def _h_type(self, rest: str) -> str:
if not rest:
return "请提供要打的字"
t = self._get()
t.type(rest)
t.wait(0.3)
return f"← 打字: {rest}"
def _h_key(self, rest: str) -> str:
if not rest:
return "请提供按键名"
t = self._get()
t.key(rest)
t.wait(0.3)
return f"← 按键: {rest}"
def _h_read(self, rest: str) -> str:
t = self._get()
return t.read() or "(屏幕为空)"
def _h_last(self, rest: str) -> str:
t = self._get()
n = int(rest) if rest.strip().isdigit() else 20
return t.read(n) or "(屏幕为空)"
def _h_wait(self, rest: str) -> str:
secs = float(rest) if rest.strip() else 1.0
t = self._get()
t.wait(secs)
return t.read()
def _h_switch(self, rest: str) -> str:
sid = rest.strip()
if sid not in self._terminals:
return f"终端 '{sid}' 不存在"
self._current = sid
return f"✓ 切换到 {sid}"
def _h_list(self, _: str) -> str:
if not self._terminals:
return "无终端"
lines = []
for sid, t in self._terminals.items():
cur = " ← 当前" if sid == self._current else ""
dot = "●" if t.alive() else "○"
lines.append(f" {dot} {sid} [{t.shell}]{cur}")
return "\n".join(lines)
def _h_close(self, rest: str) -> str:
sid = rest.strip() or self._current
if sid not in self._terminals:
return f"终端 '{sid}' 不存在"
self._terminals[sid].close()
del self._terminals[sid]
if self._current == sid:
self._current = next(iter(self._terminals), "main")
return f"✓ 终端 {sid} 已关闭"
+26 -19
View File
@@ -189,6 +189,13 @@ class WorkflowEngine:
self.state_dir = os.path.join(self.workflows_dir, ".state") self.state_dir = os.path.join(self.workflows_dir, ".state")
os.makedirs(self.state_dir, exist_ok=True) os.makedirs(self.state_dir, exist_ok=True)
def _out(self, msg):
"""TUI 兼容输出"""
if self.cli and getattr(self.cli, 'tui_output_callback', None):
self.cli.tui_output_callback(msg)
else:
print(msg)
def run(self, path: str, args: dict = None) -> str: def run(self, path: str, args: dict = None) -> str:
"""执行工作流""" """执行工作流"""
if not os.path.exists(path): if not os.path.exists(path):
@@ -226,8 +233,8 @@ class WorkflowEngine:
'started_at': context['workflow']['started_at'], 'started_at': context['workflow']['started_at'],
}) })
print(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}") self._out(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}")
print(f" 共 {len(steps)} 个步骤\n") self._out(f" 共 {len(steps)} 个步骤\n")
failed = False failed = False
for i, step in enumerate(steps): for i, step in enumerate(steps):
@@ -240,7 +247,7 @@ class WorkflowEngine:
if condition: if condition:
resolved_condition = _resolve_vars(condition, context) resolved_condition = _resolve_vars(condition, context)
if not self._eval_condition(resolved_condition): if not self._eval_condition(resolved_condition):
print(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}") self._out(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}")
context['steps'][step_name] = { context['steps'][step_name] = {
'output': '', 'exit_code': 0, 'skipped': True 'output': '', 'exit_code': 0, 'skipped': True
} }
@@ -249,7 +256,7 @@ class WorkflowEngine:
# 解析步骤参数 # 解析步骤参数
resolved_step = _resolve_value(step, context) resolved_step = _resolve_value(step, context)
print(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}") self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
# 更新状态 # 更新状态
self._save_state(wf_id, { self._save_state(wf_id, {
@@ -268,29 +275,29 @@ class WorkflowEngine:
if result.get('exit_code', 0) == 0: if result.get('exit_code', 0) == 0:
output_preview = str(result.get('output', ''))[:100] output_preview = str(result.get('output', ''))[:100]
print(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}" self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}"
f"{' → ' + output_preview if output_preview else ''}") f"{' → ' + output_preview if output_preview else ''}")
else: else:
failed = True failed = True
print(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}") self._out(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}")
print(f" {result.get('error', result.get('output', ''))[:200]}") self._out(f" {result.get('error', result.get('output', ''))[:200]}")
if not continue_on_error: if not continue_on_error:
# 检查 on_fail 分支 # 检查 on_fail 分支
on_fail = resolved_step.get('on_fail') on_fail = resolved_step.get('on_fail')
if on_fail: if on_fail:
print(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}") self._out(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}")
self._execute_on_fail(on_fail, context) self._execute_on_fail(on_fail, context)
break break
else: else:
print(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}") self._out(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}")
except Exception as e: except Exception as e:
failed = True failed = True
context['steps'][step_name] = { context['steps'][step_name] = {
'output': '', 'exit_code': 1, 'error': str(e) 'output': '', 'exit_code': 1, 'error': str(e)
} }
print(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}") self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
if not continue_on_error: if not continue_on_error:
break break
@@ -310,7 +317,7 @@ class WorkflowEngine:
}) })
icon = '✅' if not failed else '❌' icon = '✅' if not failed else '❌'
print(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})") self._out(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})")
return f"{icon} 工作流 '{wf_name}' {status}" return f"{icon} 工作流 '{wf_name}' {status}"
def resume(self, wf_id: str) -> str: def resume(self, wf_id: str) -> str:
@@ -335,7 +342,7 @@ class WorkflowEngine:
start_from = state.get('current_step', 0) start_from = state.get('current_step', 0)
context = state.get('context', {}) context = state.get('context', {})
print(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}") self._out(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}")
failed = False failed = False
for i in range(start_from, len(steps)): for i in range(start_from, len(steps)):
@@ -345,23 +352,23 @@ class WorkflowEngine:
continue_on_error = step.get('continue_on_error', False) continue_on_error = step.get('continue_on_error', False)
resolved_step = _resolve_value(step, context) resolved_step = _resolve_value(step, context)
print(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}") self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
try: try:
result = self._execute_step(resolved_step, context) result = self._execute_step(resolved_step, context)
context['steps'][step_name] = result context['steps'][step_name] = result
if result.get('exit_code', 0) == 0: if result.get('exit_code', 0) == 0:
print(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}") self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}")
else: else:
failed = True failed = True
print(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}") self._out(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}")
if not continue_on_error: if not continue_on_error:
break break
except Exception as e: except Exception as e:
failed = True failed = True
context['steps'][step_name] = {'output': '', 'exit_code': 1, 'error': str(e)} context['steps'][step_name] = {'output': '', 'exit_code': 1, 'error': str(e)}
print(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}") self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
if not continue_on_error: if not continue_on_error:
break break
@@ -553,7 +560,7 @@ class WorkflowEngine:
"""打印步骤""" """打印步骤"""
message = step.get('message', step.get('text', '')) message = step.get('message', step.get('text', ''))
resolved = _resolve_vars(str(message), context) resolved = _resolve_vars(str(message), context)
print(f" 💬 {resolved}") self._out(f" 💬 {resolved}")
return {'output': resolved, 'exit_code': 0} return {'output': resolved, 'exit_code': 0}
def _exec_wait_step(self, step: dict, context: dict) -> dict: def _exec_wait_step(self, step: dict, context: dict) -> dict:
@@ -622,7 +629,7 @@ class WorkflowEngine:
def _execute_on_fail(self, on_fail, context: dict): def _execute_on_fail(self, on_fail, context: dict):
"""执行 on_fail 处理""" """执行 on_fail 处理"""
if isinstance(on_fail, str): if isinstance(on_fail, str):
print(f" 💬 {on_fail}") self._out(f" 💬 {on_fail}")
elif isinstance(on_fail, list): elif isinstance(on_fail, list):
for step in on_fail: for step in on_fail:
self._execute_step(step, context) self._execute_step(step, context)
@@ -636,7 +643,7 @@ class WorkflowEngine:
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2, default=str) json.dump(state, f, ensure_ascii=False, indent=2, default=str)
except Exception as e: except Exception as e:
print(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}") self._out(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}")
def _load_state(self, wf_id: str) -> dict: def _load_state(self, wf_id: str) -> dict:
"""加载工作流状态""" """加载工作流状态"""
+23 -18
View File
@@ -1,20 +1,25 @@
# 小狸插件配置 # 小狸插件配置 - 由启动器自动生成
ENABLE_AI_SEARCH = True ENABLE_AI_SEARCH = True
ENABLE_AUDIO_PLAYER = True
ENABLE_CMD_EXECUTOR = True
ENABLE_FILE_MANAGER = True
ENABLE_CODE_EDITOR = True
ENABLE_CODE_SEARCH = True
ENABLE_GIT_TOOLS = True
ENABLE_FRONTEND_TESTER = True
ENABLE_NETWORK_TOOLS = True
ENABLE_SCHEDULER = True
ENABLE_SPEECH_RECOGNITION = True
ENABLE_TASK_MANAGER = True
ENABLE_TOOL_SEARCH = True
ENABLE_AUTO_ENGINEER = True ENABLE_AUTO_ENGINEER = True
ENABLE_GIT_WORKFLOW = True ENABLE_BROWSER_AUTO = True
ENABLE_PROJECT_ANALYZER = True ENABLE_CMD_EXECUTOR = True
ENABLE_CODE_EDITOR = True
ENABLE_DGLAB_WS = False ENABLE_FILE_MANAGER = True
ENABLE_FRONTEND_TESTER = True
ENABLE_GIT_TOOLS = True
ENABLE_GUI_AUTO = True
ENABLE_ID_GENERATOR = True
ENABLE_IMAGE_READER = True
ENABLE_JSON_TOOLKIT = True
ENABLE_MARKDOWN_TOOL = True
ENABLE_MEMORY_PLUGIN = True
ENABLE_NETWORK_TOOLS = True
ENABLE_REGEX_TESTER = True
ENABLE_SCHEDULER = True
ENABLE_SNIPPET_MANAGER = True
ENABLE_SPEECH_RECOGNITION = True
ENABLE_SUB_AGENT = True
ENABLE_TASK_MANAGER = True
ENABLE_TERMINAL = True
ENABLE_TOOL_SEARCH = True
ENABLE_WORKFLOW = True
+11 -29
View File
@@ -1,30 +1,12 @@
# 小狸 Pro-CLI 依赖库 # 小狸 Pro-CLI 依赖
# 修复后的包名(使用 pip 安装名) # 生成于 2026-06-26 20:58
# 核心依赖 PIL
colorama>=0.4.6 ascii_magic
openai>=1.0.0 colorama
requests>=2.28.0 cv2
ollama>=0.1.0 numpy
ollama
# 图像处理 openai
Pillow>=9.0.0 requests
opencv-python>=4.5.0 tkinter
numpy>=1.21.0
ascii-magic>=2.0.0
# 数据处理
pandas>=1.3.0
scipy>=1.7.0
# 音频
pygame>=2.0.0
# TUI 模式
textual>=0.40.0
# WebSocket 远程
websockets>=11.0
# 类型提示
typing-extensions>=4.0.0
-14
View File
@@ -380,17 +380,3 @@ class SkillLoader:
else: else:
definitions.append(skill.get_tool_definition()) definitions.append(skill.get_tool_definition())
return definitions return definitions
def get_skill(self, name: str) -> Optional[Union[Skill, MarkdownSkill]]:
"""获取已加载的技能"""
return self._skills.get(name)
def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]:
"""获取所有技能的工具定义"""
definitions = []
for skill in self._skills.values():
if format == "anthropic":
definitions.append(skill.get_anthropic_tool_definition())
else:
definitions.append(skill.get_tool_definition())
return definitions
+2 -1
View File
@@ -90,7 +90,8 @@ def read_file(file_path: str, encoding: str = "utf-8", lines: int = None) -> str
break break
content_lines.append(line.rstrip('\n\r')) content_lines.append(line.rstrip('\n\r'))
content = '\n'.join(content_lines) content = '\n'.join(content_lines)
total_lines = sum(1 for _ in open(file_path, 'r', encoding=encoding)) with open(file_path, 'r', encoding=encoding) as f2:
total_lines = sum(1 for _ in f2)
else: else:
content = f.read() content = f.read()
total_lines = content.count('\n') + 1 total_lines = content.count('\n') + 1
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env python3
"""测试 TUI 回车发送消息功能(改进版)"""
import pexpect
import time
import sys
def test_tui_send():
child = pexpect.spawn(
'python3 ai_cli.py',
encoding='utf-8',
timeout=30,
dimensions=(30, 100),
)
# 1. 等待 diff 选择提示
child.expect('请选择')
print('[OK] 出现 diff 选择提示')
child.sendline('2')
# 2. 等待 CLI 提示符
child.expect('>', timeout=10)
print('[OK] CLI 就绪')
# 3. 发送 /tui
child.sendline('/tui')
time.sleep(3)
# 4. 检查 TUI 启动
try:
child.expect('回车发送', timeout=5)
print('[OK] TUI 启动成功')
except pexpect.TIMEOUT:
print('[FAIL] TUI 未启动')
child.close()
return False
# 5. 输入 hello 并回车
time.sleep(1)
child.send('hello')
time.sleep(0.5)
child.send('\r') # Enter
time.sleep(5) # 等待处理(ollama 可能超时)
# 6. 读取所有可用输出
full_output = ''
try:
while True:
chunk = child.read_nonblocking(size=4096, timeout=1)
full_output += chunk
except (pexpect.TIMEOUT, pexpect.EOF):
pass
# 7. 检测结果
sent = False
# 检查是否出现用户消息 "hello"
if 'hello' in full_output:
print('[OK] 检测到 "hello" — 用户消息已显示')
sent = True
# 检查是否出现错误(ollama 未运行)
if 'Ollama' in full_output or '服务未运行' in full_output:
print('[OK] 收到 Ollama 错误 — 消息已发送并处理')
sent = True
# 检查是否出现 "思考中"
if '思考中' in full_output:
print('[OK] 看到 "思考中" — AI 正在处理')
sent = True
# 检查是否出现消息气泡标记
if ' ' in full_output: # 用户消息前缀
# 检查是否有新内容出现
lines = [l for l in full_output.split('\n') if l.strip()]
if len(lines) > 0:
print(f'[INFO] 捕获到 {len(lines)} 行输出')
if not sent:
# 最后检查:如果 TUI 仍在运行且输入框为空,说明消息被发送了
# (因为发送后会清空输入框)
print('[INFO] 未检测到明确结果,但 TUI 仍在运行')
# 认为通过:TUI 没有崩溃,说明逻辑正常
sent = True
# 退出
try:
child.send('\x03') # Ctrl+C
time.sleep(0.5)
child.send('\x03')
except Exception:
pass
child.close()
return sent
if __name__ == '__main__':
result = test_tui_send()
print(f'\n结果: {"✅ 测试通过" if result else "❌ 测试失败"}')
sys.exit(0 if result else 1)
+179
View File
@@ -0,0 +1,179 @@
"""终端模拟器 v2 测试"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins.terminal import Liugin, Terminal
def test_basic_open_close():
p = Liugin()
r = p.handle("open")
assert "已打开" in r
r = p.handle("list")
assert "main" in r
r = p.handle("close")
assert "已关闭" in r
r = p.handle("list")
assert "无终端" in r
print("✓ test_basic_open_close")
def test_type_and_enter():
p = Liugin()
p.handle("open")
p.handle("type echo hello_v2")
p.handle("key enter")
time.sleep(0.5)
r = p.handle("read")
assert "hello_v2" in r
p.handle("close")
print("✓ test_type_and_enter")
def test_last_n():
p = Liugin()
p.handle("open")
for i in range(10):
p.handle(f"type echo line_{i}")
p.handle("key enter")
time.sleep(0.15)
time.sleep(0.5)
r = p.handle("last 3")
assert "line_9" in r
# last 3 不应该包含 line_0
assert "line_0" not in r
p.handle("close")
print("✓ test_last_n")
def test_read_shortcut():
"""read 5 等同 last 5"""
p = Liugin()
p.handle("open")
p.handle("type echo aaa")
p.handle("key enter")
time.sleep(0.3)
p.handle("type echo bbb")
p.handle("key enter")
time.sleep(0.3)
r = p.handle("read 5")
assert "bbb" in r
p.handle("close")
print("✓ test_read_shortcut")
def test_env_persist():
p = Liugin()
p.handle("open")
p.handle("type export MYVAR=hello")
p.handle("key enter")
time.sleep(0.3)
p.handle("type echo val=$MYVAR")
p.handle("key enter")
time.sleep(0.5)
r = p.handle("read")
assert "val=hello" in r
p.handle("close")
print("✓ test_env_persist")
def test_python_repl():
p = Liugin()
p.handle("open python3")
time.sleep(1)
p.handle("type print(2+3)")
p.handle("key enter")
time.sleep(0.5)
r = p.handle("read")
assert "5" in r
p.handle("close")
print("✓ test_python_repl")
def test_ctrl_c():
p = Liugin()
p.handle("open")
p.handle("type python3 -c 'import time; time.sleep(99)'")
p.handle("key enter")
time.sleep(0.5)
p.handle("key ctrl+c")
time.sleep(0.3)
# 终端应该还活着
r = p.handle("list")
assert "●" in r
p.handle("close")
print("✓ test_ctrl_c")
def test_multi_session():
p = Liugin()
p.handle("open")
p.handle("open python3")
r = p.handle("list")
assert "main" in r
assert "s2" in r
p.handle("switch main")
p.handle("type echo from_bash")
p.handle("key enter")
time.sleep(0.3)
r = p.handle("read")
assert "from_bash" in r
p.handle("close s2")
p.handle("close main")
print("✓ test_multi_session")
def test_cwd():
p = Liugin()
p.handle("open")
p.handle("type cd /tmp")
p.handle("key enter")
time.sleep(0.3)
p.handle("type pwd")
p.handle("key enter")
time.sleep(0.5)
r = p.handle("read")
assert "/tmp" in r
p.handle("close")
print("✓ test_cwd")
def test_long_output():
p = Liugin()
p.handle("open")
p.handle("type seq 1 100")
p.handle("key enter")
time.sleep(1)
r = p.handle("last 5")
assert "100" in r
p.handle("close")
print("✓ test_long_output")
def test_auto_create():
"""不 open 直接 type,自动创建终端"""
p = Liugin()
p.handle("type echo auto")
p.handle("key enter")
time.sleep(0.5)
r = p.handle("read")
assert "auto" in r
p.handle("close")
print("✓ test_auto_create")
if __name__ == "__main__":
test_basic_open_close()
test_type_and_enter()
test_last_n()
test_read_shortcut()
test_env_persist()
test_python_repl()
test_ctrl_c()
test_multi_session()
test_cwd()
test_long_output()
test_auto_create()
print("\n🎉 全部 11 个测试通过!")
+20 -21
View File
@@ -417,21 +417,17 @@ class LocalServer:
image_engine = getattr(self.cli, 'image_engine', None) image_engine = getattr(self.cli, 'image_engine', None)
if not image_engine: if not image_engine:
try: try:
from image_engine import get_image_engine from image_engine import get_engine_manager
image_engine = get_image_engine() image_engine = get_engine_manager()
except: except:
return f"@{file_path}" # 没有图像引擎,直接返回路径 return f"@{file_path}" # 没有图像引擎,直接返回路径
if websocket: if websocket:
await self._send_tool_status(websocket, "图像识别", text or "分析图片", "calling") await self._send_tool_status(websocket, "图像识别", text or "分析图片", "calling")
# 读取图片
with open(file_path, 'rb') as f:
image_data = f.read()
# 调用图像识别 # 调用图像识别
try: try:
result = image_engine.analyze(image_data, text or "请描述这张图片") result = image_engine.analyze(file_path, text or "请描述这张图片")
if websocket: if websocket:
await self._send_tool_status(websocket, "图像识别", text or "分析图片", "success", result[:200] if result else "无结果") await self._send_tool_status(websocket, "图像识别", text or "分析图片", "success", result[:200] if result else "无结果")
@@ -638,9 +634,13 @@ class LocalServer:
from colorama import Fore, Style from colorama import Fore, Style
try: try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80)) try:
local_ip = s.getsockname()[0] s.connect(("8.8.8.8", 80))
s.close() local_ip = s.getsockname()[0]
except:
local_ip = "未知"
finally:
s.close()
except: except:
local_ip = "未知" local_ip = "未知"
@@ -657,9 +657,13 @@ class LocalServer:
def get_status(self) -> dict: def get_status(self) -> dict:
try: try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80)) try:
local_ip = s.getsockname()[0] s.connect(("8.8.8.8", 80))
s.close() local_ip = s.getsockname()[0]
except:
local_ip = "未知"
finally:
s.close()
except: except:
local_ip = "未知" local_ip = "未知"
return { return {
@@ -817,15 +821,12 @@ class ProxyClient:
image_engine = getattr(self.cli, 'image_engine', None) image_engine = getattr(self.cli, 'image_engine', None)
if not image_engine: if not image_engine:
try: try:
from image_engine import get_image_engine from image_engine import get_engine_manager
image_engine = get_image_engine() image_engine = get_engine_manager()
except: except:
return f"@{file_path}" return f"@{file_path}"
with open(file_path, 'rb') as f: result = image_engine.analyze(file_path, text or "请描述这张图片")
image_data = f.read()
result = image_engine.analyze(image_data, text or "请描述这张图片")
return await self._process_message(f"@{file_path}", [], f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}") return await self._process_message(f"@{file_path}", [], f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}")
except Exception as e: except Exception as e:
@@ -1134,8 +1135,6 @@ def start_proxy_client(cli_instance, server_host: str, server_port: int,
return result return result
return _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password)
def stop_all(): def stop_all():
global _local_server, _proxy_client, websocket_server global _local_server, _proxy_client, websocket_server
+23 -19
View File
@@ -95,8 +95,8 @@ class BaseAICLI:
self.load_liugins(liugins_dir, skills_dir) self.load_liugins(liugins_dir, skills_dir)
# 初始化共享对话历史 # 初始化共享对话历史
self.shared_conversation_history = [] self.shared_conversation_history = []
# 设置最大历史记录数 # 设置最大历史记录数 — 从 config 读取,未配置则用默认值
self.max_history = DEFAULT_MAX_HISTORY self.max_history = get_system_config('max_history', DEFAULT_MAX_HISTORY)
# 为所有已加载的引擎设置共享对话历史 # 为所有已加载的引擎设置共享对话历史
self._set_shared_conversation_history() self._set_shared_conversation_history()
# 初始化代码执行相关功能 # 初始化代码执行相关功能
@@ -115,6 +115,14 @@ class BaseAICLI:
self._clawli_monitor_running = False self._clawli_monitor_running = False
# TUI 输出回调(用于 TUI 模式下的实时输出) # TUI 输出回调(用于 TUI 模式下的实时输出)
self.tui_output_callback = None self.tui_output_callback = None
# TUI 流式回调(用于 TUI 模式下逐字流式输出)
self.tui_stream_callback = None
# TUI 思考回调(用于 TUI 模式下深度思考内容单独显示)
self.tui_thinking_callback = None
# TUI 钩子:每次调用 AI 前触发(重新显示思考指示器等)
self.tui_before_generate_callback = None
# TUI 确认回调(用于 TUI 模式下工具调用审查,返回 True/False)
self.tui_confirm_callback = None
# 初始化用户ID(基于机器硬件动态生成) # 初始化用户ID(基于机器硬件动态生成)
self.user_id = str(uuid.getnode()) self.user_id = str(uuid.getnode())
@@ -319,6 +327,13 @@ class BaseAICLI:
"""获取插件管理器""" """获取插件管理器"""
return self.liugin_manager return self.liugin_manager
def _output(self, message):
"""统一输出方法,支持 TUI 和 CLI 模式"""
if getattr(self, 'tui_output_callback', None):
self.tui_output_callback(message)
else:
print(message)
def switch_engine(self, engine_name): def switch_engine(self, engine_name):
"""切换AI引擎""" """切换AI引擎"""
if engine_name in self.engines: if engine_name in self.engines:
@@ -333,35 +348,24 @@ class BaseAICLI:
self.current_engine.max_history = self.max_history self.current_engine.max_history = self.max_history
if old_engine != self.current_engine and self.shared_conversation_history: if old_engine != self.current_engine and self.shared_conversation_history:
print(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}") self._output(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}")
context_message = { context_message = {
"role": "system", "role": "system",
"content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n" "content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n"
} }
self.shared_conversation_history.append(context_message)
if self.shared_conversation_history: self._output(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}")
print(f"{Fore.YELLOW}当前对话历史已传递给新引擎:{Style.RESET_ALL}")
for msg in self.shared_conversation_history[-self.max_history:]:
if isinstance(msg, dict) and "role" in msg and "content" in msg:
role = msg["role"]
content = msg["content"]
if role == "user":
print(f"{Fore.CYAN}用户: {content}{Style.RESET_ALL}")
elif role == "assistant":
print(f"{Fore.GREEN}AI: {content}{Style.RESET_ALL}")
elif role == "tool":
print(f"{Fore.MAGENTA}工具结果: {content}{Style.RESET_ALL}")
else: else:
print(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}") self._output(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}")
try: try:
set_system_config('default_engine', engine_name) set_system_config('default_engine', engine_name)
print(f"{Fore.CYAN}已将 {engine_name} 设置为默认引擎{Style.RESET_ALL}")
except Exception as e: except Exception as e:
print(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}") self._output(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}")
else: else:
print(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}") self._output(f"{Fore.RED}未找到AI引擎: {engine_name}{Style.RESET_ALL}")
def _set_shared_conversation_history(self): def _set_shared_conversation_history(self):
"""为所有引擎设置共享对话历史""" """为所有引擎设置共享对话历史"""
+6 -7
View File
@@ -96,6 +96,7 @@ class ClawliMixin:
tool_calls = self._extract_tool_calls(json_data) tool_calls = self._extract_tool_calls(json_data)
if tool_calls: if tool_calls:
all_results = []
for tool_data in tool_calls: for tool_data in tool_calls:
tool_name = tool_data.get('tool', '未知工具') tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '') tool_args = tool_data.get('args', '')
@@ -105,6 +106,7 @@ class ClawliMixin:
result = self.process_tool_call(tool_data) result = self.process_tool_call(tool_data)
result_text = result.get('result', '无结果') result_text = result.get('result', '无结果')
all_results.append(result_text)
if CLAWLI_SERVER_AVAILABLE and clawli_server: if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text) clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
@@ -114,7 +116,7 @@ class ClawliMixin:
"content": response "content": response
}) })
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in [result.get('result', '')]]) tool_result_str = "\n".join(f"工具执行结果: {r}" for r in all_results)
self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。") self._process_clawli_user_message(f"{tool_result_str}\n请根据工具执行结果继续回答。")
return return
@@ -134,16 +136,13 @@ class ClawliMixin:
image_engine = getattr(self, 'image_engine', None) image_engine = getattr(self, 'image_engine', None)
if not image_engine: if not image_engine:
try: try:
from image_engine import get_image_engine from image_engine import get_engine_manager
image_engine = get_image_engine() image_engine = get_engine_manager()
except: except:
image_engine = None image_engine = None
if image_engine: if image_engine:
with open(image_path, 'rb') as f: result = image_engine.analyze(image_path, text or "请描述这张图片")
image_data = f.read()
result = image_engine.analyze(image_data, text or "请描述这张图片")
self._process_clawli_user_message(f"@{image_path}\n图片内容: {result}\n\n{text}") self._process_clawli_user_message(f"@{image_path}\n图片内容: {result}\n\n{text}")
else: else:
self._process_clawli_user_message(f"@{image_path}\n{text}") self._process_clawli_user_message(f"@{image_path}\n{text}")
+135 -9
View File
@@ -12,7 +12,7 @@ from colorama import Fore, Style
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
from .config import get_system_config, logger from .config import get_system_config, logger
from .cli_base import BaseAICLI from .cli_base import BaseAICLI
from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL from .safety import get_safety, MODE_UNRESTRICTED, MODE_NORMAL, MODE_MANUAL, MODE_PLAN
from .cli_clawli import ClawliMixin from .cli_clawli import ClawliMixin
from .cli_tools import ToolMixin from .cli_tools import ToolMixin
from .cli_code_exec import CodeExecMixin from .cli_code_exec import CodeExecMixin
@@ -152,6 +152,30 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
{tool_search_rule} {tool_search_rule}
【⚠️ 兜底策略 — 遇到能力不足时的解决路径】
当你发现现有工具无法完成用户需求时,按以下顺序逐级尝试,不要直接放弃:
第一级:查插件仓库
用 /plugin search <关键词> 或 /plugin list 搜索是否有社区插件可以解决。
找到后用 /plugin install <插件码> 安装,安装后立即使用。
示例:用户要“发邮件” → 没有邮件插件 → /plugin search 邮件 → 找到就装 → 用
第二级:网上搜索方案
用 ai_search 搜索“<需求> python 实现”或“<需求> 工具”,看看有没有现成的解决方案、
API 或命令行工具可以直接调用。
示例:用户要“PDF 转 Word” → 没有插件 → ai_search “PDF to Word python CLI” → 用搜到的方案
第三级:自己写插件
如果以上都解决不了,你有完整的插件开发能力。写一个 Python 插件文件放到 plugins/ 目录,
实现 Liugin 类(get_tool_info + get_mcp_definition + handle),然后重启加载。
你可以:
- 用 Python 标准库实现
- 调用系统已安装的命令行工具
- 用 urllib 访问公开 API
示例:用户要“二维码生成” → 没有插件 → 搜不到 → 写一个 qrcode_plugin.py → 加载 → 使用
记住:放弃是最后的选择。能装就装,能搜就搜,能写就写。
【工具调用格式】 【工具调用格式】
在回复中包含 JSON 指令来执行操作: 在回复中包含 JSON 指令来执行操作:
@@ -219,6 +243,20 @@ multi 操作支持一次修改多处:
{tool_search_rule} {tool_search_rule}
【⚠️ 兜底策略 — 遇到能力不足时的解决路径】
当你发现现有工具无法完成用户需求时,按以下顺序逐级尝试,不要直接放弃:
第一级:查插件仓库
用 /plugin search <关键词> 搜索社区插件,找到后 /plugin install 安装使用。
第二级:网上搜索方案
用 ai_search 搜索解决方案或命令行工具。
第三级:自己写插件
写 Python 插件放到 plugins/ 目录,实现 Liugin 类,重启加载。
记住:放弃是最后的选择。能装就装,能搜就搜,能写就写。
【工具调用格式】 【工具调用格式】
{{"action": "use_tool", "tool": "工具名", "args": "参数"}} {{"action": "use_tool", "tool": "工具名", "args": "参数"}}
@@ -488,7 +526,16 @@ multi 操作支持一次修改多处:
max_loops = 20 max_loops = 20
while loop_count < max_loops: while loop_count < max_loops:
response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts) # ── 计划模式:带引擎错误处理的响应生成 ──
safety = get_safety()
if safety.is_plan_mode():
response = self._plan_generate(current_input, liugin_prompts)
if response is None:
# 所有引擎都失败,退出循环
self._display_response("[计划模式] 所有引擎均不可用,任务中断。")
break
else:
response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts)
processed_response = self.process_thinking_response(response) processed_response = self.process_thinking_response(response)
processed_response = self._process_code_blocks(processed_response) processed_response = self._process_code_blocks(processed_response)
@@ -827,6 +874,83 @@ multi 操作支持一次修改多处:
return f"未找到工具: {tool_name}" return f"未找到工具: {tool_name}"
# ── 计划模式:带重试和引擎切换的响应生成 ──
def _plan_generate(self, current_input: str, liugin_prompts=None) -> Optional[str]:
"""
计划模式专用响应生成。
- 自动重试
- 引擎错误 10 次自动切换
- 无其他引擎时继续重试但不污染上下文
"""
safety = get_safety()
max_retries = 5
for attempt in range(max_retries):
try:
response = self._generate_response_with_animation(current_input, liugin_prompts=liugin_prompts)
# 检查是否是引擎错误(空响应或明确错误)
if response and not response.startswith("错误") and not response.startswith("API 调用失败"):
safety.reset_engine_errors()
return response
# 引擎返回错误
is_fatal = safety.record_engine_error()
if is_fatal:
new_engine = safety.try_switch_engine()
if new_engine:
print(f"[计划模式] 引擎连续错误 {safety.plan_engine_error_limit} 次,已切换到 {new_engine}")
continue # 用新引擎重试
else:
# 无其他引擎,继续重试但不把错误加入上下文
print(f"[计划模式] 无其他引擎可用,继续重试...")
time.sleep(2)
continue
# 未达阈值,重试
continue
except Exception as e:
is_fatal = safety.record_engine_error()
if is_fatal:
new_engine = safety.try_switch_engine()
if new_engine:
print(f"[计划模式] 引擎异常,已切换到 {new_engine}")
continue
continue
return "[计划模式] 重试次数已用完。"
def _plan_review(self, task_description: str, task_result: str):
"""
计划模式:任务完成后自动审查。
"""
safety = get_safety()
review_prompt = safety.get_review_prompt(task_result)
print(f" [计划模式] 自动审查中...")
try:
review = self._generate_response_with_animation(review_prompt)
if review:
# 检查审查结果是否发现问题
review_lower = review.lower()
has_issue = any(w in review_lower for w in [
'问题', '错误', '失败', '遗漏', '修正', '重新', 'issue', 'error', 'fail',
'bug', 'wrong', 'missing', 'fix', 'retry'
])
if has_issue:
print(f" [计划模式] 审查发现问题,自动重试...")
return True # 需要重试
else:
print(f" [计划模式] 审查通过。")
return False
except Exception:
pass
return False
def _generate_response_with_animation(self, current_input, liugin_prompts=None): def _generate_response_with_animation(self, current_input, liugin_prompts=None):
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消""" """生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
# TUI 模式:不做动画 # TUI 模式:不做动画
@@ -1003,7 +1127,7 @@ multi 操作支持一次修改多处:
print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/safe' 切换安全模式 (普通→人工→无限制){Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/safe' 或 '/mode' 切换工作模式 (普通→人工→无限制→计划){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/notify' 切换任务完成通知 (开/关){Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/notify' 切换任务完成通知 (开/关){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/scheduler' 或 '/remind' 管理定时任务{Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/scheduler' 或 '/remind' 管理定时任务{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/memory' 管理记忆系统 (日记/搜索/聊天记录){Style.RESET_ALL}") print(f"{Fore.GREEN}输入 '/memory' 管理记忆系统 (日记/搜索/聊天记录){Style.RESET_ALL}")
@@ -1215,7 +1339,7 @@ multi 操作支持一次修改多处:
print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}") print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}")
continue continue
if user_input.startswith('/safe'): if user_input.startswith('/safe') or user_input.startswith('/mode'):
parts = user_input.split() parts = user_input.split()
safety = get_safety() safety = get_safety()
if len(parts) > 1: if len(parts) > 1:
@@ -1226,16 +1350,18 @@ multi 操作支持一次修改多处:
safety.set_mode(MODE_NORMAL) safety.set_mode(MODE_NORMAL)
elif mode_arg in ('manual', '人工', 'all'): elif mode_arg in ('manual', '人工', 'all'):
safety.set_mode(MODE_MANUAL) safety.set_mode(MODE_MANUAL)
elif mode_arg in ('plan', '计划', 'auto'):
safety.set_mode(MODE_PLAN)
else: else:
print(f"{Fore.RED}用法: /safe [off|on|manual]{Style.RESET_ALL}") print(f"{Fore.RED}用法: /mode [off|on|manual|plan]{Style.RESET_ALL}")
continue continue
else: else:
# 无参数:循环切换
safety.cycle_mode() safety.cycle_mode()
mode_name = safety.get_mode_name() mode_name = safety.get_mode_name()
icons = {"无限制": "", "普通": "", "人工确认": ""} icon = safety.get_mode_icon()
icon = icons.get(mode_name, "") print(f"{icon} 工作模式: {mode_name}")
print(f"{icon} 安全模式: {mode_name}") if mode_name == "计划模式":
print(f" {Fore.CYAN}全自动执行 | 任务审查 | 自动重试 | 引擎故障切换{Style.RESET_ALL}")
continue continue
if user_input.startswith('/notify'): if user_input.startswith('/notify'):
+4 -5
View File
@@ -45,13 +45,12 @@ class DisplayMixin:
response_lines = content.split('\n') response_lines = content.split('\n')
if self.tui_output_callback: if self.tui_output_callback:
for i, line in enumerate(response_lines): for i, line in enumerate(response_lines):
if i == 0: # 为每一行都添加前缀,确保整个AI回复都使用正确的样式
prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] " prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] "
line = prefix + line
if i == len(response_lines) - 1: if i == len(response_lines) - 1:
self._output(f"{line} {user_id_display}") self._output(f"{prefix}{line} {user_id_display}")
else: else:
self._output(line) self._output(f"{prefix}{line}")
self._output("") self._output("")
else: else:
for i, line in enumerate(response_lines): for i, line in enumerate(response_lines):
+49 -48
View File
@@ -1,60 +1,61 @@
import os
import time
import json
from colorama import Fore, Style from colorama import Fore, Style
class HistoryMixin: class HistoryMixin:
"""聊天记录管理相关方法""" """聊天记录管理 — 统一委托给 MemoryManager"""
def save_chat_history(self, name): def save_chat_history(self, name):
"""保存当前聊天记录到文件""" """保存当前聊天记录"""
try: if not hasattr(self, 'memory_manager') or not self.memory_manager:
chat_data = { print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}")
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"conversation": self.shared_conversation_history.copy()
}
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(chat_data, f, ensure_ascii=False, indent=2, default=str)
print(f"{Fore.GREEN}聊天记录已保存为: {name}{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}保存聊天记录失败: {e}{Style.RESET_ALL}")
return False return False
self.memory_manager.auto_save_chat(
self.shared_conversation_history,
label=name
)
print(f"{Fore.GREEN}聊天记录已保存: {name}{Style.RESET_ALL}")
return True
def list_chat_history(self): def list_chat_history(self):
"""列出所有已保存的聊天记录""" """列出所有聊天记录"""
try: if not hasattr(self, 'memory_manager') or not self.memory_manager:
chat_files = [f for f in os.listdir(self.chat_history_dir) if f.endswith('.json')] print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}")
if not chat_files: return
print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}") chats = self.memory_manager.list_chats(limit=30)
return if not chats:
print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}") print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}")
for i, file in enumerate(chat_files, 1): return
file_path = os.path.join(self.chat_history_dir, file) print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}")
mod_time = os.path.getmtime(file_path) for i, c in enumerate(chats, 1):
mod_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mod_time)) print(f"{Fore.GREEN}{i}. {c['name']} ({c['time']}, {c['size_kb']}KB){Style.RESET_ALL}")
chat_name = file[:-5]
print(f"{Fore.GREEN}{i}. {chat_name} (保存时间: {mod_time_str}){Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.RED}列出聊天记录失败: {e}{Style.RESET_ALL}")
def load_chat_history(self, name): def load_chat_history(self, name):
"""加载指定的聊天记录""" """加载聊天记录(支持序号或文件名)"""
try: if not hasattr(self, 'memory_manager') or not self.memory_manager:
file_path = os.path.join(self.chat_history_dir, f"{name}.json") print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}")
if not os.path.exists(file_path):
print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}")
return False
with open(file_path, 'r', encoding='utf-8') as f:
chat_data = json.load(f)
self.shared_conversation_history = chat_data.get("conversation", [])
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}")
if self.shared_conversation_history:
print(f"{Fore.CYAN}加载了 {len(self.shared_conversation_history)} 条对话记录{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
return False return False
# 支持序号加载
if name.isdigit():
chats = self.memory_manager.list_chats(limit=50)
idx = int(name) - 1
if 0 <= idx < len(chats):
name = chats[idx]['name'].replace('.json', '')
else:
print(f"{Fore.RED}序号 {name} 超出范围{Style.RESET_ALL}")
return False
conversation = self.memory_manager.load_chat(name)
if conversation is None:
# 尝试加 .json 后缀
if not name.endswith('.json'):
conversation = self.memory_manager.load_chat(name + '.json')
if conversation is None:
print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}")
return False
self.shared_conversation_history = conversation
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}")
print(f"{Fore.CYAN}加载了 {len(conversation)} 条对话记录{Style.RESET_ALL}")
return True
+7 -2
View File
@@ -105,6 +105,9 @@ class ToolMixin:
result = self.liugin_manager.execute(tool_name, args, **arguments) result = self.liugin_manager.execute(tool_name, args, **arguments)
else: else:
result = self.liugin_manager.execute(tool_name, args) result = self.liugin_manager.execute(tool_name, args)
# 将 ToolResult 对象转换为字典格式,兼容旧接口
if hasattr(result, 'to_dict'):
return result.to_dict()
return result return result
# 回退:直接调用 handler(旧逻辑) # 回退:直接调用 handler(旧逻辑)
@@ -212,8 +215,10 @@ class ToolMixin:
else: else:
tool_args_display = tool_args tool_args_display = tool_args
# TUI 模式下不做延迟和 ESC 检测 # TUI 模式下显示工具执行状态
if not self.tui_output_callback: if self.tui_output_callback:
self._output(f" 正在执行工具: {tool_name} {tool_args_display}")
else:
try: try:
import msvcrt import msvcrt
canceled = False canceled = False
+54
View File
@@ -42,6 +42,60 @@ def set_system_config(key, value):
save_config(config) save_config(config)
# ── 引擎配置档案管理 ──
# 存储结构: config["engine_profiles"][engine_name] = [ {name, base_url, api_key, model}, ... ]
def get_profiles(engine_name):
"""获取指定引擎的所有配置档案"""
config = load_config()
return config.get("engine_profiles", {}).get(engine_name, [])
def save_profile(engine_name, profile):
"""保存/更新一个配置档案(按 name 去重,已存在则覆盖)"""
config = load_config()
if "engine_profiles" not in config:
config["engine_profiles"] = {}
profiles = config["engine_profiles"].setdefault(engine_name, [])
# 按 name 去重
for i, p in enumerate(profiles):
if p.get("name") == profile.get("name"):
profiles[i] = profile
break
else:
profiles.append(profile)
config["engine_profiles"][engine_name] = profiles
save_config(config)
def delete_profile(engine_name, profile_name):
"""删除指定引擎的一个配置档案"""
config = load_config()
profiles = config.get("engine_profiles", {}).get(engine_name, [])
profiles = [p for p in profiles if p.get("name") != profile_name]
if "engine_profiles" not in config:
config["engine_profiles"] = {}
config["engine_profiles"][engine_name] = profiles
save_config(config)
def apply_profile(engine_name, profile):
"""将档案应用到 config.json 的 api.engines 节点(下次启动或重载时生效)"""
config = load_config()
if "api" not in config:
config["api"] = {}
if "engines" not in config["api"]:
config["api"]["engines"] = {}
engine_cfg = config["api"]["engines"].setdefault(engine_name, {})
if "base_url" in profile:
engine_cfg["base_url"] = profile["base_url"]
if "api_key" in profile:
engine_cfg["api_key"] = profile["api_key"]
if "model" in profile:
engine_cfg["model"] = profile["model"]
save_config(config)
# 配置日志 # 配置日志
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
+4
View File
@@ -162,6 +162,10 @@ def parse_fc_response(response_data: dict) -> Optional[List[Dict]]:
except json.JSONDecodeError: except json.JSONDecodeError:
args = {"raw": args_str} args = {"raw": args_str}
# 确保 args 是 dict(LLM 可能返回非对象 JSON)
if not isinstance(args, dict):
args = {"raw": args}
# 统一为小狸内部格式: {"action": "use_tool", "tool": name, "args": "..." } # 统一为小狸内部格式: {"action": "use_tool", "tool": name, "args": "..." }
# 如果 args 里有 args 字段,直接用;否则把整个 args 序列化 # 如果 args 里有 args 字段,直接用;否则把整个 args 序列化
if "args" in args and isinstance(args["args"], str): if "args" in args and isinstance(args["args"], str):
+100
View File
@@ -17,13 +17,71 @@ from typing import List, Dict, Optional, Tuple
class MemoryManager: class MemoryManager:
"""持久化记忆管理器""" """持久化记忆管理器"""
# 记忆模式常量
MODE_COMPANION = "companion" # 陪伴模式(长期记忆)
MODE_WORK = "work" # 工作模式(短期记忆)
def __init__(self, project_dir: str): def __init__(self, project_dir: str):
self.project_dir = project_dir self.project_dir = project_dir
self.memory_dir = os.path.join(project_dir, "memory") self.memory_dir = os.path.join(project_dir, "memory")
self.memory_file = os.path.join(project_dir, "MEMORY.md") self.memory_file = os.path.join(project_dir, "MEMORY.md")
self.chat_dir = os.path.join(project_dir, "chat_history") self.chat_dir = os.path.join(project_dir, "chat_history")
self.config_file = os.path.join(project_dir, "config.json")
self.compress_threshold = 50 # 超过 50 条消息触发压缩 self.compress_threshold = 50 # 超过 50 条消息触发压缩
self._ensure_dirs() self._ensure_dirs()
self._memory_mode = self._load_memory_mode()
def _load_memory_mode(self) -> str:
"""加载记忆模式配置"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get("system", {}).get("memory_mode", "")
except Exception:
pass
return ""
def _save_memory_mode(self, mode: str):
"""保存记忆模式配置"""
try:
config = {}
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
if "system" not in config:
config["system"] = {}
config["system"]["memory_mode"] = mode
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
self._memory_mode = mode
except Exception as e:
print(f"保存记忆模式配置失败: {e}")
def get_memory_mode(self) -> str:
"""获取当前记忆模式"""
return self._memory_mode
def is_mode_selected(self) -> bool:
"""检查是否已选择记忆模式"""
return self._memory_mode in [self.MODE_COMPANION, self.MODE_WORK]
def select_mode(self, mode: str) -> bool:
"""选择记忆模式(只能选择一次)"""
if self.is_mode_selected():
return False # 已经选择过,不能更改
if mode not in [self.MODE_COMPANION, self.MODE_WORK]:
return False # 无效的模式
self._save_memory_mode(mode)
return True
def get_mode_description(self) -> str:
"""获取当前模式的描述"""
if self._memory_mode == self.MODE_COMPANION:
return "陪伴模式(长期记忆):拥有成熟的记忆功能,记住用户的偏好、习惯、历史对话等"
elif self._memory_mode == self.MODE_WORK:
return "工作模式(短期记忆):每次对话是独立的短期记忆,可通过指令手动存储聊天记录"
return "未选择模式"
def _ensure_dirs(self): def _ensure_dirs(self):
"""确保目录存在""" """确保目录存在"""
@@ -522,6 +580,44 @@ class MemoryManager:
# 记忆插件(注册到插件系统) # 记忆插件(注册到插件系统)
# ══════════════════════════════════════════════ # ══════════════════════════════════════════════
def save_conversation_by_mode(self, conversation: list, label: str = ""):
"""根据模式保存对话"""
if self._memory_mode == self.MODE_COMPANION:
# 陪伴模式:自动保存到长期记忆
self.auto_save_chat(conversation, label)
# 提取关键信息保存到MEMORY.md
self._extract_and_save_key_info(conversation)
elif self._memory_mode == self.MODE_WORK:
# 工作模式:自动保存聊天记录
self.auto_save_chat(conversation, label)
def _extract_and_save_key_info(self, conversation: list):
"""从对话中提取关键信息保存到MEMORY.md(陪伴模式专用)"""
# 这里可以添加AI提取关键信息的逻辑
# 暂时简单实现:保存最后一条助手消息
for msg in reversed(conversation):
if msg.get("role") == "assistant":
content = msg.get("content", "")
if content and len(content) > 20:
self.append_memory("对话记录", f"- {content[:200]}")
break
def load_context_by_mode(self, conversation: list) -> list:
"""根据模式加载上下文"""
if self._memory_mode == self.MODE_COMPANION:
# 陪伴模式:加载长期记忆作为上下文
memory_content = self.load_memory()
if memory_content:
context_msg = {"role": "system", "content": f"长期记忆:{memory_content}"}
return [context_msg] + conversation
return conversation
elif self._memory_mode == self.MODE_WORK:
# 工作模式:只返回当前对话
return conversation
return conversation
class Liugin: class Liugin:
"""记忆系统插件 — AI 可读写记忆、搜索历史""" """记忆系统插件 — AI 可读写记忆、搜索历史"""
@@ -596,3 +692,7 @@ JSON格式示例:
"required": ["action"] "required": ["action"]
} }
} }
# ══════════════════════════════════════════════
# 模式特定的方法
# ══════════════════════════════════════════════
+3 -12
View File
@@ -109,17 +109,8 @@ class NotificationManager:
pass pass
def _notify_windows(self, title: str, message: str): def _notify_windows(self, title: str, message: str):
"""Windows 通知 — 优先使用 win10toast,降级到 PowerShell""" """Windows 通知 — 使用 PowerShell(win10toast 在 Python 3.12+ 上不兼容)"""
# 方法1: win10toast (pip install win10toast) # 方法1: PowerShell BurntToast 模块
try:
from win10toast import ToastNotifier
toaster = ToastNotifier()
toaster.show_toast(title, message, duration=3, threaded=True)
return
except ImportError:
pass
# 方法2: PowerShell BurntToast 模块
try: try:
ps_script = f''' ps_script = f'''
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
@@ -138,7 +129,7 @@ class NotificationManager:
except Exception: except Exception:
pass pass
# 方法3: PowerShell 简单弹窗 (最后降级) # 方法2: PowerShell 简单弹窗 (最后降级)
try: try:
escaped_msg = message.replace("'", "''") escaped_msg = message.replace("'", "''")
escaped_title = title.replace("'", "''") escaped_title = title.replace("'", "''")
+13 -28
View File
@@ -107,24 +107,16 @@ class ProcessProtection:
if sys.platform == 'darwin': if sys.platform == 'darwin':
# macOS: 使用 fcntl 文件锁 # macOS: 使用 fcntl 文件锁
import fcntl import fcntl
self._lock_fd = open(self._lock_file_path, 'w') self._lock_fd = open(self._lock_file_path, 'a+')
self._lock_fd.seek(0)
try: try:
fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError): except (IOError, OSError):
# 文件已被锁定 — 已有实例在运行 # flock 失败 — 已有存活实例在运行
# 检查锁文件中的 PID 是否仍在运行 # (flock 是进程级锁,进程退出时自动释放)
try: self._lock_fd.close()
old_pid = self._lock_fd.read().strip() self._lock_fd = None
if old_pid and self._is_process_running(int(old_pid)): return False
self._lock_fd.close()
self._lock_fd = None
return False
else:
# 旧进程已死,清理残留锁
self._lock_fd.seek(0)
self._lock_fd.truncate()
except (ValueError, OSError):
pass
self._lock_fd.seek(0) self._lock_fd.seek(0)
self._lock_fd.truncate() self._lock_fd.truncate()
@@ -135,22 +127,15 @@ class ProcessProtection:
else: else:
# Linux: 使用 fcntl 文件锁 # Linux: 使用 fcntl 文件锁
import fcntl import fcntl
self._lock_fd = open(self._lock_file_path, 'w') self._lock_fd = open(self._lock_file_path, 'a+')
self._lock_fd.seek(0)
try: try:
fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError): except (IOError, OSError):
# 检查旧进程是否仍在运行 # flock 失败 — 已有存活实例在运行
try: self._lock_fd.close()
old_pid = self._lock_fd.read().strip() self._lock_fd = None
if old_pid and self._is_process_running(int(old_pid)): return False
self._lock_fd.close()
self._lock_fd = None
return False
else:
self._lock_fd.seek(0)
self._lock_fd.truncate()
except (ValueError, OSError):
pass
self._lock_fd.seek(0) self._lock_fd.seek(0)
self._lock_fd.truncate() self._lock_fd.truncate()
+141 -91
View File
@@ -1,32 +1,50 @@
""" """
安全层 - 统一的指令安全检查 工作模式 - 统一的安全检查 + 工作流控制
拦截所有工具调用,通过 AI 识别风险并请求用户确认 整合安全模式与计划模式,控制 AI 的执行策略
""" """
import json import json
import threading import threading
from typing import Optional, Tuple from typing import Optional, Tuple
# ── 安全模式 ── # ── 工作模式 ──
MODE_UNRESTRICTED = 0 # 无限制:直接执行 MODE_UNRESTRICTED = 0 # 无限制:直接执行
MODE_NORMAL = 1 # 普通:AI 识别风险 MODE_NORMAL = 1 # 普通:AI 识别风险
MODE_MANUAL = 2 # 人工:所有指令都确认 MODE_MANUAL = 2 # 人工:所有指令确认
MODE_PLAN = 3 # 计划:全自动 + 审查 + 重试 + 引擎切换
MODE_NAMES = { MODE_NAMES = {
MODE_UNRESTRICTED: "无限制", MODE_UNRESTRICTED: "无限制",
MODE_NORMAL: "普通", MODE_NORMAL: "普通",
MODE_MANUAL: "人工确认", MODE_MANUAL: "人工确认",
MODE_PLAN: "计划模式",
} }
class SafetyLayer: MODE_ICONS = {
"""统一安全检查层""" MODE_UNRESTRICTED: "",
MODE_NORMAL: "",
MODE_MANUAL: "",
MODE_PLAN: "",
}
class WorkMode:
"""统一工作模式控制"""
def __init__(self): def __init__(self):
self.mode = MODE_NORMAL self.mode = MODE_NORMAL
self.cli = None self.cli = None
self._analysis_cache = {} # 缓存分析结果 self._analysis_cache = {}
self._lock = threading.Lock() self._lock = threading.Lock()
# ── 计划模式状态 ──
self.plan_engine_errors = 0 # 当前引擎连续错误次数
self.plan_engine_error_limit = 10 # 触发引擎切换的错误阈值
self.plan_retry_count = 0 # 当前重试次数
self.plan_max_retries = 3 # 单步最大重试
self.plan_review_pending = False # 是否有待审查的任务
self.plan_last_task = "" # 最近一次任务描述
def set_cli(self, cli): def set_cli(self, cli):
self.cli = cli self.cli = cli
@@ -36,26 +54,103 @@ class SafetyLayer:
def get_mode_name(self) -> str: def get_mode_name(self) -> str:
return MODE_NAMES.get(self.mode, "未知") return MODE_NAMES.get(self.mode, "未知")
def get_mode_icon(self) -> str:
return MODE_ICONS.get(self.mode, "")
def set_mode(self, mode: int): def set_mode(self, mode: int):
old = self.mode
self.mode = mode self.mode = mode
# 切换模式时重置计划模式状态
if mode != old:
self.plan_engine_errors = 0
self.plan_retry_count = 0
self.plan_review_pending = False
def cycle_mode(self) -> str: def cycle_mode(self) -> str:
"""切换到下一个模式,返回新模式名称""" """循环切换: 普通 → 人工 → 无限制 → 计划 → 普通"""
self.mode = (self.mode + 1) % 3 self.mode = (self.mode + 1) % 4
return self.get_mode_name() return self.get_mode_name()
# ── 核心:拦截检查 ── def is_plan_mode(self) -> bool:
return self.mode == MODE_PLAN
# ── 计划模式:引擎错误管理 ──
def record_engine_error(self) -> bool:
"""
记录一次引擎错误。
返回 True 表示应该切换引擎。
"""
self.plan_engine_errors += 1
return self.plan_engine_errors >= self.plan_engine_error_limit
def reset_engine_errors(self):
"""引擎成功响应后重置错误计数"""
self.plan_engine_errors = 0
def should_switch_engine(self) -> bool:
return self.plan_engine_errors >= self.plan_engine_error_limit
def get_available_engines(self) -> list:
"""获取当前可用的其他引擎列表"""
if not self.cli:
return []
engines = []
for name, engine in getattr(self.cli, 'engines', {}).items():
if engine != self.cli.current_engine:
engines.append((name, engine))
return engines
def try_switch_engine(self) -> Optional[str]:
"""
尝试切换到另一个可用引擎。
返回新引擎名称,或 None 表示无其他引擎。
"""
available = self.get_available_engines()
if not available:
return None
name, engine = available[0]
old_name = getattr(self.cli.current_engine, 'name', '?')
self.cli.current_engine = engine
self.plan_engine_errors = 0
return name
# ── 计划模式:任务审查 ──
def request_review(self, task_description: str):
"""标记需要审查的任务"""
self.plan_review_pending = True
self.plan_last_task = task_description
def get_review_prompt(self, task_result: str) -> str:
"""生成审查提示词"""
return (
f"你刚才完成了以下任务:\n"
f"任务:{self.plan_last_task}\n"
f"结果:{task_result[:500]}\n\n"
f"请审查执行结果:\n"
f"1. 任务是否完整完成?\n"
f"2. 结果是否正确?\n"
f"3. 是否有遗漏或需要修正的地方?\n\n"
f"如果发现问题,请说明并重新执行。如果没问题,请确认完成。"
)
# ── 核心:安全检查 ──
def check(self, tool_name: str, tool_args: str) -> Tuple[bool, str]: def check(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
""" """
检查工具调用是否安全 检查工具调用是否安全。
返回: (允许执行, 消息) 计划模式下自动放行(全自动)。
""" """
# 计划模式:自动放行,不问用户
if self.mode == MODE_PLAN:
return True, ""
# 无限制模式:直接放行 # 无限制模式:直接放行
if self.mode == MODE_UNRESTRICTED: if self.mode == MODE_UNRESTRICTED:
return True, "" return True, ""
# 人工确认模式:所有指令都确认 # 人工确认模式:所有指令确认
if self.mode == MODE_MANUAL: if self.mode == MODE_MANUAL:
return self._ask_user_confirm( return self._ask_user_confirm(
tool_name, tool_args, tool_name, tool_args,
@@ -70,26 +165,20 @@ class SafetyLayer:
# ── AI 风险识别 ── # ── AI 风险识别 ──
def _ai_analyze(self, tool_name: str, tool_args: str) -> Tuple[bool, str]: def _ai_analyze(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
"""使用当前引擎独立分析指令风险(不带历史对话)""" """使用当前引擎独立分析指令风险"""
# 快速预检:明显安全的指令直接放行
if self._is_quick_safe(tool_name, tool_args): if self._is_quick_safe(tool_name, tool_args):
return True, "" return True, ""
# 获取引擎
engine = None engine = None
if self.cli and hasattr(self.cli, 'current_engine'): if self.cli and hasattr(self.cli, 'current_engine'):
engine = self.cli.current_engine engine = self.cli.current_engine
if not engine: if not engine:
# 无引擎时降级为人工确认
return self._ask_user_confirm( return self._ask_user_confirm(
tool_name, tool_args, tool_name, tool_args,
risk_level="未知", risk_level="未知", impact="无法进行 AI 风险分析", reason="无可用引擎"
impact="无法进行 AI 风险分析",
reason="无可用引擎"
) )
# 检查缓存
cache_key = f"{tool_name}:{tool_args[:100]}" cache_key = f"{tool_name}:{tool_args[:100]}"
with self._lock: with self._lock:
if cache_key in self._analysis_cache: if cache_key in self._analysis_cache:
@@ -98,12 +187,9 @@ class SafetyLayer:
return True, "" return True, ""
return self._ask_user_confirm( return self._ask_user_confirm(
tool_name, tool_args, tool_name, tool_args,
risk_level=cached["level"], risk_level=cached["level"], impact=cached["impact"], reason=cached["reason"]
impact=cached["impact"],
reason=cached["reason"]
) )
# 调用 AI 独立分析(不带任何历史对话)
analysis_prompt = f"""你是一个安全分析器。分析以下工具调用是否存在安全风险。 analysis_prompt = f"""你是一个安全分析器。分析以下工具调用是否存在安全风险。
工具: {tool_name} 工具: {tool_name}
@@ -114,85 +200,58 @@ class SafetyLayer:
判断标准: 判断标准:
- safe=true: 读取操作、查看信息、无害查询 - safe=true: 读取操作、查看信息、无害查询
- safe=false: 删除文件、执行系统命令、修改配置、网络请求、git 危险操作 - safe=false: 删除文件、执行系统命令、修改配置、网络请求、git 危险操作"""
- level 低: 只读操作但可能泄露信息
- level 中: 修改操作可撤销
- level 高: 不可逆操作、系统级操作"""
try: try:
response = engine.generate_response(analysis_prompt, system_prompt="你是安全分析器。只返回 JSON,不要其他内容。") response = engine.generate_response(
analysis_prompt,
system_prompt="你是安全分析器。只返回 JSON,不要其他内容。"
)
if not response: if not response:
return self._ask_user_confirm( return self._ask_user_confirm(
tool_name, tool_args, tool_name, tool_args,
risk_level="未知", risk_level="未知", impact="AI 分析无响应", reason="引擎未返回结果"
impact="AI 分析无响应",
reason="引擎未返回结果"
) )
# 解析 JSON
result = self._parse_analysis(response) result = self._parse_analysis(response)
if result and result.get("safe") is True: if result and result.get("safe") is True:
# 缓存安全结果
with self._lock: with self._lock:
self._analysis_cache[cache_key] = result self._analysis_cache[cache_key] = result
return True, "" return True, ""
# 不安全,请求用户确认
level = result.get("level", "中") if result else "未知" level = result.get("level", "中") if result else "未知"
reason = result.get("reason", "AI 识别到潜在风险") if result else "AI 分析失败" reason = result.get("reason", "AI 识别到潜在风险") if result else "AI 分析失败"
impact = result.get("impact", "未知影响") if result else "未知影响" impact = result.get("impact", "未知影响") if result else "未知影响"
return self._ask_user_confirm( return self._ask_user_confirm(
tool_name, tool_args, tool_name, tool_args, risk_level=level, impact=impact, reason=reason
risk_level=level,
impact=impact,
reason=reason
) )
except Exception as e: except Exception as e:
return self._ask_user_confirm( return self._ask_user_confirm(
tool_name, tool_args, tool_name, tool_args,
risk_level="未知", risk_level="未知", impact=f"AI 分析异常: {e}", reason="分析过程出错"
impact=f"AI 分析异常: {e}",
reason="分析过程出错"
) )
def _parse_analysis(self, response: str) -> Optional[dict]: def _parse_analysis(self, response: str) -> Optional[dict]:
"""解析 AI 返回的分析结果"""
import re import re
# 尝试直接解析 JSON
response = response.strip() response = response.strip()
# 去掉 markdown 代码块
if response.startswith("```"): if response.startswith("```"):
response = response.split("\n", 1)[-1] if "\n" in response else response[3:] response = response.split("\n", 1)[-1] if "\n" in response else response[3:]
if response.endswith("```"): if response.endswith("```"):
response = response[:-3] response = response[:-3]
response = response.strip() response = response.strip()
try: try:
return json.loads(response) return json.loads(response)
except json.JSONDecodeError: except json.JSONDecodeError:
pass match = re.search(r'\{[^{}]*"safe"[^{}]*\}', response)
if match:
# 尝试从文本中提取 JSON try:
match = re.search(r'\{[^{}]*"safe"[^{}]*\}', response) return json.loads(match.group())
if match: except json.JSONDecodeError:
try: pass
return json.loads(match.group())
except json.JSONDecodeError:
pass
return None return None
def _is_quick_safe(self, tool_name: str, tool_args: str) -> bool: def _is_quick_safe(self, tool_name: str, tool_args: str) -> bool:
"""快速预检:明显安全的操作直接放行"""
args = tool_args.strip().lower() args = tool_args.strip().lower()
# 纯读取操作
safe_patterns = { safe_patterns = {
"code_editor": ["read_range", "find ", "regex ", "symbols", "imports", "code_editor": ["read_range", "find ", "regex ", "symbols", "imports",
"ast_info", "stats", "structure", "context", "todo", "ast_info", "stats", "structure", "context", "todo",
@@ -200,7 +259,7 @@ class SafetyLayer:
"git_tools": ["status", "log", "diff", "branch", "remote", "show", "git_tools": ["status", "log", "diff", "branch", "remote", "show",
"blame", "contributors", "summary"], "blame", "contributors", "summary"],
"file_manager": ["list", "read", "info", "search"], "file_manager": ["list", "read", "info", "search"],
"cmd_executor": [], # 命令执行不跳过 "cmd_executor": [],
"auto_engineer": ["metrics", "security", "complexity", "duplicates", "auto_engineer": ["metrics", "security", "complexity", "duplicates",
"suggest", "info"], "suggest", "info"],
"task_manager": ["list"], "task_manager": ["list"],
@@ -210,35 +269,23 @@ class SafetyLayer:
"network_tools": ["ping", "status", "headers", "ip"], "network_tools": ["ping", "status", "headers", "ip"],
"tool_search": [], "tool_search": [],
} }
patterns = safe_patterns.get(tool_name, []) patterns = safe_patterns.get(tool_name, [])
for p in patterns: return any(args.startswith(p) for p in patterns)
if args.startswith(p):
return True
return False
# ── 用户交互 ── # ── 用户交互 ──
def _ask_user_confirm(self, tool_name: str, tool_args: str, def _ask_user_confirm(self, tool_name: str, tool_args: str,
risk_level: str = "中", impact: str = "", risk_level: str = "中", impact: str = "",
reason: str = "") -> Tuple[bool, str]: reason: str = "") -> Tuple[bool, str]:
"""向用户展示风险信息并请求确认"""
from colorama import Fore, Style from colorama import Fore, Style
# 风险等级颜色
level_colors = { level_colors = {
"低": Fore.YELLOW, "低": Fore.YELLOW, "中": Fore.LIGHTYELLOW_EX,
"中": Fore.LIGHTYELLOW_EX, "高": Fore.RED, "未知": Fore.LIGHTBLACK_EX,
"高": Fore.RED,
"未知": Fore.LIGHTBLACK_EX,
} }
level_color = level_colors.get(risk_level, Fore.YELLOW) level_color = level_colors.get(risk_level, Fore.YELLOW)
# 截断过长的参数 display_args = tool_args[:200] + "..." if len(tool_args) > 200 else tool_args
display_args = tool_args
if len(display_args) > 200:
display_args = display_args[:200] + "..."
print() print()
print(f" {Fore.RED}{'═' * 50}{Style.RESET_ALL}") print(f" {Fore.RED}{'═' * 50}{Style.RESET_ALL}")
@@ -255,17 +302,20 @@ class SafetyLayer:
try: try:
choice = input(f" {Fore.WHITE}是否执行? (y/n): {Style.RESET_ALL}").strip().lower() choice = input(f" {Fore.WHITE}是否执行? (y/n): {Style.RESET_ALL}").strip().lower()
if choice in ('y', 'yes', '是'): return (choice in ('y', 'yes', '是'), "用户确认执行" if choice in ('y', 'yes', '是') else "用户取消执行")
return True, "用户确认执行"
else:
return False, "用户取消执行"
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
print() print()
return False, "用户中断" return False, "用户中断"
# ── 全局单例 ── # ── 兼容旧接口 ──
_safety = SafetyLayer() MODE_UNRESTRICTED = MODE_UNRESTRICTED
MODE_NORMAL = MODE_NORMAL
MODE_MANUAL = MODE_MANUAL
def get_safety() -> SafetyLayer: # ── 全局单例 ──
return _safety _work_mode = WorkMode()
def get_safety() -> WorkMode:
"""兼容旧接口:返回 WorkMode 实例"""
return _work_mode
+2085 -474
View File
File diff suppressed because it is too large. Load diff