55 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
shuiyu1123 b8faaae0c6 release: v5.4.1 2026-06-19 00:30:15 +08:00
shuiyu1123 bac5ea01a9 feat: 统一错误处理 - ToolResult 类
- 新增 xcli_core/tool_result.py: ToolResult + ErrorCode
- unified_tool_manager: execute/mcp_call 返回 ToolResult
- cmd_executor: 所有返回改为 ToolResult
- file_manager: 所有返回改为 ToolResult
- 兼容旧接口: execute_dict() 返回 dict, handler 可返回 str 或 ToolResult
2026-06-19 00:26:49 +08:00
shuiyu1123 161ab9d3a8 feat: MCP stdio server + TUI回车发送 + 子Agent无限制 + 安全引擎优化 + diff弹窗绿红背景
- 新增 mcp_server.py: 标准 MCP stdio 传输层入口
- TUI: 回车发送消息,Shift+回车换行 (SendTextArea子类)
- 子Agent: 移除 max_steps=20 限制
- 安全引擎: 移除 ALWAYS_CONFIRM 关键词强制拦截
- code_editor: diff弹窗绿底(+)/红底(-)背景色
2026-06-19 00:10:27 +08:00
xiaoli-cli b0d9a24003 release: v5.4.0 - 跨平台进程保护机制 2026-05-09 20:33:14 +08:00
xiaoli-cli 97cc2bb0f7 feat: 跨平台进程保护机制 v5.3.0
- 新增 process_protection.py 模块
  - 单实例保护: Windows命名Mutex / macOS+Linux fcntl文件锁
  - 进程优先级提升: Windows SetPriorityClass / POSIX os.nice()
  - Windows PPL风格保护: SetErrorMode抑制错误弹窗
  - POSIX信号保护: 忽略SIGHUP/SIGPIPE, 注册SIGTERM优雅关闭
  - 看门狗监控: 后台线程监控父进程状态
  - 关闭钩子系统: 进程退出时自动执行清理
  - /protect 命令查看保护状态
- 更新 platform_utils.py 新增进程工具函数
- 集成到 cli_base.py 启动时自动初始化
- 更新 CHANGELOG.md / README.md 至 v5.3.0
2026-05-09 20:28:17 +08:00
xiaoli-cli bot a7f64cd797 refactor: 移除插件内存管理系统 + 新增工作流引擎
移除内容:
- 删除 plugin_memory_manager.py (TTL淘汰 + 懒加载系统)
- unified_tool_manager.py 精简为直接加载,去掉内存管理器集成
- tui.py 移除内存管理状态显示

新增内容:
- plugins/workflow.py: YAML 工作流引擎
  - 顺序执行多步骤 (tool/shell/echo/wait/set/if)
  - 步骤间变量传递 ({{steps.name.output}})
  - 条件分支 (if/then/else)
  - 错误处理 (continue_on_error / on_fail)
  - 断点续跑 (状态持久化)
  - 调用其他插件作为步骤
- workflows/example.yaml: 示例工作流

修复:
- unified_tool_manager.shutdown() 增加 graceful cleanup
- unregister_tool() 增加实例清理
2026-05-08 18:07:55 +08:00
OpenClaw 115ce38370 docs: CHANGELOG 补充插件格式统一记录 2026-05-08 00:54:02 +08:00
OpenClaw 357cd0958f refactor: 统一自研插件格式为 Liugin
18个插件: class Plugin -> class Liugin
10个插件: 测试块 Plugin() -> Liugin()
移除 ai_image.py 中多余的 Liugin = Plugin 别名

所有自研插件现统一使用 class Liugin 作为入口类名,
加载器保留 Plugin 兼容以支持第三方插件。
2026-05-08 00:52:25 +08:00
OpenClaw bfefbef475 release: v5.2.0 - 插件内存管理 + TUI 修复 2026-05-08 00:47:38 +08:00
OpenClaw fb41c79b14 feat: 插件内存管理 - TTL自动淘汰 + 懒加载
新增 plugin_memory_manager.py:
- 后台线程定期扫描空闲插件,超过TTL自动从内存卸载
- AI调用已卸载插件时自动重新加载(懒加载)
- 核心插件(tool_search)可钉住永不淘汰
- 可配置: TTL(300s), 最大空闲数(10), 扫描间隔(60s), 钉住列表

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

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

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

文件:
- xcli_core/plugin_market.py - PluginMarketMixin 核心逻辑
- plugin_registry.json - 本地插件码注册表
- xcli_core/cli_core.py - 集成 PluginMarketMixin
- HELP.md - 添加插件市场文档
2026-04-30 02:37:05 +08:00
xiaoli-cli-bot 0d27f2b774 feat: add /manual command + fix cross-engine command routing
- /manual → switch to manual engine (CLI + TUI)
- /manual status → run manual engine sub-commands
- /manual restart → restart manual terminal
- /engine.manual status → now works from any engine (not just manual)
- Fixed handle_engine_command to route to correct engine by name
2026-04-29 20:04:40 +08:00
xiaoli-cli-bot 03685ab5e7 fix: WebUI engine switch now updates UI and shows errors 2026-04-29 19:57:17 +08:00
xiaoli-cli-bot 7a72a5c69e fix: Ollama model validation, TUI CSS, WebUI overhaul
1. Ollama: validate model is configured before API call, show helpful error
2. TUI: remove unsupported scroll-behavior CSS property
3. WebUI server: add full tool calling loop (FC + JSON action)
4. WebUI HTML: complete redesign
   - Modern dark theme with better typography
   - Markdown rendering via marked.js
   - Tool call/result visualization with colored cards
   - Dynamic sidebar (engines + tools from server)
   - Better input area with gradient send button
   - Responsive layout
2026-04-29 19:51:42 +08:00
xiaoli-cli-bot baa2cbc49a feat: diff display mode - popup or inline (SSH compatible)
- Startup prompt asks user to choose popup or inline diff mode
- Added inline diff display in main terminal with colored output
- Green for added lines, red for deleted lines (both modes)
- TUI mode uses Rich markup, CLI mode uses ANSI colors
- New commands: diff_popup popup / diff_popup inline
- Default: inline mode (SSH compatible)
2026-04-29 19:40:07 +08:00
xiaoli-cli-bot f41d5d5a7c fix: remove unsupported @keyframes from Textual CSS
Textual CSS parser does not support @keyframes syntax (it's a Web CSS feature).
These keyframes were unused - all animations are implemented via Python's
set_interval() and animate() methods. Removing them fixes the stylesheet
parse error that prevented TUI mode from starting.
2026-04-29 19:28:53 +08:00
shuiyu1123 8865084905 !4 chore: 清理仓库,删除不需要的文件
Merge pull request !4 from shuiyu1123/cleanup-v5.1.4
2026-04-29 10:36:31 +00:00
shuiyu1123 da9cfb6c9d chore: 清理仓库,删除不需要的文件
删除内容:
- xiaoli-cli-v5.1.2.zip(旧版本构建产物)
- compare.html / webui-analysis.html(AI编程助手横评页面)
- scheduler_tasks.json(空运行时数据)
- skills/file-manager/scripts/test.txt / test_multi.txt(测试数据)
- .gitee/pipeline.yml / .github/workflows/ci.yml(CI配置)
2026-04-29 18:34:20 +08:00
shuiyu1123 f142e3bae8 删除文件 scheduler_tasks.json 2026-04-29 10:26:01 +00:00
shuiyu1123 e3b2d56686 删除文件 webui-analysis.html 2026-04-29 10:25:47 +00:00
shuiyu1123 aa37ec0e44 删除文件 compare.html 2026-04-29 10:25:33 +00:00
openclaw-bot 2bd8ca0fba release: v5.1.4 — 桌面GUI自动化 + AI生图 + Web UI后端
- 新增 gui_auto 插件 (20 操作): Windows UIA 桌面元素识别与操控
- 新增 ai_image 插件: 基于 ai.2x.nz 自然语言生图
- 新增 webui_server.py: WebSocket 实时通信后端
- 重写 webui.html: 大厂设计感 WebSocket 交互界面
- 插件数: 17 → 19, 操作数: 141+ → 161+
- 测试数: 113 → 150+
- 更新 README/CHANGELOG/HELP
2026-04-29 15:50:40 +08:00
shuiyu1123 530a255091 !3 feat: 新增桌面GUI自动化插件 (gui_auto) - Windows UIA 元素识别与操控
Merge pull request !3 from shuiyu1123/feature/gui-auto-plugin
2026-04-29 07:42:34 +00:00
openclaw-bot e6fddf14f8 feat: 新增桌面GUI自动化插件 (gui_auto) - Windows UIA 元素识别与操控
- 新增 gui_auto 插件 (20 操作): 元素树获取、点击/输入/按键、窗口管理、元素查询、高亮闪烁
- 基于 Windows UI Automation API,支持模糊匹配、中文输入、ValuePattern 自动识别
- 完整 MCP/FC 定义,AI 可通过 Function Calling 直接调用
- 非 Windows 环境优雅降级
- 新增 30 个单元测试
- 更新 README/CHANGELOG/HELP
- 插件数: 17 → 18, 操作数: 141+ → 160+
2026-04-29 15:37:10 +08:00
shuiyu1123 a00396b4fd !2 feat(webui): 重写 Web UI — WebSocket 后端 + 大厂设计感
Merge pull request !2 from shuiyu1123/feat/webui-v2
2026-04-29 07:16:16 +00:00
shuiyu1123 c2dfa284d1 !1 feat: 添加 AI 生图插件 (ai_image)
Merge pull request !1 from shuiyu1123/feature/ai-image-plugin
2026-04-29 07:15:47 +00:00
XiaoLi Contributor 614b081364 feat(webui): 重写 Web UI — WebSocket 后端 + 大厂设计感
- 新增 webui_server.py: WebSocket 后端,桥接小狸 AI 引擎
  - WebSocket 实时通信(对话/工具调用/状态同步)
  - HTTP 静态文件服务
  - 引擎热切换、安全模式切换
  - 延迟初始化,不阻塞启动

- 重写 webui.html: 纯静态前端,连接 WebSocket 后端
  - 设计风格: Linear/Vercel 级别的暗色主题
  - 真实 WebSocket 通信(非前端模拟)
  - 对话/工具双视图
  - 实时状态栏(引擎/安全模式)
  - 代码高亮、打字动画、快速操作
  - 响应式布局

用法:
  python webui_server.py              # 启动 (HTTP:8080, WS:8079)
  python webui_server.py --port 3000  # 自定义端口
  python webui_server.py --no-ai      # 纯 UI 模式
2026-04-28 19:06:12 +08:00
openclaw-bot 68d085df28 fix: 自动选择默认工作流,无 Lora 优先 2026-04-28 00:52:09 +08:00
openclaw-bot 62c761b7d8 feat: 添加 AI 生图插件 (ai_image)
基于 ai.2x.nz 接口的自然语言生图插件,支持:
- 自然语言描述生图 (generate)
- Danbooru Tag 生图 (tag)
- Tag + 自然语言混合模式 (both)
- 工作流列表与切换 (workflows/switch)
- 图片尺寸设置 (size/width/height)
- 改写模式与覆写模式开关 (rewrite/override)
- 生成状态查询 (status)
- 自动下载生成图片到本地

依赖: requests, websocket-client
2026-04-28 00:36:36 +08:00
80 changed files with 14485 additions and 4497 deletions

No files matched your search

-117
View File
@@ -1,117 +0,0 @@
# Gitee Go CI 流水线
triggers:
push:
branches:
- master
- main
pull_request:
branches:
- master
- main
stages:
- lint
- test
- structure
jobs:
py-compile:
name: 语法检查
stage: lint
image: python:3.12
script:
- python -c "
import py_compile, os, sys;
errors = 0;
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in {'.git', '__pycache__', 'node_modules', 'embedded_python'}];
for f in files:
if f.endswith('.py'):
path = os.path.join(root, f);
try:
py_compile.compile(path, doraise=True);
except py_compile.PyCompileError as e:
print(f'ERROR: {e}');
errors += 1;
if errors:
print(f'{errors} 个文件有语法错误');
sys.exit(1);
print('所有文件语法正确')
"
ruff-lint:
name: Ruff lint
stage: lint
image: python:3.12
script:
- pip install ruff -q
- ruff check . --select E,F,W --ignore E501,E402 --exclude .git,__pycache__,embedded_python
pytest-310:
name: 插件测试 (Python 3.10)
stage: test
image: python:3.10
script:
- pip install pytest colorama -q
- pytest tests/ -v --tb=short
pytest-311:
name: 插件测试 (Python 3.11)
stage: test
image: python:3.11
script:
- pip install pytest colorama -q
- pytest tests/ -v --tb=short
pytest-312:
name: 插件测试 (Python 3.12)
stage: test
image: python:3.12
script:
- pip install pytest colorama -q
- pytest tests/ -v --tb=short
structure:
name: 项目结构检查
stage: structure
image: python:3.12
script:
- echo "检查项目结构..."
- |
files=(
"ai_cli.py"
"launcher.py"
"xcli_core/cli_core.py"
"xcli_core/cli_base.py"
"xcli_core/config.py"
"xcli_core/constants.py"
"ai_engines/ollama_engine.py"
"ai_engines/openai_engine.py"
"plugins/code_editor.py"
"plugins/cmd_executor.py"
"plugins/file_manager.py"
"plugins/auto_engineer.py"
)
missing=0
for f in "${files[@]}"; do
if [ -f "$f" ]; then
echo " ✓ $f"
else
echo " ✗ $f (缺失!)"
missing=$((missing + 1))
fi
done
if [ $missing -gt 0 ]; then
echo "${missing} 个文件缺失"
exit 1
fi
echo "项目结构完整"
- |
engines=$(ls ai_engines/*_engine.py 2>/dev/null | wc -l)
echo "AI 引擎数量: $engines"
if [ "$engines" -gt 2 ]; then
echo "警告: 引擎数量超过 2 个"
exit 1
fi
echo "引擎数量正常"
-148
View File
@@ -1,148 +0,0 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
lint:
name: 代码检查
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: 安装依赖
run: pip install ruff
- name: 语法检查 (py_compile)
run: |
python -c "
import py_compile, os, sys
errors = 0
for root, dirs, files in os.walk('.'):
dirs[:] = [d for d in dirs if d not in {'.git', '__pycache__', 'node_modules', 'embedded_python'}]
for f in files:
if f.endswith('.py'):
path = os.path.join(root, f)
try:
py_compile.compile(path, doraise=True)
except py_compile.PyCompileError as e:
print(f'ERROR: {e}')
errors += 1
if errors:
print(f'\n{errors} 个文件有语法错误')
sys.exit(1)
print('所有文件语法正确')
"
- name: Ruff lint
run: ruff check . --select E,F,W --ignore E501,E402,W503 --exclude .git,__pycache__,embedded_python
test:
name: 插件测试
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: 安装 Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: 安装测试依赖
run: pip install pytest colorama
- name: 运行测试
run: pytest tests/ -v --tb=short
structure:
name: 项目结构检查
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: 安装 Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: 检查必要文件
run: |
echo "检查项目结构..."
files=(
"ai_cli.py"
"launcher.py"
"xcli_core/cli_core.py"
"xcli_core/cli_base.py"
"xcli_core/config.py"
"xcli_core/constants.py"
"ai_engines/ollama_engine.py"
"ai_engines/openai_engine.py"
"plugins/code_editor.py"
"plugins/git_tools.py"
"plugins/cmd_executor.py"
"plugins/file_manager.py"
"plugins/auto_engineer.py"
)
missing=0
for f in "${files[@]}"; do
if [ -f "$f" ]; then
echo " $f"
else
echo " $f (缺失!)"
missing=$((missing + 1))
fi
done
if [ $missing -gt 0 ]; then
echo "\n${missing} 个文件缺失"
exit 1
fi
echo "\n项目结构完整"
- name: 检查旧文件已清理
run: |
old_files=(
"ai_engines/mimo_engine.py"
"ai_engines/GLM_http_engine.py"
"ai_engines/qwq_engine.py"
"ai_engines/xfyun_spark_engine.py"
"ai_engines/iflow_api_engine.py"
"ai_engines/maas_http_engine.py"
"ai_engines/SDK_openAI.py"
"plugins/git_workflow.py"
"plugins/code_search.py"
"plugins/project_analyzer.py"
"plugins/scheduler.py"
)
found=0
for f in "${old_files[@]}"; do
if [ -f "$f" ]; then
echo " 旧文件未清理: $f"
found=$((found + 1))
fi
done
if [ $found -gt 0 ]; then
echo "\n${found} 个旧文件未清理"
exit 1
fi
echo "旧文件已全部清理"
- name: 检查无重复引擎
run: |
engines=$(ls ai_engines/*_engine.py 2>/dev/null | wc -l)
echo "AI 引擎数量: $engines"
if [ "$engines" -gt 2 ]; then
echo "警告: 引擎数量超过 2 个 (ollama + openai)"
exit 1
fi
echo "引擎数量正常"
+192
View File
@@ -1,9 +1,201 @@
# 更新日志
## 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/)。
---
## v5.4.0 (2026-05-09) — Release
### ✨ 新增功能
- **跨平台进程保护模块** (`xcli_core/process_protection.py`)
- **单实例保护** — 防止重复启动,Windows 使用命名 Mutex,macOS/Linux 使用 fcntl 文件锁(自动清理残留锁文件)
- **进程优先级提升** — Windows: `SetPriorityClass` (ABOVE_NORMAL/HIGH/REALTIME),macOS/Linux: `os.nice()` + `renice`
- **Windows PPL 风格保护** — `SetErrorMode` 抑制系统错误弹窗 (SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX)、`faulthandler` 崩溃堆栈捕获
- **POSIX 信号保护** — macOS/Linux 忽略 SIGHUP(终端关闭不退出)、注册 SIGTERM 优雅关闭、macOS 忽略 SIGPIPE
- **看门狗监控** — 后台线程监控父进程状态,父进程退出时自动清理并退出
- **关闭钩子系统** — 注册自定义清理函数,进程退出时自动执行
- **一键启用** — `enable_process_protection()` 单函数调用启用全部保护
- **`/protect` 命令** — CLI 中查看保护状态,`/protect test` 重新测试所有保护
### 改进
- `platform_utils.py` 新增 `is_process_running()`、`get_process_priority()`、`set_process_priority()` 跨平台工具函数
- `cli_base.py` 启动时自动初始化进程保护,退出时自动清理
- `cli_core.py` 启动 banner 显示保护状态图标(单实例/优先级/PPL/看门狗)
- 全版本号统一更新至 v5.4.0
---
## v5.2.0 (2026-05-08)
### ✨ 新增功能
- **插件内存管理器** (`plugin_memory_manager.py`) — TTL 自动淘汰 + 懒加载机制
- 后台线程定期扫描空闲插件,超过 TTL(默认 5 分钟)自动从内存卸载,释放资源
- AI 调用已卸载的插件时自动重新加载(懒加载),对用户完全透明
- 核心插件(`tool_search`)可钉住永不淘汰,保障基础功能可用
- 可配置参数:TTL 时间、最大空闲插件数、扫描间隔、钉住列表
- `/status` 命令和 TUI 侧栏实时显示内存管理状态(已加载/已卸载/统计)
- `search_tools` 支持搜索已卸载插件(使用缓存的元数据,无需加载实例)
### 统一插件格式
- **统一自研插件入口类名为 `Liugin`** — 18 个插件从 `class Plugin` 统一为 `class Liugin`
- 加载器保留 `Plugin` 回退兼容,第三方插件仍可用旧格式
- 测试块中 `Plugin()` 调用同步更新为 `Liugin()`
### TUI 修复
- **修复消息分类失效** — `_write_raw` 中空字符串 `'' in msg` 永远为 True,导致所有消息显示为工具成功样式;替换为正确的 emoji 标记(✅/❌/🤖)
- **修复侧栏切换失效** — Textual 无 `.visible` 布尔属性,改用 `has_class("hidden")` + CSS 类控制显隐
- **修复侧栏隐藏动画** — CSS `display: none` 阻止过渡动画,改用 `width: 0` + `opacity: 0` 方案
- **修复 TextArea 导入缺失** — `constants.py` 可用性检查未导入 `TextArea`、`Rule`
- **修复 `set_timer(0)` 崩溃** — 欢迎动画首行延迟为 0.0 导致 Textual 8.x 除零错误(`ZeroDivisionError`),使用 `max(0.01, delay)` 修复
### 改进
- `UnifiedToolManager` 重构:集成 `PluginMemoryManager`,所有工具访问统一走 `_ensure_loaded` 路径
- 新增 `get_all_tools_info()` 方法,系统提示词可列出所有注册工具(含已卸载)
- 新增 `get_tool_by_name()` / `memory_manager` 属性,兼容旧 `LiuginManager` 接口
---
## v5.1.4 (2026-04-29)
### 新增功能
- **桌面GUI自动化插件** (`gui_auto`, 20 操作) — 基于 Windows UI Automation (UIA) API,AI 可识别和操控桌面应用
- 元素树获取 (`snapshot`) — 获取整个屏幕或指定窗口的 UI 元素树,支持自定义遍历深度
- 元素交互 — 点击/右键/双击(名称模糊匹配 + 坐标点击),文本输入(中文 Unicode)、清空、发送按键
- 窗口管理 — 列出所有窗口 (`windows`)、聚焦指定窗口 (`focus`)
- 元素查询 — 查找/查找所有/详情/值/子树/存在检查/状态,共 8 个查询操作
- 高级功能 — 等待元素出现(带超时)、高亮闪烁元素(GDI 红色边框)、滚动
- MCP/FC 完整定义,AI 通过 Function Calling 直接调用,非 Windows 环境优雅降级
- **AI 生图插件** (`ai_image`) — 基于 ai.2x.nz 自然语言生图接口
- 自然语言描述 + 直接 Tag 双模式
- 工作流切换、画风选择、分辨率调整
- 异步生成 + WebSocket 进度推送
- 生成历史记录
- **Web UI 后端** (`webui_server.py`) — WebSocket 实时通信 + 静态文件服务
- 纯 Python 实现,基于 `websockets` 库
- 支持命令行参数 (`--port` / `--host`)
- 与前端 `webui.html` 配合,提供完整的浏览器端 AI 交互体验
### 改进
- **Web UI 前端重写** (`webui.html`) — 从纯静态页面升级为 WebSocket 实时交互
- 大厂设计感 UI,响应式布局
- 实时消息流、打字机效果
- 支持工具调用结果展示
### 文档
- 更新 README.md: 插件列表、项目结构、badge 版本号
- 更新 CHANGELOG.md: v5.1.4 完整变更记录
- 更新 HELP.md: 新增 GUI 自动化使用说明
### 测试
- 新增 gui_auto 插件 30 个单元测试(工具信息、MCP 定义、参数转换、错误处理、操作路由)
---
## v5.1.3 (2026-04-28)
### 新增功能
+68
View File
@@ -154,6 +154,26 @@ run make build --timeout 60 # 60秒超时
| `pdf page.pdf` | 保存 PDF |
| `stop` | 关闭浏览器 |
### 桌面GUI自动化 (`gui_auto`) ⭐ NEW
> 仅支持 Windows,基于 UI Automation API
| 操作 | 说明 |
|------|------|
| `snapshot [深度]` | 获取屏幕 UI 元素树 |
| `snapshot --window 记事本` | 获取指定窗口元素树 |
| `screenshot [路径]` | 全屏截图 |
| `windows` | 列出所有窗口 |
| `focus 记事本` | 聚焦窗口 |
| `click 确定` | 点击元素(模糊匹配) |
| `click --xy 500 300` | 点击坐标 |
| `type 搜索框 Hello` | 在元素中输入文本 |
| `keys ctrl+s` | 发送按键 |
| `find 保存` | 查找元素 |
| `exists 确定` | 检查元素是否存在 |
| `wait 加载完成 10` | 等待元素出现 |
| `highlight 确定` | 高亮闪烁元素 |
### 子 Agent (`sub_agent`)
| 操作 | 说明 |
@@ -202,6 +222,54 @@ tool_search 浏览器 # 搜索浏览器相关工具
---
## 插件市场
通过 `/plugin` 命令管理插件,支持从远程仓库一键安装。
### 查看可用插件
```
/plugin list # 列出所有可用插件(自动同步远程源)
/plugin search 郊狼 # 搜索关键词
/plugin info dglab # 查看插件详情
```
### 安装插件
```
/plugin install dglab # 通过插件码安装
/plugin install https://example.com/my_plugin.py # 从 URL 安装
```
安装后自动:
- 下载插件文件到 `plugins/` 目录
- 在 `plugins_config.py` 中启用
- 尝试动态加载(无需重启)
### 卸载插件
```
/plugin remove dglab_ws # 卸载指定插件
```
### 更新插件
```
/plugin update dglab # 重新下载更新
```
### 添加插件源
```
/plugin add-source https://example.com/registry.json # 同步远程插件源
```
### 插件码注册表
插件码是插件的唯一标识,映射到下载地址。注册表文件:`plugin_registry.json`
---
## 聊天记录
```
+1 -1
View File
@@ -5,7 +5,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-5.1.2-blue" alt="version">
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version">
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
+121 -107
View File
@@ -1,39 +1,46 @@
# 小狸 Pro-CLI
<p align="center">
<img src="logo.png" width="120" alt="xiaoli-cli logo">
</p>
<p align="center">
<strong>智能编程助手 — 代码编辑 · Git 集成 · 浏览器自动化 · 多 Agent 协作 · 图像生成 · 记忆系统</strong>
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-5.1.3-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/license-MIT-orange" alt="license">
<img src="https://img.shields.io/badge/tests-113%20passed-brightgreen" alt="tests">
<img src="https://img.shields.io/badge/plugins-17-blueviolet" alt="plugins">
<img src="https://img.shields.io/badge/MCP-supported-brightgreen" alt="mcp">
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
<img src="https://img.shields.io/badge/plugins-24-blueviolet" alt="plugins">
</p>
---
## 特性
## 简介
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索、**自动语法检查**
- **Git 版本控制** — 完整 Git 操作 + 工作流自动化(smart-commit、changelog)
- **浏览器自动化** — Playwright 驱动,导航/交互/截图/JS 执行/PDF
- **子 Agent 系统** — 多 Agent 并行协作,独立任务分配与结果回收
- **统一安全层** — AI 风险识别 + 三模式切换(普通/人工/无限制)
- **图像生成** — 基于 ComfyUI 在线服务,自然语言/Tag 双模式,支持中文描述
- **记忆系统** — 长期记忆 + 每日日记 + AI 写日记 + 全局搜索 + 自动保存对话
- **Clawli 远程模式** — PC-手机 WebSocket 双向通信,支持内网穿透代理
- **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI
- **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux)
- **MCP 协议** — 17 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
- **17 个插件 141+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
- **三模式界面** — TUI 图形化 + 传统命令行 + **Web UI 浏览器界面**
- **113 个自动化测试** — pytest 覆盖全部插件和核心模块
小狸 Pro-CLI 是一款基于 Python 的智能编程助手,集成了代码编辑、版本控制、浏览器自动化、图像生成、记忆系统等功能,支持多种 AI 引擎,帮助开发者提升工作效率。
## 快速开始
## 特性
- **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等)
- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索、自动语法检查
- **Git 版本控制** — 完整 Git 操作 + 工作流自动化(smart-commit、changelog)
- **浏览器自动化** — Playwright 驱动,导航、交互、截图、JS 执行、PDF 导出
- **子 Agent 系统** — 多 Agent 并行协作,独立任务分配与结果回收
- **图像生成** — 基于 ComfyUI 在线服务,自然语言/Tag 双模式,支持中文描述
- **记忆系统** — 长期记忆 + 每日日记 + AI 写日记 + 全局搜索 + 自动保存对话
- **远程通信** — PC 与手机 WebSocket 双向通信
- **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI
- **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux)
- **MCP 协议** — 18 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
- **进程保护** — 跨平台单实例保护 + 进程优先级提升 + Windows PPL 风格保护 + 看门狗监控 + 优雅关闭
- **18 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
- **三模式界面** — TUI 图形化 + 传统命令行 + Web UI 浏览器界面
- **150+ 个自动化测试** — pytest 覆盖全部插件和核心模块
## 快速开始
### 安装
@@ -52,11 +59,11 @@ python ai_cli.py
# 指定引擎
python ai_cli.py --engine openai
# Windows 一键启动(无需 Python 环境)
# Windows 一键启动
launcher.exe
```
## AI 引擎
## AI 引擎
| 引擎 | 类型 | 说明 |
|------|------|------|
@@ -87,15 +94,17 @@ launcher.exe
}
```
## 插件列表 (17 个)
## 插件列表 (23 个)
> 更多社区插件请访问 [xiaoli-cli-plugins](https://gitee.com/shuiyu1123/xiaoli-cli-plugins),使用 `/plugin install <插件码>` 一键安装。
### 核心生产力
| 插件 | 操作数 | 说明 |
|------|--------|------|
| `code_editor` | 24 | 代码编辑+搜索。精准替换、批量编辑、find/regex/symbols/imports/callers/todo/structure + 自动语法检查 + diff 弹窗 |
| `code_editor` | 24 | 代码编辑+搜索。精准替换、批量编辑、find/regex/symbols/imports/callers/todo/structure + 自动语法检查 |
| `git_tools` | 17 | Git 版本控制。status/diff/log/commit/branch + smart-commit/changelog/contributors/stale |
| `cmd_executor` | 1 | Shell 命令执行。跨平台,超时控制,危险命令拦截 |
| `cmd_executor` | 1 | Shell 命令执行,跨平台,超时控制 |
| `file_manager` | 10 | 文件系统。list/read/write/copy/move/delete/search/info/mkdir/append |
| `auto_engineer` | 12 | 工程化。lint/format/test/build/deps + 复杂度/安全扫描/质量指标/项目初始化 |
| `task_manager` | 7 | 任务管理。add/list/complete/delete/clear,JSON 持久化 |
@@ -105,24 +114,29 @@ launcher.exe
| 插件 | 操作数 | 说明 |
|------|--------|------|
| `sub_agent` | 8 | 子 Agent 系统。spawn/list/status/result/send/kill,多 Agent 并行协作 |
| `browser_auto` | 28 | 浏览器自动化。导航/点击/填写/截图/JS执行/PDF/cookies(基于 Playwright) |
| `image_generator` | 7 | **图像生成**。基于 ComfyUI 在线服务,自然语言/Tag 双模式,28 个角色工作流 |
| `memory_plugin` | 11 | **记忆系统**。AI 写日记/搜索记忆/管理聊天记录/长期记忆 MEMORY.md |
| `scheduler` | 4 | **定时任务**。支持相对时间/绝对时间/重复任务,到期自动激活 AI |
| `tool_search` | — | 工具搜索。按关键词发现可用插件(供 AI 按需调用) |
| `terminal` | 8 | 终端模拟器。AI 的虚拟键盘和屏幕,打字/按键/看屏幕,支持 Python REPL |
### 辅助工具
| 插件 | 说明 |
|------|------|
| `browser_auto` | 浏览器自动化。导航/点击/填写/截图/JS执行(基于 Playwright) |
| `gui_auto` | 桌面 GUI 自动化。Windows UIA 元素识别/点击/输入(仅 Windows) |
| `memory_plugin` | 记忆系统。AI 写日记/搜索记忆/管理聊天记录 |
| `scheduler` | 定时任务。支持相对/绝对/重复任务,到期自动激活 AI |
| `tool_search` | 工具搜索。按关键词发现可用插件 |
| `workflow` | 工作流引擎。YAML 多步骤自动化,变量传递,条件分支 |
| `network_tools` | 网络工具。ping/get/status/headers/ip |
| `ai_search` | AI 搜索。jina.ai 智能搜索 + 网页内容提取 |
| `frontend_tester` | 前端测试。HTML/CSS/JS 语法检查、响应式验证 |
| `speech_recognition` | 语音识别。录音 + 语音转文字(Whisper) |
| `audio_player` | 音频播放 |
| `send_image` | Clawli 模式下发图片到手机 |
| `json_toolkit` | JSON 工具箱。格式化/压缩/校验/路径查询/差异比较 |
| `regex_tester` | 正则表达式测试器。匹配/提取/替换/解释 |
| `snippet_manager` | 代码片段管理器。保存/搜索/复用常用代码 |
| `markdown_tool` | Markdown 工具。TOC/统计/表格生成/待办提取 |
| `id_generator` | ID 生成器。UUID/NanoID/短ID/雪花ID/ULID |
## 记忆系统
## 记忆系统
内置持久化记忆系统,AI 可以记住之前的工作:
@@ -142,28 +156,26 @@ memory_plugin remember 用户偏好深色主题
memory_plugin chat_search 图像生成
```
## Clawli 远程模式
## 远程通信
通过 WebSocket 实现 PC-手机双向通信:
通过 WebSocket 实现 PC 与手机双向通信:
```bash
/remote start 9079 mypassword # 启动本地服务
/remote proxy relay.example.com # 通过中继服务器穿透内网
/remote stop # 停止服务
/remote status # 查看状态
```
功能:手机发消息给 AI、AI 回复推到手机、文件传输、图片识别。
## 安全机制
## 安全机制
### 三模式切换 (`/safe`)
### 模式切换 (`/safe`)
| 模式 | 图标 | 说明 |
|------|------|------|
| **普通模式** (默认) | | AI 识别风险,有风险才请求确认 |
| **人工确认** | | 所有指令都需要用户确认 |
| **无限制** | | 直接执行,不检查 |
| 模式 | 说明 |
|------|------|
| **普通模式** (默认) | AI 识别风险,有风险才请求确认 |
| **人工确认** | 所有指令都需要用户确认 |
### 检查流程
@@ -171,22 +183,22 @@ memory_plugin chat_search 图像生成
工具调用 → safety.check()
├─ 快速预检(读取/查询类)→ 直接放行
├─ 普通模式 → AI 独立分析风险
│ ├─ safe=true → 放行
│ └─ safe=false → 展示风险等级+影响,y/n 确认
│ ├─ safe → 放行
│ └─ unsafe → 展示风险等级+影响,y/n 确认
└─ 特殊指令(rm/delete/chmod)→ 始终确认
```
### 代码沙箱
### 代码执行
独立的安全代码执行环境:
- AST 级危险代码检测
- 模块白名单(阻断 os/subprocess/socket 等 25 个模块)
- 模块白名单(阻断 25 个高危模块)
- 子进程隔离执行
- 内存限制
## MCP 协议支持
## MCP 协议支持
所有 17 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议:
所有 18 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议:
| 协议 | 说明 |
|------|------|
@@ -194,110 +206,112 @@ memory_plugin chat_search 图像生成
| **FC** | OpenAI Function Calling 原生调用 |
| **JSON** | 传统 `{"action": "use_tool", ...}` 格式 |
`fc_tools.py` 自动将 MCP 定义转换为 OpenAI FC 格式,支持混合响应解析。
## 命令列表
## 命令列表
| 命令 | 说明 |
|------|------|
| `/help` | 显示帮助 |
| `/safe` | 切换安全模式(普通→人工→无限制) |
| `/safe` | 切换安全模式 |
| `/engine list` | 列出可用引擎 |
| `/engine switch <引擎>` | 切换引擎 |
| `/engine.openai models` | 列出 OpenAI 引擎模型 |
| `/chat save/list/open` | 聊天记录管理 |
| `/file.read <路径>` | 读取文件 |
| `/remote` | Clawli 远程连接帮助 |
| `/remote` | 远程连接帮助 |
| `/notify` | 切换任务完成通知 |
| `/scheduler` / `/remind` | 管理定时任务 |
| `/memory` | 管理记忆系统 |
| `/tui` | 切换 TUI 模式 |
| `/quit` | 退出 |
## Web UI
## Web UI
纯静态 HTML 界面,无需后端,浏览器直接打开。
WebSocket 实时交互界面,支持完整的 AI 对话体验。
```bash
# 方式 1: 启动 WebSocket 后端(推荐)
python webui_server.py --port 8080
# 浏览器访问 http://localhost:8080
# 方式 2: 直接打开静态页面
start webui.html # Windows
open webui.html # macOS
xdg-open webui.html # Linux
start webui.html # Windows
```
## 项目结构
## 项目结构
```
xiaoli-cli/
├── ai_cli.py # 主入口
├── webui.html # Web UI 界面 (纯静态 HTML)
├── launcher.py # 智能启动器(自动检测环境/安装依赖)
├── webui.html # Web UI 界面
├── webui_server.py # Web UI 后端
├── launcher.py # 智能启动器
├── config.json.example # 配置模板
├── easter_egg.py # 彩蛋
│
├── easter_egg.py # 彩蛋
├── xcli_core/ # 核心模块
│ ├── cli_core.py # 主循环 + 提示词构建
│ ├── cli_base.py # 初始化 + 引擎/插件加载
│ ├── cli_tools.py # 工具调用执行(支持 MCP/FC/JSON)
│ ├── cli_clawli.py # Clawli 远程模式
│ ├── cli_code_exec.py # 代码沙箱执行
│ ├── cli_tools.py # 工具调用执行
│ ├── cli_clawli.py # 远程模式
│ ├── cli_code_exec.py # 代码执行
│ ├── cli_display.py # 显示输出
│ ├── cli_history.py # 聊天记录
│ ├── config.py # 配置管理
│ ├── constants.py # 常量定义
│ ├── fc_tools.py # MCP → FC 格式转换
│ ├── memory.py # 记忆系统(长期记忆+日记+搜索+压缩)
│ ├── memory.py # 记忆系统
│ ├── notification.py # 跨平台系统通知
│ ├── plugin_manager.py # 插件管理器(Liugin/Plugin 双协议)
│ ├── safety.py # 统一安全层(AI 风险分析)
│ ├── sandbox.py # 代码安全沙箱(AST 级检测)
│ └── tui.py # TUI 界面(Textual + 动画)
│
│ ├── plugin_manager.py # 插件管理器
│ ├── safety.py # 统一安全层
│ ├── sandbox.py # 代码安全沙箱
│ └── tui.py # TUI 界面
├── ai_engines/ # AI 引擎
│ ├── ollama_engine.py # Ollama 本地模型
│ └── openai_engine.py # OpenAI 兼容格式
│
├── plugins/ # 插件 (17 个)
│ ├── code_editor.py # 代码编辑+搜索 (24 操作)
│ ├── git_tools.py # Git 版本控制 (17 操作)
│ ├── cmd_executor.py # Shell 命令执行
│ ├── file_manager.py # 文件管理 (10 操作)
│ ├── auto_engineer.py # 工程化自动化 (12 操作)
│ ├── task_manager.py # 任务管理
│ ├── sub_agent.py # 子 Agent 系统 (8 操作)
│ ├── browser_auto.py # 浏览器自动化 (28 操作)
│ ├── image_generator.py # 图像生成 (7 操作) ⭐ NEW
│ ├── memory_plugin.py # 记忆系统 (11 操作) ⭐ NEW
│ ├── scheduler.py # 定时任务
│ ├── tool_search.py # 工具搜索
│ ├── network_tools.py # 网络工具
│ ├── ai_search.py # AI 搜索
│ ├── frontend_tester.py # 前端测试
│ ├── speech_recognition.py # 语音识别
│ └── send_image.py # 图片发送
│
├── skills/ # 技能 (Skill 协议)
├── image_engine/ # 图像识别引擎(Ollama 视觉模型)
├── tests/ # 测试套件 (113 用例)
│ ├── ollama_engine.py
│ └── openai_engine.py
├── plugins/ # 插件 (18 个)
│ ├── code_editor.py
│ ├── git_tools.py
│ ├── cmd_executor.py
│ ├── file_manager.py
│ ├── auto_engineer.py
│ ├── task_manager.py
│ ├── sub_agent.py
│ ├── browser_auto.py
│ ├── gui_auto.py
│ ├── ai_image.py
│ ├── image_generator.py
│ ├── memory_plugin.py
│ ├── scheduler.py
│ ├── tool_search.py
│ ├── network_tools.py
│ ├── ai_search.py
│ ├── frontend_tester.py
│ ├── speech_recognition.py
│ └── terminal.py
├── skills/ # 技能
├── image_engine/ # 图像识别引擎
├── tests/ # 测试套件 (150+ 用例)
├── love.txt # 彩蛋情话
└── about.txt # 关于信息
```
## 测试
## 测试
```bash
# 运行全部测试 (113 用例)
# 运行全部测试
python -m pytest tests/ -v
# 语法检查
python -c "import py_compile; py_compile.compile('ai_cli.py', doraise=True)"
```
## 插件开发
## 插件开发
小狸支持 `Liugin`(自研协议)和 `Plugin`(通用协议)两种类名,均可被插件管理器自动识别:
```python
class Plugin: # 或 class Liugin
class Plugin:
"""我的插件"""
def __init__(self):
@@ -331,13 +345,13 @@ class Plugin: # 或 class Liugin
return "处理结果"
```
## 许可证
## 许可证
[MIT License](LICENSE)
## 链接
## 链接
- Gitee: https://gitee.com/shuiyu1123/xiaoli-cli
- [更新日志](CHANGELOG.md)
- [使用指南](HELP.md)
- [AI 编程助手横评](compare.html)
- Gitee: https://gitee.com/shuiyu1123/xiaoli-cli
- 插件仓库: https://gitee.com/shuiyu1123/xiaoli-cli-plugins
- [更新日志](CHANGELOG.md)
- [使用指南](HELP.md)
+2 -2
View File
@@ -1,7 +1,7 @@
**产品标识**
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
- **内部版本号**:20260428
- **发布版本**:发行版5.1.3
- **内部版本号**:20260628
- **发布版本**:发行版6.1.0
**技术规格**
- **主要语言**:简体中文(UTF-8)
+119 -16
View File
@@ -22,6 +22,26 @@ project_root = os.path.dirname(current_dir)
sys.path.insert(0, project_root)
def _safe_get(obj, key, default=None):
"""兼容 dict 和 pydantic 对象的安全取值(新版 ollama 返回 pydantic 模型,不支持 .get())"""
if obj is None:
return default
# 1. 先尝试 dict 访问
try:
return obj[key]
except (KeyError, TypeError):
pass
# 2. 尝试属性访问(pydantic 模型)
val = getattr(obj, key, None)
if val is not None:
return val
# 3. 尝试 model_dump 后的 dict 访问
try:
return obj.model_dump()[key]
except (AttributeError, KeyError, TypeError):
return default
class OllamaAI:
"""基于Ollama的本地AI引擎"""
requires_api_key = False # Ollama是本地引擎,不需要API密钥
@@ -80,7 +100,10 @@ Ollama AI引擎插件帮助信息
# 共享对话历史引用(由CLI设置)
self.shared_conversation_history = None
# 取消标志
self._cancelled = False
# 直接启用思考标记,由主程序自动处理深度思考模型的响应
self.thinking_start_marker = "<thinking>"
self.thinking_end_marker = "</thinking>"
@@ -93,6 +116,33 @@ Ollama AI引擎插件帮助信息
else:
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
def cancel(self):
"""取消正在进行的请求"""
self._cancelled = True
def apply_config(self, base_url=None, api_key=None, model=None):
"""应用新配置到当前引擎实例(内存),并持久化到 config.json"""
if base_url is not None:
self.base_url = base_url.rstrip('/')
if api_key is not None:
self.api_key = api_key
if model is not None:
self.model = model
# 持久化到 config.json
try:
import os, json
config_file = os.path.join(project_root, "config.json")
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
engine_cfg = config.setdefault("api", {}).setdefault("engines", {}).setdefault("ollama", {})
engine_cfg["base_url"] = self.base_url
engine_cfg["api_key"] = self.api_key
engine_cfg["model"] = self.model
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"持久化引擎配置失败: {e}")
def check_ollama_service(self):
"""检测Ollama服务是否运行"""
# 检查 ollama 库是否可用
@@ -107,7 +157,7 @@ Ollama AI引擎插件帮助信息
# 如果出现异常,说明服务未运行
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
@@ -122,6 +172,13 @@ Ollama AI引擎插件帮助信息
if not self.is_service_running:
return "错误:Ollama服务未运行,请启动Ollama服务后再使用此引擎."
# 检查模型是否已配置
if not self.model or not self.model.strip():
return ("错误:Ollama模型未配置。请执行以下操作:\n"
" 1. /engine.ollama models → 查看本地可用模型\n"
" 2. /engine.ollama set <模型名> → 设置要使用的模型\n"
" 或编辑 config.json 中 api.engines.ollama.model 字段")
# 如果有工具结果,将其作为上下文返回给AI处理
if tool_results:
tool_result_text = f"工具执行结果: {tool_results.get('result', '无结果')}"
@@ -130,10 +187,20 @@ Ollama AI引擎插件帮助信息
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 = {
"model": self.model,
"messages": messages,
"stream": False
"stream": True if stream_cb else False
}
# ── Function Calling: 注入工具定义 ──
@@ -143,16 +210,45 @@ Ollama AI引擎插件帮助信息
# 使用Ollama Python库调用API
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 ──
message = response.get("message", {})
tool_calls_raw = message.get("tool_calls")
if tool_calls_raw:
# Ollama 的 tool_calls 格式: [{"function": {"name": ..., "arguments": {...}}}]
fc_calls = []
for i, tc in enumerate(tool_calls_raw):
func = tc.get("function", {})
name = func.get("name", "")
args = func.get("arguments", {})
func = _safe_get(tc, "function", {})
name = _safe_get(func, "name", "")
args = _safe_get(func, "arguments", {})
call_id = f"call_{name}_{i}"
# 统一为小狸内部格式
@@ -182,7 +278,7 @@ Ollama AI引擎插件帮助信息
}, ensure_ascii=False)
# 普通文本响应
content = message.get("content", "")
content = _safe_get(message, "content", "")
# 清理无效的 UTF-8 代理字符
content = content.encode('utf-8', 'replace').decode('utf-8')
return content
@@ -299,11 +395,16 @@ Ollama AI引擎插件帮助信息
"""列出本地可用的模型"""
if not self.is_service_running:
return ["错误:Ollama服务未运行"]
try:
response = ollama_client.list()
models = [model["name"] for model in response["models"]]
return models
models_list = _safe_get(response, "models", [])
result = []
for model in models_list:
name = _safe_get(model, "model", "") or _safe_get(model, "name", "")
if name:
result.append(name)
return result if result else ["未找到本地模型"]
except Exception as e:
return [f"获取模型列表失败: {str(e)}"]
@@ -385,12 +486,14 @@ Ollama AI引擎插件帮助信息
# 逐块处理响应
for part in response:
if 'status' in part:
status = part['status']
status = _safe_get(part, "status", None)
if status:
print(f"{Fore.YELLOW}{status}{Style.RESET_ALL}")
# 检查是否完成
if 'completed' in part and 'total' in part:
if part['completed'] == part['total']:
completed = _safe_get(part, "completed", None)
total = _safe_get(part, "total", None)
if completed is not None and total is not None:
if completed == total:
print(f"{Fore.GREEN}模型 {model_name} 拉取完成!{Style.RESET_ALL}")
print(f"{Fore.GREEN}模型 {model_name} 拉取成功!{Style.RESET_ALL}")
+141 -5
View File
@@ -57,6 +57,10 @@ OpenAI 兼容格式引擎帮助信息
/engine.openai models - 列出可用模型(需要服务端支持)
/engine.openai set <模型名> - 切换模型
/engine.openai info - 显示当前配置信息
/engine.openai think - 查看深度思考状态
/engine.openai think on - 开启深度思考
/engine.openai think off - 关闭深度思考
/engine.openai think high - 开启并设置推理强度 (low/medium/high)
使用说明:
1. 在 config.json 中配置 api_key、base_url、model
@@ -84,12 +88,17 @@ OpenAI 兼容格式引擎帮助信息
self.base_url = engine_config.get("base_url", "").rstrip('/')
self.model = engine_config.get("model", "")
self.max_history = config.get("system", {}).get("max_history", 10)
# 深度思考配置
self.deep_think = engine_config.get("deep_think", False)
self.reasoning_effort = engine_config.get("reasoning_effort", "medium")
except Exception as e:
logger.warning(f"读取配置文件失败: {e}")
self.api_key = ""
self.base_url = ""
self.model = ""
self.max_history = 10
self.deep_think = False
self.reasoning_effort = "medium"
# 初始化对话历史
self.conversation_history = []
@@ -153,6 +162,21 @@ OpenAI 兼容格式引擎帮助信息
"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: 注入工具定义 ──
if tools:
body["tools"] = tools
@@ -188,11 +212,16 @@ OpenAI 兼容格式引擎帮助信息
# 普通文本响应
ai_response = message.get("content", "")
reasoning = message.get("reasoning_content", "")
if ai_response:
# 清理无效的 UTF-8 代理字符
ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8')
# 处理思考内容
# 处理思考内容(从 <think> 标签中提取)
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 ""
else:
return f"API 返回格式错误: {response_data}"
@@ -337,6 +366,93 @@ OpenAI 兼容格式引擎帮助信息
return response
def _apply_deep_think(self, body: dict) -> dict:
"""
通用深度思考适配。
原生支持的模型用 API 参数,不支持的用提示词模拟。
"""
model = (self.model or "").lower()
effort = self.reasoning_effort # low / medium / high
# ── 原生支持:API 参数 ──
# DeepSeek / MiMo
if "deepseek" in model or "mimo" in model:
body["enable_thinking"] = True
body.pop("temperature", None)
if effort:
body["reasoning_effort"] = effort
return body
# OpenAI o-series (o1, o3, o4-mini 等)
if model.startswith("o") and (model[1:2].isdigit() or model[1:3] in ("1-", "3-", "4-")):
body["reasoning"] = {"effort": effort}
return body
# Anthropic Claude (通过 OpenAI 兼容层)
if "claude" in model or "anthropic" in model:
body["thinking"] = {"type": "enabled", "budget_tokens": self._thinking_budget(effort)}
return body
# Grok
if "grok" in model:
body["thinking"] = {"enabled": True}
return body
# Gemini thinking 模型
if "gemini" in model and "thinking" in model:
return body # 模型名自带 thinking,不需要额外参数
# ── 不支持原生思考:用提示词模拟 ──
self._inject_thinking_prompt = effort
return body
@staticmethod
def _thinking_budget(effort: str) -> int:
"""思考 token 预算"""
return {"low": 2048, "medium": 8192, "high": 32768}.get(effort, 8192)
def _get_thinking_prompt(self, effort: str) -> str:
"""根据思考等级生成提示词(注入系统提示词末尾)"""
if effort == "low":
return ""
if effort == "medium":
return (
"\n\n【思考模式:中等】\n"
"回答前请先逐步思考:\n"
"1. 分析用户的核心需求\n"
"2. 列出关键因素和约束\n"
"3. 考虑可能的方案\n"
"4. 选择最优方案并解释理由\n"
"然后再给出最终回答。"
)
# high
return (
"\n\n【思考模式:深度推理】\n"
"你必须使用结构化深度推理框架回答问题。严格遵循以下步骤:\n\n"
"## 第一步:问题分解\n"
"- 将问题拆解为子问题\n"
"- 识别关键约束和边界条件\n"
"- 明确输入输出要求\n\n"
"## 第二步:多角度分析\n"
"- 从至少 3 个不同角度分析问题\n"
"- 评估每种方案的优缺点\n"
"- 考虑边界情况和异常场景\n\n"
"## 第三步:方案设计\n"
"- 选择最优方案并详细说明理由\n"
"- 给出具体的实现步骤\n"
"- 预判可能的问题并给出预防措施\n\n"
"## 第四步:验证与优化\n"
"- 检验方案是否满足所有约束\n"
"- 考虑是否有更优解\n"
"- 给出最终答案和注意事项\n\n"
"## 输出格式\n"
"先输出推理过程(用 [思考] 标记),再输出最终答案。\n"
"推理过程要详细但不啰嗦,最终答案要简洁明确。"
)
def set_model(self, model_name):
"""设置模型"""
self.model = model_name
@@ -392,11 +508,31 @@ OpenAI 兼容格式引擎帮助信息
elif command == "info":
print(f"{Fore.GREEN}OpenAI 兼容引擎配置:{Style.RESET_ALL}")
print(f" base_url: {self.base_url}")
print(f" model: {self.model}")
print(f" api_key: {'已设置' if self.api_key else '未设置'}")
print(f" base_url: {self.base_url}")
print(f" model: {self.model}")
print(f" api_key: {'已设置' if self.api_key else '未设置'}")
print(f" deep_think: {'开启' if self.deep_think else '关闭'}")
if self.deep_think:
print(f" reasoning: {self.reasoning_effort}")
return True
elif command.startswith("think"):
# /engine.openai think [on|off|low|medium|high]
arg = command.replace("think", "").strip().lower()
if arg in ("off", "0", "false"):
self.deep_think = False
print(f"{Fore.YELLOW}深度思考已关闭{Style.RESET_ALL}")
elif arg in ("on", "1", "true", ""):
self.deep_think = True
print(f"{Fore.GREEN}深度思考已开启 (reasoning_effort: {self.reasoning_effort}){Style.RESET_ALL}")
elif arg in ("low", "medium", "high"):
self.deep_think = True
self.reasoning_effort = arg
print(f"{Fore.GREEN}深度思考已开启 (reasoning_effort: {arg}){Style.RESET_ALL}")
else:
print(f"{Fore.RED}用法: /engine.openai think [on|off|low|medium|high]{Style.RESET_ALL}")
return True
else:
print(f"{Fore.RED}未知命令. 可用命令: models, set, info{Style.RESET_ALL}")
print(f"{Fore.RED}未知命令. 可用: models, set, info, think{Style.RESET_ALL}")
return False
-1161
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -9,7 +9,9 @@
"openai": {
"api_key": "your-api-key-here",
"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):
if filename.endswith('_engine.py') and filename != '__init__.py':
engine_name = filename[:-11] # 移除 _engine.py
engine_name = filename[:-10] # 移除 _engine.py
try:
# 动态导入模块
module_path = os.path.join(engine_dir, filename)
+11 -3
View File
@@ -14,6 +14,14 @@ except ImportError:
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):
"""Ollama 视觉引擎"""
@@ -69,7 +77,7 @@ class OllamaImageEngine(BaseImageEngine):
try:
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:
self.installed_models = []
@@ -137,7 +145,7 @@ class OllamaImageEngine(BaseImageEngine):
'images': [image_path]
}]
)
return response.get('message', {}).get('content', '')
return _safe_get(_safe_get(response, 'message', {}), 'content', '')
except Exception as e:
raise Exception(f"Ollama识别失败: {str(e)}")
@@ -168,7 +176,7 @@ class OllamaImageEngine(BaseImageEngine):
stream=False
)
content = response.get('message', {}).get('content', '')
content = _safe_get(_safe_get(response, 'message', {}), 'content', '')
if content:
yield content
else:
+2 -2
View File
@@ -1,10 +1,10 @@
@echo off
chcp 65001 >nul 2>&1
title 小狸 Pro-CLI v5.1.2
title 小狸 Pro-CLI v5.4.0
echo.
echo ╔══════════════════════════════════════╗
echo ║ 小狸 Pro-CLI 启动器 v5.1.2 ║
echo ║ 小狸 Pro-CLI 启动器 v5.4.0 ║
echo ║ 正在检测环境... ║
echo ╚══════════════════════════════════════╝
echo.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
小狸 MCP Stdio Server
=====================
标准 MCP (Model Context Protocol) stdio 传输层入口。
通过 stdin/stdout 以 JSON-RPC 2.0 格式通信,
可被 Claude Desktop、Cursor、OpenClaw 等标准 MCP 客户端直接连接。
用法:
python mcp_server.py [--plugins-dir plugins] [--skills-dir skills]
Claude Desktop 配置示例:
{
"mcpServers": {
"xiaoli": {
"command": "python",
"args": ["/path/to/xiaoli-cli/mcp_server.py"]
}
}
}
"""
import sys
import os
import json
import argparse
import logging
# 确保当前目录在 sys.path 中,以便导入项目模块
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
# 日志输出到 stderr,不干扰 stdout 上的 JSON-RPC 通信
logging.basicConfig(
level=logging.INFO,
format="[xiaoli-mcp] %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger("xiaoli-mcp")
def main():
parser = argparse.ArgumentParser(description="小狸 MCP Stdio Server")
parser.add_argument("--plugins-dir", default="plugins", help="插件目录 (默认: plugins)")
parser.add_argument("--skills-dir", default="skills", help="技能目录 (默认: skills)")
args = parser.parse_args()
# 切换到脚本所在目录,保证相对路径正确
os.chdir(SCRIPT_DIR)
# 初始化 UnifiedToolManager
# 将 initialize() 中的 print 重定向到 stderr,避免污染 stdout JSON-RPC 通道
import io
from contextlib import redirect_stdout
from unified_tool_manager import UnifiedToolManager
manager = UnifiedToolManager()
captured_output = io.StringIO()
with redirect_stdout(captured_output):
manager.initialize(
plugins_dir=args.plugins_dir,
skills_dir=args.skills_dir,
)
# 将初始化时的 print 输出转发到 stderr
init_output = captured_output.getvalue()
if init_output:
for line in init_output.strip().split('\n'):
logger.info(line)
tool_count = len(manager._tools)
logger.info(f"MCP Server 已启动,共加载 {tool_count} 个工具")
logger.info("等待 stdin JSON-RPC 请求...")
# 主循环:从 stdin 逐行读取 JSON-RPC 请求
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError as e:
# 无法解析的 JSON,返回解析错误
error_resp = {
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32700, "message": f"Parse error: {e}"},
}
_send(error_resp)
continue
# 处理通知(没有 id 字段的请求)— MCP 规范中通知不需要响应
req_id = request.get("id")
method = request.get("method", "")
# 特殊处理 notifications/initialized — 客户端初始化完成通知
if method == "notifications/initialized":
logger.info("客户端已发送 initialized 通知")
continue
# 特殊处理 $/cancelRequest
if method == "$/cancelRequest":
logger.info(f"收到取消请求: {request.get('params', {}).get('id')}")
continue
# 委托给 UnifiedToolManager 处理
# 拦截插件可能产生的 stdout 输出,转发到 stderr
captured = io.StringIO()
try:
with redirect_stdout(captured):
response = manager.handle_mcp_request(request)
plugin_output = captured.getvalue()
if plugin_output:
for p_line in plugin_output.strip().split('\n'):
logger.info(f"[plugin] {p_line}")
except Exception as e:
logger.error(f"处理请求异常: {e}")
response = {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32603, "message": f"Internal error: {e}"},
}
_send(response)
except KeyboardInterrupt:
logger.info("收到中断信号,正在关闭...")
except EOFError:
logger.info("stdin 已关闭,正在退出...")
finally:
manager.shutdown()
logger.info("MCP Server 已关闭")
def _send(response: dict):
"""将 JSON-RPC 响应写入 stdout 并刷新"""
text = json.dumps(response, ensure_ascii=False)
sys.stdout.write(text + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+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
+76 -1
View File
@@ -121,4 +121,79 @@ def get_config_directory():
elif platform == 'macos':
return os.path.join(os.path.expanduser("~"), 'Library', 'Application Support', 'Xiaoli Pro-CLI 3')
else: # Linux
return os.path.join(os.path.expanduser('~'), '.config', 'xiaoli-pro-cli-3')
return os.path.join(os.path.expanduser('~'), '.config', 'xiaoli-pro-cli-3')
def is_process_running(pid):
"""检查指定 PID 的进程是否在运行"""
platform = get_platform()
try:
if platform == 'windows':
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
if handle:
kernel32.CloseHandle(handle)
return True
return False
else:
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
return False
def get_process_priority():
"""获取当前进程优先级"""
platform = get_platform()
try:
if platform == 'windows':
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetCurrentProcess()
return kernel32.GetPriorityClass(handle)
else:
import resource
return resource.getpriority(resource.PRIO_PROCESS, 0)
except Exception:
return None
def set_process_priority(level="above_normal"):
"""
设置进程优先级
Args:
level: "below_normal" | "normal" | "above_normal" | "high"
Returns:
是否设置成功
"""
platform = get_platform()
try:
if platform == 'windows':
import ctypes
kernel32 = ctypes.windll.kernel32
priority_map = {
"below_normal": 0x00004000,
"normal": 0x00000020,
"above_normal": 0x00008000,
"high": 0x00000080,
}
priority = priority_map.get(level, 0x00008000)
handle = kernel32.GetCurrentProcess()
return bool(kernel32.SetPriorityClass(handle, priority))
else:
nice_map = {
"below_normal": 10,
"normal": 0,
"above_normal": -10,
"high": -15,
}
nice_val = nice_map.get(level, -10)
os.nice(nice_val)
return True
except Exception:
return False
+53
View File
@@ -0,0 +1,53 @@
{
"version": 1,
"description": "小狸 CLI 插件码注册表 - 通过 /plugin install <插件码> 安装",
"plugins": {
"dglab": {
"name": "DG-Lab 郊狼 WebSocket 控制",
"description": "基于 DG-LAB-OPENSOURCE 官方 Socket V2 协议,支持郊狼 3.0 脉冲主机远程控制",
"author": "xiaoli-cli-plugins",
"version": "1.0.0",
"file": "dglab_ws.py",
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/dglab_ws.py",
"docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/dglab_ws/README.md",
"deps": [
"websocket-client"
],
"optional_deps": [
"qrcode"
],
"keywords": [
"dg-lab",
"dglab",
"郊狼",
"coyote",
"websocket",
"脉冲"
]
},
"onebot": {
"name": "OneBot v11 QQ 机器人",
"description": "让 xiaoli-cli 成为 QQ 机器人,连接 go-cqhttp/Lagrange/LLOneBot 等 OneBot 实现",
"author": "xiaoli-cli-plugins",
"version": "1.0.0",
"file": "onebot.py",
"url": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/onebot.py",
"docs": "https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/onebot/README.md",
"deps": [
"websocket-client"
],
"optional_deps": [],
"keywords": [
"onebot",
"qq",
"机器人",
"bot",
"go-cqhttp",
"lagrange",
"群聊",
"私聊"
]
}
}
}
+52 -26
View File
@@ -1,5 +1,6 @@
import subprocess
import json
import time
from urllib.parse import quote
import requests
@@ -7,6 +8,14 @@ import requests
class Liugin:
"""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):
self.usage = """AI搜索工具使用方法:/r
ai_search <操作> <参数>/r
@@ -27,6 +36,7 @@ ai_search <操作> <参数>/r
"""
self.cli = None # CLI实例引用
self.timeout = 30 # 请求超时时间(秒)
self.max_retries = 2 # 最大重试次数
def set_cli(self, cli):
"""设置CLI实例引用"""
@@ -162,34 +172,50 @@ JSON格式示例:
return f"网络搜索错误: {str(e)}"
def _execute_curl(self, url):
"""使用 requests 库获取内容(替代 curl 命令)"""
try:
# 使用 requests 库发送请求
response = requests.get(
url,
timeout=self.timeout,
headers={'User-Agent': 'Mozilla/5.0 (compatible; AI-Search-Bot)'}
)
# 检查状态码
if response.status_code != 200:
return f"错误:HTTP请求失败,状态码: {response.status_code}"
"""使用 requests 库获取内容(带重试、UA、编码处理)"""
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = requests.get(
url,
timeout=self.timeout,
headers=self._HEADERS,
allow_redirects=True
)
# 获取内容
content = response.text.strip()
# 检查状态码
if response.status_code != 200:
return f"错误:HTTP请求失败,状态码: {response.status_code}"
if not content:
return "错误:未获取到任何内容"
# 显式编码处理:避免 ISO-8859-1 默认导致中文乱码
if not response.encoding or response.encoding.lower() == 'iso-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
# 限制输出长度以避免过长输出
if len(content) > 5000:
content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)"
# 获取内容
content = response.text.strip()
return content
if not content:
return "错误:未获取到任何内容"
except requests.Timeout:
return f"错误:请求超时 (>{self.timeout}秒)"
except requests.ConnectionError:
return "错误:网络连接失败,请检查网络"
except Exception as e:
return f"错误:请求时发生错误: {str(e)}"
# 限制输出长度以避免过长输出
if len(content) > 5000:
content = content[:5000] + f"\n\n... (内容已截断,完整内容共{len(content)}字符)"
return content
except requests.exceptions.Timeout:
last_error = f"请求超时 (>{self.timeout}秒)"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.ConnectionError:
last_error = "网络连接失败,请检查网络"
if attempt < self.max_retries:
time.sleep(1)
except requests.exceptions.RequestException as e:
last_error = str(e)
break
except Exception as e:
last_error = str(e)
break
return f"错误:请求时发生错误: {last_error}"
-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
+11 -3
View File
@@ -10,7 +10,7 @@ import subprocess
from typing import Tuple, List
class Plugin:
class Liugin:
"""工程化工具 - lint/format/test/build + 代码分析"""
def __init__(self):
@@ -368,8 +368,16 @@ class Plugin:
if not test_files and len(py_files) > 5:
suggestions.append(" 添加单元测试")
uses_print = sum(1 for f in py_files if 'print(' in open(f, errors='ignore').read())
uses_log = sum(1 for f in py_files if 'logging' in open(f, errors='ignore').read())
uses_print = 0
uses_log = 0
for f in py_files:
with open(f, errors='ignore') as fh:
content = fh.read()
if 'print(' in content:
uses_print += 1
if 'logging' in content:
uses_log += 1
if uses_print > uses_log and uses_print > 3:
suggestions.append(" 考虑用 logging 替代 print")
+1 -1
View File
@@ -9,7 +9,7 @@ import time
from typing import Optional
class Plugin:
class Liugin:
"""浏览器自动化 - 网页操控、表单填写、数据抓取、截图"""
def __init__(self):
+19 -13
View File
@@ -5,9 +5,14 @@ import os
import re
import subprocess
import platform
import sys
# 添加项目根目录到 path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.tool_result import ToolResult, ErrorCode
class Plugin:
class Liugin:
"""Shell 命令执行器 - 跨平台命令行工具"""
def __init__(self):
@@ -68,18 +73,18 @@ class Plugin:
':(){ :|:& };:', 'chmod -R 777 /', 'mv / ', 'wget http',
}
def handle(self, args: str) -> str:
def handle(self, args: str):
try:
parts = args.strip().split(maxsplit=1)
if not parts:
return "错误:请提供操作。可用: run <命令>"
return ToolResult.missing("操作。可用: run <命令>")
operation = parts[0].lower()
if operation != "run":
return f"错误:不支持的操作 '{operation}'。可用: run"
return ToolResult.fail(f"不支持的操作 '{operation}'。可用: run", ErrorCode.UNSUPPORTED_OP)
if len(parts) < 2:
return "错误:请提供要执行的命令"
return ToolResult.missing("要执行的命令")
rest = parts[1]
@@ -91,21 +96,20 @@ class Plugin:
rest = re.sub(r'--timeout\s+\d+', '', rest).strip()
if not rest:
return "错误:请提供要执行的命令"
return ToolResult.missing("要执行的命令")
# 安全检查
for blocked in self.BLOCKED:
if blocked in rest:
return f" 安全拦截: 检测到危险命令模式 '{blocked}'"
return ToolResult.fail(f"安全拦截: 检测到危险命令模式 '{blocked}'", ErrorCode.PERMISSION_DENIED)
return self._execute(rest, timeout)
except Exception as e:
return f"命令执行错误: {str(e)}"
return ToolResult.fail(str(e), ErrorCode.INTERNAL_ERROR, tool_name="cmd_executor")
def _execute(self, command: str, timeout: int) -> str:
def _execute(self, command: str, timeout: int):
try:
# Windows 下用 shell=True,Unix 下直接执行
use_shell = platform.system() == 'Windows'
result = subprocess.run(
@@ -136,9 +140,11 @@ class Plugin:
if len(output) > 5000:
output = output[:5000] + f"\n... (截断,超时: {timeout}s)"
return output.strip()
if rc != 0:
return ToolResult.fail(output.strip(), ErrorCode.EXEC_FAILED, tool_name="cmd_executor")
return ToolResult.ok(output.strip(), tool_name="cmd_executor")
except subprocess.TimeoutExpired:
return f" 命令超时 ({timeout}s): {command}"
return ToolResult.timeout(timeout, tool_name="cmd_executor")
except Exception as e:
return f"执行失败: {str(e)}"
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED, tool_name="cmd_executor")
+149 -33
View File
@@ -16,7 +16,7 @@ import threading
from typing import Optional, Tuple, List
class Plugin:
class Liugin:
"""代码编辑与搜索 - 精准编辑 + 代码理解"""
def __init__(self):
@@ -37,9 +37,10 @@ class Plugin:
编辑操作后自动执行,无需手动调用
Diff 弹窗:
diff_popup [on|off] - 开关 diff 弹窗 (别名: popup)
编辑操作后弹出新窗口显示修改前后对比(默认开启)
on/开/enable → 开启 | off/关/disable → 关闭 | 无参数 → 切换
diff_popup [on|off|popup|inline] - 开关/切换 diff 显示模式 (别名: popup)
popup/弹窗 → 弹窗模式(新终端窗口)
inline/ssh → 主终端内显示(SSH 兼容)
on/开 → 开启 | off/关 → 关闭 | 无参数 → 切换
查看操作:
read_range <文件> <起始行> [结束行] - 读取指定行范围
@@ -59,7 +60,8 @@ Diff 弹窗:
stats <目录> - 代码统计
"""
self.cli = None
self.diff_popup_enabled = True # diff 弹窗开关,默认开启
self.diff_popup_enabled = True # diff 弹窗开关
self.diff_popup_mode = None # None=启动时询问, True=弹窗, False=主终端内显示
def set_cli(self, cli):
self.cli = cli
@@ -611,20 +613,26 @@ else:
def _spawn_diff_popup(self, filepath: str, before: str, after: str):
"""
在新终端窗口中显示修改前后的对比。
使用独立线程避免阻塞主流程。
显示修改前后的对比。
根据 diff_popup_mode 决定弹窗还是主终端内显示。
"""
if not self.diff_popup_enabled:
return
if before == after:
return # 内容无变化,不弹窗
thread = threading.Thread(
target=self._do_spawn_diff_popup,
args=(filepath, before, after),
daemon=True
)
thread.start()
# 启动时未选择模式,默认为主终端内显示(SSH 兼容)
popup = self.diff_popup_mode if self.diff_popup_mode is not None else False
if popup:
thread = threading.Thread(
target=self._do_spawn_diff_popup,
args=(filepath, before, after),
daemon=True
)
thread.start()
else:
self._show_inline_diff(filepath, before, after)
def _do_spawn_diff_popup(self, filepath: str, before: str, after: str):
"""实际执行弹窗创建(在子线程中运行)"""
@@ -642,7 +650,6 @@ else:
tofile=f"修改后: {basename}",
lineterm=''
))
diff_text = '\n'.join(diff_lines)
# 统计变更
added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++'))
@@ -655,7 +662,7 @@ else:
display = self._build_diff_display(
basename, filepath,
before_numbered, after_numbered,
diff_text, added, removed,
diff_lines, added, removed,
before_count, after_count
)
@@ -670,38 +677,64 @@ else:
def _build_diff_display(self, basename, filepath,
before_numbered, after_numbered,
diff_text, added, removed,
diff_lines, added, removed,
before_count, after_count):
"""构建完整的 diff 显示文本"""
"""构建完整的 diff 显示文本(带 ANSI 颜色)"""
sep = '═' * 70
thin_sep = '─' * 70
# ANSI 颜色
R = '\033[31m' # 红字
G = '\033[32m' # 绿字
C = '\033[36m' # 青
B = '\033[1m' # 粗体
D = '\033[2m' # 暗
W = '\033[37m' # 白
N = '\033[0m' # 重置
RBG = '\033[41m\033[97m' # 红底白字
GBG = '\033[42m\033[97m' # 绿底白字
# 构建带颜色的 diff
diff_colored_lines = []
for line in diff_lines:
if line.startswith('@@'):
diff_colored_lines.append(f"{C}{line}{N}")
elif line.startswith('+++') or line.startswith('---'):
diff_colored_lines.append(f"{B}{line}{N}")
elif line.startswith('+'):
diff_colored_lines.append(f"{GBG} {line} {N}")
elif line.startswith('-'):
diff_colored_lines.append(f"{RBG} {line} {N}")
else:
diff_colored_lines.append(line)
diff_colored = '\n'.join(diff_colored_lines) if diff_colored_lines else ' (无差异)'
display = f"""
{sep}
文件变更对比: {basename}
路径: {filepath}
{sep}
{W}{sep}{N}
{B} 文件变更对比: {basename}{N}
{D} 路径: {filepath}{N}
{W}{sep}{N}
统计: {before_count} 行 → {after_count} 行 | +{added} 新增 -{removed} 删除
统计: {before_count} 行 → {after_count} 行 | {G}+{added} 新增{N} {R}-{removed} 删除{N}
{thin_sep}
{W}{thin_sep}{N}
修改前 ({before_count} 行):
{thin_sep}
{W}{thin_sep}{N}
{before_numbered}
{thin_sep}
{W}{thin_sep}{N}
修改后 ({after_count} 行):
{thin_sep}
{W}{thin_sep}{N}
{after_numbered}
{thin_sep}
{W}{thin_sep}{N}
差异 (Diff):
{thin_sep}
{diff_text if diff_text else ' (无差异)'}
{W}{thin_sep}{N}
{diff_colored}
{sep}
{W}{sep}{N}
按任意键关闭此窗口...
{sep}
{W}{sep}{N}
"""
return display
@@ -775,8 +808,81 @@ else:
except Exception as e:
print(f"[Diff 弹窗] 创建失败: {e}")
def _show_inline_diff(self, filepath: str, before: str, after: str):
"""在主终端内显示 diff(SSH 兼容模式),带颜色"""
basename = os.path.basename(filepath)
is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
# 生成 diff
diff_lines = list(difflib.unified_diff(
before.splitlines(keepends=True),
after.splitlines(keepends=True),
fromfile=f"修改前: {basename}",
tofile=f"修改后: {basename}",
lineterm=''
))
# 统计
added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++'))
removed = sum(1 for l in diff_lines if l.startswith('-') and not l.startswith('---'))
before_count = len(before.splitlines())
after_count = len(after.splitlines())
sep = '═' * 60
thin = '─' * 60
if is_tui:
# TUI 模式:使用 Rich 标记
output = self.cli._output
output(f"[dim]{sep}[/]")
output(f"[bold cyan] 文件变更: {basename}[/] [dim]{filepath}[/]")
output(f"[dim]{sep}[/]")
output(f"[bold] 统计: {before_count} 行 → {after_count} 行 | "
f"[green]+{added} 新增[/] [red]-{removed} 删除[/]")
output(f"[dim]{thin}[/]")
for line in diff_lines:
if line.startswith('@@'):
output(f"[cyan]{line}[/]")
elif line.startswith('+++') or line.startswith('---'):
output(f"[bold]{line}[/]")
elif line.startswith('+'):
output(f"[white on green] {line} [/]")
elif line.startswith('-'):
output(f"[white on red] {line} [/]")
else:
output(line)
output(f"[dim]{sep}[/]")
else:
# CLI 模式:使用 ANSI 颜色
from colorama import Fore, Style
_out = print
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
_out(f"{Fore.CYAN} 文件变更: {basename}{Style.RESET_ALL} {filepath}")
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
_out(f" 统计: {before_count} 行 → {after_count} 行 | "
f"{Fore.GREEN}+{added} 新增{Style.RESET_ALL} "
f"{Fore.RED}-{removed} 删除{Style.RESET_ALL}")
_out(f"{Fore.WHITE}{thin}{Style.RESET_ALL}")
for line in diff_lines:
if line.startswith('@@'):
_out(f"{Fore.CYAN}{line}{Style.RESET_ALL}")
elif line.startswith('+++') or line.startswith('---'):
_out(f"{Style.BRIGHT}{line}{Style.RESET_ALL}")
elif line.startswith('+'):
_out(f"\033[42m\033[97m {line} {Style.RESET_ALL}")
elif line.startswith('-'):
_out(f"\033[41m\033[97m {line} {Style.RESET_ALL}")
else:
_out(line)
_out(f"{Fore.WHITE}{sep}{Style.RESET_ALL}")
def _op_diff_popup(self, path: str, rest: str) -> str:
"""切换 diff 弹窗开关"""
"""切换 diff 弹窗开关 / 设置显示模式"""
arg = (path + ' ' + rest).strip().lower()
if arg in ('on', '开', '开启', 'enable', '1', 'true'):
self.diff_popup_enabled = True
@@ -784,6 +890,14 @@ else:
elif arg in ('off', '关', '关闭', 'disable', '0', 'false'):
self.diff_popup_enabled = False
return " Diff 弹窗已关闭"
elif arg in ('popup', '弹窗', '弹窗模式'):
self.diff_popup_enabled = True
self.diff_popup_mode = True
return " Diff 模式: 弹窗(新终端窗口)"
elif arg in ('inline', '内联', 'ssh', '终端', '主终端'):
self.diff_popup_enabled = True
self.diff_popup_mode = False
return " Diff 模式: 主终端内显示(SSH 兼容)"
else:
# 无参数:切换状态
self.diff_popup_enabled = not self.diff_popup_enabled
@@ -1044,8 +1158,10 @@ else:
def _op_diff(self, path: str, rest: str) -> str:
if not path:
return "错误:请提供第一个文件路径"
if not rest.strip():
return "错误:请提供第二个文件路径"
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):
return f"错误:文件不存在: {f1}"
if not os.path.exists(f2):
+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",
)
+56 -51
View File
@@ -5,12 +5,17 @@
import os
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from typing import List
# 添加项目根目录到 path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from xcli_core.tool_result import ToolResult, ErrorCode
class Plugin:
class Liugin:
"""文件管理器 - 文件和目录的增删改查"""
def __init__(self):
@@ -70,11 +75,11 @@ class Plugin:
path = arguments.get("path", "")
return f"{op} {path}".strip()
def handle(self, args: str) -> str:
def handle(self, args: str):
try:
parts = self._parse_args(args)
if not parts:
return "错误:请提供操作类型"
return ToolResult.missing("操作类型")
operation = parts[0].lower()
handlers = {
@@ -92,7 +97,7 @@ class Plugin:
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return ToolResult.fail(f"不支持的操作 '{operation}'", ErrorCode.UNSUPPORTED_OP)
return handler(parts[1:])
except Exception as e:
@@ -115,7 +120,7 @@ class Plugin:
parts.append(current)
return parts
def _op_list(self, args: List[str]) -> str:
def _op_list(self, args: List[str]):
path, pattern = ".", None
i = 0
while i < len(args):
@@ -127,7 +132,7 @@ class Plugin:
path = os.path.abspath(path)
if not os.path.isdir(path):
return f"错误:不是目录: {path}"
return ToolResult.not_found(f"目录: {path}")
items = []
try:
@@ -153,13 +158,13 @@ class Plugin:
result.append(f" ... 还有 {len(items) - 100} 项")
result.append("─" * 50)
result.append(f"共 {dirs} 个文件夹, {files} 个文件")
return '\n'.join(result)
return ToolResult.ok('\n'.join(result), tool_name="file_manager")
except Exception as e:
return f"列出目录失败: {e}"
return ToolResult.fail(str(e), ErrorCode.EXEC_FAILED, tool_name="file_manager")
def _op_read(self, args: List[str]) -> str:
def _op_read(self, args: List[str]):
if not args:
return "错误:请提供文件路径"
return ToolResult.missing("文件路径")
file_path = os.path.abspath(args[0])
max_lines = None
i = 1
@@ -168,16 +173,16 @@ class Plugin:
try:
max_lines = int(args[i + 1])
except ValueError:
return "错误:行数必须是整数"
return ToolResult.fail("行数必须是整数", ErrorCode.INVALID_ARGS)
i += 1
i += 1
if not os.path.isfile(file_path):
return f"错误:文件不存在: {file_path}"
return ToolResult.not_found(f"文件: {file_path}")
size = os.path.getsize(file_path)
if size > 10 * 1024 * 1024:
return f"文件过大 ({size/1024/1024:.1f}MB),请用 -n 限制行数"
return ToolResult.fail(f"文件过大 ({size/1024/1024:.1f}MB),请用 -n 限制行数", ErrorCode.INVALID_ARGS)
try:
with open(file_path, 'r', encoding='utf-8') as f:
@@ -195,13 +200,13 @@ class Plugin:
content = '\n'.join(f"{i+1:4d} | {l.rstrip()}" for i, l in enumerate(all_lines))
header = f" {file_path} ({size}B, {len(all_lines)} 行)"
return f"{header}\n{'─' * 50}\n{content}"
return ToolResult.ok(f"{header}\n{'─' * 50}\n{content}", tool_name="file_manager")
except UnicodeDecodeError:
return "错误:无法解码(可能是二进制文件)"
return ToolResult.fail("无法解码(可能是二进制文件)", ErrorCode.DECODE_ERROR)
def _op_write(self, args: List[str]) -> str:
def _op_write(self, args: List[str]):
if len(args) < 2:
return "错误:格式: write <文件> -c <内容>"
return ToolResult.fail("格式: write <文件> -c <内容>", ErrorCode.MISSING_ARGS)
fp = os.path.abspath(args[0])
content = None
i = 1
@@ -210,15 +215,15 @@ class Plugin:
content = args[i + 1]; i += 1
i += 1
if content is None:
return "错误:请用 -c 提供内容"
return ToolResult.missing("内容 (-c 参数)")
os.makedirs(os.path.dirname(fp) or ".", exist_ok=True)
with open(fp, 'w', encoding='utf-8') as f:
f.write(content)
return f" 已写入: {fp} ({len(content)} 字符)"
return ToolResult.ok(f"已写入: {fp} ({len(content)} 字符)", tool_name="file_manager")
def _op_append(self, args: List[str]) -> str:
def _op_append(self, args: List[str]):
if len(args) < 2:
return "错误:格式: append <文件> -c <内容>"
return ToolResult.fail("格式: append <文件> -c <内容>", ErrorCode.MISSING_ARGS)
fp = os.path.abspath(args[0])
content = None
i = 1
@@ -227,59 +232,59 @@ class Plugin:
content = args[i + 1]; i += 1
i += 1
if content is None:
return "错误:请用 -c 提供内容"
return ToolResult.missing("内容 (-c 参数)")
if not os.path.isfile(fp):
return f"错误:文件不存在: {fp}"
return ToolResult.not_found(f"文件: {fp}")
with open(fp, 'a', encoding='utf-8') as f:
f.write(content)
return f" 已追加: {fp}"
return ToolResult.ok(f"已追加: {fp}", tool_name="file_manager")
def _op_copy(self, args: List[str]) -> str:
def _op_copy(self, args: List[str]):
if len(args) < 2:
return "错误:请提供源和目标路径"
return ToolResult.missing("源和目标路径")
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
if not os.path.exists(src):
return f"错误:源不存在: {src}"
return ToolResult.not_found(f"源: {src}")
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
if os.path.isfile(src):
shutil.copy2(src, dst)
else:
shutil.copytree(src, dst, dirs_exist_ok=True)
return f" 已复制:\n {src}\n → {dst}"
return ToolResult.ok(f"已复制:\n {src}\n → {dst}", tool_name="file_manager")
def _op_move(self, args: List[str]) -> str:
def _op_move(self, args: List[str]):
if len(args) < 2:
return "错误:请提供源和目标路径"
return ToolResult.missing("源和目标路径")
src, dst = os.path.abspath(args[0]), os.path.abspath(args[1])
if not os.path.exists(src):
return f"错误:源不存在: {src}"
return ToolResult.not_found(f"源: {src}")
os.makedirs(os.path.dirname(dst) or ".", exist_ok=True)
shutil.move(src, dst)
return f" 已移动:\n {src}\n → {dst}"
return ToolResult.ok(f"已移动:\n {src}\n → {dst}", tool_name="file_manager")
def _op_delete(self, args: List[str]) -> str:
def _op_delete(self, args: List[str]):
if not args:
return "错误:请提供路径"
return ToolResult.missing("路径")
path = os.path.abspath(args[0])
recursive = "-r" in args
if not os.path.exists(path):
return f"错误:不存在: {path}"
return ToolResult.not_found(f"路径: {path}")
if os.path.isfile(path):
os.remove(path)
return f" 已删除文件: {path}"
return ToolResult.ok(f"已删除文件: {path}", tool_name="file_manager")
elif os.path.isdir(path):
if recursive:
shutil.rmtree(path)
return f" 已删除目录: {path}"
return ToolResult.ok(f"已删除目录: {path}", tool_name="file_manager")
elif not os.listdir(path):
os.rmdir(path)
return f" 已删除空目录: {path}"
return ToolResult.ok(f"已删除空目录: {path}", tool_name="file_manager")
else:
return "错误:目录不为空,用 -r 递归删除"
return ToolResult.fail("目录不为空,用 -r 递归删除", ErrorCode.INVALID_ARGS)
def _op_search(self, args: List[str]) -> str:
def _op_search(self, args: List[str]):
if not args:
return "错误:请提供搜索目录"
return ToolResult.missing("搜索目录")
directory = os.path.abspath(args[0])
pattern, content_search = None, None
i = 1
@@ -291,7 +296,7 @@ class Plugin:
i += 1
if not os.path.isdir(directory):
return f"错误:目录不存在: {directory}"
return ToolResult.not_found(f"目录: {directory}")
results = []
for root, dirs, files in os.walk(directory):
@@ -317,14 +322,14 @@ class Plugin:
if len(results) >= 50:
break
return f"搜索结果 ({len(results)}):\n" + '\n'.join(results) if results else "未找到匹配"
return ToolResult.ok(f"搜索结果 ({len(results)}):\n" + '\n'.join(results), tool_name="file_manager") if results else ToolResult.ok("未找到匹配", tool_name="file_manager")
def _op_info(self, args: List[str]) -> str:
def _op_info(self, args: List[str]):
if not args:
return "错误:请提供路径"
return ToolResult.missing("路径")
path = os.path.abspath(args[0])
if not os.path.exists(path):
return f"错误:不存在: {path}"
return ToolResult.not_found(f"路径: {path}")
stat = os.stat(path)
result = [
@@ -337,13 +342,13 @@ class Plugin:
if os.path.isfile(path):
ext = os.path.splitext(path)[1]
result.append(f"扩展名: {ext or '无'}")
return '\n'.join(result)
return ToolResult.ok('\n'.join(result), tool_name="file_manager")
def _op_mkdir(self, args: List[str]) -> str:
def _op_mkdir(self, args: List[str]):
if not args:
return "错误:请提供目录路径"
return ToolResult.missing("目录路径")
path = os.path.abspath(args[0])
if os.path.exists(path):
return f"错误:已存在: {path}"
return ToolResult.fail(f"已存在: {path}", ErrorCode.INVALID_ARGS)
os.makedirs(path, exist_ok=True)
return f" 已创建: {path}"
return ToolResult.ok(f"已创建: {path}", tool_name="file_manager")
+3 -2
View File
@@ -4,6 +4,7 @@
"""
import os
import re
import subprocess
from colorama import Fore, Style
@@ -317,7 +318,7 @@ JSON格式示例:
"message": "使用了var声明,建议使用let或const"
})
if '==' in content and '!==' not in content:
if re.search(r'(?<![=!])==(?!=)', content):
issues.append({
"level": "warning",
"message": "使用了==比较,建议使用===严格相等"
@@ -443,7 +444,7 @@ JSON格式示例:
# 测试函数
def test_plugin():
"""测试插件功能"""
plugin = Plugin()
plugin = Liugin()
print("前端测试验证插件测试:")
print("\n1. 创建测试HTML文件:")
+2 -2
View File
@@ -8,7 +8,7 @@ import subprocess
from typing import Tuple, Optional
class Plugin:
class Liugin:
"""Git 版本控制 - 基础操作 + 工作流自动化"""
def __init__(self):
@@ -354,7 +354,7 @@ class Plugin:
def _auto_commit_message(self, status: str) -> str:
lines = status.strip().split('\n')
added = len([l for l in lines if l.startswith('A ') or 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')])
deleted = len([l for l in lines if l.startswith('D ') or (len(l) > 1 and l[1] == 'D')])
+1036
View File
File diff suppressed because it is too large. Load diff
+383
View File
@@ -0,0 +1,383 @@
"""
UUID / ID 生成器插件 - 生成各种格式的唯一标识符
支持 UUID v1/v4/v5、NanoID、短ID、雪花ID、自定义格式
"""
import uuid
import hashlib
import time
import random
import string
import json
import os
import struct
from datetime import datetime
from typing import List
class Liugin:
"""UUID / ID 生成器 - 多格式唯一标识符生成"""
def __init__(self):
self.usage = """UUID / ID 生成器
操作:
uuid [-v 版本] [-n 数量] - 生成 UUID(默认 v4)
v1=时间戳 v4=随机 v5=命名
nanoid [-s 长度] [-n 数量] - 生成 NanoID(默认21位)
short [-s 长度] [-n 数量] - 生成短ID(默认8位)
snowflake [-n 数量] - 生成雪花ID(时间排序)
custom <模板> [-n 数量] - 自定义格式生成
模板: {U}=大写字母 {L}=小写 {D}=数字 {X}=十六进制
ulid [-n 数量] - 生成 ULID(时间排序+随机)
timestamp - 当前时间戳(秒/毫秒/微秒)
from_name <命名空间> <名称> - 基于名称生成确定性 UUID (v5)
batch <类型> [-n 数量] [-o 文件] - 批量生成并保存到文件
示例:
id_generator uuid
id_generator uuid -v 1 -n 5
id_generator nanoid -s 16
id_generator short -s 6 -n 10
id_generator custom "USER-{D}{D}{D}{D}{U}{U}" -n 5
id_generator snowflake -n 3
id_generator batch uuid -n 100 -o ids.txt
"""
self.cli = None
self._machine_id = random.randint(0, 1023)
self._sequence = 0
self._last_ts = 0
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "id_generator",
"description": "UUID/ID 生成器 - 支持 UUID、NanoID、短ID、雪花ID、ULID、自定义格式",
"keywords": ["uuid", "id", "生成", "唯一", "标识", "nanoid", "snowflake", "ulid", "短id"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "id_generator",
"description": "UUID/ID 生成器,支持多种格式",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["uuid", "nanoid", "short", "snowflake", "custom",
"ulid", "timestamp", "from_name", "batch"],
"description": "操作类型"
},
"version": {"type": "integer", "description": "UUID 版本 (1/4/5)"},
"length": {"type": "integer", "description": "ID 长度"},
"count": {"type": "integer", "description": "生成数量"},
"template": {"type": "string", "description": "自定义模板"},
"namespace": {"type": "string", "description": "命名空间"},
"name": {"type": "string", "description": "名称"},
"output": {"type": "string", "description": "输出文件"}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
count = arguments.get("count", "")
parts = [op]
if count:
parts.extend(["-n", str(count)])
return " ".join(parts)
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return self._op_uuid([])
operation = parts[0].lower()
handlers = {
"uuid": self._op_uuid,
"nanoid": self._op_nanoid,
"short": self._op_short,
"snowflake": self._op_snowflake,
"custom": self._op_custom,
"ulid": self._op_ulid,
"timestamp": self._op_timestamp,
"from_name": self._op_from_name,
"batch": self._op_batch,
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return handler(parts[1:])
except Exception as e:
return f"ID 生成错误: {str(e)}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == ' ' and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _parse_opts(self, args: List[str]) -> dict:
opts = {}
i = 0
while i < len(args):
if args[i].startswith("-") and i + 1 < len(args):
key = args[i].lstrip("-")
opts[key] = args[i + 1]
i += 1
i += 1
return opts
def _op_uuid(self, args: List[str]) -> str:
opts = self._parse_opts(args)
version = int(opts.get("v", 4))
count = int(opts.get("n", 1))
count = min(count, 1000)
ids = []
for _ in range(count):
if version == 1:
ids.append(str(uuid.uuid1()))
elif version == 4:
ids.append(str(uuid.uuid4()))
elif version == 5:
ids.append(str(uuid.uuid5(uuid.NAMESPACE_DNS, "example.com")))
else:
return f"错误:不支持的 UUID 版本: {version}"
header = f" UUID v{version} ({count} 个):\n"
if count <= 20:
return header + "\n".join(f" {u}" for u in ids)
else:
return header + "\n".join(f" {u}" for u in ids[:10]) + f"\n ... (共 {count} 个)"
def _op_nanoid(self, args: List[str]) -> str:
opts = self._parse_opts(args)
size = int(opts.get("s", 21))
count = int(opts.get("n", 1))
count = min(count, 1000)
alphabet = string.ascii_letters + string.digits + "_-"
ids = []
for _ in range(count):
ids.append(''.join(random.choices(alphabet, k=size)))
header = f" NanoID (长度 {size}, {count} 个):\n"
if count <= 20:
return header + "\n".join(f" {i}" for i in ids)
else:
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
def _op_short(self, args: List[str]) -> str:
opts = self._parse_opts(args)
size = int(opts.get("s", 8))
count = int(opts.get("n", 1))
count = min(count, 1000)
chars = string.ascii_letters + string.digits
ids = []
for _ in range(count):
ids.append(''.join(random.choices(chars, k=size)))
header = f" 短ID (长度 {size}, {count} 个):\n"
if count <= 20:
return header + "\n".join(f" {i}" for i in ids)
else:
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
def _op_snowflake(self, args: List[str]) -> str:
opts = self._parse_opts(args)
count = int(opts.get("n", 1))
count = min(count, 1000)
# Twitter Snowflake: 41位时间戳 + 10位机器ID + 12位序列号
epoch = 1288834974657 # Twitter epoch (2010-11-04)
ids = []
for _ in range(count):
ts = int(time.time() * 1000) - epoch
if ts == self._last_ts:
self._sequence = (self._sequence + 1) & 0xFFF
if self._sequence == 0:
while ts <= self._last_ts:
ts = int(time.time() * 1000) - epoch
else:
self._sequence = 0
self._last_ts = ts
snowflake_id = (ts << 22) | (self._machine_id << 12) | self._sequence
ids.append(str(snowflake_id))
header = f"❄️ 雪花ID ({count} 个):\n"
if count <= 20:
return header + "\n".join(f" {i}" for i in ids)
else:
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
def _op_custom(self, args: List[str]) -> str:
if not args:
return "错误:请提供模板\n示例: custom \"ORDER-{D}{D}{D}{D}-{U}{U}{U}\""
template = args[0]
opts = self._parse_opts(args[1:])
count = int(opts.get("n", 1))
count = min(count, 1000)
ids = []
for _ in range(count):
result = []
for ch in template:
if ch == '{':
continue # handled below
result.append(ch)
# Actually use proper template parsing
ids.append(self._expand_template(template))
header = f" ️ 自定义ID (模板: {template}, {count} 个):\n"
if count <= 20:
return header + "\n".join(f" {i}" for i in ids)
else:
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
def _expand_template(self, template: str) -> str:
result = []
i = 0
while i < len(template):
if template[i] == '{' and i + 2 < len(template) and template[i + 2] == '}':
token = template[i + 1]
if token == 'U':
result.append(random.choice(string.ascii_uppercase))
elif token == 'L':
result.append(random.choice(string.ascii_lowercase))
elif token == 'D':
result.append(random.choice(string.digits))
elif token == 'X':
result.append(random.choice('0123456789ABCDEF'))
elif token == 'x':
result.append(random.choice('0123456789abcdef'))
elif token == 'A':
result.append(random.choice(string.ascii_letters))
else:
result.append(template[i:i + 3])
i += 3
else:
result.append(template[i])
i += 1
return ''.join(result)
def _op_ulid(self, args: List[str]) -> str:
opts = self._parse_opts(args)
count = int(opts.get("n", 1))
count = min(count, 1000)
# ULID: 48位时间戳 + 80位随机数, Crockford Base32
alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
ids = []
for _ in range(count):
ts = int(time.time() * 1000)
# 时间部分 (10 chars)
t_chars = []
t = ts
for _ in range(10):
t_chars.append(alphabet[t & 0x1F])
t >>= 5
t_chars.reverse()
# 随机部分 (16 chars)
r_chars = [random.choice(alphabet) for _ in range(16)]
ids.append(''.join(t_chars) + ''.join(r_chars))
header = f" ULID ({count} 个):\n"
if count <= 20:
return header + "\n".join(f" {i}" for i in ids)
else:
return header + "\n".join(f" {i}" for i in ids[:10]) + f"\n ... (共 {count} 个)"
def _op_timestamp(self, args: List[str]) -> str:
now = time.time()
return (f"⏱ 当前时间戳:\n"
f" 秒: {int(now)}\n"
f" 毫秒: {int(now * 1000)}\n"
f" 微秒: {int(now * 1000000)}\n"
f" ISO: {datetime.now().isoformat()}")
def _op_from_name(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: from_name <命名空间> <名称>\n命名空间: dns, url, oid, x500"
ns_map = {"dns": uuid.NAMESPACE_DNS, "url": uuid.NAMESPACE_URL,
"oid": uuid.NAMESPACE_OID, "x500": uuid.NAMESPACE_X500}
ns = ns_map.get(args[0].lower(), uuid.NAMESPACE_DNS)
name = args[1]
result = uuid.uuid5(ns, name)
return f" 确定性 UUID v5:\n 命名空间: {args[0]}\n 名称: {name}\n UUID: {result}"
def _op_batch(self, args: List[str]) -> str:
if not args:
return "错误:请提供类型 (uuid/nanoid/short/snowflake/ulid)"
id_type = args[0]
opts = self._parse_opts(args[1:])
count = int(opts.get("n", 10))
output = opts.get("o", "")
# 生成对应类型的ID
gen_args = [f"-n", str(count)]
handlers = {
"uuid": self._op_uuid,
"nanoid": self._op_nanoid,
"short": self._op_short,
"snowflake": self._op_snowflake,
"ulid": self._op_ulid,
}
handler = handlers.get(id_type)
if not handler:
return f"错误:不支持的类型 '{id_type}'"
result = handler(gen_args)
if output:
# 提取纯ID保存到文件
ids = [line.strip().lstrip(' ') for line in result.split('\n')
if line.strip() and not any(c in line for c in [' ', '️', '❄️'])]
try:
with open(output, 'w', encoding='utf-8') as f:
f.write('\n'.join(ids))
return f"{result}\n\n 已保存到: {output}"
except Exception as e:
return f"{result}\n\n 保存失败: {e}"
return result
def test_plugin():
plugin = Liugin()
print("ID 生成器测试:")
print(plugin.handle('uuid'))
print(plugin.handle('uuid -v 1 -n 3'))
print(plugin.handle('nanoid -s 16'))
print(plugin.handle('short -s 6 -n 5'))
print(plugin.handle('snowflake -n 3'))
print(plugin.handle('ulid'))
print(plugin.handle('timestamp'))
print(plugin.handle('custom "ORD-{D}{D}{D}{D}-{U}{U}{U}" -n 5'))
if __name__ == "__main__":
test_plugin()
-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 Plugin:
"""图像生成工具 — 自然语言/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"
+386
View File
@@ -0,0 +1,386 @@
"""
JSON 工具箱插件 - JSON 格式化、校验、查询、转换
提供 JSON 美化、压缩、验证、路径查询、类型转换等功能
"""
import json
import os
import re
from typing import List
class Liugin:
"""JSON 工具箱 - 格式化/校验/查询/转换"""
def __init__(self):
self.usage = """JSON 工具箱
操作:
format <文件或JSON> - 美化 JSON(自动缩进)
minify <文件或JSON> - 压缩 JSON(去除空白)
validate <文件或JSON> - 校验 JSON 是否合法
query <文件或JSON> <路径> - 按路径查询值(如 users[0].name)
keys <文件或JSON> - 列出所有键(扁平化)
diff <文件1> <文件2> - 比较两个 JSON 的差异
to_csv <文件或JSON> - 将 JSON 数组转为 CSV
merge <文件1> <文件2> - 合并两个 JSON 对象
sample <文件或JSON> [-n 数量] - 从数组中随机采样
schema <文件或JSON> - 推断 JSON Schema
示例:
json_toolkit format data.json
json_toolkit validate '{"name": "test"}'
json_toolkit query config.json "database.host"
json_toolkit keys data.json
json_toolkit diff a.json b.json
"""
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "json_toolkit",
"description": "JSON 工具箱 - 格式化、压缩、校验、路径查询、键提取、差异比较、转换",
"keywords": ["json", "格式化", "校验", "查询", "格式", "转换", "美化", "压缩", "validate", "format"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "json_toolkit",
"description": "JSON 工具箱,支持格式化、压缩、校验、路径查询、键提取、差异比较",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["format", "minify", "validate", "query", "keys",
"diff", "to_csv", "merge", "sample", "schema"],
"description": "操作类型"
},
"input": {"type": "string", "description": "JSON 字符串或文件路径"},
"path": {"type": "string", "description": "查询路径(query 操作用)"},
"input2": {"type": "string", "description": "第二个 JSON 输入(diff/merge 用)"},
"count": {"type": "integer", "description": "采样数量(sample 操作用)"}
},
"required": ["operation", "input"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
inp = arguments.get("input", "")
path = arguments.get("path", "")
parts = [op, inp]
if path:
parts.append(path)
return " ".join(parts)
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return "错误:请提供操作类型"
operation = parts[0].lower()
handlers = {
"format": self._op_format,
"minify": self._op_minify,
"validate": self._op_validate,
"query": self._op_query,
"keys": self._op_keys,
"diff": self._op_diff,
"to_csv": self._op_to_csv,
"merge": self._op_merge,
"sample": self._op_sample,
"schema": self._op_schema,
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'\n支持: {', '.join(handlers.keys())}"
return handler(parts[1:])
except Exception as e:
return f"JSON 工具错误: {str(e)}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == ' ' and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _load_json(self, text: str):
"""加载 JSON(支持文件路径或 JSON 字符串)"""
if os.path.isfile(text):
with open(text, 'r', encoding='utf-8') as f:
return json.load(f), f"文件: {text}"
try:
return json.loads(text), "JSON 字符串"
except json.JSONDecodeError as e:
raise ValueError(f"无效的 JSON: {e}")
def _op_format(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
data, source = self._load_json(args[0])
formatted = json.dumps(data, indent=2, ensure_ascii=False)
return f"✅ 格式化结果 ({source}):\n{formatted}"
def _op_minify(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
data, source = self._load_json(args[0])
minified = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
original_len = len(json.dumps(data, indent=2, ensure_ascii=False))
saved = original_len - len(minified)
return f"✅ 压缩结果 ({source}):\n{minified}\n\n📊 大小: {len(minified)} 字符 (节省 {saved} 字符, {saved/original_len*100:.1f}%)"
def _op_validate(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
text = args[0]
try:
if os.path.isfile(text):
with open(text, 'r', encoding='utf-8') as f:
data = json.load(f)
source = f"文件: {text}"
else:
data = json.loads(text)
source = "JSON 字符串"
depth = self._get_depth(data)
keys_count = self._count_keys(data)
result = f"✅ JSON 合法 ({source})\n"
result += f" 类型: {type(data).__name__}\n"
result += f" 嵌套深度: {depth}\n"
result += f" 键总数: {keys_count}"
return result
except json.JSONDecodeError as e:
return f"❌ JSON 无效:\n 行 {e.lineno}, 列 {e.colno}: {e.msg}"
except Exception as e:
return f"❌ 验证失败: {e}"
def _op_query(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: query <JSON或文件> <路径>\n示例: query data.json users[0].name"
data, _ = self._load_json(args[0])
path = args[1]
result = data
parts = re.findall(r'\w+|\[\d+\]', path)
current_path = ""
for part in parts:
if part.startswith('['):
idx = int(part[1:-1])
current_path += part
if isinstance(result, list) and 0 <= idx < len(result):
result = result[idx]
else:
return f"❌ 路径 '{current_path}' 无效: 索引越界或非数组"
else:
current_path += f".{part}" if current_path else part
if isinstance(result, dict) and part in result:
result = result[part]
else:
return f"❌ 路径 '{current_path}' 无效: 键不存在"
output = json.dumps(result, indent=2, ensure_ascii=False) if isinstance(result, (dict, list)) else str(result)
return f"🔍 查询结果 ({path}):\n{output}"
def _op_keys(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
data, _ = self._load_json(args[0])
keys = []
self._flatten_keys(data, "", keys)
result = f"🔑 键列表 (共 {len(keys)} 个):\n"
for k in keys:
result += f" {k}\n"
return result.rstrip()
def _flatten_keys(self, obj, prefix, keys):
if isinstance(obj, dict):
for k, v in obj.items():
full_key = f"{prefix}.{k}" if prefix else k
keys.append(full_key)
self._flatten_keys(v, full_key, keys)
elif isinstance(obj, list) and obj:
self._flatten_keys(obj[0], f"{prefix}[0]", keys)
def _op_diff(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:请提供两个 JSON 文件或字符串"
data1, src1 = self._load_json(args[0])
data2, src2 = self._load_json(args[1])
diffs = []
self._compare(data1, data2, "", diffs)
if not diffs:
return "✅ 两个 JSON 完全相同"
result = f"🔀 差异比较 ({len(diffs)} 处不同):\n"
for d in diffs[:50]:
result += f" {d}\n"
if len(diffs) > 50:
result += f" ... 还有 {len(diffs) - 50} 处差异"
return result.rstrip()
def _compare(self, a, b, path, diffs):
if type(a) != type(b):
diffs.append(f" 类型不同 @ {path or '根'}: {type(a).__name__} vs {type(b).__name__}")
return
if isinstance(a, dict):
all_keys = set(a.keys()) | set(b.keys())
for k in sorted(all_keys):
p = f"{path}.{k}" if path else k
if k not in a:
diffs.append(f"➕ 新增 @ {p}: {b[k]}")
elif k not in b:
diffs.append(f"➖ 删除 @ {p}: {a[k]}")
else:
self._compare(a[k], b[k], p, diffs)
elif isinstance(a, list):
if len(a) != len(b):
diffs.append(f" 数组长度不同 @ {path}: {len(a)} vs {len(b)}")
for i in range(min(len(a), len(b))):
self._compare(a[i], b[i], f"{path}[{i}]", diffs)
else:
if a != b:
diffs.append(f" 值不同 @ {path}: {a!r} → {b!r}")
def _op_to_csv(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
data, _ = self._load_json(args[0])
if not isinstance(data, list) or not data:
return "错误:需要非空 JSON 数组"
if not isinstance(data[0], dict):
return "错误:数组元素必须是对象"
headers = list(data[0].keys())
lines = [",".join(headers)]
for item in data:
row = []
for h in headers:
val = str(item.get(h, ""))
if "," in val or '"' in val or "\n" in val:
val = '"' + val.replace('"', '""') + '"'
row.append(val)
lines.append(",".join(row))
csv_content = "\n".join(lines)
return f"📊 CSV 转换结果 ({len(data)} 行):\n{csv_content}"
def _op_merge(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:请提供两个 JSON 文件或字符串"
data1, _ = self._load_json(args[0])
data2, _ = self._load_json(args[1])
if not isinstance(data1, dict) or not isinstance(data2, dict):
return "错误:合并仅支持 JSON 对象"
merged = self._deep_merge(data1, data2)
return f"✅ 合并结果:\n{json.dumps(merged, indent=2, ensure_ascii=False)}"
def _deep_merge(self, a, b):
result = a.copy()
for k, v in b.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = self._deep_merge(result[k], v)
else:
result[k] = v
return result
def _op_sample(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
data, _ = self._load_json(args[0])
count = 3
for i, a in enumerate(args):
if a == "-n" and i + 1 < len(args):
count = int(args[i + 1])
if not isinstance(data, list):
return "错误:需要 JSON 数组"
import random
sampled = random.sample(data, min(count, len(data)))
return f"🎲 随机采样 ({len(sampled)}/{len(data)}):\n{json.dumps(sampled, indent=2, ensure_ascii=False)}"
def _op_schema(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 文件或字符串"
data, _ = self._load_json(args[0])
schema = self._infer_schema(data)
return f"📋 推断 Schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
def _infer_schema(self, obj, depth=0):
if depth > 10:
return {"type": "any"}
if isinstance(obj, dict):
props = {}
for k, v in obj.items():
props[k] = self._infer_schema(v, depth + 1)
return {"type": "object", "properties": props}
elif isinstance(obj, list):
if obj:
return {"type": "array", "items": self._infer_schema(obj[0], depth + 1)}
return {"type": "array", "items": {}}
elif isinstance(obj, bool):
return {"type": "boolean"}
elif isinstance(obj, int):
return {"type": "integer"}
elif isinstance(obj, float):
return {"type": "number"}
else:
return {"type": "string"}
def _get_depth(self, obj, depth=0):
if depth > 50:
return depth
if isinstance(obj, dict):
return max((self._get_depth(v, depth + 1) for v in obj.values()), default=depth)
elif isinstance(obj, list):
return max((self._get_depth(v, depth + 1) for v in obj), default=depth)
return depth
def _count_keys(self, obj):
if isinstance(obj, dict):
return len(obj) + sum(self._count_keys(v) for v in obj.values())
elif isinstance(obj, list):
return sum(self._count_keys(v) for v in obj)
return 0
def test_plugin():
plugin = Liugin()
print("JSON 工具箱测试:")
print(plugin.handle('validate \'{"name": "test", "age": 25}\''))
print(plugin.handle('format \'{"name":"test","items":[1,2,3]}\''))
print(plugin.handle('keys \'{"database": {"host": "localhost", "port": 3306}}\''))
print(plugin.handle('query \'{"users": [{"name": "Alice"}]}\' users[0].name'))
if __name__ == "__main__":
test_plugin()
+540
View File
@@ -0,0 +1,540 @@
"""
Markdown 工具插件 - Markdown 渲染、目录生成、格式转换
支持 TOC 生成、标题提取、表格生成、格式化、统计
"""
import re
import json
import os
from typing import List, Tuple
class Liugin:
"""Markdown 工具 - TOC/渲染/格式转换/统计"""
def __init__(self):
self.usage = """Markdown 工具
操作:
toc <文件或Markdown> - 生成目录 (Table of Contents)
headings <文件或Markdown> - 提取所有标题
stats <文件或Markdown> - 统计信息(字数/行数/段落数等)
table <数据> - 从 JSON 数组生成 Markdown 表格
checklist <文件> - 提取所有待办事项
links <文件或Markdown> - 提取所有链接
images <文件或Markdown> - 提取所有图片
frontmatter <文件> - 提取 YAML frontmatter
format <文件或Markdown> - 格式化(标题层级、列表缩进等)
wordcount <文件或Markdown> - 详细字数统计
outline <文件或Markdown> - 生成大纲视图
lint <文件> - Markdown 语法检查
示例:
markdown_tool toc README.md
markdown_tool stats article.md
markdown_tool table '[{"name":"Alice","age":25},{"name":"Bob","age":30}]'
markdown_tool checklist TODO.md
markdown_tool links README.md
markdown_tool lint README.md
"""
self.cli = None
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "markdown_tool",
"description": "Markdown 工具 - TOC 生成、标题提取、统计、表格生成、语法检查",
"keywords": ["markdown", "md", "目录", "toc", "标题", "表格", "统计", "链接", "检查"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "markdown_tool",
"description": "Markdown 工具,支持 TOC、统计、表格生成、链接提取、语法检查",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["toc", "headings", "stats", "table", "checklist",
"links", "images", "frontmatter", "format",
"wordcount", "outline", "lint"],
"description": "操作类型"
},
"input": {"type": "string", "description": "Markdown 文件路径或内容"},
"data": {"type": "string", "description": "JSON 数据(table 操作用)"}
},
"required": ["operation", "input"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
inp = arguments.get("input", "")
return f"{op} {inp}".strip()
def _load_content(self, text: str) -> Tuple[str, str]:
"""加载内容(文件路径或文本)"""
if os.path.isfile(text):
with open(text, 'r', encoding='utf-8') as f:
return f.read(), f"文件: {text}"
return text, "文本输入"
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return "错误:请提供操作类型"
operation = parts[0].lower()
handlers = {
"toc": self._op_toc,
"headings": self._op_headings,
"stats": self._op_stats,
"table": self._op_table,
"checklist": self._op_checklist,
"links": self._op_links,
"images": self._op_images,
"frontmatter": self._op_frontmatter,
"format": self._op_format,
"wordcount": self._op_wordcount,
"outline": self._op_outline,
"lint": self._op_lint,
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return handler(parts[1:])
except Exception as e:
return f"Markdown 工具错误: {str(e)}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == ' ' and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _extract_headings(self, content: str) -> List[Tuple[int, str, str]]:
"""提取标题: [(level, text, slug)]"""
headings = []
in_code = False
for line in content.split('\n'):
if line.strip().startswith('```'):
in_code = not in_code
continue
if in_code:
continue
match = re.match(r'^(#{1,6})\s+(.+)', line)
if match:
level = len(match.group(1))
text = match.group(2).strip()
# 生成 slug
slug = re.sub(r'[^\w\s-]', '', text.lower())
slug = re.sub(r'\s+', '-', slug.strip())
headings.append((level, text, slug))
return headings
def _op_toc(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
headings = self._extract_headings(content)
if not headings:
return "📭 未找到标题"
result = f" 目录 ({source}):\n"
result += "─" * 40 + "\n"
for level, text, slug in headings:
indent = " " * (level - 1)
result += f"{indent}- [{text}](#{slug})\n"
return result.rstrip()
def _op_headings(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
headings = self._extract_headings(content)
if not headings:
return "📭 未找到标题"
result = f" 标题列表 ({len(headings)} 个):\n"
for level, text, _ in headings:
prefix = "#" * level
result += f" {prefix:8s} {text}\n"
return result.rstrip()
def _op_stats(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
lines = content.split('\n')
total_lines = len(lines)
blank_lines = sum(1 for l in lines if not l.strip())
code_blocks = content.count('```') // 2
headings = len(self._extract_headings(content))
links = len(re.findall(r'\[([^\]]+)\]\([^)]+\)', content))
images = len(re.findall(r'!\[([^\]]*)\]\([^)]+\)', content))
# 中文字符
chinese = len(re.findall(r'[\u4e00-\u9fff]', content))
# 英文单词
english = len(re.findall(r'[a-zA-Z]+', content))
# 段落数(连续非空行)
paragraphs = 0
in_para = False
for l in lines:
if l.strip():
if not in_para:
paragraphs += 1
in_para = True
else:
in_para = False
result = f"📊 Markdown 统计 ({source})\n"
result += "─" * 35 + "\n"
result += f" 总行数: {total_lines}\n"
result += f" 空行: {blank_lines}\n"
result += f" 段落: {paragraphs}\n"
result += f" 代码块: {code_blocks}\n"
result += f" 标题: {headings}\n"
result += f" 链接: {links}\n"
result += f" 图片: {images}\n"
result += f" 中文字: {chinese}\n"
result += f" 英文词: {english}\n"
result += f" 字符数: {len(content)}\n"
return result.rstrip()
def _op_table(self, args: List[str]) -> str:
if not args:
return "错误:请提供 JSON 数组"
data_str = args[0]
try:
data = json.loads(data_str)
except json.JSONDecodeError as e:
return f"❌ JSON 解析错误: {e}"
if not isinstance(data, list) or not data:
return "❌ 需要非空 JSON 数组"
if not isinstance(data[0], dict):
return "❌ 数组元素必须是对象"
headers = list(data[0].keys())
# 计算列宽
widths = {h: len(h) for h in headers}
for row in data:
for h in headers:
widths[h] = max(widths[h], len(str(row.get(h, ""))))
# 生成表格
header_line = "| " + " | ".join(h.ljust(widths[h]) for h in headers) + " |"
sep_line = "| " + " | ".join("-" * widths[h] for h in headers) + " |"
rows = []
for row in data:
rows.append("| " + " | ".join(str(row.get(h, "")).ljust(widths[h]) for h in headers) + " |")
result = f" Markdown 表格 ({len(data)} 行):\n\n"
result += header_line + "\n" + sep_line + "\n" + "\n".join(rows)
return result
def _op_checklist(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件"
content, source = self._load_content(args[0])
todos = []
done = []
for i, line in enumerate(content.split('\n'), 1):
match = re.match(r'^(\s*)[-*]\s*\[([ xX])\]\s*(.+)', line)
if match:
indent = match.group(1)
checked = match.group(2).lower() == 'x'
text = match.group(3).strip()
if checked:
done.append((i, text))
else:
todos.append((i, text))
result = f" 待办事项 ({source}):\n"
result += "─" * 45 + "\n"
result += f" ☑ 已完成: {len(done)} 个\n"
result += f" 未完成: {len(todos)} 个\n"
result += "─" * 45 + "\n"
if todos:
result += "\n 未完成:\n"
for line_no, text in todos[:30]:
result += f" L{line_no:4d} {text}\n"
if done:
result += "\n 已完成:\n"
for line_no, text in done[:20]:
result += f" L{line_no:4d} {text}\n"
return result.rstrip()
def _op_links(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', content)
if not links:
return f"📭 未找到链接 ({source})"
result = f" 链接列表 ({len(links)} 个, {source}):\n"
result += "─" * 50 + "\n"
seen = set()
for text, url in links:
if url not in seen:
seen.add(url)
result += f" {text[:30]:30s} → {url}\n"
return result.rstrip()
def _op_images(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
images = re.findall(r'!\[([^\]]*)\]\(([^)]+)\)', content)
if not images:
return f"📭 未找到图片 ({source})"
result = f" ️ 图片列表 ({len(images)} 个, {source}):\n"
result += "─" * 50 + "\n"
seen = set()
for alt, url in images:
if url not in seen:
seen.add(url)
result += f" {alt[:25]:25s} → {url}\n"
return result.rstrip()
def _op_frontmatter(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件"
content, source = self._load_content(args[0])
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
if not match:
return f"📭 未找到 frontmatter ({source})"
fm = match.group(1)
result = f" Frontmatter ({source}):\n"
result += "─" * 35 + "\n"
result += fm
return result
def _op_format(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
lines = content.split('\n')
formatted = []
prev_blank = False
for line in lines:
stripped = line.strip()
# 确保标题前后有空行
if re.match(r'^#{1,6}\s', stripped):
if formatted and not prev_blank:
formatted.append('')
formatted.append(stripped)
prev_blank = False
continue
# 确保列表项格式一致
if re.match(r'^[-*]\s', stripped):
formatted.append(re.sub(r'^[-*]\s', '- ', stripped))
prev_blank = False
continue
# 去除多余空行
if not stripped:
if not prev_blank:
formatted.append('')
prev_blank = True
continue
prev_blank = False
formatted.append(line.rstrip())
# 确保文件末尾有换行
while formatted and not formatted[-1]:
formatted.pop()
formatted.append('')
return f"✅ 格式化完成 ({source}):\n" + '\n'.join(formatted)
def _op_wordcount(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
# 移除代码块
no_code = re.sub(r'```[\s\S]*?```', '', content)
# 移除行内代码
no_code = re.sub(r'`[^`]+`', '', no_code)
# 移除链接标记
no_code = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', no_code)
# 移除图片标记
no_code = re.sub(r'!\[([^\]]*)\]\([^)]+\)', '', no_code)
# 移除标题标记
no_code = re.sub(r'^#{1,6}\s+', '', no_code, flags=re.MULTILINE)
# 移除加粗/斜体标记
no_code = re.sub(r'\*{1,2}([^*]+)\*{1,2}', r'\1', no_code)
chinese = len(re.findall(r'[\u4e00-\u9fff]', no_code))
english_words = re.findall(r'[a-zA-Z]+', no_code)
english = len(english_words)
total_chars = len(no_code.strip())
# 阅读时间估算(中文 300字/分钟, 英文 200词/分钟)
read_time_cn = chinese / 300
read_time_en = english / 200
read_time = read_time_cn + read_time_en
result = f" 详细字数统计 ({source}):\n"
result += "─" * 35 + "\n"
result += f" 中文字数: {chinese}\n"
result += f" 英文单词: {english}\n"
result += f" 总字符数: {total_chars}\n"
result += f" 预计阅读: {read_time:.1f} 分钟\n"
return result.rstrip()
def _op_outline(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件或内容"
content, source = self._load_content(args[0])
headings = self._extract_headings(content)
if not headings:
return "📭 未找到标题"
result = f" 大纲视图 ({source}):\n"
result += "═" * 40 + "\n"
for level, text, _ in headings:
if level == 1:
result += f"\n {text}\n"
result += "─" * 30 + "\n"
elif level == 2:
result += f" {text}\n"
elif level == 3:
result += f" {text}\n"
else:
result += f" {'·' * (level - 3)} {text}\n"
return result.rstrip()
def _op_lint(self, args: List[str]) -> str:
if not args:
return "错误:请提供 Markdown 文件"
file_path = args[0]
if not os.path.isfile(file_path):
return f"❌ 文件不存在: {file_path}"
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
lines = content.split('\n')
issues = []
in_code = False
prev_blank = False
heading_levels = []
for i, line in enumerate(lines, 1):
stripped = line.strip()
# 代码块跟踪
if stripped.startswith('```'):
in_code = not in_code
continue
if in_code:
continue
# 检查标题格式
heading_match = re.match(r'^(#{1,6})\s+(.+)', line)
if heading_match:
level = len(heading_match.group(1))
text = heading_match.group(2)
# 检查标题前后空行
if i > 1 and lines[i - 2].strip() and not lines[i - 2].startswith('#'):
issues.append(f" L{i:4d} 标题前缺少空行")
# 检查标题层级跳跃
if heading_levels and level > heading_levels[-1] + 1:
issues.append(f" L{i:4d} 标题层级跳跃 ({heading_levels[-1]} → {level})")
heading_levels.append(level)
# 检查标题末尾空格
if text != text.rstrip():
issues.append(f" L{i:4d} 标题末尾有多余空格")
else:
# 检查行尾空格
if line != line.rstrip() and stripped:
trailing = len(line) - len(line.rstrip())
if trailing >= 2: # 2个以上空格是换行符
pass # 这是合法的 Markdown 换行
elif trailing > 0:
issues.append(f" L{i:4d} 行尾有多余空格")
# 检查连续空行
if not stripped:
if prev_blank:
issues.append(f" L{i:4d} 连续空行")
prev_blank = True
else:
prev_blank = False
# 检查列表格式
list_match = re.match(r'^(\s*)[-*+]\s', line)
if list_match:
indent = len(list_match.group(1))
if indent % 2 != 0 and indent > 0:
issues.append(f" L{i:4d} 列表缩进不是2的倍数")
# 检查链接格式
if re.search(r'\[[^\]]*\]\(\s*\)', line):
issues.append(f" L{i:4d} 链接URL为空")
# 检查重复词
words = stripped.lower().split()
for j in range(len(words) - 1):
if len(words[j]) > 2 and words[j] == words[j + 1]:
issues.append(f" L{i:4d} 重复词: '{words[j]}'")
result = f" ️ Markdown 语法检查 ({file_path}):\n"
result += "─" * 45 + "\n"
if not issues:
result += " 未发现问题!\n"
else:
result += f" 发现 {len(issues)} 个问题:\n\n"
for issue in issues[:50]:
result += f" {issue}\n"
if len(issues) > 50:
result += f"\n ... 还有 {len(issues) - 50} 个问题\n"
return result.rstrip()
def test_plugin():
plugin = Liugin()
print("Markdown 工具测试:")
print(plugin.handle('headings "# Hello\\n## World\\n### Sub"'))
print(plugin.handle('stats "# Hello\\n\\nThis is a test."'))
print(plugin.handle('table \'[{"name":"Alice","age":25},{"name":"Bob","age":30}]\''))
if __name__ == "__main__":
test_plugin()
+1 -1
View File
@@ -13,7 +13,7 @@ sys.path.insert(0, project_root)
from xcli_core.memory import MemoryManager
class Plugin:
class Liugin:
"""持久化记忆系统 — AI 可写日记、搜索记忆、管理聊天记录"""
def __init__(self):
+113 -95
View File
@@ -8,7 +8,15 @@ from colorama import Fore, Style
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):
self.usage = """网络工具使用方法:/r
network_tools <操作> <参数>/r
@@ -35,6 +43,7 @@ network_tools <操作> <参数>/r
"""
self.cli = None # CLI实例引用
self.timeout = 10 # 请求超时时间(秒)
self.max_retries = 2 # 最大重试次数
def set_cli(self, cli):
"""设置CLI实例引用"""
@@ -161,128 +170,137 @@ JSON格式示例:
except Exception as 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):
"""测试网站连通性和延迟"""
"""测试网站连通性和延迟(单次请求,不重试,保证延迟测量准确)"""
try:
# 确保URL格式正确
if not target.startswith(('http://', 'https://')):
test_url = 'http://' + target
else:
test_url = target
# 添加协议如果缺失
if not test_url.startswith(('http://', 'https://')):
test_url = 'http://' + test_url
# 单次请求测量延迟(不使用重试,避免重试干扰延迟数值)
start_time = time.time()
response = requests.get(test_url, timeout=self.timeout)
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()
response_time = (end_time - start_time) * 1000 # 转换为毫秒
result = f".ping 测试结果:\n"
result += f"目标: {target}\n"
result += f"HTTP状态码: {response.status_code}\n"
result += f"响应时间: {response_time:.2f} ms\n"
result += f"连接状态: {'成功' if response.status_code < 400 else '失败'}\n"
if response.status_code >= 400:
result += f"错误信息: {response.reason if hasattr(response, 'reason') else '请求失败'}\n"
return result
except requests.exceptions.Timeout:
return f".ping 测试结果:\n目标: {target}\n错误: 请求超时 (>{self.timeout}秒)\n连接状态: 失败"
except requests.exceptions.ConnectionError:
return f".ping 测试结果:\n目标: {target}\n错误: 连接失败 (无法连接到服务器)\n连接状态: 失败"
except requests.exceptions.RequestException as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
except Exception as e:
return f".ping 测试结果:\n目标: {target}\n错误: {str(e)}\n连接状态: 失败"
def _get_content(self, url):
"""获取网页内容"""
try:
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = requests.get(url, timeout=self.timeout)
if response.status_code == 200:
content = response.text
# 限制内容长度以避免过长输出
if len(content) > 2000:
content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)"
result = f"网页内容 (来自 {url}):\n"
result += content
return result
else:
return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}"
except requests.exceptions.Timeout:
return f"获取网页内容超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"获取网页内容时发生错误: {str(e)}"
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = self._request(url)
# _request 返回字符串表示错误
if isinstance(response, str):
return f"获取网页内容失败: {response}"
if response.status_code == 200:
content = response.text
# 限制内容长度以避免过长输出
if len(content) > 2000:
content = content[:2000] + f"\n... (内容已截断,完整内容共{len(content)}字符)"
result = f"网页内容 (来自 {url}):\n"
result += content
return result
else:
return f"获取网页内容失败,状态码: {response.status_code}, 原因: {response.reason if hasattr(response, 'reason') else ''}"
def _check_status(self, url):
"""检查网站状态码"""
try:
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = requests.get(url, timeout=self.timeout)
result = f"网站状态 (来自 {url}):\n"
result += f"HTTP状态码: {response.status_code}\n"
result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n"
result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n"
result += f"内容长度: {len(response.content)} 字节\n"
result += f"服务器: {response.headers.get('Server', '未知')}\n"
result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n"
return result
except requests.exceptions.Timeout:
return f"检查网站状态超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"检查网站状态时发生错误: {str(e)}"
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = self._request(url)
if isinstance(response, str):
return f"检查网站状态失败: {response}"
result = f"网站状态 (来自 {url}):\n"
result += f"HTTP状态码: {response.status_code}\n"
result += f"状态描述: {response.reason if hasattr(response, 'reason') else 'Unknown'}\n"
result += f"响应时间: {response.elapsed.total_seconds():.2f} 秒\n"
result += f"内容长度: {len(response.content)} 字节\n"
result += f"服务器: {response.headers.get('Server', '未知')}\n"
result += f"内容类型: {response.headers.get('Content-Type', '未知')}\n"
return result
def _get_headers(self, url):
"""获取网站响应头"""
try:
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
response = requests.get(url, timeout=self.timeout)
headers = dict(response.headers)
result = f"响应头 (来自 {url}):\n"
for key, value in headers.items():
result += f"{key}: {value}\n"
return result
except requests.exceptions.Timeout:
return f"获取响应头超时 (>{self.timeout}秒)"
except requests.exceptions.ConnectionError:
return f"无法连接到 {url},请检查URL是否正确"
except requests.exceptions.RequestException as e:
return f"请求失败: {str(e)}"
except Exception as e:
return f"获取响应头时发生错误: {str(e)}"
"""获取网站响应头(优先 HEAD 请求,失败则回退 GET)"""
# 确保URL格式正确
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
# 优先用 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)
result = f"响应头 (来自 {url}):\n"
for key, value in headers.items():
result += f"{key}: {value}\n"
return result
def _get_ip(self, domain):
"""获取域名对应的IP地址"""
+442
View File
@@ -0,0 +1,442 @@
"""
正则表达式测试器插件 - 测试、调试、管理正则表达式
支持匹配测试、提取、替换、常用模式库
"""
import re
import json
import os
from typing import List
class Liugin:
"""正则表达式测试器 - 测试、调试、管理正则"""
def __init__(self):
self.usage = """正则表达式测试器
操作:
test <正则> <文本> [-f flags] - 测试正则是否匹配
match <正则> <文本> [-f flags] - 提取所有匹配项
replace <正则> <文本> <替换> - 测试替换
groups <正则> <文本> - 显示分组信息
explain <正则> - 解释正则含义
flags <正则> <文本> - 测试不同标志组合
pattern [名称] - 列出/查看常用模式
save <名称> <正则> [-d 描述] - 保存自定义模式
delete_pattern <名称> - 删除自定义模式
bench <正则> <文本> [-n 次数] - 性能测试
标志 (flags):
i - 忽略大小写 m - 多行模式 s - 点匹配换行
x - 详细模式 a - ASCII模式
示例:
regex_tester test "\\d+" "abc123def456"
regex_tester match "[a-z]+@[a-z]+\\.[a-z]+" "test@example.com foo@bar.org"
regex_tester groups "(\\w+)@(\\w+)\\.(\\w+)" "user@domain.com"
regex_tester explain "[a-z]+@\\w+\\.\\w+"
regex_tester pattern email
"""
self.cli = None
self.patterns_file = "regex_patterns.json"
self.custom_patterns = self._load_patterns()
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "regex_tester",
"description": "正则表达式测试器 - 测试匹配、提取、替换、解释、常用模式库",
"keywords": ["正则", "regex", "匹配", "表达式", "测试", "替换", "提取", "模式"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "regex_tester",
"description": "正则表达式测试器,支持匹配测试、提取、替换、解释、性能测试",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["test", "match", "replace", "groups", "explain",
"flags", "pattern", "save", "bench"],
"description": "操作类型"
},
"pattern": {"type": "string", "description": "正则表达式"},
"text": {"type": "string", "description": "测试文本"},
"replacement": {"type": "string", "description": "替换文本"},
"flags": {"type": "string", "description": "标志 (i/m/s/x)"},
"name": {"type": "string", "description": "模式名称"},
"description": {"type": "string", "description": "模式描述"},
"count": {"type": "integer", "description": "测试次数"}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
pattern = arguments.get("pattern", "")
text = arguments.get("text", "")
return f"{op} {pattern} {text}".strip()
def _load_patterns(self):
try:
if os.path.exists(self.patterns_file):
with open(self.patterns_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
pass
return self._default_patterns()
def _save_patterns(self):
try:
with open(self.patterns_file, 'w', encoding='utf-8') as f:
json.dump(self.custom_patterns, f, ensure_ascii=False, indent=2)
except Exception:
pass
def _default_patterns(self):
return {
"email": {"pattern": r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "desc": "邮箱地址"},
"phone_cn": {"pattern": r"1[3-9]\d{9}", "desc": "中国手机号"},
"url": {"pattern": r"https?://[^\s<>\"']+|www\.[^\s<>\"']+", "desc": "URL链接"},
"ipv4": {"pattern": r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "desc": "IPv4地址"},
"date": {"pattern": r"\d{4}[-/]\d{1,2}[-/]\d{1,2}", "desc": "日期格式"},
"time": {"pattern": r"\d{2}:\d{2}(:\d{2})?", "desc": "时间格式"},
"chinese": {"pattern": r"[\u4e00-\u9fff]+", "desc": "中文字符"},
"id_card": {"pattern": r"\d{17}[\dXx]", "desc": "身份证号"},
"html_tag": {"pattern": r"<[^>]+>", "desc": "HTML标签"},
"ip_port": {"pattern": r"\b(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}\b", "desc": "IP:端口"},
"mac_addr": {"pattern": r"([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}", "desc": "MAC地址"},
"hex_color": {"pattern": r"#[0-9A-Fa-f]{3,8}", "desc": "十六进制颜色"},
"number": {"pattern": r"-?\d+\.?\d*", "desc": "数字(整数/浮点)"},
"word": {"pattern": r"\b\w+\b", "desc": "单词"},
}
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return "错误:请提供操作类型"
operation = parts[0].lower()
handlers = {
"test": self._op_test,
"match": self._op_match,
"replace": self._op_replace,
"groups": self._op_groups,
"explain": self._op_explain,
"flags": self._op_flags,
"pattern": self._op_pattern,
"save": self._op_save,
"delete_pattern": self._op_delete_pattern,
"bench": self._op_bench,
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return handler(parts[1:])
except Exception as e:
return f"正则测试错误: {str(e)}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == ' ' and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _parse_flags(self, flags_str: str) -> int:
flags = 0
for c in flags_str.lower():
if c == 'i': flags |= re.IGNORECASE
elif c == 'm': flags |= re.MULTILINE
elif c == 's': flags |= re.DOTALL
elif c == 'x': flags |= re.VERBOSE
return flags
def _op_test(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: test <正则> <文本> [-f 标志]"
pattern_str, text = args[0], args[1]
flags = 0
for i, a in enumerate(args):
if a == "-f" and i + 1 < len(args):
flags = self._parse_flags(args[i + 1])
try:
pattern = re.compile(pattern_str, flags)
match = pattern.search(text)
if match:
return (f"✅ 匹配成功!\n"
f" 正则: {pattern_str}\n"
f" 位置: {match.start()}-{match.end()}\n"
f" 匹配: '{match.group()}'\n"
f" 全文: {pattern.findall(text)}")
else:
return f"❌ 无匹配\n 正则: {pattern_str}\n 文本: {text[:100]}"
except re.error as e:
return f"❌ 正则语法错误: {e}"
def _op_match(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: match <正则> <文本>"
pattern_str, text = args[0], args[1]
flags = 0
for i, a in enumerate(args):
if a == "-f" and i + 1 < len(args):
flags = self._parse_flags(args[i + 1])
try:
pattern = re.compile(pattern_str, flags)
matches = pattern.finditer(text)
results = []
for m in matches:
results.append(f" [{m.start():3d}-{m.end():3d}] '{m.group()}'")
if results:
return f" 找到 {len(results)} 个匹配:\n" + "\n".join(results)
else:
return "❌ 未找到匹配"
except re.error as e:
return f"❌ 正则语法错误: {e}"
def _op_replace(self, args: List[str]) -> str:
if len(args) < 3:
return "错误:格式: replace <正则> <文本> <替换>"
pattern_str, text, replacement = args[0], args[1], args[2]
try:
result = re.sub(pattern_str, replacement, text)
count = len(re.findall(pattern_str, text))
return (f" 替换结果 ({count} 处):\n"
f" 原文: {text}\n"
f" 结果: {result}")
except re.error as e:
return f"❌ 正则语法错误: {e}"
def _op_groups(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: groups <正则> <文本>"
pattern_str, text = args[0], args[1]
try:
pattern = re.compile(pattern_str)
match = pattern.search(text)
if not match:
return "❌ 无匹配"
result = f" 分组信息:\n"
result += f" 完整匹配: '{match.group()}' (span: {match.span()})\n"
for i, group in enumerate(match.groups(), 1):
result += f" 分组 {i}: '{group}' (span: {match.span(i) if group else 'N/A'})\n"
if match.groupdict():
result += f" 命名分组:\n"
for name, value in match.groupdict().items():
result += f" {name}: '{value}'\n"
return result.rstrip()
except re.error as e:
return f"❌ 正则语法错误: {e}"
def _op_explain(self, args: List[str]) -> str:
if not args:
return "错误:请提供正则表达式"
pattern_str = args[0]
explanations = []
i = 0
while i < len(pattern_str):
ch = pattern_str[i]
if ch == '\\':
if i + 1 < len(pattern_str):
next_ch = pattern_str[i + 1]
escape_map = {
'd': '数字 [0-9]', 'D': '非数字', 'w': '字母/数字/下划线',
'W': '非字母数字下划线', 's': '空白字符', 'S': '非空白字符',
'b': '单词边界', 'B': '非单词边界', 'n': '换行', 't': '制表符',
}
if next_ch in escape_map:
explanations.append(('\\' + next_ch, escape_map[next_ch]))
else:
explanations.append(('\\' + next_ch, f'转义字符 {next_ch}'))
i += 2
continue
if ch == '.':
explanations.append(('.', '任意字符(除换行)'))
elif ch == '^':
explanations.append(('^', '行/字符串开头'))
elif ch == '$':
explanations.append(('$', '行/字符串结尾'))
elif ch == '*':
explanations.append(('*', '0次或多次'))
elif ch == '+':
explanations.append(('+', '1次或多次'))
elif ch == '?':
explanations.append(('?', '0次或1次'))
elif ch == '{':
end = pattern_str.find('}', i)
if end != -1:
quantifier = pattern_str[i:end + 1]
explanations.append((quantifier, f'量词: {quantifier}'))
i = end + 1
continue
elif ch == '[':
end = pattern_str.find(']', i)
if end != -1:
char_class = pattern_str[i:end + 1]
explanations.append((char_class, f'字符类: {char_class}'))
i = end + 1
continue
elif ch == '(':
if i + 1 < len(pattern_str) and pattern_str[i + 1] == '?':
if i + 2 < len(pattern_str):
special = pattern_str[i + 2]
if special == ':':
explanations.append(('(?:', '非捕获组'))
elif special == '=':
explanations.append(('(?=', '正向前瞻'))
elif special == '!':
explanations.append(('(?!', '负向前瞻'))
i += 3
continue
explanations.append(('(', '捕获组开始'))
elif ch == ')':
explanations.append((')', '组结束'))
elif ch == '|':
explanations.append(('|', '或 (交替)'))
i += 1
result = f" 正则解释: {pattern_str}\n"
result += "─" * 45 + "\n"
for token, meaning in explanations:
result += f" {token:12s} → {meaning}\n"
return result.rstrip()
def _op_flags(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: flags <正则> <文本>"
pattern_str, text = args[0], args[1]
flag_combos = [
("", 0, "默认"),
("i", re.IGNORECASE, "忽略大小写"),
("m", re.MULTILINE, "多行模式"),
("s", re.DOTALL, "点匹配换行"),
("im", re.IGNORECASE | re.MULTILINE, "忽略大小写+多行"),
]
result = f" ️ 标志测试: {pattern_str}\n"
result += "─" * 45 + "\n"
for flag_str, flag_val, desc in flag_combos:
try:
matches = re.findall(pattern_str, text, flag_val)
count = len(matches) if isinstance(matches, list) else 1
result += f" [{flag_str or '-':3s}] {desc:12s} → {count} 个匹配: {matches[:3]}\n"
except re.error as e:
result += f" [{flag_str or '-':3s}] {desc:12s} → 错误: {e}\n"
return result.rstrip()
def _op_pattern(self, args: List[str]) -> str:
if args:
name = args[0]
if name in self.custom_patterns:
p = self.custom_patterns[name]
return f" 模式 '{name}':\n 正则: {p['pattern']}\n 描述: {p.get('desc', '')}"
return f"❌ 未找到模式: {name}"
result = " 常用正则模式:\n"
result += "─" * 50 + "\n"
for name, p in self.custom_patterns.items():
result += f" {name:15s} {p['pattern'][:35]:35s} {p.get('desc', '')}\n"
return result.rstrip()
def _op_save(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: save <名称> <正则> [-d 描述]"
name, pattern_str = args[0], args[1]
desc = ""
for i, a in enumerate(args):
if a == "-d" and i + 1 < len(args):
desc = args[i + 1]
# 验证正则
try:
re.compile(pattern_str)
except re.error as e:
return f"❌ 正则语法错误: {e}"
self.custom_patterns[name] = {"pattern": pattern_str, "desc": desc}
self._save_patterns()
return f"✅ 已保存模式 '{name}': {pattern_str}"
def _op_delete_pattern(self, args: List[str]) -> str:
if not args:
return "错误:请提供模式名称"
name = args[0]
if name in self.custom_patterns:
del self.custom_patterns[name]
self._save_patterns()
return f" ️ 已删除模式: {name}"
return f"❌ 未找到模式: {name}"
def _op_bench(self, args: List[str]) -> str:
if len(args) < 2:
return "错误:格式: bench <正则> <文本> [-n 次数]"
pattern_str, text = args[0], args[1]
count = 10000
for i, a in enumerate(args):
if a == "-n" and i + 1 < len(args):
count = int(args[i + 1])
try:
import time
pattern = re.compile(pattern_str)
start = time.perf_counter()
for _ in range(count):
pattern.findall(text)
elapsed = time.perf_counter() - start
per_op = elapsed / count * 1_000_000 # 微秒
return (f"⏱ 性能测试:\n"
f" 正则: {pattern_str}\n"
f" 次数: {count}\n"
f" 总耗时: {elapsed:.3f}s\n"
f" 每次: {per_op:.2f}μs\n"
f" 吞吐: {count / elapsed:.0f} ops/s")
except re.error as e:
return f"❌ 正则语法错误: {e}"
def test_plugin():
plugin = Liugin()
print("正则表达式测试器测试:")
print(plugin.handle('test "\\d+" "abc123def456"'))
print(plugin.handle('match "[a-z]+" "hello world foo bar"'))
print(plugin.handle('groups "(\\w+)@(\\w+)\\.(\\w+)" "user@domain.com"'))
print(plugin.handle('explain "\\d{4}-\\d{2}-\\d{2}"'))
if __name__ == "__main__":
test_plugin()
+31 -11
View File
@@ -295,17 +295,37 @@ JSON格式示例:
except Exception:
pass
# 打印醒目提醒
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}")
# 打印醒目提醒(TUI 兼容)
is_tui = self.cli and getattr(self.cli, 'tui_output_callback', None)
is_generating = self.cli and getattr(self.cli, 'tui_stream_callback', None)
if is_tui and not is_generating:
# TUI 模式且未在生成中:通过回调输出
lines = [
"",
f"{Fore.YELLOW}{'═' * 50}{Style.RESET_ALL}",
f"{Fore.YELLOW} ⏰ 定时任务提醒 #{task_id}{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 — 将提醒注入对话
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
+445
View File
@@ -0,0 +1,445 @@
"""
代码片段管理器插件 - 保存、搜索、复用常用代码片段
支持分类、标签、搜索、导出,JSON 持久化存储
"""
import json
import os
import time
import hashlib
from datetime import datetime
from typing import List, Optional
class Liugin:
"""代码片段管理器 - 保存和复用代码片段"""
def __init__(self):
self.usage = """代码片段管理器
操作:
add <名称> -l <语言> -c "代码" [-t 标签1,标签2] [-d 描述]
- 添加代码片段
list [-l 语言] [-t 标签] - 列出片段(可按语言/标签过滤)
get <名称或ID> - 获取片段详情
search <关键词> - 搜索片段(名称/描述/代码/标签)
delete <名称或ID> - 删除片段
edit <名称或ID> -c "新代码" - 更新片段代码
export [文件] - 导出所有片段
import <文件> - 导入片段
tags - 列出所有标签
stats - 统计信息
copy <名称或ID> - 复制到剪贴板(如可用)
recent [-n 数量] - 最近添加的片段
示例:
snippet_manager add quick_sort -l python -c "def qsort(arr): ..." -t 排序,算法
snippet_manager list -l python
snippet_manager search 排序
snippet_manager get quick_sort
"""
self.cli = None
self.snippets_file = "snippets.json"
self.snippets = []
self._load_snippets()
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "snippet_manager",
"description": "代码片段管理器 - 保存、搜索、复用常用代码片段,支持分类和标签",
"keywords": ["代码", "片段", "snippet", "模板", "复用", "保存", "搜索", "代码片段"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "snippet_manager",
"description": "代码片段管理器,支持添加、搜索、获取、删除代码片段",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["add", "list", "get", "search", "delete",
"edit", "export", "import", "tags", "stats", "recent"],
"description": "操作类型"
},
"name": {"type": "string", "description": "片段名称或ID"},
"language": {"type": "string", "description": "编程语言"},
"code": {"type": "string", "description": "代码内容"},
"tags": {"type": "string", "description": "标签(逗号分隔)"},
"description": {"type": "string", "description": "描述"},
"keyword": {"type": "string", "description": "搜索关键词"},
"file": {"type": "string", "description": "文件路径"},
"count": {"type": "integer", "description": "数量"}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
name = arguments.get("name", "")
parts = [op]
if name:
parts.append(name)
if arguments.get("keyword"):
parts.append(arguments["keyword"])
if arguments.get("file"):
parts.append(arguments["file"])
if arguments.get("count"):
parts.append(str(arguments["count"]))
if arguments.get("language"):
parts.extend(["-l", arguments["language"]])
if arguments.get("code"):
parts.extend(["-c", f'"{arguments["code"]}"'])
if arguments.get("tags"):
parts.extend(["-t", arguments["tags"]])
if arguments.get("description"):
parts.extend(["-d", f'"{arguments["description"]}"'])
return " ".join(parts)
def _load_snippets(self):
try:
if os.path.exists(self.snippets_file):
with open(self.snippets_file, 'r', encoding='utf-8') as f:
self.snippets = json.load(f)
except Exception:
self.snippets = []
def _save_snippets(self):
try:
with open(self.snippets_file, 'w', encoding='utf-8') as f:
json.dump(self.snippets, f, ensure_ascii=False, indent=2)
return None
except Exception as e:
return f"保存失败: {e}"
def _gen_id(self, name: str) -> str:
return hashlib.md5(f"{name}{time.time()}".encode()).hexdigest()[:8]
def handle(self, args: str) -> str:
try:
parts = self._parse_args(args)
if not parts:
return self._op_list([])
operation = parts[0].lower()
handlers = {
"add": self._op_add,
"list": self._op_list,
"get": self._op_get,
"search": self._op_search,
"delete": self._op_delete,
"edit": self._op_edit,
"export": self._op_export,
"import": self._op_import,
"tags": self._op_tags,
"stats": self._op_stats,
"recent": self._op_recent,
"copy": self._op_copy,
}
handler = handlers.get(operation)
if not handler:
return f"错误:不支持的操作 '{operation}'"
return handler(parts[1:])
except Exception as e:
return f"片段管理错误: {str(e)}"
def _parse_args(self, args: str) -> List[str]:
parts, current, in_quotes, qc = [], "", False, None
for ch in args:
if ch in ('"', "'") and not in_quotes:
in_quotes, qc = True, ch
elif ch == qc and in_quotes:
in_quotes, qc = False, None
elif ch == ' ' and not in_quotes:
if current:
parts.append(current)
current = ""
else:
current += ch
if current:
parts.append(current)
return parts
def _op_add(self, args: List[str]) -> str:
if not args:
return "错误:请提供片段名称"
name = args[0]
language, code, tags, desc = "", "", [], ""
i = 1
while i < len(args):
if args[i] == "-l" and i + 1 < len(args):
language = args[i + 1]; i += 1
elif args[i] == "-c" and i + 1 < len(args):
code = args[i + 1]; i += 1
elif args[i] == "-t" and i + 1 < len(args):
tags = [t.strip() for t in args[i + 1].split(",") if t.strip()]; i += 1
elif args[i] == "-d" and i + 1 < len(args):
desc = args[i + 1]; i += 1
i += 1
if not code:
return "错误:请用 -c 提供代码内容"
# 检查重名
for s in self.snippets:
if s["name"] == name:
return f"错误:片段 '{name}' 已存在,用 edit 更新"
snippet = {
"id": self._gen_id(name),
"name": name,
"language": language,
"code": code,
"tags": tags,
"description": desc,
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"usage_count": 0
}
self.snippets.append(snippet)
err = self._save_snippets()
if err:
return err
return f"✅ 已添加片段 '{name}' (ID: {snippet['id']}, 语言: {language or '未指定'})"
def _op_list(self, args: List[str]) -> str:
lang_filter, tag_filter = None, None
i = 0
while i < len(args):
if args[i] == "-l" and i + 1 < len(args):
lang_filter = args[i + 1].lower(); i += 1
elif args[i] == "-t" and i + 1 < len(args):
tag_filter = args[i + 1].lower(); i += 1
i += 1
filtered = self.snippets
if lang_filter:
filtered = [s for s in filtered if s.get("language", "").lower() == lang_filter]
if tag_filter:
filtered = [s for s in filtered if any(tag_filter in t.lower() for t in s.get("tags", []))]
if not filtered:
return "📭 没有找到代码片段"
result = f"\n📝 代码片段列表 (共 {len(filtered)} 个)\n"
result += "─" * 55 + "\n"
for s in filtered:
lang = s.get("language", "未知")
tags = ", ".join(s.get("tags", [])) if s.get("tags") else ""
code_preview = s.get("code", "")[:60].replace("\n", "↵")
result += f" [{s['id']}] {s['name']} ({lang})\n"
if tags:
result += f" ️ {tags}\n"
result += f" {code_preview}...\n"
result += f" ⏱ {s.get('created_at', '未知')}\n\n"
result += "─" * 55
return result
def _op_get(self, args: List[str]) -> str:
if not args:
return "错误:请提供片段名称或ID"
key = args[0]
snippet = self._find_snippet(key)
if not snippet:
return f"❌ 未找到片段: {key}"
snippet["usage_count"] = snippet.get("usage_count", 0) + 1
self._save_snippets()
result = f"\n 片段: {snippet['name']}\n"
result += "─" * 50 + "\n"
result += f" ID: {snippet['id']}\n"
result += f" 语言: {snippet.get('language', '未指定')}\n"
if snippet.get("tags"):
result += f" ️ 标签: {', '.join(snippet['tags'])}\n"
if snippet.get("description"):
result += f" 描述: {snippet['description']}\n"
result += f" 创建: {snippet.get('created_at', '未知')}\n"
result += f" 使用: {snippet.get('usage_count', 0)} 次\n"
result += "─" * 50 + "\n"
result += f" 代码:\n{snippet['code']}\n"
result += "─" * 50
return result
def _op_search(self, args: List[str]) -> str:
if not args:
return "错误:请提供搜索关键词"
keyword = " ".join(args).lower()
results = []
for s in self.snippets:
searchable = f"{s['name']} {s.get('description', '')} {s.get('code', '')} {' '.join(s.get('tags', []))} {s.get('language', '')}".lower()
if keyword in searchable:
results.append(s)
if not results:
return f" 未找到匹配 '{keyword}' 的片段"
output = f" 搜索结果 ({len(results)} 个):\n"
output += "─" * 50 + "\n"
for s in results:
lang = s.get("language", "")
output += f" [{s['id']}] {s['name']} ({lang}) - {s.get('description', '')[:40]}\n"
return output.rstrip()
def _op_delete(self, args: List[str]) -> str:
if not args:
return "错误:请提供片段名称或ID"
key = args[0]
for i, s in enumerate(self.snippets):
if s["name"] == key or s["id"] == key:
removed = self.snippets.pop(i)
self._save_snippets()
return f" ️ 已删除片段: {removed['name']}"
return f"❌ 未找到片段: {key}"
def _op_edit(self, args: List[str]) -> str:
if not args:
return "错误:请提供片段名称或ID"
key = args[0]
snippet = self._find_snippet(key)
if not snippet:
return f"❌ 未找到片段: {key}"
new_code = None
new_desc = None
i = 1
while i < len(args):
if args[i] == "-c" and i + 1 < len(args):
new_code = args[i + 1]; i += 1
elif args[i] == "-d" and i + 1 < len(args):
new_desc = args[i + 1]; i += 1
i += 1
if new_code:
snippet["code"] = new_code
if new_desc:
snippet["description"] = new_desc
snippet["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._save_snippets()
return f"✅ 已更新片段: {snippet['name']}"
def _op_export(self, args: List[str]) -> str:
output_file = args[0] if args else "snippets_export.json"
try:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(self.snippets, f, ensure_ascii=False, indent=2)
return f"✅ 已导出 {len(self.snippets)} 个片段到: {output_file}"
except Exception as e:
return f"❌ 导出失败: {e}"
def _op_import(self, args: List[str]) -> str:
if not args:
return "错误:请提供导入文件路径"
file_path = args[0]
if not os.path.isfile(file_path):
return f"❌ 文件不存在: {file_path}"
try:
with open(file_path, 'r', encoding='utf-8') as f:
imported = json.load(f)
if not isinstance(imported, list):
return "❌ 文件格式错误:需要 JSON 数组"
existing_names = {s["name"] for s in self.snippets}
added = 0
for s in imported:
if isinstance(s, dict) and s.get("name") and s["name"] not in existing_names:
s["id"] = self._gen_id(s["name"])
self.snippets.append(s)
existing_names.add(s["name"])
added += 1
self._save_snippets()
return f"✅ 已导入 {added} 个片段 (跳过 {len(imported) - added} 个重复)"
except Exception as e:
return f"❌ 导入失败: {e}"
def _op_tags(self, args: List[str]) -> str:
tag_count = {}
for s in self.snippets:
for t in s.get("tags", []):
tag_count[t] = tag_count.get(t, 0) + 1
if not tag_count:
return " ️ 暂无标签"
result = " ️ 标签列表:\n"
for tag, count in sorted(tag_count.items(), key=lambda x: -x[1]):
result += f" {tag} ({count})\n"
return result.rstrip()
def _op_stats(self, args: List[str]) -> str:
total = len(self.snippets)
if not total:
return "📊 暂无片段"
lang_count = {}
total_usage = 0
for s in self.snippets:
lang = s.get("language", "未知")
lang_count[lang] = lang_count.get(lang, 0) + 1
total_usage += s.get("usage_count", 0)
result = f"📊 片段统计\n"
result += "─" * 30 + "\n"
result += f" 总数: {total}\n"
result += f" 总使用: {total_usage} 次\n"
result += f" 语言分布:\n"
for lang, count in sorted(lang_count.items(), key=lambda x: -x[1]):
result += f" {lang}: {count}\n"
return result.rstrip()
def _op_recent(self, args: List[str]) -> str:
count = 5
for i, a in enumerate(args):
if a == "-n" and i + 1 < len(args):
count = int(args[i + 1])
recent = sorted(self.snippets, key=lambda s: s.get("created_at", ""), reverse=True)[:count]
if not recent:
return "📭 暂无片段"
result = f"⏰ 最近 {len(recent)} 个片段:\n"
for s in recent:
result += f" [{s['id']}] {s['name']} - {s.get('created_at', '')}\n"
return result.rstrip()
def _op_copy(self, args: List[str]) -> str:
if not args:
return "错误:请提供片段名称或ID"
snippet = self._find_snippet(args[0])
if not snippet:
return f"❌ 未找到片段: {args[0]}"
snippet["usage_count"] = snippet.get("usage_count", 0) + 1
self._save_snippets()
return f" 代码内容 ({snippet['name']}):\n{snippet['code']}"
def _find_snippet(self, key: str) -> Optional[dict]:
for s in self.snippets:
if s["name"] == key or s["id"] == key:
return s
return None
def test_plugin():
plugin = Liugin()
print("代码片段管理器测试:")
print(plugin.handle('add hello -l python -c "print(\'Hello, World!\')" -t 示例,入门 -d 最简单的Python程序'))
print(plugin.handle('add fib -l python -c "def fib(n): return n if n < 2 else fib(n-1)+fib(n-2)" -t 算法,递归'))
print(plugin.handle('list'))
print(plugin.handle('search python'))
if __name__ == "__main__":
test_plugin()
+1 -1
View File
@@ -543,7 +543,7 @@ if __name__ == "__main__":
if __name__ == "__main__":
plugin = Plugin()
plugin = Liugin()
print("语音识别插件")
print(f"临时目录: {plugin.temp_dir}")
print("\n" + plugin.usage)
+30 -34
View File
@@ -26,7 +26,7 @@ class SubAgent:
self.created_at = datetime.now().strftime("%H:%M:%S")
self.finished_at = None
self.steps = 0 # 已执行步数
self.max_steps = 20
self.max_steps = float('inf') # 不限制步数
def to_dict(self):
return {
@@ -42,7 +42,7 @@ class SubAgent:
}
class Plugin:
class Liugin:
"""子 Agent 系统 - 创建、管理、协作"""
def __init__(self):
@@ -238,7 +238,7 @@ class Plugin:
if agent.status == "running":
agent.status = "done"
agent.result = "已达到最大步数限制,任务暂停。"
agent.result = "任务执行结束。"
except Exception as e:
agent.status = "failed"
@@ -319,18 +319,29 @@ class Plugin:
return None
def _find_agent(self, agent_id: str):
"""线程安全地查找 Agent(精确匹配 + 模糊匹配)"""
with self._lock:
agent = self.agents.get(agent_id)
if not agent:
matches = [a for a in self.agents.values() if agent_id in a.id or agent_id == a.name]
if matches:
agent = matches[0]
return agent
def _list(self, args: str) -> str:
"""列出所有子 Agent"""
with self._lock:
if not self.agents:
return "没有子 Agent"
agents_list = list(self.agents.values())
result = [f"子 Agent 列表 ({len(self.agents)} 个):\n"]
result = [f"子 Agent 列表 ({len(agents_list)} 个):\n"]
# 按状态分组
running = [a for a in self.agents.values() if a.status == "running"]
done = [a for a in self.agents.values() if a.status == "done"]
failed = [a for a in self.agents.values() if a.status == "failed"]
running = [a for a in agents_list if a.status == "running"]
done = [a for a in agents_list if a.status == "done"]
failed = [a for a in agents_list if a.status == "failed"]
if running:
result.append(f" 运行中 ({len(running)}):")
@@ -355,21 +366,16 @@ class Plugin:
if not agent_id:
return "错误:请提供 Agent ID"
agent = self.agents.get(agent_id)
agent = self._find_agent(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]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
lines = [
f"Agent: {agent.id}",
f" 名称: {agent.name}",
f" 任务: {agent.task}",
f" 状态: {agent.status}",
f" 步骤: {agent.steps}/{agent.max_steps}",
f" 步骤: {agent.steps}",
f" 创建: {agent.created_at}",
]
if agent.finished_at:
@@ -386,16 +392,12 @@ class Plugin:
if not agent_id:
return "错误:请提供 Agent ID"
agent = self.agents.get(agent_id)
agent = self._find_agent(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]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
if agent.status == "running":
return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps}/{agent.max_steps})"
return f"Agent {agent.id} 仍在运行中 (步骤 {agent.steps})"
if agent.status == "failed":
return f"Agent {agent.id} 执行失败: {agent.error}"
@@ -417,13 +419,9 @@ class Plugin:
agent_id = parts[0]
message = parts[1]
agent = self.agents.get(agent_id)
agent = self._find_agent(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]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
if agent.status != "running":
return f"Agent {agent.id} 未在运行 (状态: {agent.status})"
@@ -467,17 +465,15 @@ class Plugin:
except Exception as e:
agent.error = str(e)
agent.status = "failed"
agent.finished_at = datetime.now().strftime("%H:%M:%S")
def _kill(self, args: str) -> str:
"""终止子 Agent"""
agent_id = args.strip()
agent = self.agents.get(agent_id)
agent = self._find_agent(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]
else:
return f"错误:未找到 Agent '{agent_id}'"
return f"错误:未找到 Agent '{agent_id}'"
agent.status = "failed"
agent.error = "被主 Agent 终止"
+1 -1
View File
@@ -258,7 +258,7 @@ JSON格式示例:
# 测试函数
def test_plugin():
"""测试插件功能"""
plugin = Plugin()
plugin = Liugin()
print("任务管理插件测试:")
print("\n1. 添加任务:")
+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} 已关闭"
+811
View File
@@ -0,0 +1,811 @@
"""
工作流插件 - YAML 定义的多步骤自动化流程
支持:
- 顺序执行多步骤
- 步骤间变量传递 ({{prev.output}})
- 条件分支 (on_fail / on_success)
- 错误处理 (continue_on_error)
- 状态持久化 (断点续跑)
- 调用其他插件作为步骤
用法:
/workflow run <file.yaml>
/workflow list
/workflow status <workflow_id>
/workflow resume <workflow_id>
/workflow abort <workflow_id>
"""
import json
import os
import re
import time
import uuid
from datetime import datetime
from colorama import Fore, Style
# ── YAML 解析(内置轻量实现,不依赖 PyYAML) ──
def _parse_yaml(text: str) -> dict:
"""轻量 YAML 解析器,支持工作流所需的子集"""
import yaml # 延迟导入,失败时回退
return yaml.safe_load(text)
def _parse_yaml_fallback(text: str) -> dict:
"""内置回退解析器,仅支持简单结构"""
result = {}
current_key = None
current_list = None
current_dict = None
for line in text.split('\n'):
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
indent = len(line) - len(line.lstrip())
if indent == 0 and ':' in stripped:
key, _, val = stripped.partition(':')
key = key.strip()
val = val.strip()
if val:
result[key] = _yaml_value(val)
else:
result[key] = None
current_key = key
current_list = None
current_dict = None
elif indent > 0 and stripped.startswith('- '):
if current_key:
if not isinstance(result.get(current_key), list):
result[current_key] = []
item_text = stripped[2:].strip()
if ':' in item_text:
item = {}
k, _, v = item_text.partition(':')
item[k.strip()] = _yaml_value(v.strip())
current_dict = item
result[current_key].append(item)
else:
result[current_key].append(_yaml_value(item_text))
elif indent > 0 and ':' in stripped and current_dict is not None:
key, _, val = stripped.partition(':')
current_dict[key.strip()] = _yaml_value(val.strip())
return result
def _yaml_value(s: str):
"""转换单个 YAML 值"""
if not s:
return None
# 去除引号
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
return s[1:-1]
if s.lower() in ('true', 'yes', 'on'):
return True
if s.lower() in ('false', 'no', 'off'):
return False
if s.lower() in ('null', 'none', '~'):
return None
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
pass
return s
def _load_yaml_file(path: str) -> dict:
"""加载 YAML 文件"""
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
try:
return _parse_yaml(text)
except ImportError:
return _parse_yaml_fallback(text)
# ── 变量替换引擎 ──
def _resolve_vars(text: str, context: dict) -> str:
"""
替换 {{key}} 和 {{key.nested}} 形式的变量
支持:
{{var}} → context['var']
{{step.output}} → context['steps']['step']['output']
{{step.exit_code}} → context['steps']['step']['exit_code']
{{env.HOME}} → os.environ['HOME']
{{args.name}} → context['args']['name']
"""
def replacer(match):
path = match.group(1).strip()
parts = path.split('.')
if parts[0] == 'env':
return os.environ.get('.'.join(parts[1:]), '')
if parts[0] == 'args' and len(parts) > 1:
val = context.get('args', {})
for p in parts[1:]:
if isinstance(val, dict):
val = val.get(p, '')
else:
return ''
return str(val)
if parts[0] == 'steps' and len(parts) >= 3:
step_name = parts[1]
attr = parts[2]
step_data = context.get('steps', {}).get(step_name, {})
val = step_data.get(attr, '')
# 支持 deeper nesting: {{steps.foo.output.field}}
for p in parts[3:]:
if isinstance(val, dict):
val = val.get(p, '')
else:
return ''
return str(val)
# 顶层变量
val = context
for p in parts:
if isinstance(val, dict):
val = val.get(p, '')
else:
return ''
return str(val)
return re.sub(r'\{\{(.+?)\}\}', replacer, text)
def _resolve_value(val, context):
"""递归解析值中的变量(支持 dict/list/str)"""
if isinstance(val, str):
return _resolve_vars(val, context)
elif isinstance(val, dict):
return {k: _resolve_value(v, context) for k, v in val.items()}
elif isinstance(val, list):
return [_resolve_value(item, context) for item in val]
return val
# ── 工作流引擎 ──
class WorkflowEngine:
"""工作流执行引擎"""
def __init__(self, cli_instance=None):
self.cli = cli_instance
self.workflows_dir = "workflows"
self.state_dir = os.path.join(self.workflows_dir, ".state")
os.makedirs(self.state_dir, exist_ok=True)
def _out(self, msg):
"""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:
"""执行工作流"""
if not os.path.exists(path):
return f"❌ 工作流文件不存在: {path}"
try:
wf = _load_yaml_file(path)
except Exception as e:
return f"❌ 解析工作流文件失败: {e}"
wf_name = wf.get('name', os.path.basename(path).replace('.yaml', '').replace('.yml', ''))
steps = wf.get('steps', [])
if not steps:
return f"❌ 工作流 '{wf_name}' 没有步骤"
wf_id = f"{wf_name}-{uuid.uuid4().hex[:8]}"
context = {
'args': args or {},
'steps': {},
'workflow': {
'id': wf_id,
'name': wf_name,
'path': path,
'started_at': datetime.now().isoformat(),
}
}
# 保存初始状态
self._save_state(wf_id, {
'workflow': wf_name,
'path': path,
'status': 'running',
'current_step': 0,
'context': context,
'started_at': context['workflow']['started_at'],
})
self._out(f"\n{Fore.CYAN}🔄 工作流 '{wf_name}' 开始 (ID: {wf_id}){Style.RESET_ALL}")
self._out(f" 共 {len(steps)} 个步骤\n")
failed = False
for i, step in enumerate(steps):
step_name = step.get('name', f'step_{i+1}')
step_type = step.get('type', 'tool')
condition = step.get('if', None)
continue_on_error = step.get('continue_on_error', False)
# 检查条件
if condition:
resolved_condition = _resolve_vars(condition, context)
if not self._eval_condition(resolved_condition):
self._out(f" {Fore.YELLOW}⏭ [{step_name}] 条件不满足,跳过{Style.RESET_ALL}")
context['steps'][step_name] = {
'output': '', 'exit_code': 0, 'skipped': True
}
continue
# 解析步骤参数
resolved_step = _resolve_value(step, context)
self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
# 更新状态
self._save_state(wf_id, {
'workflow': wf_name,
'path': path,
'status': 'running',
'current_step': i,
'current_step_name': step_name,
'context': context,
'started_at': context['workflow']['started_at'],
})
try:
result = self._execute_step(resolved_step, context)
context['steps'][step_name] = result
if result.get('exit_code', 0) == 0:
output_preview = str(result.get('output', ''))[:100]
self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}"
f"{' → ' + output_preview if output_preview else ''}")
else:
failed = True
self._out(f" {Fore.RED}✗ [{step_name}] 失败 (exit={result.get('exit_code')}){Style.RESET_ALL}")
self._out(f" {result.get('error', result.get('output', ''))[:200]}")
if not continue_on_error:
# 检查 on_fail 分支
on_fail = resolved_step.get('on_fail')
if on_fail:
self._out(f" {Fore.YELLOW}→ 执行 on_fail 分支{Style.RESET_ALL}")
self._execute_on_fail(on_fail, context)
break
else:
self._out(f" {Fore.YELLOW}⚠ continue_on_error=true,继续{Style.RESET_ALL}")
except Exception as e:
failed = True
context['steps'][step_name] = {
'output': '', 'exit_code': 1, 'error': str(e)
}
self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
if not continue_on_error:
break
# 完成状态
status = 'failed' if failed else 'completed'
context['workflow']['finished_at'] = datetime.now().isoformat()
context['workflow']['status'] = status
self._save_state(wf_id, {
'workflow': wf_name,
'path': path,
'status': status,
'current_step': len(steps),
'context': context,
'started_at': context['workflow']['started_at'],
'finished_at': context['workflow']['finished_at'],
})
icon = '✅' if not failed else '❌'
self._out(f"\n{icon} 工作流 '{wf_name}' {status} (ID: {wf_id})")
return f"{icon} 工作流 '{wf_name}' {status}"
def resume(self, wf_id: str) -> str:
"""从断点恢复执行工作流"""
state = self._load_state(wf_id)
if not state:
return f"❌ 未找到工作流状态: {wf_id}"
if state.get('status') == 'completed':
return f"ℹ️ 工作流已完成: {wf_id}"
path = state.get('path', '')
if not os.path.exists(path):
return f"❌ 工作流文件不存在: {path}"
try:
wf = _load_yaml_file(path)
except Exception as e:
return f"❌ 解析工作流文件失败: {e}"
steps = wf.get('steps', [])
start_from = state.get('current_step', 0)
context = state.get('context', {})
self._out(f"\n{Fore.CYAN}🔄 恢复工作流 '{wf.get('name', wf_id)}' 从步骤 {start_from + 1}{Style.RESET_ALL}")
failed = False
for i in range(start_from, len(steps)):
step = steps[i]
step_name = step.get('name', f'step_{i+1}')
step_type = step.get('type', 'tool')
continue_on_error = step.get('continue_on_error', False)
resolved_step = _resolve_value(step, context)
self._out(f" {Fore.CYAN}▶ [{step_name}] {step_type}{Style.RESET_ALL}")
try:
result = self._execute_step(resolved_step, context)
context['steps'][step_name] = result
if result.get('exit_code', 0) == 0:
self._out(f" {Fore.GREEN}✓ [{step_name}] 完成{Style.RESET_ALL}")
else:
failed = True
self._out(f" {Fore.RED}✗ [{step_name}] 失败{Style.RESET_ALL}")
if not continue_on_error:
break
except Exception as e:
failed = True
context['steps'][step_name] = {'output': '', 'exit_code': 1, 'error': str(e)}
self._out(f" {Fore.RED}✗ [{step_name}] 异常: {e}{Style.RESET_ALL}")
if not continue_on_error:
break
status = 'failed' if failed else 'completed'
self._save_state(wf_id, {**state, 'status': status, 'current_step': len(steps)})
return f"{'✅' if not failed else '❌'} 工作流 {status}"
def list_workflows(self) -> str:
"""列出所有工作流文件"""
if not os.path.exists(self.workflows_dir):
return "📁 workflows/ 目录不存在"
files = [f for f in os.listdir(self.workflows_dir)
if f.endswith(('.yaml', '.yml')) and not f.startswith('.')]
if not files:
return "📁 workflows/ 目录中没有工作流文件"
result = f"📁 工作流列表 ({len(files)} 个):\n"
for f in sorted(files):
path = os.path.join(self.workflows_dir, f)
try:
wf = _load_yaml_file(path)
name = wf.get('name', f.replace('.yaml', '').replace('.yml', ''))
desc = wf.get('description', '')
steps_count = len(wf.get('steps', []))
result += f" 📄 {f}\n"
result += f" 名称: {name} 步骤: {steps_count}\n"
if desc:
result += f" 描述: {desc}\n"
except Exception:
result += f" 📄 {f} (解析失败)\n"
return result
def status(self, wf_id: str) -> str:
"""查看工作流状态"""
state = self._load_state(wf_id)
if not state:
return f"❌ 未找到工作流: {wf_id}"
ctx = state.get('context', {})
wf_info = ctx.get('workflow', {})
steps_done = ctx.get('steps', {})
lines = [
f"📋 工作流: {state.get('workflow', '?')}",
f" ID: {wf_id}",
f" 状态: {state.get('status', '?')}",
f" 进度: 步骤 {state.get('current_step', 0)}",
f" 开始: {state.get('started_at', '?')}",
]
if state.get('finished_at'):
lines.append(f" 结束: {state['finished_at']}")
if steps_done:
lines.append(f" 已完成步骤:")
for name, data in steps_done.items():
icon = '✓' if data.get('exit_code', 0) == 0 else ('⏭' if data.get('skipped') else '✗')
lines.append(f" {icon} {name}")
return '\n'.join(lines)
def list_states(self) -> str:
"""列出所有工作流运行状态"""
if not os.path.exists(self.state_dir):
return "没有工作流运行记录"
files = [f for f in os.listdir(self.state_dir) if f.endswith('.json')]
if not files:
return "没有工作流运行记录"
result = f"📋 工作流运行记录 ({len(files)} 个):\n"
for f in sorted(files, reverse=True)[:20]:
wf_id = f.replace('.json', '')
try:
with open(os.path.join(self.state_dir, f), 'r') as fh:
state = json.load(fh)
status_icon = {'running': '🔄', 'completed': '✅', 'failed': '❌'}.get(state.get('status'), '❓')
result += f" {status_icon} {wf_id} — {state.get('workflow', '?')} ({state.get('status', '?')})\n"
except Exception:
result += f" ❓ {wf_id}\n"
return result
def abort(self, wf_id: str) -> str:
"""中止工作流"""
state = self._load_state(wf_id)
if not state:
return f"❌ 未找到工作流: {wf_id}"
state['status'] = 'aborted'
self._save_state(wf_id, state)
return f"🛑 工作流已中止: {wf_id}"
# ── 步骤执行 ──
def _execute_step(self, step: dict, context: dict) -> dict:
"""执行单个步骤"""
step_type = step.get('type', 'tool')
if step_type == 'tool':
return self._exec_tool_step(step, context)
elif step_type == 'shell':
return self._exec_shell_step(step, context)
elif step_type == 'set':
return self._exec_set_step(step, context)
elif step_type == 'echo':
return self._exec_echo_step(step, context)
elif step_type == 'wait':
return self._exec_wait_step(step, context)
elif step_type == 'if':
return self._exec_if_step(step, context)
else:
return {'output': '', 'exit_code': 1, 'error': f'未知步骤类型: {step_type}'}
def _exec_tool_step(self, step: dict, context: dict) -> dict:
"""执行插件工具步骤"""
tool_name = step.get('tool', '')
tool_args = step.get('args', '')
if not tool_name:
return {'output': '', 'exit_code': 1, 'error': '未指定 tool 名称'}
if not self.cli:
return {'output': '', 'exit_code': 1, 'error': 'CLI 实例不可用'}
try:
# 获取工具管理器
tool_mgr = getattr(self.cli, 'tool_manager', None) or getattr(self.cli, 'liugin_manager', None)
if not tool_mgr:
return {'output': '', 'exit_code': 1, 'error': '工具管理器不可用'}
# 解析参数
args_str = str(tool_args) if tool_args else ''
# 调用工具
if hasattr(tool_mgr, 'execute'):
result = tool_mgr.execute(tool_name, args_str)
output = result.get('result', '') if isinstance(result, dict) else str(result)
elif hasattr(tool_mgr, 'tools'):
# LiuginManager 兼容
tool = tool_mgr.get_tool_by_name(tool_name)
if tool and 'handler' in tool:
output = tool['handler'](args_str)
else:
return {'output': '', 'exit_code': 1, 'error': f'未找到工具: {tool_name}'}
else:
return {'output': '', 'exit_code': 1, 'error': '无法调用工具'}
return {'output': str(output), 'exit_code': 0}
except Exception as e:
return {'output': '', 'exit_code': 1, 'error': str(e)}
def _exec_shell_step(self, step: dict, context: dict) -> dict:
"""执行 shell 命令步骤"""
import subprocess
cmd = step.get('command', step.get('cmd', ''))
if not cmd:
return {'output': '', 'exit_code': 1, 'error': '未指定 command'}
cwd = step.get('cwd', None)
timeout = step.get('timeout', 60)
try:
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True,
timeout=timeout, cwd=cwd
)
return {
'output': result.stdout.strip(),
'error': result.stderr.strip(),
'exit_code': result.returncode,
}
except subprocess.TimeoutExpired:
return {'output': '', 'exit_code': 1, 'error': f'命令超时 ({timeout}s)'}
except Exception as e:
return {'output': '', 'exit_code': 1, 'error': str(e)}
def _exec_set_step(self, step: dict, context: dict) -> dict:
"""设置变量步骤"""
variables = step.get('variables', step.get('vars', {}))
for key, val in variables.items():
context[key] = _resolve_value(val, context)
return {'output': f'设置 {len(variables)} 个变量', 'exit_code': 0}
def _exec_echo_step(self, step: dict, context: dict) -> dict:
"""打印步骤"""
message = step.get('message', step.get('text', ''))
resolved = _resolve_vars(str(message), context)
self._out(f" 💬 {resolved}")
return {'output': resolved, 'exit_code': 0}
def _exec_wait_step(self, step: dict, context: dict) -> dict:
"""等待步骤"""
seconds = step.get('seconds', step.get('time', 1))
time.sleep(seconds)
return {'output': f'等待 {seconds} 秒', 'exit_code': 0}
def _exec_if_step(self, step: dict, context: dict) -> dict:
"""条件分支步骤"""
condition = step.get('condition', '')
resolved = _resolve_vars(str(condition), context)
if self._eval_condition(resolved):
then_steps = step.get('then', [])
return self._run_sub_steps(then_steps, context)
else:
else_steps = step.get('else', [])
if else_steps:
return self._run_sub_steps(else_steps, context)
return {'output': '条件不满足,无 else 分支', 'exit_code': 0}
def _run_sub_steps(self, steps: list, context: dict) -> dict:
"""执行子步骤列表"""
outputs = []
for sub_step in steps:
result = self._execute_step(sub_step, context)
outputs.append(result.get('output', ''))
if result.get('exit_code', 0) != 0:
return result
return {'output': '\n'.join(outputs), 'exit_code': 0}
def _eval_condition(self, condition: str) -> bool:
"""评估条件表达式"""
condition = condition.strip()
if not condition:
return True
# 简单比较: "value == expected"
for op in ['==', '!=', '>=', '<=', '>', '<']:
if op in condition:
left, _, right = condition.partition(op)
left = left.strip().strip('"').strip("'")
right = right.strip().strip('"').strip("'")
try:
left_n, right_n = float(left), float(right)
if op == '==': return left_n == right_n
if op == '!=': return left_n != right_n
if op == '>=': return left_n >= right_n
if op == '<=': return left_n <= right_n
if op == '>': return left_n > right_n
if op == '<': return left_n < right_n
except ValueError:
if op == '==': return left == right
if op == '!=': return left != right
# 布尔值
if condition.lower() in ('true', '1', 'yes'):
return True
if condition.lower() in ('false', '0', 'no', ''):
return False
# 非空即真
return bool(condition)
def _execute_on_fail(self, on_fail, context: dict):
"""执行 on_fail 处理"""
if isinstance(on_fail, str):
self._out(f" 💬 {on_fail}")
elif isinstance(on_fail, list):
for step in on_fail:
self._execute_step(step, context)
# ── 状态持久化 ──
def _save_state(self, wf_id: str, state: dict):
"""保存工作流状态"""
path = os.path.join(self.state_dir, f"{wf_id}.json")
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(state, f, ensure_ascii=False, indent=2, default=str)
except Exception as e:
self._out(f"{Fore.YELLOW}⚠ 保存状态失败: {e}{Style.RESET_ALL}")
def _load_state(self, wf_id: str) -> dict:
"""加载工作流状态"""
path = os.path.join(self.state_dir, f"{wf_id}.json")
if not os.path.exists(path):
return {}
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return {}
# ── Liugin 插件接口 ──
class Liugin:
"""工作流插件"""
def __init__(self):
self.engine = None
self.usage = """工作流工具使用方法:
JSON格式示例:
{"action": "use_tool", "tool": "workflow", "args": "run deploy.yaml"} - 执行工作流
{"action": "use_tool", "tool": "workflow", "args": "list"} - 列出工作流文件
{"action": "use_tool", "tool": "workflow", "args": "states"} - 查看运行记录
{"action": "use_tool", "tool": "workflow", "args": "status <id>"} - 查看状态
{"action": "use_tool", "tool": "workflow", "args": "resume <id>"} - 恢复执行
{"action": "use_tool", "tool": "workflow", "args": "abort <id>"} - 中止工作流
工作流 YAML 格式示例:
name: deploy
description: 部署流程
steps:
- name: lint
type: shell
command: "npm run lint"
- name: test
type: shell
command: "npm test"
continue_on_error: true
- name: build
type: shell
command: "npm run build"
if: "{{test.exit_code}} == 0"
- name: notify
type: echo
message: "构建完成: {{build.output}}"
支持的步骤类型:
- tool: 调用插件工具 (tool, args)
- shell: 执行系统命令 (command, cwd, timeout)
- echo: 打印消息 (message)
- wait: 等待 (seconds)
- set: 设置变量 (variables)
- if: 条件分支 (condition, then, else)
变量引用:
{{steps.step_name.output}} - 步骤输出
{{steps.step_name.exit_code}} - 步骤退出码
{{env.VAR_NAME}} - 环境变量
{{args.param}} - 工作流参数"""
def set_cli(self, cli):
self.cli = cli
self.engine = WorkflowEngine(cli)
cli.register_liugin_command('workflow', self.command_handler)
cli.register_liugin_command('wf', self.command_handler)
def command_handler(self, args):
return self.handle(args)
def get_tool_info(self):
return {
"name": "workflow",
"description": "工作流引擎 - YAML 定义的多步骤自动化流程,支持变量传递、条件分支、断点续跑",
"keywords": ["workflow", "工作流", "流程", "自动化", "pipeline", "deploy", "build", "run"],
"usage": self.usage
}
def get_mcp_definition(self):
return {
"name": "workflow",
"description": "工作流引擎 - 执行、管理 YAML 定义的自动化工作流",
"inputSchema": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["run", "list", "states", "status", "resume", "abort"],
"description": "操作类型"
},
"target": {
"type": "string",
"description": "工作流文件路径(run) 或工作流ID(status/resume/abort)"
},
"args": {
"type": "object",
"description": "工作流参数 (run 时使用)"
}
},
"required": ["operation"]
}
}
def convert_mcp_args(self, arguments):
op = arguments.get("operation", "")
target = arguments.get("target", "")
args = arguments.get("args", "")
if args and isinstance(args, dict):
args_str = ' '.join(f'{k}={v}' for k, v in args.items())
return f"{op} {target} {args_str}".strip()
return f"{op} {target}".strip()
def handle(self, args):
try:
args = args.strip()
if not args:
return self.usage
parts = args.split(None, 2)
action = parts[0].lower()
if action == 'run':
if len(parts) < 2:
return "❌ 请指定工作流文件: workflow run <file.yaml>"
path = parts[1]
# 解析内联参数
wf_args = {}
if len(parts) > 2:
for part in parts[2].split():
if '=' in part:
k, v = part.split('=', 1)
wf_args[k] = v
return self.engine.run(path, wf_args)
elif action == 'list':
return self.engine.list_workflows()
elif action == 'states':
return self.engine.list_states()
elif action == 'status':
if len(parts) < 2:
return "❌ 请指定工作流 ID: workflow status <id>"
return self.engine.status(parts[1])
elif action == 'resume':
if len(parts) < 2:
return "❌ 请指定工作流 ID: workflow resume <id>"
return self.engine.resume(parts[1])
elif action == 'abort':
if len(parts) < 2:
return "❌ 请指定工作流 ID: workflow abort <id>"
return self.engine.abort(parts[1])
else:
# 默认当作 run
if os.path.exists(action):
return self.engine.run(action)
return f"❌ 未知操作: {action}\n\n{self.usage}"
except Exception as e:
return f"❌ 工作流操作错误: {e}"
+23 -16
View File
@@ -1,18 +1,25 @@
# 小狸插件配置
# 小狸插件配置 - 由启动器自动生成
ENABLE_AI_SEARCH = True
ENABLE_AUDIO_PLAYER = True
ENABLE_CMD_EXECUTOR = True
ENABLE_FILE_MANAGER = True
ENABLE_CODE_EDITOR = True
ENABLE_CODE_SEARCH = True
ENABLE_GIT_TOOLS = True
ENABLE_FRONTEND_TESTER = True
ENABLE_NETWORK_TOOLS = True
ENABLE_SCHEDULER = True
ENABLE_SPEECH_RECOGNITION = True
ENABLE_TASK_MANAGER = True
ENABLE_TOOL_SEARCH = True
ENABLE_AUTO_ENGINEER = True
ENABLE_GIT_WORKFLOW = True
ENABLE_PROJECT_ANALYZER = True
ENABLE_BROWSER_AUTO = True
ENABLE_CMD_EXECUTOR = True
ENABLE_CODE_EDITOR = True
ENABLE_FILE_MANAGER = True
ENABLE_FRONTEND_TESTER = True
ENABLE_GIT_TOOLS = True
ENABLE_GUI_AUTO = True
ENABLE_ID_GENERATOR = True
ENABLE_IMAGE_READER = True
ENABLE_JSON_TOOLKIT = True
ENABLE_MARKDOWN_TOOL = True
ENABLE_MEMORY_PLUGIN = True
ENABLE_NETWORK_TOOLS = True
ENABLE_REGEX_TESTER = True
ENABLE_SCHEDULER = True
ENABLE_SNIPPET_MANAGER = True
ENABLE_SPEECH_RECOGNITION = True
ENABLE_SUB_AGENT = True
ENABLE_TASK_MANAGER = True
ENABLE_TERMINAL = True
ENABLE_TOOL_SEARCH = True
ENABLE_WORKFLOW = True
+11 -29
View File
@@ -1,30 +1,12 @@
# 小狸 Pro-CLI 依赖库
# 修复后的包名(使用 pip 安装名)
# 小狸 Pro-CLI 依赖
# 生成于 2026-06-26 20:58
# 核心依赖
colorama>=0.4.6
openai>=1.0.0
requests>=2.28.0
ollama>=0.1.0
# 图像处理
Pillow>=9.0.0
opencv-python>=4.5.0
numpy>=1.21.0
ascii-magic>=2.0.0
# 数据处理
pandas>=1.3.0
scipy>=1.7.0
# 音频
pygame>=2.0.0
# TUI 模式
textual>=0.40.0
# WebSocket 远程
websockets>=11.0
# 类型提示
typing-extensions>=4.0.0
PIL
ascii_magic
colorama
cv2
numpy
ollama
openai
requests
tkinter
-14
View File
@@ -371,20 +371,6 @@ class SkillLoader:
"""获取已加载的技能"""
return self._skills.get(name)
def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]:
"""获取所有技能的工具定义"""
definitions = []
for skill in self._skills.values():
if format == "anthropic":
definitions.append(skill.get_anthropic_tool_definition())
else:
definitions.append(skill.get_tool_definition())
return definitions
def get_skill(self, name: str) -> Optional[Union[Skill, MarkdownSkill]]:
"""获取已加载的技能"""
return self._skills.get(name)
def get_all_tool_definitions(self, format: str = "openai") -> List[Dict]:
"""获取所有技能的工具定义"""
definitions = []
+2 -1
View File
@@ -90,7 +90,8 @@ def read_file(file_path: str, encoding: str = "utf-8", lines: int = None) -> str
break
content_lines.append(line.rstrip('\n\r'))
content = '\n'.join(content_lines)
total_lines = sum(1 for _ in open(file_path, 'r', encoding=encoding))
with open(file_path, 'r', encoding=encoding) as f2:
total_lines = sum(1 for _ in f2)
else:
content = f.read()
total_lines = content.count('\n') + 1
-3
View File
@@ -1,3 +0,0 @@
Line 1
MODIFIED LINE
Line 3
@@ -1,5 +0,0 @@
Line 1
REPLACED LINE 2
Line 3
REPLACED LINE 4
Line 5
+141
View File
@@ -357,6 +357,147 @@ class TestBrowserAuto:
# 引擎加载测试
# ══════════════════════════════════════
class TestGuiAuto:
"""gui_auto 功能测试"""
@pytest.fixture
def gui(self):
mod = load_module("gui_auto", os.path.join(PLUGINS_DIR, "gui_auto.py"))
return mod.Plugin()
def test_tool_info(self, gui):
info = gui.get_tool_info()
assert info["name"] == "gui_auto"
assert "GUI" in info["description"]
assert len(info["keywords"]) >= 5
def test_mcp_definition(self, gui):
mcp = gui.get_mcp_definition()
assert mcp["name"] == "gui_auto"
assert "inputSchema" in mcp
assert "operation" in mcp["inputSchema"]["properties"]
def test_convert_mcp_args(self, gui):
args = {"operation": "click", "target": "确定"}
result = gui.convert_mcp_args(args)
assert "click" in result
assert "确定" in result
def test_convert_mcp_args_xy(self, gui):
args = {"operation": "click", "x": 100, "y": 200}
result = gui.convert_mcp_args(args)
assert "--xy" in result
assert "100" in result
def test_convert_mcp_args_snapshot(self, gui):
args = {"operation": "snapshot", "depth": 3}
result = gui.convert_mcp_args(args)
assert "snapshot" in result
assert "3" in result
def test_empty_args(self, gui):
result = gui.handle("")
assert "错误" in result
def test_invalid_operation(self, gui):
result = gui.handle("nonexistent_op")
assert "不支持" in result
def test_click_no_args(self, gui):
result = gui.handle("click")
assert "错误" in result
def test_type_no_args(self, gui):
result = gui.handle("type")
assert "错误" in result
def test_type_missing_text(self, gui):
result = gui.handle("type 按钮")
assert "错误" in result
def test_find_no_args(self, gui):
result = gui.handle("find")
assert "错误" in result
def test_findall_no_args(self, gui):
result = gui.handle("findall")
assert "错误" in result
def test_info_no_args(self, gui):
result = gui.handle("info")
assert "错误" in result
def test_value_no_args(self, gui):
result = gui.handle("value")
assert "错误" in result
def test_tree_no_args(self, gui):
result = gui.handle("tree")
assert "错误" in result
def test_wait_no_args(self, gui):
result = gui.handle("wait")
assert "错误" in result
def test_exists_no_args(self, gui):
result = gui.handle("exists")
assert "错误" in result
def test_highlight_no_args(self, gui):
result = gui.handle("highlight")
assert "错误" in result
def test_state_no_args(self, gui):
result = gui.handle("state")
assert "错误" in result
def test_keys_no_args(self, gui):
result = gui.handle("keys")
assert "错误" in result
def test_focus_no_args(self, gui):
result = gui.handle("focus")
assert "错误" in result
def test_click_xy_format_error(self, gui):
result = gui.handle("click --xy abc")
assert "错误" in result
def test_format_element_info(self, gui):
el = {
"name": "测试按钮", "type": "Button", "class": "Button",
"automation_id": "btn_ok",
"bounds": {"left": 100, "top": 200, "width": 80, "height": 30,
"right": 180, "bottom": 230},
"enabled": True, "focused": False
}
result = gui._format_element_info(el)
assert "测试按钮" in result
assert "Button" in result
assert "btn_ok" in result
def test_format_element_info_detailed(self, gui):
el = {
"name": "输入框", "type": "Edit", "class": "Edit",
"automation_id": "", "enabled": True, "focused": True,
"value": "hello",
"bounds": {"left": 0, "top": 0, "width": 200, "height": 25,
"right": 200, "bottom": 25}
}
result = gui._format_element_info(el, detailed=True)
assert "输入框" in result
assert "hello" in result
def test_all_operations_route(self, gui):
"""所有操作都能正确路由"""
for op in ["snapshot", "screenshot", "windows", "focus", "click",
"rightclick", "doubleclick", "type", "clear", "keys",
"scroll", "find", "findall", "info", "value", "tree",
"wait", "exists", "highlight", "state"]:
result = gui.handle(f"{op} test")
assert isinstance(result, str)
class TestSubAgent:
"""sub_agent 功能测试"""
+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 个测试通过!")
+250 -420
View File
File diff suppressed because it is too large. Load diff
+20 -21
View File
@@ -417,21 +417,17 @@ class LocalServer:
image_engine = getattr(self.cli, 'image_engine', None)
if not image_engine:
try:
from image_engine import get_image_engine
image_engine = get_image_engine()
from image_engine import get_engine_manager
image_engine = get_engine_manager()
except:
return f"@{file_path}" # 没有图像引擎,直接返回路径
if websocket:
await self._send_tool_status(websocket, "图像识别", text or "分析图片", "calling")
# 读取图片
with open(file_path, 'rb') as f:
image_data = f.read()
# 调用图像识别
try:
result = image_engine.analyze(image_data, text or "请描述这张图片")
result = image_engine.analyze(file_path, text or "请描述这张图片")
if websocket:
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
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
try:
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
except:
local_ip = "未知"
finally:
s.close()
except:
local_ip = "未知"
@@ -657,9 +657,13 @@ class LocalServer:
def get_status(self) -> dict:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
try:
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
except:
local_ip = "未知"
finally:
s.close()
except:
local_ip = "未知"
return {
@@ -817,15 +821,12 @@ class ProxyClient:
image_engine = getattr(self.cli, 'image_engine', None)
if not image_engine:
try:
from image_engine import get_image_engine
image_engine = get_image_engine()
from image_engine import get_engine_manager
image_engine = get_engine_manager()
except:
return f"@{file_path}"
with open(file_path, 'rb') as f:
image_data = f.read()
result = image_engine.analyze(image_data, text or "请描述这张图片")
result = image_engine.analyze(file_path, text or "请描述这张图片")
return await self._process_message(f"@{file_path}", [], f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}")
except Exception as e:
@@ -1133,8 +1134,6 @@ def start_proxy_client(cli_instance, server_host: str, server_port: int,
websocket_server = None
return result
return _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password)
def stop_all():
-381
View File
@@ -1,381 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小狸 Pro-CLI v5.1.2 · 竞品实力对比</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#06080d;--bg2:#0d1117;--bg3:#161b22;--border:#21262d;--txt:#e6edf3;--dim:#8b949e;--mute:#484f58;--blue:#58a6ff;--purple:#bc8cff;--cyan:#39d2c0;--green:#3fb950;--red:#f85149;--yellow:#d29922;--orange:#f0883e}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;line-height:1.7}
/* ── Hero ── */
.hero{text-align:center;padding:80px 20px 50px;background:linear-gradient(180deg,#0d1117 0%,#06080d 100%);position:relative;overflow:hidden}
.hero::before{content:'';position:absolute;top:0;left:50%;width:600px;height:600px;transform:translateX(-50%);background:radial-gradient(circle,rgba(88,166,255,.08) 0%,transparent 70%);pointer-events:none}
.hero h1{font-size:2.6em;font-weight:800;background:linear-gradient(135deg,#58a6ff,#bc8cff,#39d2c0);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px}
.hero .sub{color:var(--dim);font-size:1.1em;margin-bottom:24px}
.hero .tags{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
.tag{padding:5px 14px;border-radius:20px;font-size:.82em;font-weight:600;border:1px solid}
.tag-b{background:rgba(88,166,255,.1);color:var(--blue);border-color:rgba(88,166,255,.25)}
.tag-p{background:rgba(188,140,255,.1);color:var(--purple);border-color:rgba(188,140,255,.25)}
.tag-g{background:rgba(63,185,80,.1);color:var(--green);border-color:rgba(63,185,80,.25)}
.tag-c{background:rgba(57,210,192,.1);color:var(--cyan);border-color:rgba(57,210,192,.25)}
.tag-y{background:rgba(210,153,34,.1);color:var(--yellow);border-color:rgba(210,153,34,.25)}
.container{max-width:1100px;margin:0 auto;padding:0 24px}
section{padding:56px 0;border-bottom:1px solid rgba(33,38,45,.5)}
section:last-child{border:none}
h2{font-size:1.7em;margin-bottom:6px;background:linear-gradient(90deg,var(--blue),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
h2 .em{-webkit-text-fill-color:initial}
.sec-desc{color:var(--dim);margin-bottom:32px;font-size:1.02em}
/* ── What's New ── */
.new-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.new-card{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:24px;transition:.3s;position:relative;overflow:hidden}
.new-card:hover{border-color:var(--blue);box-shadow:0 0 24px rgba(88,166,255,.1);transform:translateY(-2px)}
.new-card .icon{font-size:1.8em;margin-bottom:8px}
.new-card h3{font-size:1.08em;margin-bottom:8px;color:#f0f6fc}
.new-card p{color:var(--dim);font-size:.93em}
.new-card .badge-new{position:absolute;top:12px;right:12px;background:var(--green);color:#000;font-size:.72em;font-weight:700;padding:2px 8px;border-radius:10px}
/* ── Compare Table ── */
.tbl-wrap{overflow-x:auto;border-radius:12px;border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:.9em}
thead th{background:var(--bg3);color:#f0f6fc;padding:13px 16px;text-align:left;font-weight:700;position:sticky;top:0;border-bottom:1px solid var(--border)}
tbody td{padding:12px 16px;border-bottom:1px solid rgba(33,38,45,.5)}
tbody tr:hover{background:rgba(88,166,255,.04)}
tbody td:first-child{font-weight:600;color:var(--dim);white-space:nowrap}
.y{color:var(--green);font-weight:700}
.n{color:var(--red);opacity:.5}
.p{color:var(--yellow)}
.hl{background:rgba(88,166,255,.06);font-weight:600}
/* ── Score Cards ── */
.score-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px;margin-top:20px}
.score{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:22px;text-align:center;transition:.3s}
.score:hover{border-color:var(--blue);box-shadow:0 0 20px rgba(88,166,255,.08)}
.score .nm{font-size:1.05em;font-weight:700;margin-bottom:4px}
.score .tot{font-size:2.6em;font-weight:800;margin:6px 0}
.score .sub{font-size:.82em;color:var(--dim)}
.score .bars{text-align:left;margin-top:14px;font-size:.86em;color:var(--dim)}
.score .bars .row{display:flex;justify-content:space-between;margin-bottom:3px}
.score .bars .bw{background:rgba(255,255,255,.05);border-radius:4px;height:6px;margin-bottom:8px;overflow:hidden}
.score .bars .bd{height:100%;border-radius:4px}
/* ── Radar ── */
.radar-wrap{display:flex;justify-content:center;margin:30px 0}
.radar{position:relative;width:400px;height:400px}
.radar svg{width:100%;height:100%}
/* ── SWOT ── */
.swot{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.sc{border-radius:12px;padding:24px;border:1px solid}
.sc h3{font-size:1.08em;margin-bottom:12px}
.sc ul{list-style:none;padding:0}
.sc li{padding:5px 0 5px 22px;position:relative;color:var(--dim);font-size:.93em}
.sc li::before{content:'→';position:absolute;left:0;font-weight:700}
.sc-s{background:rgba(63,185,80,.04);border-color:rgba(63,185,80,.2)}.sc-s h3{color:var(--green)}.sc-s li::before{color:var(--green)}
.sc-w{background:rgba(248,81,73,.04);border-color:rgba(248,81,73,.2)}.sc-w h3{color:var(--red)}.sc-w li::before{color:var(--red)}
.sc-o{background:rgba(88,166,255,.04);border-color:rgba(88,166,255,.2)}.sc-o h3{color:var(--blue)}.sc-o li::before{color:var(--blue)}
.sc-t{background:rgba(240,136,62,.04);border-color:rgba(240,136,62,.2)}.sc-t h3{color:var(--orange)}.sc-t li::before{color:var(--orange)}
/* ── Verdict ── */
.verdict{background:linear-gradient(135deg,rgba(88,166,255,.06),rgba(188,140,255,.06));border:1px solid rgba(88,166,255,.2);border-radius:14px;padding:36px;text-align:center;margin-top:28px}
.verdict h3{font-size:1.4em;margin-bottom:10px;color:#f0f6fc}
.verdict p{color:var(--dim);max-width:680px;margin:0 auto;font-size:1.02em}
footer{text-align:center;padding:36px 20px;color:var(--mute);font-size:.86em;border-top:1px solid rgba(33,38,45,.5)}
footer a{color:var(--blue);text-decoration:none}
@media(max-width:768px){.hero h1{font-size:1.7em}.swot{grid-template-columns:1fr}table{font-size:.8em}}
</style>
</head>
<body>
<div class="hero">
<h1>小狸 Pro-CLI v5.1.2</h1>
<p class="sub">新增自动语法检查 · Diff 弹窗 · Web UI 后,与同类产品的实力对比</p>
<div class="tags">
<span class="tag tag-g"> 新增语法检查 · 11 种语言</span>
<span class="tag tag-b"> 新增 Diff 弹窗 · 可开关</span>
<span class="tag tag-p"> 新增 Web UI · 纯静态</span>
<span class="tag tag-c">v5.1.2 · 2026-04-27</span>
<span class="tag tag-y">16 插件 · 142 操作 · 约 19K 行</span>
</div>
</div>
<!-- ════════════════ 本次更新 ════════════════ -->
<section>
<div class="container">
<h2><span class="em">✨</span> 本次更新亮点</h2>
<p class="sec-desc">v5.1.2 新增 3 大功能,补齐了编辑后自动验证和可视化界面的短板。</p>
<div class="new-grid">
<div class="new-card">
<span class="badge-new">NEW</span>
<div class="icon"> </div>
<h3>自动语法检查</h3>
<p>编辑操作完成后自动触发,覆盖 <strong>11 种语言</strong>:Python (ast.parse + py_compile 双重验证)、JavaScript、TypeScript、JSON、YAML、TOML、HTML/XML、CSS、Shell (bash -n)、SQL。通过显示 ✓,失败显示精确的行号/列号/错误描述。AI 也可主动调用 <code>syntax_check</code>。</p>
</div>
<div class="new-card">
<span class="badge-new">NEW</span>
<div class="icon"> </div>
<h3>Diff 弹窗</h3>
<p>每次编辑后自动弹出新终端窗口,显示修改前/后的完整内容(带行号)、unified diff 差异、+N/-M 行数统计。兼容 <strong>Windows (cmd) / macOS (Terminal) / Linux (gnome-terminal 等 6 种)</strong>。异步不阻塞,<code>diff_popup on/off</code> 一键开关。</p>
</div>
<div class="new-card">
<span class="badge-new">NEW</span>
<div class="icon"> </div>
<h3>Web UI</h3>
<p>纯静态 HTML 界面,<strong>零依赖、零后端</strong>,浏览器直接打开。三栏布局:左侧文件树、中间对话/编辑器(Tab 切换)、右侧状态面板。暗色主题、动画效果、快捷芯片、行号编辑器、Ctrl+S 保存。</p>
</div>
</div>
</div>
</section>
<!-- ════════════════ 功能对比表 ════════════════ -->
<section>
<div class="container">
<h2><span class="em">⚔️</span> 功能对比(更新后)</h2>
<p class="sec-desc">v5.1.2 新增功能后,小狸在语法验证和界面维度补齐了关键差距。绿色高亮 = 本次新增优势。</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>维度</th><th>小狸 v5.1.2</th><th>Claude Code</th><th>OpenClaw</th><th>Aider</th><th>Cursor</th></tr>
</thead>
<tbody>
<tr><td>开源</td><td class="y">✅ MIT</td><td class="n">❌</td><td class="y">✅</td><td class="y">✅ Apache</td><td class="n">❌</td></tr>
<tr><td>本地模型</td><td class="y">✅ Ollama 原生</td><td class="n">❌</td><td class="p">⚠️ 有限</td><td class="p">⚠️ 需配置</td><td class="n">❌</td></tr>
<tr><td>代码编辑</td><td class="y">✅ 24 操作</td><td class="y">✅</td><td class="y">✅</td><td class="y">✅</td><td class="y">✅</td></tr>
<tr class="hl"><td> 自动语法检查</td><td class="y">✅ 11 语言 · 自动+主动</td><td class="n">❌</td><td class="n">❌</td><td class="n">❌</td><td class="p">⚠️ 有限</td></tr>
<tr class="hl"><td> Diff 弹窗</td><td class="y">✅ 自动弹窗 · 可开关</td><td class="n">❌</td><td class="n">❌</td><td class="n">❌</td><td class="p">⚠️ 内置 diff</td></tr>
<tr class="hl"><td> Web UI</td><td class="y">✅ 纯静态 HTML</td><td class="n">❌</td><td class="p">⚠️ Canvas</td><td class="n">❌</td><td class="y">✅ GUI IDE</td></tr>
<tr><td>Git 集成</td><td class="y">✅ 20 操作</td><td class="y">✅</td><td class="y">✅ GitHub</td><td class="y">✅</td><td class="y">✅</td></tr>
<tr><td>浏览器自动化</td><td class="y">✅ 31 操作</td><td class="n">❌</td><td class="y">✅</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>子 Agent</td><td class="y">✅ 多 Agent 并行</td><td class="n">❌</td><td class="y">✅ 强大</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>安全沙箱</td><td class="y">✅ AST+子进程+三模式</td><td class="p">⚠️ 权限</td><td class="y">✅ 多层</td><td class="p">⚠️ 基础</td><td class="p">⚠️ 沙箱</td></tr>
<tr><td>持久化记忆</td><td class="y">✅ MEMORY+日记+检索</td><td class="p">⚠️ CLAUDE.md</td><td class="y">✅ 完整</td><td class="p">⚠️ 有限</td><td class="p">⚠️ 有限</td></tr>
<tr><td>TUI 界面</td><td class="y">✅ Textual 动画</td><td class="n">❌ 纯 CLI</td><td class="p">⚠️</td><td class="n">❌</td><td class="y">✅ GUI</td></tr>
<tr><td>工程化工具</td><td class="y">✅ lint/format/test/build</td><td class="p">⚠️</td><td class="p">⚠️</td><td class="p">⚠️</td><td class="y">✅</td></tr>
<tr><td>语音识别</td><td class="y">✅ STT</td><td class="n">❌</td><td class="p">⚠️ TTS</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>远程控制</td><td class="y">✅ Clawli+WebSocket</td><td class="n">❌</td><td class="y">✅ 多渠道</td><td class="n">❌</td><td class="n">❌</td></tr>
<tr><td>测试覆盖</td><td class="y">✅ 113 用例</td><td class="n">❌</td><td class="p">⚠️</td><td class="y">✅</td><td class="n">❌</td></tr>
<tr><td>中文原生</td><td class="y">✅</td><td class="p">⚠️</td><td class="y">✅</td><td class="p">⚠️</td><td class="p">⚠️</td></tr>
<tr><td>价格</td><td class="y">免费</td><td>$20/月</td><td class="y">免费</td><td>$20/月</td><td>$20/月</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ════════════════ 评分对比 ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> 多维度评分(v5.1.2 更新后)</h2>
<p class="sec-desc">满分 10 分,基于代码审查和功能实测。</p>
<div class="score-row">
<div class="score" style="border-color:rgba(88,166,255,.35)">
<div class="nm" style="color:var(--blue)">小狸 v5.1.2</div>
<div class="tot" style="color:var(--blue)">7.3</div>
<div class="sub">↑ 0.5 · 语法检查 + Diff弹窗 + Web UI</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--blue)"></div></div>
<div class="row"><span>代码质量</span><span>6.5</span></div>
<div class="bw"><div class="bd" style="width:65%;background:var(--blue)"></div></div>
<div class="row"><span>架构设计</span><span>6.5</span></div>
<div class="bw"><div class="bd" style="width:65%;background:var(--blue)"></div></div>
<div class="row"><span>安全机制</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--blue)"></div></div>
<div class="row"><span>用户体验</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--blue)"></div></div>
<div class="row"><span>生态与社区</span><span>4.0</span></div>
<div class="bw"><div class="bd" style="width:40%;background:var(--blue)"></div></div>
<div class="row"><span>文档完整度</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--blue)"></div></div>
<div class="row"><span>工程化水平</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--blue)"></div></div>
</div>
</div>
<div class="score" style="border-color:rgba(240,136,62,.35)">
<div class="nm" style="color:var(--orange)">Claude Code</div>
<div class="tot" style="color:var(--orange)">8.5</div>
<div class="sub">模型能力碾压,但无语法检查/Diff弹窗</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--orange)"></div></div>
<div class="row"><span>代码质量</span><span>9.5</span></div>
<div class="bw"><div class="bd" style="width:95%;background:var(--orange)"></div></div>
<div class="row"><span>架构设计</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
<div class="row"><span>安全机制</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--orange)"></div></div>
<div class="row"><span>用户体验</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
<div class="row"><span>生态与社区</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--orange)"></div></div>
<div class="row"><span>文档完整度</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
<div class="row"><span>工程化水平</span><span>9.0</span></div>
<div class="bw"><div class="bd" style="width:90%;background:var(--orange)"></div></div>
</div>
</div>
<div class="score" style="border-color:rgba(63,185,80,.35)">
<div class="nm" style="color:var(--green)">OpenClaw</div>
<div class="tot" style="color:var(--green)">7.8</div>
<div class="sub">Agent 平台标杆,多渠道生态</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--green)"></div></div>
<div class="row"><span>代码质量</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--green)"></div></div>
<div class="row"><span>架构设计</span><span>8.5</span></div>
<div class="bw"><div class="bd" style="width:85%;background:var(--green)"></div></div>
<div class="row"><span>安全机制</span><span>8.5</span></div>
<div class="bw"><div class="bd" style="width:85%;background:var(--green)"></div></div>
<div class="row"><span>用户体验</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--green)"></div></div>
<div class="row"><span>生态与社区</span><span>7.0</span></div>
<div class="bw"><div class="bd" style="width:70%;background:var(--green)"></div></div>
<div class="row"><span>文档完整度</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--green)"></div></div>
<div class="row"><span>工程化水平</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--green)"></div></div>
</div>
</div>
<div class="score" style="border-color:rgba(188,140,255,.35)">
<div class="nm" style="color:var(--purple)">Aider</div>
<div class="tot" style="color:var(--purple)">7.2</div>
<div class="sub">专注代码编辑,简洁高效</div>
<div class="bars">
<div class="row"><span>功能丰富度</span><span>6.5</span></div>
<div class="bw"><div class="bd" style="width:65%;background:var(--purple)"></div></div>
<div class="row"><span>代码质量</span><span>8.5</span></div>
<div class="bw"><div class="bd" style="width:85%;background:var(--purple)"></div></div>
<div class="row"><span>架构设计</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--purple)"></div></div>
<div class="row"><span>安全机制</span><span>5.0</span></div>
<div class="bw"><div class="bd" style="width:50%;background:var(--purple)"></div></div>
<div class="row"><span>用户体验</span><span>8.0</span></div>
<div class="bw"><div class="bd" style="width:80%;background:var(--purple)"></div></div>
<div class="row"><span>生态与社区</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--purple)"></div></div>
<div class="row"><span>文档完整度</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--purple)"></div></div>
<div class="row"><span>工程化水平</span><span>7.5</span></div>
<div class="bw"><div class="bd" style="width:75%;background:var(--purple)"></div></div>
</div>
</div>
</div>
</div>
</section>
<!-- ════════════════ 差异化优势 ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> v5.1.2 新增的独有优势</h2>
<p class="sec-desc">这三项功能在同类产品中均无直接竞品。</p>
<div class="new-grid">
<div class="new-card" style="border-left:3px solid var(--green)">
<h3> 自动语法检查 — 行业空白</h3>
<p><strong>Claude Code</strong>:不做语法检查,依赖模型自身判断。<br>
<strong>Aider</strong>:不做语法检查。<br>
<strong>Cursor</strong>:有 lint 集成但非自动触发。<br><br>
<strong>小狸</strong>:编辑后 <strong>自动</strong> 触发,11 种语言,AST 级精确错误定位。AI 修改代码后立刻知道对不对,不用等运行时才发现。<strong>这是编程 CLI 赛道的首创。</strong></p>
</div>
<div class="new-card" style="border-left:3px solid var(--blue)">
<h3> Diff 弹窗 — 所见即所得</h3>
<p><strong>Claude Code</strong>:终端内显示 diff 文本。<br>
<strong>Aider</strong>:终端内显示 diff。<br>
<strong>Cursor</strong>:IDE 内置 diff 视图。<br><br>
<strong>小狸</strong>:每次编辑后 <strong>弹出独立窗口</strong>,完整显示修改前后内容 + 行号 + unified diff + 统计。不挤在终端里,一目了然。<code>diff_popup off</code> 一键关闭。</p>
</div>
<div class="new-card" style="border-left:3px solid var(--purple)">
<h3> Web UI — 零门槛</h3>
<p><strong>Claude Code</strong>:纯 CLI,无 UI。<br>
<strong>Aider</strong>:纯 CLI,无 UI。<br>
<strong>OpenClaw</strong>:Canvas 组件,非完整 UI。<br><br>
<strong>小狸</strong>:纯静态 HTML 文件,<strong>双击打开</strong>。暗色主题三栏布局,对话 + 编辑器 + 文件树 + 状态面板。不需要 npm、不需要 Flask、不需要任何后端。</p>
</div>
</div>
</div>
</section>
<!-- ════════════════ SWOT ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> SWOT 分析(更新后)</h2>
<div class="swot">
<div class="sc sc-s">
<h3> Strengths</h3>
<ul>
<li><strong>自动语法检查</strong> — 行业首创,11 语言,编辑后即时反馈</li>
<li><strong>Diff 弹窗</strong> — 独立窗口对比,所见即所得</li>
<li><strong>三模式界面</strong> — CLI + TUI + Web UI,覆盖所有场景</li>
<li><strong>功能密度极高</strong> — 16 插件 142 操作,2 个月一人完成</li>
<li><strong>安全纵深</strong> — AST 沙箱 + 子进程隔离 + 三模式</li>
<li><strong>本地模型原生</strong> — Ollama 开箱即用,完全离线</li>
<li><strong>中文原生 + MIT 开源</strong> — 国内开发者零门槛</li>
</ul>
</div>
<div class="sc sc-w">
<h3> Weaknesses</h3>
<ul>
<li><strong>模型能力差距</strong> — 本地模型 vs Claude/GPT-4 鸿沟仍在</li>
<li><strong>单人维护</strong> — 长期迭代速度和可持续性存疑</li>
<li><strong>社区生态空白</strong> — 无插件市场、无活跃贡献者</li>
<li><strong>架构复杂度</strong> — cli_core.py 对话循环待拆分</li>
<li><strong>Web UI 是静态</strong> — 无后端,不能真正执行操作</li>
</ul>
</div>
<div class="sc sc-o">
<h3> Opportunities</h3>
<ul>
<li><strong>国产替代</strong> — 中文原生 + 本地模型是巨大优势</li>
<li><strong>本地 AI 爆发</strong> — Ollama/llama.cpp 快速发展</li>
<li><strong>语法检查可产品化</strong> — 独立为 CI/CD 工具或 IDE 插件</li>
<li><strong>教育市场</strong> — 免费 + 中文 + 有趣(彩蛋情书)</li>
<li><strong>Web UI 可进化</strong> — 加后端即可变为完整 Web IDE</li>
</ul>
</div>
<div class="sc sc-t">
<h3>⚠️ Threats</h3>
<ul>
<li><strong>大厂碾压</strong> — Claude Code / Cursor 资金和模型优势</li>
<li><strong>开源竞品</strong> — Aider、Cline、Continue.dev 社区更活跃</li>
<li><strong>AI 工具快速迭代</strong> — 功能可能被快速复制</li>
<li><strong>维护可持续性</strong> — 单人项目最大风险</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ════════════════ 结论 ════════════════ -->
<section>
<div class="container">
<h2><span class="em"> </span> 结论</h2>
<div class="verdict">
<h3>能不能抗衡?</h3>
<p><strong>功能维度:能。</strong>16 插件 142 操作 + 自动语法检查 + Diff 弹窗 + 三模式界面,功能广度已经超过 Claude Code 和 Aider,在语法验证维度甚至领先全行业。</p>
<p style="margin-top:14px"><strong>体验维度:接近。</strong>Web UI 补齐了可视化短板,TUI 动画体验独特。但底层模型能力差距决定了对话质量和代码生成精度仍有代差。</p>
<p style="margin-top:14px"><strong>生态维度:差距大。</strong>社区、插件市场、贡献者体系是最大的结构性劣势。功能做得再好,一个人扛不住长期迭代。</p>
<p style="margin-top:18px;color:var(--blue);font-weight:600">一句话:功能够打,体验能看,生态要追。v5.1.2 是一次关键的能力补齐。</p>
</div>
</div>
</section>
<footer>
<p>基于 <a href="https://gitee.com/shuiyu1123/xiaoli-cli">xiaoli-cli v5.1.2</a> 全部源代码逐行审查生成 · 2026-04-27</p>
</footer>
</body>
</html>
+375 -387
View File
@@ -3,448 +3,436 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小狸 Pro-CLI · Web UI</title>
<title>小狸 Pro-CLI</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'> </text></svg>">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0a0e17;--bg2:#111827;--bg3:#1a2332;--bg4:#21283b;
--border:#1e3a5f;--border-f:#58a6ff;
--txt:#e2e8f0;--dim:#94a3b8;--mute:#475569;
--blue:#3b82f6;--purple:#8b5cf6;--cyan:#06b6d4;
--green:#22c55e;--red:#ef4444;--yellow:#eab308;--orange:#f97316;
--r:10px;
--bg:#0a0a0c;--bg1:#111114;--bg2:#18181c;--bg3:#1f1f24;--bg4:#27272e;
--border:rgba(255,255,255,.06);--border-h:rgba(255,255,255,.12);--border-f:rgba(120,130,255,.45);
--t0:#f0f0f2;--t1:#a0a0aa;--t2:#6a6a76;--t3:#48484f;
--ac:#7c7cff;--ac-h:#9494ff;--ac-s:rgba(124,124,255,.12);--ac-xs:rgba(124,124,255,.06);
--green:#34d399;--green-s:rgba(52,211,153,.1);
--red:#f87171;--red-s:rgba(248,113,113,.1);
--amber:#fbbf24;--amber-s:rgba(251,191,36,.1);
--cyan:#22d3ee;--cyan-s:rgba(34,211,238,.1);
--r-sm:6px;--r-md:10px;--r-lg:14px;--r-xl:18px;
--ease:cubic-bezier(.4,0,.2,1);
}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;height:100vh;overflow:hidden}
html{font-size:14px}
body{font-family:-apple-system,BlinkMacSystemFont,'Inter','Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--t0);line-height:1.55;-webkit-font-smoothing:antialiased;height:100vh;overflow:hidden}
/* ── Grid ── */
.app{display:grid;grid-template-columns:250px 1fr 300px;grid-template-rows:52px 1fr;height:100vh}
.hdr{grid-column:1/-1;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:16px}
.side{background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:12px}
.main{display:flex;flex-direction:column;overflow:hidden}
.rpanel{background:var(--bg2);border-left:1px solid var(--border);overflow-y:auto;padding:14px}
/* Layout */
.app{display:grid;grid-template-columns:260px 1fr;grid-template-rows:52px 1fr;height:100vh}
/* ── Header ── */
.logo{font-size:1.25em;font-weight:800;background:linear-gradient(135deg,var(--blue),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.tabs{display:flex;gap:4px}
.tab{padding:7px 18px;border-radius:8px;cursor:pointer;font-size:.88em;color:var(--dim);border:1px solid transparent;transition:.2s}
.tab:hover{background:var(--bg3);color:var(--txt)}
.tab.on{background:var(--bg4);color:var(--blue);border-color:var(--border)}
.hdr-r{margin-left:auto;display:flex;gap:10px;align-items:center}
.pill{padding:4px 12px;border-radius:14px;font-size:.78em;font-weight:600}
.pill-e{background:rgba(59,130,246,.12);color:var(--blue);border:1px solid rgba(59,130,246,.25)}
.pill-s{background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.25)}
.pill-m{background:rgba(139,92,246,.12);color:var(--purple);border:1px solid rgba(139,92,246,.25)}
/* Topbar */
.topbar{grid-column:1/-1;display:flex;align-items:center;padding:0 20px;background:var(--bg1);border-bottom:1px solid var(--border);gap:14px;z-index:10}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:14px;letter-spacing:-.02em;color:var(--t0);padding-right:14px;border-right:1px solid var(--border)}
.brand-icon{width:28px;height:28px;border-radius:var(--r-md);background:linear-gradient(135deg,var(--ac),#a855f7);display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 0 20px rgba(124,124,255,.25)}
.tabs{display:flex;gap:2px}
.tab{padding:7px 16px;border-radius:var(--r-sm);font-size:12.5px;font-weight:600;color:var(--t2);cursor:pointer;transition:all .15s var(--ease);border:none;background:none}
.tab:hover{color:var(--t1);background:rgba(255,255,255,.04)}
.tab.active{color:var(--t0);background:var(--bg3)}
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:8px}
.pill{display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:100px;font-size:11.5px;font-weight:600;border:1px solid var(--border);background:var(--bg2);color:var(--t1)}
.dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px rgba(52,211,153,.4)}
.dot.off{background:var(--t3);box-shadow:none}
/* ── Sidebar ── */
.sb-title{font-size:.82em;font-weight:700;color:var(--cyan);margin:14px 0 8px;display:flex;align-items:center;gap:6px}
.sb-title:first-child{margin-top:4px}
.sb-item{padding:6px 10px;border-radius:7px;font-size:.86em;color:var(--dim);cursor:pointer;display:flex;align-items:center;gap:8px;transition:.15s}
.sb-item:hover{background:var(--bg3);color:var(--txt)}
.sb-item.on{background:var(--bg4);color:var(--blue)}
.sb-icon{width:20px;text-align:center;flex-shrink:0}
/* Sidebar */
.sidebar{background:var(--bg1);border-right:1px solid var(--border);overflow-y:auto;padding:10px 8px}
.sb-label{padding:8px 10px 5px;font-size:10.5px;font-weight:700;color:var(--t3);text-transform:uppercase;letter-spacing:.08em}
.sb-item{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:var(--r-sm);font-size:12.5px;color:var(--t2);cursor:pointer;transition:all .15s var(--ease);position:relative}
.sb-item:hover{color:var(--t1);background:rgba(255,255,255,.04)}
.sb-item.active{color:var(--t0);background:rgba(255,255,255,.06)}
.sb-item.active::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);width:2.5px;height:16px;background:var(--ac);border-radius:2px}
.sb-icon{width:16px;text-align:center;font-size:12px;flex-shrink:0;opacity:.7}
.sb-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sb-meta{font-size:.76em;color:var(--mute)}
.sep{height:1px;background:var(--border);margin:10px 0}
.sb-badge{font-size:10px;color:var(--t3);background:var(--bg3);padding:1px 7px;border-radius:100px}
.sb-sep{height:1px;background:var(--border);margin:6px 10px}
/* ── Chat ── */
.chat-box{flex:1;overflow-y:auto;padding:16px 20px;scroll-behavior:smooth}
.msg{margin-bottom:18px;animation:fadeUp .3s}
@keyframes fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.msg-role{font-size:.82em;font-weight:700;margin-bottom:5px;display:flex;align-items:center;gap:6px}
.msg-role.user{color:var(--cyan)}
.msg-role.ai{color:var(--blue)}
.msg-time{font-size:.74em;color:var(--mute);font-weight:400}
.msg-body{padding:12px 16px;border-radius:var(--r);line-height:1.7;font-size:.93em;white-space:pre-wrap;word-break:break-word}
.msg.user .msg-body{background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.15)}
.msg.ai .msg-body{background:var(--bg2);border:1px solid var(--border)}
.msg-body code{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-size:.91em}
.msg-body pre{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;margin:8px 0;font-size:.87em}
.msg-tool{margin-top:8px;padding:8px 12px;background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.12);border-radius:8px;font-size:.87em;color:var(--green)}
.msg-err{margin-top:8px;padding:8px 12px;background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.12);border-radius:8px;font-size:.87em;color:var(--red)}
/* Main */
.main{display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
.view{display:none;height:100%}.view.active{display:flex;flex-direction:column}
/* ── Input ── */
.input-area{padding:14px 18px;border-top:1px solid var(--border);background:var(--bg2)}
.input-row{display:flex;gap:10px;align-items:flex-end}
.inp{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:11px 15px;color:var(--txt);font-size:.94em;resize:none;min-height:44px;max-height:150px;outline:none;font-family:inherit;line-height:1.5;transition:border-color .2s}
.inp:focus{border-color:var(--border-f)}
.btn-send{background:linear-gradient(135deg,var(--blue),var(--purple));color:#fff;border:none;border-radius:var(--r);padding:11px 22px;cursor:pointer;font-weight:700;font-size:.94em;transition:.2s;white-space:nowrap}
.btn-send:hover{filter:brightness(1.15);transform:translateY(-1px)}
.btn-send:active{transform:translateY(0)}
.hint{font-size:.78em;color:var(--mute);margin-top:7px}
/* Chat */
.chat-scroll{flex:1;overflow-y:auto;padding:0 28px;scroll-behavior:smooth}
/* ── Editor ── */
.ed-wrap{flex:1;display:flex;flex-direction:column}
.ed-bar{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border)}
.ed-bar .fi{font-size:.88em;color:var(--dim);flex:1}
.ed-bar button{padding:6px 16px;border-radius:7px;border:1px solid var(--border);background:var(--bg3);color:var(--txt);cursor:pointer;font-size:.84em;transition:.2s}
.ed-bar button:hover{background:var(--bg4);border-color:var(--blue)}
.ed-bar .bsave{background:rgba(34,197,94,.12);color:var(--green);border-color:rgba(34,197,94,.25)}
.ed-bar .bsave:hover{background:rgba(34,197,94,.2)}
.ed-body{flex:1;display:flex;overflow:hidden}
.ed-lines{padding:14px 10px;background:var(--bg2);color:var(--mute);font-family:'JetBrains Mono','Fira Code',monospace;font-size:13px;line-height:1.65;text-align:right;user-select:none;overflow:hidden;min-width:50px;border-right:1px solid var(--border)}
.ed-code{flex:1;padding:14px 16px;background:var(--bg);color:var(--txt);font-family:'JetBrains Mono','Fira Code',monospace;font-size:13px;line-height:1.65;border:none;outline:none;resize:none;tab-size:4;white-space:pre;overflow:auto}
.ed-status{padding:5px 14px;background:var(--bg2);border-top:1px solid var(--border);font-size:.78em;color:var(--mute);display:flex;gap:18px}
/* Welcome */
.welcome{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;text-align:center;animation:fadeUp .5s var(--ease)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
.welcome-icon{width:64px;height:64px;border-radius:var(--r-xl);background:linear-gradient(135deg,var(--ac),#a855f7);display:flex;align-items:center;justify-content:center;font-size:32px;margin-bottom:20px;box-shadow:0 0 50px rgba(124,124,255,.25)}
.welcome h1{font-size:22px;font-weight:800;letter-spacing:-.02em;margin-bottom:8px}
.welcome p{color:var(--t2);font-size:13px;max-width:400px;margin-bottom:28px}
.quick{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
.qbtn{padding:8px 16px;border-radius:100px;font-size:12px;font-weight:600;color:var(--t1);border:1px solid var(--border);background:var(--bg2);cursor:pointer;transition:all .15s var(--ease);display:flex;align-items:center;gap:6px}
.qbtn:hover{border-color:var(--border-h);background:var(--bg3);color:var(--t0);transform:translateY(-1px)}
/* ── Right Panel ── */
.rp-title{font-size:.88em;font-weight:700;color:var(--blue);margin:14px 0 8px;display:flex;align-items:center;gap:6px}
.rp-title:first-child{margin-top:2px}
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.stat{background:var(--bg3);border-radius:9px;padding:12px;text-align:center}
.stat-v{font-size:1.5em;font-weight:800;background:linear-gradient(135deg,var(--blue),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.stat-l{font-size:.78em;color:var(--mute);margin-top:2px}
.rp-item{padding:6px 10px;border-radius:7px;font-size:.84em;color:var(--dim);display:flex;justify-content:space-between;align-items:center;cursor:pointer;transition:.15s}
.rp-item:hover{background:var(--bg3);color:var(--txt)}
.rp-item.on{background:rgba(59,130,246,.1);color:var(--blue)}
.log-e{font-size:.8em;padding:3px 0;color:var(--dim);border-bottom:1px solid rgba(30,58,95,.3)}
.log-t{font-weight:700;margin-right:6px}
.log-t.sys{color:var(--blue)}.log-t.eng{color:var(--purple)}.log-t.safe{color:var(--green)}.log-t.file{color:var(--yellow)}.log-t.err{color:var(--red)}
/* Messages */
.msg{padding:18px 0;animation:msgIn .25s var(--ease)}
@keyframes msgIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.msg+.msg{border-top:1px solid var(--border)}
.msg-head{display:flex;align-items:center;gap:9px;margin-bottom:8px}
.msg-av{width:26px;height:26px;border-radius:var(--r-sm);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0}
.msg-av.user{background:var(--cyan-s);color:var(--cyan)}
.msg-av.ai{background:var(--ac-s);color:var(--ac)}
.msg-name{font-size:12.5px;font-weight:700;color:var(--t0)}
.msg-time{font-size:10.5px;color:var(--t3)}
.msg-body{padding-left:35px;font-size:13.5px;line-height:1.75;color:var(--t1)}
.msg-body p{margin-bottom:8px}.msg-body p:last-child{margin-bottom:0}
.msg-body code{background:var(--bg3);padding:2px 6px;border-radius:4px;font-size:12.5px;font-family:'JetBrains Mono','Fira Code',monospace;color:var(--t0)}
.msg-body pre{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r-md);padding:14px 18px;margin:10px 0;overflow-x:auto;font-size:12.5px;line-height:1.65;font-family:'JetBrains Mono','Fira Code',monospace;position:relative}
.msg-body pre code{background:none;padding:0;font-size:inherit;color:var(--t0)}
.msg-body ul,.msg-body ol{margin:6px 0 6px 20px}
.msg-body li{margin-bottom:4px}
.msg-body blockquote{border-left:3px solid var(--ac);padding:8px 14px;margin:8px 0;background:var(--ac-xs);border-radius:0 var(--r-sm) var(--r-sm) 0;color:var(--t1)}
.msg-body a{color:var(--ac);text-decoration:none}.msg-body a:hover{text-decoration:underline}
.msg-body strong{color:var(--t0);font-weight:700}
.msg-body h1,.msg-body h2,.msg-body h3{color:var(--t0);margin:12px 0 6px;font-weight:700}
.msg-body table{border-collapse:collapse;margin:8px 0;width:100%}
.msg-body th,.msg-body td{border:1px solid var(--border);padding:6px 10px;font-size:12.5px;text-align:left}
.msg-body th{background:var(--bg3);color:var(--t0);font-weight:600}
/* ── Views ── */
.view{display:none;height:100%}.view.on{display:flex;flex-direction:column}
/* Tool cards */
.tool-card{margin:10px 0;padding:10px 14px;background:var(--green-s);border:1px solid rgba(52,211,153,.15);border-radius:var(--r-md);font-size:12.5px;color:var(--green);display:flex;align-items:center;gap:8px;animation:toolIn .3s var(--ease)}
@keyframes toolIn{from{opacity:0;transform:scale(.97)}to{opacity:1;transform:none}}
.tool-card.error{background:var(--red-s);border-color:rgba(248,113,113,.15);color:var(--red)}
.tool-card .tool-icon{font-size:14px}
.tool-card .tool-name{font-weight:700}
.tool-card .tool-args{color:var(--t2);font-size:11.5px;margin-top:2px;word-break:break-all}
/* ── Typing ── */
.typing{display:inline-flex;gap:5px;padding:6px 0}
.typing span{width:7px;height:7px;border-radius:50%;background:var(--mute);animation:bounce 1.4s infinite}
.typing span:nth-child(2){animation-delay:.2s}
.typing span:nth-child(3){animation-delay:.4s}
@keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-7px)}}
/* Thinking */
.thinking{display:flex;align-items:center;gap:10px;padding:12px 0;color:var(--t2);font-size:12.5px;animation:fadeUp .3s var(--ease)}
.thinking-dots{display:flex;gap:4px}
.thinking-dots span{width:5px;height:5px;border-radius:50%;background:var(--t3);animation:bounce 1.4s infinite}
.thinking-dots span:nth-child(2){animation-delay:.2s}
.thinking-dots span:nth-child(3){animation-delay:.4s}
@keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-5px)}}
/* ── Scrollbar ── */
::-webkit-scrollbar{width:5px;height:5px}
/* System */
.sys-msg{padding:4px 0;font-size:12px;color:var(--t2);opacity:.8;padding-left:35px}
.sys-msg.error{color:var(--red)}
/* Input */
.input-area{padding:12px 28px 18px;border-top:1px solid var(--border);background:var(--bg1)}
.input-wrap{display:flex;align-items:flex-end;gap:10px;background:var(--bg2);border:1.5px solid var(--border);border-radius:var(--r-lg);padding:10px 14px;transition:border-color .15s var(--ease),box-shadow .15s var(--ease)}
.input-wrap:focus-within{border-color:var(--border-f);box-shadow:0 0 0 3px rgba(124,124,255,.1)}
.chat-input{flex:1;background:none;border:none;outline:none;color:var(--t0);font-size:13.5px;font-family:inherit;line-height:1.5;resize:none;min-height:22px;max-height:140px}
.chat-input::placeholder{color:var(--t3)}
.send-btn{width:34px;height:34px;border-radius:var(--r-md);background:linear-gradient(135deg,var(--ac),#9060ff);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s var(--ease);flex-shrink:0;box-shadow:0 2px 10px rgba(124,124,255,.3)}
.send-btn:hover{transform:scale(1.06);box-shadow:0 4px 16px rgba(124,124,255,.4)}
.send-btn:active{transform:scale(.95)}
.send-btn:disabled{opacity:.35;cursor:not-allowed;transform:none;box-shadow:none}
.input-hint{font-size:11px;color:var(--t3);padding:7px 4px 0;display:flex;justify-content:space-between}
/* Connection banner */
.conn{padding:8px 20px;font-size:12px;text-align:center;font-weight:600;display:none}
.conn.connecting{display:block;background:var(--amber-s);color:var(--amber);border-bottom:1px solid rgba(251,191,36,.15)}
.conn.error{display:block;background:var(--red-s);color:var(--red);border-bottom:1px solid rgba(248,113,113,.15)}
.conn.ok{display:block;background:var(--green-s);color:var(--green);border-bottom:1px solid rgba(52,211,153,.15);animation:fadeOut 2s forwards 1.5s}
@keyframes fadeOut{to{opacity:0;height:0;padding:0;border:none}}
/* Scrollbar */
::-webkit-scrollbar{width:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--mute)}
::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--t3)}
/* ── Welcome ── */
.welcome{text-align:center;padding:60px 20px;animation:fadeUp .5s}
.welcome h2{font-size:1.6em;margin-bottom:10px;background:linear-gradient(135deg,var(--blue),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.welcome p{color:var(--dim);max-width:460px;margin:0 auto;font-size:1.02em}
.welcome .chips{display:flex;justify-content:center;gap:10px;margin-top:20px;flex-wrap:wrap}
.welcome .chip{padding:6px 16px;border-radius:20px;font-size:.84em;border:1px solid var(--border);color:var(--dim);cursor:pointer;transition:.2s}
.welcome .chip:hover{border-color:var(--blue);color:var(--blue);background:rgba(59,130,246,.06)}
/* ── Responsive ── */
@media(max-width:1024px){.app{grid-template-columns:0 1fr 0}.side,.rpanel{display:none}}
@media(max-width:768px){.tabs{display:none}}
/* Responsive */
@media(max-width:900px){.app{grid-template-columns:1fr}.sidebar{display:none}}
</style>
</head>
<body>
<div class="app">
<!-- ═══ Header ═══ -->
<div class="hdr">
<div class="logo"> 小狸 Pro-CLI</div>
<!-- Topbar -->
<div class="topbar">
<div class="brand">
<div class="brand-icon"> </div>
小狸
</div>
<div class="tabs">
<div class="tab on" onclick="switchView('chat',this)"> 对话</div>
<div class="tab" onclick="switchView('editor',this)"> 编辑器</div>
<button class="tab active" data-view="chat">对话</button>
<button class="tab" data-view="tools">工具</button>
</div>
<div class="hdr-r">
<span class="pill pill-e"> gemma4:31b</span>
<span class="pill pill-s"> 普通模式</span>
<span class="pill pill-m">v5.1.2</span>
<div class="topbar-right">
<div class="pill" id="engine-pill"><span class="dot" id="engine-dot"></span><span id="engine-name">连接中...</span></div>
<div class="pill"><span id="safety-icon"> </span><span id="safety-name">普通</span></div>
</div>
</div>
<!-- ═══ Sidebar ═══ -->
<div class="side">
<div class="sb-title"> 项目文件</div>
<div class="sb-item on" onclick="openFile(this,'ai_cli.py','def main():\n from xcli_core import AICLI, main\n main()')">
<span class="sb-icon"> </span><span class="sb-name">ai_cli.py</span><span class="sb-meta">11行</span>
</div>
<div class="sb-item" onclick="openFile(this,'web_ui.py','#!/usr/bin/env python3\n# 小狸 Pro-CLI Web UI\n# 启动: python web_ui.py\n\nfrom flask import Flask\napp = Flask(__name__)')">
<span class="sb-icon"> </span><span class="sb-name">web_ui.py</span><span class="sb-meta">1.1K</span>
</div>
<div class="sb-item" onclick="openFile(this,'config.json.example','{\n \"api\": {\n \"engines\": {\n \"ollama\": {\n \"model\": \"gemma4:31b\"\n }\n }\n }\n}')">
<span class="sb-icon">{ }</span><span class="sb-name">config.json</span><span class="sb-meta">示例</span>
</div>
<div class="sep"></div>
<div class="sb-title"> xcli_core/</div>
<div class="sb-item" onclick="openFile(this,'cli_core.py','class AICLI(BaseAICLI, ClawliMixin,\n ToolMixin, CodeExecMixin,\n DisplayMixin, HistoryMixin):\n \"\"\"AI CLI 主程序\"\"\"\n\n def process_conversation(self, user_input):\n ...')">
<span class="sb-icon"> </span><span class="sb-name">cli_core.py</span><span class="sb-meta">900行</span>
</div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">safety.py</span><span class="sb-meta">292行</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">sandbox.py</span><span class="sb-meta">187行</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">memory.py</span><span class="sb-meta">记忆系统</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">tui.py</span><span class="sb-meta">899行</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">config.py</span><span class="sb-meta">配置</span></div>
<div class="sep"></div>
<div class="sb-title"> plugins/</div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">code_editor.py</span><span class="sb-meta">23操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">git_tools.py</span><span class="sb-meta">20操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">browser_auto.py</span><span class="sb-meta">31操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">sub_agent.py</span><span class="sb-meta">8操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">auto_engineer.py</span><span class="sb-meta">16操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">file_manager.py</span><span class="sb-meta">11操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">cmd_executor.py</span><span class="sb-meta">1操作</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">task_manager.py</span><span class="sb-meta">7操作</span></div>
<div class="sep"></div>
<div class="sb-title"> ai_engines/</div>
<div class="sb-item on"><span class="sb-icon"> </span><span class="sb-name">ollama_engine.py</span><span class="sb-meta">本地</span></div>
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">openai_engine.py</span><span class="sb-meta">云端</span></div>
<!-- Sidebar -->
<div class="sidebar" id="sidebar">
<div class="sb-label">引擎</div>
<div id="engine-list"></div>
<div class="sb-sep"></div>
<div class="sb-label">工具</div>
<div id="tool-sidebar-list"></div>
</div>
<!-- ═══ Main ═══ -->
<!-- Main -->
<div class="main">
<!-- Chat View -->
<div class="view on" id="v-chat">
<div class="chat-box" id="chat-box">
<div class="welcome">
<h2>小狸 Pro-CLI v5.1.2</h2>
<p>智能编程助手 — 代码编辑 · Git 集成 · 浏览器自动化 · 多 Agent 协作</p>
<div class="chips">
<div class="chip" onclick="quickSend('帮我看看项目结构')"> 查看项目结构</div>
<div class="chip" onclick="quickSend('提交代码')"> 提交代码</div>
<div class="chip" onclick="quickSend('统计代码量')"> 代码统计</div>
<div class="chip" onclick="quickSend('/help')">❓ 帮助</div>
<div class="conn" id="conn"></div>
<!-- Chat -->
<div class="view active" id="view-chat">
<div class="chat-scroll" id="chat-scroll">
<div class="welcome" id="welcome">
<div class="welcome-icon"> </div>
<h1>小狸 Pro-CLI</h1>
<p>智能编程助手 — 代码编辑 · Git · 浏览器自动化 · 多引擎支持</p>
<div class="quick">
<button class="qbtn" data-msg="帮我看看项目结构"> 项目结构</button>
<button class="qbtn" data-msg="提交代码"> 提交代码</button>
<button class="qbtn" data-msg="统计代码量"> 代码统计</button>
<button class="qbtn" data-msg="/help">❓ 帮助</button>
</div>
</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea class="inp" id="inp" placeholder="输入消息... (Enter 发送, Shift+Enter 换行)" rows="1"></textarea>
<button class="btn-send" onclick="send()">发送</button>
<div class="input-wrap">
<textarea class="chat-input" id="chat-input" placeholder="输入消息... (Enter 发送, Shift+Enter 换行)" rows="1"></textarea>
<button class="send-btn" id="send-btn" title="发送">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
<div class="hint">自然语言交互 — AI 自动选择工具执行 · /help 查看命令 · /safe 切换安全模式</div>
</div>
</div>
<!-- Editor View -->
<div class="view" id="v-editor">
<div class="ed-wrap">
<div class="ed-bar">
<span class="fi" id="ed-fi">选择左侧文件开始编辑</span>
<button onclick="edUndo()">撤销</button>
<button class="bsave" onclick="edSave()"> 保存</button>
</div>
<div class="ed-body">
<div class="ed-lines" id="ed-lines">1</div>
<textarea class="ed-code" id="ed-code" placeholder="选择左侧文件..." spellcheck="false"></textarea>
</div>
<div class="ed-status">
<span id="ed-cursor">行 1, 列 1</span>
<span id="ed-lang">--</span>
<span id="ed-enc">UTF-8</span>
<span id="ed-syn"> 就绪</span>
<div class="input-hint">
<span>Enter 发送 · Shift+Enter 换行</span>
<span id="conn-status">⚪ 未连接</span>
</div>
</div>
</div>
</div>
<!-- ═══ Right Panel ═══ -->
<div class="rpanel">
<div class="rp-title"> 系统状态</div>
<div class="stat-grid">
<div class="stat"><div class="stat-v">15</div><div class="stat-l">插件</div></div>
<div class="stat"><div class="stat-v">141</div><div class="stat-l">操作</div></div>
<div class="stat"><div class="stat-v">2</div><div class="stat-l">引擎</div></div>
<div class="stat"><div class="stat-v">113</div><div class="stat-l">测试</div></div>
</div>
<div class="rp-title"> AI 引擎</div>
<div class="rp-item on"><span>▸ ollama (本地)</span><span style="color:var(--green)">●</span></div>
<div class="rp-item"><span> openai (云端)</span><span style="color:var(--mute)">○</span></div>
<div class="rp-title">⚡ 安全模式</div>
<div class="rp-item on"><span> 普通模式</span><span style="color:var(--green)">●</span></div>
<div class="rp-item"><span> 人工确认</span><span style="color:var(--mute)">○</span></div>
<div class="rp-item"><span> 无限制</span><span style="color:var(--mute)">○</span></div>
<div class="rp-title"> 工具 (15)</div>
<div style="max-height:180px;overflow-y:auto">
<div class="rp-item"><span>code_editor</span><span class="sb-meta">23</span></div>
<div class="rp-item"><span>git_tools</span><span class="sb-meta">20</span></div>
<div class="rp-item"><span>browser_auto</span><span class="sb-meta">31</span></div>
<div class="rp-item"><span>sub_agent</span><span class="sb-meta">8</span></div>
<div class="rp-item"><span>auto_engineer</span><span class="sb-meta">16</span></div>
<div class="rp-item"><span>file_manager</span><span class="sb-meta">11</span></div>
<div class="rp-item"><span>cmd_executor</span><span class="sb-meta">1</span></div>
<div class="rp-item"><span>task_manager</span><span class="sb-meta">7</span></div>
<div class="rp-item"><span>network_tools</span><span class="sb-meta">4</span></div>
<div class="rp-item"><span>ai_search</span><span class="sb-meta">2</span></div>
<div class="rp-item"><span>tool_search</span><span class="sb-meta">1</span></div>
<div class="rp-item"><span>frontend_tester</span><span class="sb-meta">5</span></div>
<div class="rp-item"><span>speech_recognition</span><span class="sb-meta">2</span></div>
<div class="rp-item"><span>audio_player</span><span class="sb-meta">3</span></div>
<div class="rp-item"><span>send_image</span><span class="sb-meta">1</span></div>
</div>
<div class="rp-title"> 日志</div>
<div id="log-area">
<div class="log-e"><span class="log-t sys">[系统]</span>01:04:22 Web UI 已启动</div>
<div class="log-e"><span class="log-t eng">[引擎]</span>01:04:22 Ollama 已连接</div>
<div class="log-e"><span class="log-t safe">[安全]</span>01:04:22 普通模式</div>
<div class="log-e"><span class="log-t file">[文件]</span>01:04:22 已加载 15 个插件</div>
<!-- Tools -->
<div class="view" id="view-tools">
<div style="padding:28px;overflow-y:auto;flex:1">
<h2 style="font-size:17px;font-weight:800;margin-bottom:20px;color:var(--t0)">已加载工具</h2>
<div id="tools-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px"></div>
</div>
</div>
</div>
</div>
<script>
// ═══ View Switch ═══
function switchView(v,el){
document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));
document.getElementById('v-'+v).classList.add('on');
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
if(el)el.classList.add('on');
const WS_URL = `ws://${location.hostname}:8079`;
let ws = null, reconnectTimer = null, isThinking = false;
// Configure marked
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
}
function connect() {
const c = document.getElementById('conn');
c.className = 'conn connecting'; c.textContent = '⏳ 正在连接...';
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
c.className = 'conn ok'; c.textContent = '✅ 已连接';
document.getElementById('conn-status').textContent = '🟢 已连接';
clearTimeout(reconnectTimer);
};
ws.onmessage = e => { try { handleMessage(JSON.parse(e.data)) } catch(err) { console.error(err) } };
ws.onclose = () => {
document.getElementById('conn-status').textContent = '⚪ 已断开';
c.className = 'conn error'; c.textContent = '❌ 连接断开,重连中...';
reconnectTimer = setTimeout(connect, 3000);
};
ws.onerror = () => { c.className = 'conn error'; c.textContent = '❌ 无法连接服务器' };
} catch(e) { c.className = 'conn error'; c.textContent = '❌ WebSocket 不可用' }
}
function send(type, data={}) {
if (!ws || ws.readyState !== WebSocket.OPEN) { addSys('未连接到服务器', true); return }
ws.send(JSON.stringify({ type, data }));
}
function handleMessage(msg) {
const d = msg.data || {};
switch (msg.type) {
case 'status': updateStatus(d); break;
case 'thinking': showThinking(); break;
case 'reply': hideThinking(); addMsg('ai', d.message); break;
case 'partial_reply': addSys(d.message); break;
case 'tool_call': showToolCall(d.name, d.args); break;
case 'tool_result': showToolResult(d.name, d.result); break;
case 'tool_executing': showToolCall(d.name, d.args); break;
case 'fc_tool_done': /* silent */ break;
case 'multi':
hideThinking();
(d.events||[]).forEach(ev => {
if (ev.type === 'reply') addMsg('ai', ev.message);
else if (ev.type === 'tool_call') showToolCall(ev.name, ev.args);
else if (ev.type === 'tool_result') showToolResult(ev.name, ev.result);
else if (ev.type === 'partial_reply') addSys(ev.message);
});
break;
case 'engine_switched':
document.getElementById('engine-name').textContent = d.engine;
addSys(`引擎已切换: ${d.engine}`); break;
case 'safety_switched':
document.getElementById('safety-name').textContent = d.mode;
addSys(`安全模式: ${d.mode}`); break;
case 'tools': renderTools(d.tools); break;
case 'tools_list': renderTools(d.tools); break;
case 'error': hideThinking(); addSys(d.message, true); break;
}
}
function updateStatus(d) {
document.getElementById('engine-name').textContent = d.engine || '未连接';
document.getElementById('safety-name').textContent = d.safety_mode || '普通';
const dot = document.getElementById('engine-dot');
dot.className = d.engine_type === 'none' ? 'dot off' : 'dot';
// Sidebar engines
const el = document.getElementById('engine-list');
el.innerHTML = (d.engines||[]).map(n =>
`<div class="sb-item${n===d.engine?' active':''}" data-engine="${n}">
<span class="sb-icon">${n==='ollama'?' ':'☁️'}</span><span class="sb-name">${n}</span>
${n===d.engine?'<span class="sb-badge">当前</span>':''}
</div>`
).join('');
el.querySelectorAll('[data-engine]').forEach(e =>
e.addEventListener('click', () => send('switch_engine', {engine: e.dataset.engine}))
);
// Sidebar tools
const tl = document.getElementById('tool-sidebar-list');
tl.innerHTML = (d.tools||[]).map(t =>
`<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">${t.name}</span></div>`
).join('');
}
// ═══ Chat ═══
function send(){
const inp=document.getElementById('inp');
const msg=inp.value.trim();
if(!msg)return;
inp.value='';inp.style.height='auto';
addMsg('user',msg);
setTimeout(()=>{
addTyping();
setTimeout(()=>{
removeTyping();
const replies=[
`好的,我来处理: "${msg}"\n\n正在调用 code_editor 工具...`,
`收到!让我看看...\n\n 工具 [code_editor] 执行完成\n结果: 操作已成功执行`,
`明白。我来分析一下项目情况。\n\n 代码统计:\n 文件: 40\n 代码: 8,521 行\n 注释: 1,892 行\n 注释率: 22.2%`,
`我来帮你处理。\n\n git status:\n On branch master\n Your branch is up to date\n\n Changes:\n M plugins/code_editor.py\n ?? web_ui.py`,
];
addMsg('ai',replies[Math.floor(Math.random()*replies.length)]);
},800+Math.random()*1200);
},200);
}
function quickSend(msg){
document.getElementById('inp').value=msg;
send();
}
function addMsg(role, content) {
const w = document.getElementById('welcome'); if (w) w.remove();
const s = document.getElementById('chat-scroll');
const d = document.createElement('div'); d.className = 'msg';
const isU = role === 'user';
const t = new Date().toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'});
function addMsg(role,content){
const box=document.getElementById('chat-box');
// Remove welcome if present
const w=box.querySelector('.welcome');if(w)w.remove();
const d=document.createElement('div');
d.className='msg '+(role==='user'?'user':'ai');
const label=role==='user'?' 你':' 小狸';
const cls=role==='user'?'user':'ai';
const t=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
let html=escapeHtml(content);
html=html.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre><code>$2</code></pre>');
html=html.replace(/`([^`]+)`/g,'<code>$1</code>');
d.innerHTML=`<div class="msg-role ${cls}">${label}<span class="msg-time">${t}</span></div><div class="msg-body">${html}</div>`;
box.appendChild(d);
box.scrollTop=box.scrollHeight;
}
function addTyping(){
const box=document.getElementById('chat-box');
const d=document.createElement('div');
d.className='msg ai';d.id='typing';
d.innerHTML='<div class="msg-role ai"> 小狸</div><div class="msg-body"><div class="typing"><span></span><span></span><span></span></div></div>';
box.appendChild(d);box.scrollTop=box.scrollHeight;
}
function removeTyping(){const e=document.getElementById('typing');if(e)e.remove();}
function escapeHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
document.getElementById('inp').addEventListener('keydown',e=>{
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}
});
document.getElementById('inp').addEventListener('input',function(){
this.style.height='auto';
this.style.height=Math.min(this.scrollHeight,150)+'px';
});
// ═══ Editor ═══
let edHistory=[];edHistoryIdx=-1;currentEdFile='';
function openFile(el,name,content){
switchView('editor',document.querySelectorAll('.tab')[1]);
document.querySelectorAll('.sb-item').forEach(x=>x.classList.remove('on'));
if(el)el.classList.add('on');
currentEdFile=name;
const ta=document.getElementById('ed-code');
ta.value=content;
edHistory=[content];edHistoryIdx=0;
document.getElementById('ed-fi').textContent=name;
document.getElementById('ed-lang').textContent=langFromName(name);
updLines();updCursor();
}
function edSave(){
if(!currentEdFile)return;
const ta=document.getElementById('ed-code');
const lines=ta.value.split('\n').length;
document.getElementById('ed-fi').textContent=currentEdFile+' — 已保存 ('+lines+' 行)';
addLog('file','已保存: '+currentEdFile);
}
function edUndo(){
if(edHistoryIdx>0){
edHistoryIdx--;
document.getElementById('ed-code').value=edHistory[edHistoryIdx];
updLines();
let html;
if (isU) {
html = escHtml(content);
} else {
try { html = marked.parse(content) } catch(e) { html = escHtml(content) }
}
d.innerHTML = `
<div class="msg-head">
<div class="msg-av ${isU?'user':'ai'}">${isU?' ':' '}</div>
<span class="msg-name">${isU?'你':'小狸'}</span>
<span class="msg-time">${t}</span>
</div>
<div class="msg-body">${html}</div>`;
s.appendChild(d); s.scrollTop = s.scrollHeight;
}
const edTa=document.getElementById('ed-code');
edTa.addEventListener('input',function(){
clearTimeout(this._t);
this._t=setTimeout(()=>{
const v=this.value;
if(edHistory[edHistoryIdx]!==v){
edHistory=edHistory.slice(0,edHistoryIdx+1);
edHistory.push(v);edHistoryIdx=edHistory.length-1;
}
},400);
updLines();
});
edTa.addEventListener('scroll',function(){
document.getElementById('ed-lines').scrollTop=this.scrollTop;
});
edTa.addEventListener('click',updCursor);
edTa.addEventListener('keyup',updCursor);
edTa.addEventListener('keydown',function(e){
if(e.key==='Tab'){
e.preventDefault();
const s=this.selectionStart,end=this.selectionEnd;
this.value=this.value.substring(0,s)+' '+this.value.substring(end);
this.selectionStart=this.selectionEnd=s+4;updLines();
}
});
document.addEventListener('keydown',e=>{
if((e.ctrlKey||e.metaKey)&&e.key==='s'){e.preventDefault();edSave();}
});
function updLines(){
const ta=document.getElementById('ed-code');
const n=ta.value.split('\n').length;
document.getElementById('ed-lines').textContent=Array.from({length:n},(_,i)=>i+1).join('\n');
}
function updCursor(){
const ta=document.getElementById('ed-code');
const v=ta.value.substring(0,ta.selectionStart);
const line=v.split('\n').length;
const col=v.split('\n').pop().length+1;
document.getElementById('ed-cursor').textContent='行 '+line+', 列 '+col;
}
function langFromName(n){
const m={'.py':'Python','.js':'JavaScript','.ts':'TypeScript','.json':'JSON','.html':'HTML','.css':'CSS','.md':'Markdown','.sh':'Shell','.yaml':'YAML','.toml':'TOML'};
for(const[k,v]of Object.entries(m))if(n.endsWith(k))return v;
return'--';
function addSys(text, isErr=false) {
const s = document.getElementById('chat-scroll');
const d = document.createElement('div');
d.className = 'sys-msg' + (isErr?' error':'');
d.textContent = text;
s.appendChild(d); s.scrollTop = s.scrollHeight;
}
// ═══ Log ═══
function addLog(tag,msg){
const a=document.getElementById('log-area');
const t=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
const cls={sys:'sys',eng:'eng',safe:'safe',file:'file',err:'err'}[tag]||'sys';
const labels={sys:'系统',eng:'引擎',safe:'安全',file:'文件',err:'错误'};
a.innerHTML+=`<div class="log-e"><span class="log-t ${cls}">[${labels[tag]||tag}]</span>${t} ${msg}</div>`;
a.scrollTop=a.scrollHeight;
function showToolCall(name, args) {
const w = document.getElementById('welcome'); if (w) w.remove();
const s = document.getElementById('chat-scroll');
const d = document.createElement('div'); d.className = 'msg';
d.innerHTML = `
<div class="msg-head">
<div class="msg-av ai"> </div>
<span class="msg-name">工具调用</span>
</div>
<div class="msg-body">
<div class="tool-card">
<span class="tool-icon">⚡</span>
<div><span class="tool-name">${escHtml(name)}</span>
${args?'<div class="tool-args">'+escHtml(String(args).slice(0,300))+'</div>':''}</div>
</div>
</div>`;
s.appendChild(d); s.scrollTop = s.scrollHeight;
}
// ═══ Keyboard shortcut info ═══
console.log('%c 小狸 Pro-CLI Web UI ','background:#3b82f6;color:#fff;padding:4px 12px;border-radius:6px;font-size:14px');
console.log('%c 快捷键: Ctrl+S 保存 | Enter 发送 | Shift+Enter 换行 | Tab 缩进 ','color:#94a3b8');
function showToolResult(name, result) {
const s = document.getElementById('chat-scroll');
const d = document.createElement('div'); d.className = 'msg';
const isErr = String(result).includes('错误') || String(result).includes('Error');
d.innerHTML = `
<div class="msg-body" style="padding-left:35px">
<div class="tool-card${isErr?' error':''}">
<span class="tool-icon">${isErr?'❌':'✅'}</span>
<div><span class="tool-name">${escHtml(name)}</span> ${isErr?'失败':'完成'}
<div class="tool-args">${escHtml(String(result).slice(0,400))}</div></div>
</div>
</div>`;
s.appendChild(d); s.scrollTop = s.scrollHeight;
}
function showThinking() {
if (isThinking) return; isThinking = true;
const w = document.getElementById('welcome'); if (w) w.remove();
const s = document.getElementById('chat-scroll');
const d = document.createElement('div'); d.className = 'msg'; d.id = 'think';
d.innerHTML = `
<div class="msg-head">
<div class="msg-av ai"> </div>
<span class="msg-name">小狸</span>
</div>
<div class="msg-body">
<div class="thinking"><div class="thinking-dots"><span></span><span></span><span></span></div> 思考中...</div>
</div>`;
s.appendChild(d); s.scrollTop = s.scrollHeight;
document.getElementById('send-btn').disabled = true;
}
function hideThinking() {
isThinking = false;
const el = document.getElementById('think'); if (el) el.remove();
document.getElementById('send-btn').disabled = false;
}
// ═══ Tools Grid ═══
function renderTools(tools) {
document.getElementById('tools-grid').innerHTML = (tools||[]).map(t => `
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--r-md);padding:14px 16px;transition:border-color .15s" onmouseenter="this.style.borderColor='var(--border-h)'" onmouseleave="this.style.borderColor='var(--border)'">
<div style="font-size:13.5px;font-weight:700;color:var(--t0);margin-bottom:5px">${t.name}</div>
<div style="font-size:12px;color:var(--t2);line-height:1.55">${t.description||''}</div>
</div>
`).join('');
}
// ═══ Input ═══
const input = document.getElementById('chat-input');
input.addEventListener('keydown', e => { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } });
input.addEventListener('input', function() { this.style.height='auto'; this.style.height=Math.min(this.scrollHeight,140)+'px' });
document.getElementById('send-btn').addEventListener('click', handleSend);
function handleSend() {
const msg = input.value.trim(); if (!msg || isThinking) return;
input.value=''; input.style.height='auto';
addMsg('user', msg);
send('chat', {message: msg});
}
document.querySelectorAll('.qbtn').forEach(b => b.addEventListener('click', () => { input.value=b.dataset.msg; handleSend() }));
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById('view-'+tab.dataset.view).classList.add('active');
if (tab.dataset.view==='tools') send('tools');
}));
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') }
connect();
</script>
</body>
</html>
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python3
"""
小狸 Pro-CLI Web UI 后端
WebSocket 服务器 + 静态文件服务
启动:
python webui_server.py [--port 8080] [--host 0.0.0.0]
依赖:
pip install websockets
"""
import asyncio
import json
import os
import sys
import time
import argparse
import mimetypes
from pathlib import Path
from datetime import datetime
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import threading
# 添加项目根目录
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, PROJECT_DIR)
try:
import websockets
WEBSOCKETS_AVAILABLE = True
except ImportError:
WEBSOCKETS_AVAILABLE = False
print("⚠️ websockets 未安装,WebSocket 功能不可用")
print(" 安装: pip install websockets")
# ═══════════════════════════════════════════
# AI 引擎桥接
# ═══════════════════════════════════════════
class AIEngineBridge:
"""桥接到小狸的 AI 引擎和插件系统"""
def __init__(self):
self.cli = None
self._initialized = False
def initialize(self):
"""延迟初始化小狸核心"""
if self._initialized:
return True
try:
from xcli_core import AICLI
self.cli = AICLI()
self._initialized = True
return True
except Exception as e:
print(f"⚠️ 小狸核心初始化失败: {e}")
return False
def get_status(self):
"""获取系统状态"""
if not self.cli:
return {
"engine": "未连接",
"engine_type": "none",
"plugins": 0,
"operations": 0,
"safety_mode": "普通",
"tools": [],
"engines": [],
}
engine_name = getattr(self.cli.current_engine, 'name', '未知') if self.cli.current_engine else '无'
tools = []
if hasattr(self.cli, 'liugin_manager') and self.cli.liugin_manager:
for t in self.cli.liugin_manager.tools:
tools.append({
"name": t.get('name', ''),
"description": t.get('description', ''),
"ops": len(t.get('usage', '').split('\n')) if t.get('usage') else 0,
})
from xcli_core.safety import get_safety
safety = get_safety()
return {
"engine": engine_name,
"engine_type": "local" if engine_name == "ollama" else "cloud",
"plugins": len(tools),
"operations": sum(t.get('ops', 0) for t in tools),
"safety_mode": safety.get_mode_name(),
"tools": tools,
"engines": list(self.cli.engines.keys()) if self.cli.engines else [],
}
def chat(self, message: str, callback=None):
"""发送消息到 AI 并获取回复(支持工具调用循环)"""
if not self.cli:
return {"type": "error", "message": "小狸核心未初始化,请检查配置"}
results = [] # 收集所有事件
try:
# 添加用户消息到历史
self.cli.shared_conversation_history.append({
"role": "user", "content": message
})
liugin_prompts = self.cli.get_liugin_usage_prompts()
system_prompt = self.cli._build_system_prompt(liugin_prompts)
loop_count = 0
max_loops = 15
current_input = message
while loop_count < max_loops:
# 生成 AI 响应
if not self.cli.current_engine:
return {"type": "error", "message": "没有可用的 AI 引擎"}
response = self.cli.current_engine.generate_response(
current_input, system_prompt=system_prompt
)
if not response:
return {"type": "reply", "message": "(无响应)"}
# 检查 FC 响应(Ollama Function Calling)
fc_result = self.cli._handle_fc_response(response, message)
if fc_result:
# FC 工具已执行,结果作为下一轮输入
results.append({"type": "fc_tool_done", "message": fc_result[:200]})
current_input = fc_result
loop_count += 1
continue
# 解析混合响应(文本 + JSON 工具调用)
try:
text_content, json_data = self.cli._parse_mixed_response(response)
except Exception:
text_content, json_data = response, None
if json_data and isinstance(json_data, dict):
if json_data.get('action') == 'use_tool':
tool_name = json_data.get('tool', '')
tool_args = json_data.get('args', '')
results.append({"type": "tool_call", "name": tool_name, "args": tool_args[:200]})
# 执行工具
try:
tool_result = self.cli._execute_tool_by_name(tool_name, tool_args)
except Exception as e:
tool_result = f"工具执行错误: {e}"
results.append({"type": "tool_result", "name": tool_name, "result": str(tool_result)[:500]})
# 把工具结果加入历史并继续循环
self.cli.shared_conversation_history.append({
"role": "tool", "content": str(tool_result),
"name": tool_name
})
current_input = f"工具 {tool_name} 的执行结果:\n{tool_result}"
loop_count += 1
continue
elif json_data.get('continue') or json_data.get('need_continue'):
msg = json_data.get('message', text_content or '')
if msg:
results.append({"type": "partial_reply", "message": msg})
self.cli.shared_conversation_history.append({"role": "assistant", "content": msg})
current_input = "继续"
loop_count += 1
continue
elif json_data.get('message'):
final = json_data.get('message')
self.cli.shared_conversation_history.append({"role": "assistant", "content": final})
results.append({"type": "reply", "message": final})
break
# 纯文本响应
display = text_content if text_content else response
self.cli.shared_conversation_history.append({"role": "assistant", "content": display})
results.append({"type": "reply", "message": display})
break
if not results:
results.append({"type": "reply", "message": response or "(无响应)"})
return {"type": "multi", "events": results}
except Exception as e:
return {"type": "error", "message": f"错误: {e}"}
def execute_tool(self, tool_name: str, tool_args: str):
"""直接执行工具"""
if not self.cli:
return "❌ 小狸核心未初始化"
try:
result = self.cli._execute_tool_by_name(tool_name, tool_args)
return result or "(无结果)"
except Exception as e:
return f"❌ 工具执行错误: {e}"
def list_tools(self):
"""列出所有工具"""
if not self.cli or not hasattr(self.cli, 'liugin_manager'):
return []
return [
{
"name": t.get('name', ''),
"description": t.get('description', ''),
"keywords": t.get('keywords', []),
}
for t in self.cli.liugin_manager.tools
]
# ═══════════════════════════════════════════
# WebSocket 处理
# ═══════════════════════════════════════════
bridge = AIEngineBridge()
connected_clients = set()
async def ws_handler(websocket):
"""处理 WebSocket 连接"""
connected_clients.add(websocket)
client_addr = websocket.remote_address
print(f"✅ 客户端已连接: {client_addr}")
try:
# 发送初始状态
status = bridge.get_status()
await websocket.send(json.dumps({
"type": "status",
"data": status,
}, ensure_ascii=False))
async for raw_message in websocket:
try:
msg = json.loads(raw_message)
msg_type = msg.get("type", "")
msg_data = msg.get("data", {})
if msg_type == "chat":
# 用户聊天消息
user_msg = msg_data.get("message", "").strip()
if not user_msg:
continue
# 发送"正在思考"状态
await websocket.send(json.dumps({
"type": "thinking",
"data": {"message": user_msg},
}, ensure_ascii=False))
# 在线程中执行 AI 调用(避免阻塞事件循环)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, bridge.chat, user_msg
)
# 处理返回结果
if isinstance(result, dict):
if result.get("type") == "multi":
# 多事件:逐个发送
for event in result.get("events", []):
await websocket.send(json.dumps({
"type": event.get("type", "reply"),
"data": event,
}, ensure_ascii=False))
else:
await websocket.send(json.dumps({
"type": result.get("type", "reply"),
"data": result,
}, ensure_ascii=False))
else:
# 兼容旧格式
await websocket.send(json.dumps({
"type": "reply",
"data": {"message": str(result)},
}, ensure_ascii=False))
elif msg_type == "tool":
# 直接工具调用
tool_name = msg_data.get("name", "")
tool_args = msg_data.get("args", "")
await websocket.send(json.dumps({
"type": "tool_executing",
"data": {"name": tool_name, "args": tool_args},
}, ensure_ascii=False))
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, bridge.execute_tool, tool_name, tool_args
)
await websocket.send(json.dumps({
"type": "tool_result",
"data": {"name": tool_name, "result": result},
}, ensure_ascii=False))
elif msg_type == "status":
# 请求状态更新
status = bridge.get_status()
await websocket.send(json.dumps({
"type": "status",
"data": status,
}, ensure_ascii=False))
elif msg_type == "tools":
# 请求工具列表
tools = bridge.list_tools()
await websocket.send(json.dumps({
"type": "tools",
"data": {"tools": tools},
}, ensure_ascii=False))
elif msg_type == "switch_engine":
# 切换引擎
engine_name = msg_data.get("engine", "")
if bridge.cli:
if engine_name in bridge.cli.engines:
bridge.cli.switch_engine(engine_name)
# 发送切换成功 + 更新后的状态
await websocket.send(json.dumps({
"type": "engine_switched",
"data": {"engine": engine_name},
}, ensure_ascii=False))
status = bridge.get_status()
await websocket.send(json.dumps({
"type": "status",
"data": status,
}, ensure_ascii=False))
else:
await websocket.send(json.dumps({
"type": "error",
"data": {"message": f"未找到引擎: {engine_name}"},
}, ensure_ascii=False))
elif msg_type == "switch_safety":
# 切换安全模式
mode = msg_data.get("mode", "")
if bridge.cli:
from xcli_core.safety import get_safety, MODE_NORMAL, MODE_MANUAL, MODE_UNRESTRICTED
safety = get_safety()
if mode == "normal":
safety.set_mode(MODE_NORMAL)
elif mode == "manual":
safety.set_mode(MODE_MANUAL)
elif mode == "unrestricted":
safety.set_mode(MODE_UNRESTRICTED)
await websocket.send(json.dumps({
"type": "safety_switched",
"data": {"mode": safety.get_mode_name()},
}, ensure_ascii=False))
except json.JSONDecodeError:
await websocket.send(json.dumps({
"type": "error",
"data": {"message": "无效的 JSON 格式"},
}, ensure_ascii=False))
except Exception as e:
await websocket.send(json.dumps({
"type": "error",
"data": {"message": str(e)},
}, ensure_ascii=False))
except websockets.exceptions.ConnectionClosed:
pass
finally:
connected_clients.discard(websocket)
print(f"❌ 客户端已断开: {client_addr}")
# ═══════════════════════════════════════════
# 静态文件服务
# ═══════════════════════════════════════════
class WebUIHandler(SimpleHTTPRequestHandler):
"""自定义 HTTP 处理器"""
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=PROJECT_DIR, **kwargs)
def do_GET(self):
# 根路径返回 webui.html
if self.path == '/' or self.path == '/index.html':
self.path = '/webui.html'
return super().do_GET()
def log_message(self, format, *args):
# 静默日志
pass
# ═══════════════════════════════════════════
# 启动
# ═══════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(description="小狸 Pro-CLI Web UI 服务器")
parser.add_argument("--host", default="0.0.0.0", help="绑定地址")
parser.add_argument("--port", type=int, default=8080, help="HTTP 端口")
parser.add_argument("--ws-port", type=int, default=8079, help="WebSocket 端口")
parser.add_argument("--no-ai", action="store_true", help="不初始化 AI 引擎(纯 UI 模式)")
args = parser.parse_args()
print()
print("╔══════════════════════════════════════════╗")
print("║ 小狸 Pro-CLI · Web UI 服务器 ║")
print("╚══════════════════════════════════════════╝")
print()
# 初始化 AI 引擎
if not args.no_ai:
print("⏳ 正在初始化小狸核心...")
if bridge.initialize():
status = bridge.get_status()
print(f"✅ AI 引擎: {status['engine']}")
print(f"✅ 插件: {status['plugins']} 个")
print(f"✅ 安全模式: {status['safety_mode']}")
else:
print("⚠️ AI 引擎初始化失败,将以纯 UI 模式运行")
else:
print("ℹ️ 纯 UI 模式(不连接 AI 引擎)")
print()
print(f"🌐 HTTP: http://localhost:{args.port}")
print(f"🔌 WebSocket: ws://localhost:{args.ws_port}")
print(f"📂 项目目录: {PROJECT_DIR}")
print()
print("按 Ctrl+C 停止服务器")
print()
# 启动 HTTP 服务器(在单独线程中)
http_server = HTTPServer((args.host, args.port), WebUIHandler)
http_thread = threading.Thread(target=http_server.serve_forever, daemon=True)
http_thread.start()
# 启动 WebSocket 服务器
if WEBSOCKETS_AVAILABLE:
async def run_ws():
async with websockets.serve(ws_handler, args.host, args.ws_port):
await asyncio.Future() # 永远运行
try:
asyncio.run(run_ws())
except KeyboardInterrupt:
print("\n🛑 服务器已停止")
else:
print("⚠️ WebSocket 不可用,仅启动 HTTP 服务")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n🛑 服务器已停止")
http_server.shutdown()
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
# 示例工作流 - 代码质量检查与构建
name: code-quality
description: 代码质量检查、测试、构建的自动化流程
steps:
- name: info
type: echo
message: "🚀 开始代码质量检查流程"
- name: lint
type: shell
command: "echo '✓ 代码风格检查通过'"
continue_on_error: true
- name: test
type: shell
command: "echo '✓ 单元测试通过 (42/42)'"
continue_on_error: true
- name: check_results
type: echo
message: "Lint: {{lint.output}} | Test: {{test.output}}"
- name: build
type: shell
command: "echo '✓ 构建完成 → dist/'"
if: "{{lint.exit_code}} == 0"
- name: done
type: echo
message: "✅ 流程完成,构建产物: {{build.output}}"
+51 -20
View File
@@ -11,6 +11,7 @@ from .constants import (
)
from .config import get_system_config, set_system_config, logger
from .plugin_manager import LiuginManager
from .process_protection import get_protection, enable_process_protection
# 可选导入
try:
@@ -31,6 +32,14 @@ class BaseAICLI:
# 初始化colorama
init(autoreset=True)
# 初始化进程保护(单实例 + 优先级 + PPL保护 + 看门狗)
self.protection_results = enable_process_protection(
priority_level="above_normal",
watchdog=True,
restart_on_crash=False,
)
self._protection = get_protection()
# 确保插件管理器使用正确的路径
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
liugins_dir = os.path.join(project_dir, "plugins")
@@ -86,8 +95,8 @@ class BaseAICLI:
self.load_liugins(liugins_dir, skills_dir)
# 初始化共享对话历史
self.shared_conversation_history = []
# 设置最大历史记录数
self.max_history = DEFAULT_MAX_HISTORY
# 设置最大历史记录数 — 从 config 读取,未配置则用默认值
self.max_history = get_system_config('max_history', DEFAULT_MAX_HISTORY)
# 为所有已加载的引擎设置共享对话历史
self._set_shared_conversation_history()
# 初始化代码执行相关功能
@@ -106,6 +115,14 @@ class BaseAICLI:
self._clawli_monitor_running = False
# TUI 输出回调(用于 TUI 模式下的实时输出)
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(基于机器硬件动态生成)
self.user_id = str(uuid.getnode())
@@ -114,6 +131,12 @@ class BaseAICLI:
def cleanup_resources(self):
"""清理资源,确保在程序退出时正确关闭所有资源"""
print(f"{Fore.YELLOW}正在清理资源...{Style.RESET_ALL}")
# 清理进程保护
if hasattr(self, '_protection') and self._protection:
try:
self._protection.disable_all()
except Exception:
pass
self._stop_clawli_monitor()
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.stop_server()
@@ -304,6 +327,13 @@ class BaseAICLI:
"""获取插件管理器"""
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):
"""切换AI引擎"""
if engine_name in self.engines:
@@ -318,35 +348,24 @@ class BaseAICLI:
self.current_engine.max_history = self.max_history
if old_engine != self.current_engine and self.shared_conversation_history:
print(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}")
self._output(f"{Fore.CYAN}已切换到AI引擎: {engine_name},正在传递对话历史...{Style.RESET_ALL}")
context_message = {
"role": "system",
"content": f"引擎切换通知:现在由{engine_name}引擎继续之前的对话。以下是之前的对话历史:\n"
}
self.shared_conversation_history.append(context_message)
if self.shared_conversation_history:
print(f"{Fore.YELLOW}当前对话历史已传递给新引擎:{Style.RESET_ALL}")
for msg in self.shared_conversation_history[-self.max_history:]:
if isinstance(msg, dict) and "role" in msg and "content" in msg:
role = msg["role"]
content = msg["content"]
if role == "user":
print(f"{Fore.CYAN}用户: {content}{Style.RESET_ALL}")
elif role == "assistant":
print(f"{Fore.GREEN}AI: {content}{Style.RESET_ALL}")
elif role == "tool":
print(f"{Fore.MAGENTA}工具结果: {content}{Style.RESET_ALL}")
self._output(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}")
else:
print(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}")
self._output(f"{Fore.GREEN}已切换到AI引擎: {engine_name}{Style.RESET_ALL}")
try:
set_system_config('default_engine', engine_name)
print(f"{Fore.CYAN}已将 {engine_name} 设置为默认引擎{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}")
self._output(f"{Fore.YELLOW}警告: 保存默认引擎配置失败: {e}{Style.RESET_ALL}")
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):
"""为所有引擎设置共享对话历史"""
@@ -355,7 +374,19 @@ class BaseAICLI:
engine.max_history = self.max_history
def handle_engine_command(self, command):
"""处理引擎特定命令"""
"""处理引擎特定命令(支持跨引擎调用,如 /engine.manual status)"""
# 先检查是否指定了引擎名(如 "manual status" → 引擎=manual, 命令=status)
parts = command.split(' ', 1)
target_name = parts[0]
rest = parts[1] if len(parts) > 1 else ""
# 如果目标名是某个已加载的引擎,直接调用该引擎
if target_name in self.engines:
target_engine = self.engines[target_name]
if hasattr(target_engine, 'handle_command'):
return target_engine.handle_command(rest)
# 否则当作当前引擎的命令
if not hasattr(self.current_engine, 'handle_command'):
return False
engine_name = getattr(self.current_engine, 'name', '')
+6 -7
View File
@@ -96,6 +96,7 @@ class ClawliMixin:
tool_calls = self._extract_tool_calls(json_data)
if tool_calls:
all_results = []
for tool_data in tool_calls:
tool_name = tool_data.get('tool', '未知工具')
tool_args = tool_data.get('args', '')
@@ -105,6 +106,7 @@ class ClawliMixin:
result = self.process_tool_call(tool_data)
result_text = result.get('result', '无结果')
all_results.append(result_text)
if CLAWLI_SERVER_AVAILABLE and clawli_server:
clawli_server.send_tool_status(tool_name, tool_args, "success", result_text)
@@ -114,7 +116,7 @@ class ClawliMixin:
"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请根据工具执行结果继续回答。")
return
@@ -134,16 +136,13 @@ class ClawliMixin:
image_engine = getattr(self, 'image_engine', None)
if not image_engine:
try:
from image_engine import get_image_engine
image_engine = get_image_engine()
from image_engine import get_engine_manager
image_engine = get_engine_manager()
except:
image_engine = None
if image_engine:
with open(image_path, 'rb') as f:
image_data = f.read()
result = image_engine.analyze(image_data, text or "请描述这张图片")
result = image_engine.analyze(image_path, text or "请描述这张图片")
self._process_clawli_user_message(f"@{image_path}\n图片内容: {result}\n\n{text}")
else:
self._process_clawli_user_message(f"@{image_path}\n{text}")
+239 -19
View File
@@ -12,16 +12,18 @@ from colorama import Fore, Style
from .constants import TEXTUAL_AVAILABLE, LOVE_FILE_PATH, DEFAULT_MAX_HISTORY
from .config import get_system_config, logger
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_tools import ToolMixin
from .cli_code_exec import CodeExecMixin
from .cli_display import DisplayMixin
from .cli_history import HistoryMixin
from .plugin_market import PluginMarketMixin
from .notification import notify_task_complete, get_notification_manager
from .process_protection import get_protection_status
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin):
class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, HistoryMixin, PluginMarketMixin):
"""AI CLI主程序 - 组合所有 Mixin"""
# ── 深度思考处理 ──
@@ -150,6 +152,30 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
{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 指令来执行操作:
@@ -174,9 +200,10 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist
- 如果语法检查失败,根据错误信息修复代码后再次编辑
【Diff 弹窗 — 自动弹出修改对比】
- 编辑操作完成后自动弹出新终端窗口,显示修改前后的完整内容和行号对比
- 包含 diff 差异、新增/删除行数统计
- 开关: diff_popup on(开启)/ diff_popup off(关闭)/ diff_popup(切换)
- 编辑操作完成后自动显示修改前后的完整内容和行号对比
- 新增行用绿色标记,删除行用红色标记
- 支持两种模式: 弹窗模式(新终端窗口)/ 主终端内显示(SSH 兼容)
- 开关: diff_popup on / diff_popup off / diff_popup popup / diff_popup inline
- 默认开启,无需手动调用
【code_editor 编辑格式】
@@ -216,6 +243,20 @@ multi 操作支持一次修改多处:
{tool_search_rule}
【⚠️ 兜底策略 — 遇到能力不足时的解决路径】
当你发现现有工具无法完成用户需求时,按以下顺序逐级尝试,不要直接放弃:
第一级:查插件仓库
用 /plugin search <关键词> 搜索社区插件,找到后 /plugin install 安装使用。
第二级:网上搜索方案
用 ai_search 搜索解决方案或命令行工具。
第三级:自己写插件
写 Python 插件放到 plugins/ 目录,实现 Liugin 类,重启加载。
记住:放弃是最后的选择。能装就装,能搜就搜,能写就写。
【工具调用格式】
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
@@ -485,7 +526,16 @@ multi 操作支持一次修改多处:
max_loops = 20
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_code_blocks(processed_response)
@@ -771,7 +821,7 @@ multi 操作支持一次修改多处:
tool_args = tc.get("args", "")
call_id = tc.get("id", "")
print(f"\n{Fore.GREEN} FC 调用: {tool_name}({tool_args[:60]}){Style.RESET_ALL}")
self._output(f"[green] FC 调用: {tool_name}({tool_args[:60]})[/]")
# 通过插件管理器执行
result = self._execute_tool_by_name(tool_name, tool_args)
@@ -824,6 +874,83 @@ multi 操作支持一次修改多处:
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):
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
# TUI 模式:不做动画
@@ -839,11 +966,6 @@ multi 操作支持一次修改多处:
except Exception:
pass
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt, tools=fc_tools)
if response and self.shared_conversation_history is not None:
self.shared_conversation_history.append({
"role": "assistant",
"content": response
})
return response
# CLI 模式:带动画和跨平台 ESC 取消
@@ -1005,7 +1127,7 @@ multi 操作支持一次修改多处:
print(f"{Fore.GREEN}输入 '/tui' 切换到 TUI 模式{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine list' 查看可用AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/engine switch <引擎名>' 切换AI引擎{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/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}输入 '/scheduler' 或 '/remind' 管理定时任务{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/memory' 管理记忆系统 (日记/搜索/聊天记录){Style.RESET_ALL}")
@@ -1017,14 +1139,64 @@ multi 操作支持一次修改多处:
print(f"{Fore.GREEN}输入 '/chat list' 查看所有已保存的聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/chat open <名称>' 加载聊天记录{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/remote' 查看远程连接帮助{Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/plugin' 管理插件市场 (安装/卸载/搜索){Style.RESET_ALL}")
print(f"{Fore.GREEN}输入 '/protect' 查看进程保护状态{Style.RESET_ALL}")
current_engine_name = getattr(self.current_engine, 'name', '未设置') if self.current_engine else '未设置'
print(f"{Fore.GREEN}当前使用的AI引擎: {current_engine_name}{Style.RESET_ALL}")
loaded_engines = list(self.engines.keys())
if len(loaded_engines) > 1:
print(f"{Fore.GREEN}已加载的AI引擎: {', '.join(loaded_engines)}{Style.RESET_ALL}")
print(f"{Fore.GREEN}{'-' * 50}{Style.RESET_ALL}")
# 显示进程保护状态
if hasattr(self, 'protection_results') and self.protection_results:
r = self.protection_results
si_icon = '' if r.get('single_instance') else ''
prio_icon = '' if r.get('priority') else ''
ppl_icon = '' if r.get('ppl_protection') else ''
wd_icon = '' if r.get('watchdog') else ''
print(f"{Fore.CYAN} 进程保护: {si_icon}单实例 {prio_icon}优先级 {ppl_icon}PPL保护 {wd_icon}看门狗{Style.RESET_ALL}")
# 询问 diff 弹窗模式
self._ask_diff_popup_mode()
self._run_cli_loop()
def _ask_diff_popup_mode(self):
"""启动时询问 diff 显示模式"""
# 找到 code_editor 插件
code_editor = None
for tool in self.liugin_manager.tools:
if tool.get('name') == 'code_editor':
code_editor = tool.get('handler')
break
if code_editor is None:
return
# 找到插件实例(通过 handler 的 __self__)
plugin_instance = getattr(code_editor, '__self__', None)
if plugin_instance is None:
return
print(f"\n{Fore.CYAN} Diff 显示模式(AI 修改文件后的对比方式):{Style.RESET_ALL}")
print(f" {Fore.WHITE}1{Style.RESET_ALL} - 弹窗模式(新终端窗口显示)")
print(f" {Fore.WHITE}2{Style.RESET_ALL} - 主终端内显示(SSH 兼容,推荐)")
try:
choice = input(f"\n{Fore.WHITE} 请选择 [1/2](默认 2): {Style.RESET_ALL}").strip()
except (EOFError, KeyboardInterrupt):
choice = '2'
if choice == '1':
plugin_instance.diff_popup_mode = True
print(f"{Fore.GREEN} ✓ 已选择: 弹窗模式{Style.RESET_ALL}")
else:
plugin_instance.diff_popup_mode = False
print(f"{Fore.GREEN} ✓ 已选择: 主终端内显示{Style.RESET_ALL}")
print()
def _run_cli_loop(self):
"""运行 CLI 主循环"""
while True:
@@ -1062,6 +1234,22 @@ multi 操作支持一次修改多处:
print(f"{Fore.RED}当前引擎不支持该命令或命令执行失败{Style.RESET_ALL}")
continue
# /manual 快捷命令 — 切换到 manual 引擎或执行 manual 子命令
if user_input == '/manual' or user_input.startswith('/manual '):
args = user_input[8:].strip() if user_input.startswith('/manual ') else ''
if 'manual' not in self.engines:
print(f"{Fore.RED}manual 引擎未加载{Style.RESET_ALL}")
elif not args:
# 无参数:切换到 manual 引擎
self.switch_engine('manual')
print(f"{Fore.GREEN}已切换到 manual 引擎{Style.RESET_ALL}")
else:
# 有参数:执行 manual 引擎命令(如 status, restart)
result = self.engines['manual'].handle_command(args)
if result:
print(f"{Fore.GREEN}{result}{Style.RESET_ALL}")
continue
if user_input.startswith('/file.read '):
args = user_input[11:].strip()
parts = args.split(' ', 2)
@@ -1106,6 +1294,36 @@ multi 操作支持一次修改多处:
print(f"{Fore.RED}未知的聊天命令.可用命令: save, list, open{Style.RESET_ALL}")
continue
if user_input.startswith('/plugin'):
plugin_args = user_input[7:].strip()
result = self.handle_plugin_command(plugin_args)
if result:
print(result)
continue
if user_input.startswith('/protect'):
parts = user_input.split()
if len(parts) > 1 and parts[1] == 'test':
# 重新测试所有保护
from .process_protection import enable_process_protection
results = enable_process_protection()
print(f"\n{Fore.CYAN} 进程保护测试结果:{Style.RESET_ALL}")
for key, val in results.items():
icon = '' if val else ''
print(f" {icon} {key}: {val}")
else:
status = get_protection_status()
print(f"\n{Fore.CYAN} 进程保护状态:{Style.RESET_ALL}")
print(f" 平台: {status.get('platform', '未知')}")
icon = '' if status.get('is_protected') else ''
print(f" {icon} 保护已启用: {status.get('is_protected', False)}")
icon = '' if status.get('single_instance_held') else ''
print(f" {icon} 单实例锁: {status.get('single_instance_held', False)}")
icon = '' if status.get('watchdog_running') else ''
print(f" {icon} 看门狗: {status.get('watchdog_running', False)}")
print(f" 关闭钩子: {status.get('shutdown_hooks_count', 0)} 个")
continue
if user_input.startswith('/remote'):
remote_command = user_input[7:].strip()
self._handle_remote_command(remote_command)
@@ -1121,7 +1339,7 @@ multi 操作支持一次修改多处:
print(f"{Fore.CYAN}已从 TUI 模式返回 CLI 模式{Style.RESET_ALL}")
continue
if user_input.startswith('/safe'):
if user_input.startswith('/safe') or user_input.startswith('/mode'):
parts = user_input.split()
safety = get_safety()
if len(parts) > 1:
@@ -1132,16 +1350,18 @@ multi 操作支持一次修改多处:
safety.set_mode(MODE_NORMAL)
elif mode_arg in ('manual', '人工', 'all'):
safety.set_mode(MODE_MANUAL)
elif mode_arg in ('plan', '计划', 'auto'):
safety.set_mode(MODE_PLAN)
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
else:
# 无参数:循环切换
safety.cycle_mode()
mode_name = safety.get_mode_name()
icons = {"无限制": "", "普通": "", "人工确认": ""}
icon = icons.get(mode_name, "")
print(f"{icon} 安全模式: {mode_name}")
icon = safety.get_mode_icon()
print(f"{icon} 工作模式: {mode_name}")
if mode_name == "计划模式":
print(f" {Fore.CYAN}全自动执行 | 任务审查 | 自动重试 | 引擎故障切换{Style.RESET_ALL}")
continue
if user_input.startswith('/notify'):
+4 -5
View File
@@ -45,13 +45,12 @@ class DisplayMixin:
response_lines = content.split('\n')
if self.tui_output_callback:
for i, line in enumerate(response_lines):
if i == 0:
prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] "
line = prefix + line
# 为每一行都添加前缀,确保整个AI回复都使用正确的样式
prefix = "[yellow]>[/] " if is_continue else "[cyan]>[/] "
if i == len(response_lines) - 1:
self._output(f"{line} {user_id_display}")
self._output(f"{prefix}{line} {user_id_display}")
else:
self._output(line)
self._output(f"{prefix}{line}")
self._output("")
else:
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
class HistoryMixin:
"""聊天记录管理相关方法"""
"""聊天记录管理 — 统一委托给 MemoryManager"""
def save_chat_history(self, name):
"""保存当前聊天记录到文件"""
try:
chat_data = {
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
"conversation": self.shared_conversation_history.copy()
}
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(chat_data, f, ensure_ascii=False, indent=2, default=str)
print(f"{Fore.GREEN}聊天记录已保存为: {name}{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}保存聊天记录失败: {e}{Style.RESET_ALL}")
"""保存当前聊天记录"""
if not hasattr(self, 'memory_manager') or not self.memory_manager:
print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}")
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):
"""列出所有已保存的聊天记录"""
try:
chat_files = [f for f in os.listdir(self.chat_history_dir) if f.endswith('.json')]
if not chat_files:
print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}")
for i, file in enumerate(chat_files, 1):
file_path = os.path.join(self.chat_history_dir, file)
mod_time = os.path.getmtime(file_path)
mod_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mod_time))
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}")
"""列出所有聊天记录"""
if not hasattr(self, 'memory_manager') or not self.memory_manager:
print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}")
return
chats = self.memory_manager.list_chats(limit=30)
if not chats:
print(f"{Fore.YELLOW}没有已保存的聊天记录{Style.RESET_ALL}")
return
print(f"{Fore.GREEN}已保存的聊天记录:{Style.RESET_ALL}")
for i, c in enumerate(chats, 1):
print(f"{Fore.GREEN}{i}. {c['name']} ({c['time']}, {c['size_kb']}KB){Style.RESET_ALL}")
def load_chat_history(self, name):
"""加载指定的聊天记录"""
try:
file_path = os.path.join(self.chat_history_dir, f"{name}.json")
if not os.path.exists(file_path):
print(f"{Fore.RED}聊天记录 '{name}' 不存在{Style.RESET_ALL}")
return False
with open(file_path, 'r', encoding='utf-8') as f:
chat_data = json.load(f)
self.shared_conversation_history = chat_data.get("conversation", [])
self._set_shared_conversation_history()
print(f"{Fore.GREEN}已加载聊天记录: {name}{Style.RESET_ALL}")
if self.shared_conversation_history:
print(f"{Fore.CYAN}加载了 {len(self.shared_conversation_history)} 条对话记录{Style.RESET_ALL}")
return True
except Exception as e:
print(f"{Fore.RED}加载聊天记录失败: {e}{Style.RESET_ALL}")
"""加载聊天记录(支持序号或文件名)"""
if not hasattr(self, 'memory_manager') or not self.memory_manager:
print(f"{Fore.RED}记忆系统未初始化{Style.RESET_ALL}")
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)
else:
result = self.liugin_manager.execute(tool_name, args)
# 将 ToolResult 对象转换为字典格式,兼容旧接口
if hasattr(result, 'to_dict'):
return result.to_dict()
return result
# 回退:直接调用 handler(旧逻辑)
@@ -212,8 +215,10 @@ class ToolMixin:
else:
tool_args_display = tool_args
# TUI 模式下不做延迟和 ESC 检测
if not self.tui_output_callback:
# TUI 模式下显示工具执行状态
if self.tui_output_callback:
self._output(f" 正在执行工具: {tool_name} {tool_args_display}")
else:
try:
import msvcrt
canceled = False
+54
View File
@@ -42,6 +42,60 @@ def set_system_config(key, value):
save_config(config)
# ── 引擎配置档案管理 ──
# 存储结构: config["engine_profiles"][engine_name] = [ {name, base_url, api_key, model}, ... ]
def get_profiles(engine_name):
"""获取指定引擎的所有配置档案"""
config = load_config()
return config.get("engine_profiles", {}).get(engine_name, [])
def save_profile(engine_name, profile):
"""保存/更新一个配置档案(按 name 去重,已存在则覆盖)"""
config = load_config()
if "engine_profiles" not in config:
config["engine_profiles"] = {}
profiles = config["engine_profiles"].setdefault(engine_name, [])
# 按 name 去重
for i, p in enumerate(profiles):
if p.get("name") == profile.get("name"):
profiles[i] = profile
break
else:
profiles.append(profile)
config["engine_profiles"][engine_name] = profiles
save_config(config)
def delete_profile(engine_name, profile_name):
"""删除指定引擎的一个配置档案"""
config = load_config()
profiles = config.get("engine_profiles", {}).get(engine_name, [])
profiles = [p for p in profiles if p.get("name") != profile_name]
if "engine_profiles" not in config:
config["engine_profiles"] = {}
config["engine_profiles"][engine_name] = profiles
save_config(config)
def apply_profile(engine_name, profile):
"""将档案应用到 config.json 的 api.engines 节点(下次启动或重载时生效)"""
config = load_config()
if "api" not in config:
config["api"] = {}
if "engines" not in config["api"]:
config["api"]["engines"] = {}
engine_cfg = config["api"]["engines"].setdefault(engine_name, {})
if "base_url" in profile:
engine_cfg["base_url"] = profile["base_url"]
if "api_key" in profile:
engine_cfg["api_key"] = profile["api_key"]
if "model" in profile:
engine_cfg["model"] = profile["model"]
save_config(config)
# 配置日志
logging.basicConfig(
level=logging.INFO,
+1 -1
View File
@@ -102,7 +102,7 @@ except ImportError:
try:
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree
from textual.widgets import Header, Footer, Input, RichLog, Static, Button, Tree, TextArea, Rule
from textual.widgets.tree import TreeNode
from textual.binding import Binding
from textual.events import Mount
+4
View File
@@ -162,6 +162,10 @@ def parse_fc_response(response_data: dict) -> Optional[List[Dict]]:
except json.JSONDecodeError:
args = {"raw": args_str}
# 确保 args 是 dict(LLM 可能返回非对象 JSON)
if not isinstance(args, dict):
args = {"raw": args}
# 统一为小狸内部格式: {"action": "use_tool", "tool": name, "args": "..." }
# 如果 args 里有 args 字段,直接用;否则把整个 args 序列化
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:
"""持久化记忆管理器"""
# 记忆模式常量
MODE_COMPANION = "companion" # 陪伴模式(长期记忆)
MODE_WORK = "work" # 工作模式(短期记忆)
def __init__(self, project_dir: str):
self.project_dir = project_dir
self.memory_dir = os.path.join(project_dir, "memory")
self.memory_file = os.path.join(project_dir, "MEMORY.md")
self.chat_dir = os.path.join(project_dir, "chat_history")
self.config_file = os.path.join(project_dir, "config.json")
self.compress_threshold = 50 # 超过 50 条消息触发压缩
self._ensure_dirs()
self._memory_mode = self._load_memory_mode()
def _load_memory_mode(self) -> str:
"""加载记忆模式配置"""
try:
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get("system", {}).get("memory_mode", "")
except Exception:
pass
return ""
def _save_memory_mode(self, mode: str):
"""保存记忆模式配置"""
try:
config = {}
if os.path.exists(self.config_file):
with open(self.config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
if "system" not in config:
config["system"] = {}
config["system"]["memory_mode"] = mode
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2)
self._memory_mode = mode
except Exception as e:
print(f"保存记忆模式配置失败: {e}")
def get_memory_mode(self) -> str:
"""获取当前记忆模式"""
return self._memory_mode
def is_mode_selected(self) -> bool:
"""检查是否已选择记忆模式"""
return self._memory_mode in [self.MODE_COMPANION, self.MODE_WORK]
def select_mode(self, mode: str) -> bool:
"""选择记忆模式(只能选择一次)"""
if self.is_mode_selected():
return False # 已经选择过,不能更改
if mode not in [self.MODE_COMPANION, self.MODE_WORK]:
return False # 无效的模式
self._save_memory_mode(mode)
return True
def get_mode_description(self) -> str:
"""获取当前模式的描述"""
if self._memory_mode == self.MODE_COMPANION:
return "陪伴模式(长期记忆):拥有成熟的记忆功能,记住用户的偏好、习惯、历史对话等"
elif self._memory_mode == self.MODE_WORK:
return "工作模式(短期记忆):每次对话是独立的短期记忆,可通过指令手动存储聊天记录"
return "未选择模式"
def _ensure_dirs(self):
"""确保目录存在"""
@@ -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:
"""记忆系统插件 — AI 可读写记忆、搜索历史"""
@@ -596,3 +692,7 @@ JSON格式示例:
"required": ["action"]
}
}
# ══════════════════════════════════════════════
# 模式特定的方法
# ══════════════════════════════════════════════
+3 -12
View File
@@ -109,17 +109,8 @@ class NotificationManager:
pass
def _notify_windows(self, title: str, message: str):
"""Windows 通知 — 优先使用 win10toast,降级到 PowerShell"""
# 方法1: win10toast (pip install win10toast)
try:
from win10toast import ToastNotifier
toaster = ToastNotifier()
toaster.show_toast(title, message, duration=3, threaded=True)
return
except ImportError:
pass
# 方法2: PowerShell BurntToast 模块
"""Windows 通知 — 使用 PowerShell(win10toast 在 Python 3.12+ 上不兼容)"""
# 方法1: PowerShell BurntToast 模块
try:
ps_script = f'''
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
@@ -138,7 +129,7 @@ class NotificationManager:
except Exception:
pass
# 方法3: PowerShell 简单弹窗 (最后降级)
# 方法2: PowerShell 简单弹窗 (最后降级)
try:
escaped_msg = message.replace("'", "''")
escaped_title = title.replace("'", "''")
+4
View File
@@ -130,3 +130,7 @@ class LiuginManager:
if tool.get('name', '').lower() == tool_name.lower():
return tool
return None
def get_tool_info_only(self, tool_name):
"""根据名称获取工具信息(不含 handler,用于搜索已卸载插件)"""
return self.get_tool_by_name(tool_name)
+652
View File
@@ -0,0 +1,652 @@
"""
插件市场 Mixin — 提供 /plugin 命令
支持: install, remove, list, search, info
"""
import os
import sys
import json
import base64
import importlib.util
from colorama import Fore, Style
try:
from urllib.request import urlopen, Request
HAS_URLLIB = True
except ImportError:
HAS_URLLIB = False
class PluginMarketMixin:
"""插件市场功能"""
def _get_project_dir(self):
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _get_plugins_dir(self):
return os.path.join(self._get_project_dir(), "plugins")
def _get_registry_path(self):
return os.path.join(self._get_project_dir(), "plugin_registry.json")
def _get_config_path(self):
return os.path.join(self._get_project_dir(), "plugins_config.py")
# ── 注册表操作 ──
def _load_registry(self):
"""加载本地注册表"""
path = self._get_registry_path()
if not os.path.exists(path):
return {"version": 1, "plugins": {}}
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"{Fore.YELLOW}⚠ 读取注册表失败: {e},使用空注册表{Style.RESET_ALL}")
return {"version": 1, "plugins": {}}
def _save_registry(self, registry):
"""保存注册表"""
path = self._get_registry_path()
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(registry, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"{Fore.RED}❌ 保存注册表失败: {e}{Style.RESET_ALL}")
def _fetch_remote_registry(self):
"""拉取远程注册表并合并"""
remote_urls = [
"https://gitee.com/shuiyu1123/xiaoli-cli-plugins/raw/master/plugin_registry.json",
]
registry = self._load_registry()
merged_count = 0
for url in remote_urls:
try:
data = None
# 尝试直接获取
try:
req = Request(url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=10)
data = resp.read()
except Exception:
pass
# Gitee raw 失败,尝试 API
if data is None and "gitee.com" in url and "/raw/" in url:
try:
parts = url.split("gitee.com/")[1]
repo_path = parts.split("/raw/")[0]
rest = parts.split("/raw/", 1)[1]
rest_parts = rest.split("/", 1)
branch = rest_parts[0]
file_path = rest_parts[1] if len(rest_parts) > 1 else rest
api_url = f"https://gitee.com/api/v5/repos/{repo_path}/contents/{file_path}?ref={branch}"
token = os.environ.get("GITEE_TOKEN", "")
if token:
api_url += f"&access_token={token}"
req = Request(api_url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=10)
json_data = json.loads(resp.read())
if "content" in json_data:
import base64
data = base64.b64decode(json_data["content"])
except Exception:
pass
if data is None:
continue
remote = json.loads(data.decode('utf-8'))
remote_plugins = remote.get("plugins", {})
for code, info in remote_plugins.items():
if code not in registry.get("plugins", {}):
registry.setdefault("plugins", {})[code] = info
merged_count += 1
break # 成功就不再尝试其他源
except Exception:
continue
if merged_count > 0:
self._save_registry(registry)
print(f"{Style.DIM} (从远程同步了 {merged_count} 个插件){Style.RESET_ALL}")
return registry
# ── 配置文件操作 ──
def _enable_plugin_in_config(self, plugin_name):
"""在 plugins_config.py 中启用插件"""
config_path = self._get_config_path()
enable_key = f"ENABLE_{plugin_name.upper()}"
if not os.path.exists(config_path):
with open(config_path, 'w', encoding='utf-8') as f:
f.write(f"# 小狸插件配置\n\n{enable_key} = True\n")
return True
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# 检查是否已存在
if enable_key in content:
# 替换为 True
lines = content.split('\n')
new_lines = []
for line in lines:
if line.strip().startswith(enable_key):
new_lines.append(f"{enable_key} = True")
else:
new_lines.append(line)
with open(config_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
else:
# 追加
with open(config_path, 'a', encoding='utf-8') as f:
f.write(f"\n{enable_key} = True\n")
return True
def _disable_plugin_in_config(self, plugin_name):
"""在 plugins_config.py 中禁用插件"""
config_path = self._get_config_path()
enable_key = f"ENABLE_{plugin_name.upper()}"
if not os.path.exists(config_path):
return False
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
if enable_key not in content:
return False
lines = content.split('\n')
new_lines = []
for line in lines:
if line.strip().startswith(enable_key):
new_lines.append(f"{enable_key} = False")
else:
new_lines.append(line)
with open(config_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines))
return True
# ── 下载安装 ──
def _download_file(self, url, dest_path):
"""下载文件到指定路径,支持 Gitee/GitHub raw 和 API"""
if not HAS_URLLIB:
return False, "缺少 urllib 模块"
import base64
# 尝试的 URL 列表(按优先级)
urls_to_try = [url]
# Gitee raw URL → 转为 API
if "gitee.com" in url and "/raw/" in url:
try:
parts = url.split("gitee.com/")[1]
repo_path = parts.split("/raw/")[0]
rest = parts.split("/raw/", 1)[1]
# rest = "master/dglab_ws/dglab_ws.py" → 分离 branch 和 path
rest_parts = rest.split("/", 1)
branch = rest_parts[0]
file_path = rest_parts[1] if len(rest_parts) > 1 else rest
api_url = f"https://gitee.com/api/v5/repos/{repo_path}/contents/{file_path}?ref={branch}"
token = os.environ.get("GITEE_TOKEN", "")
if token:
api_url += f"&access_token={token}"
urls_to_try.insert(0, api_url)
except Exception:
pass
# GitHub raw URL → 转为 API
if "github.com" in url and "/raw/" in url:
try:
parts = url.split("github.com/")[1]
repo_path = parts.split("/raw/")[0]
file_path = parts.split("/raw/", 1)[1]
api_url = f"https://api.github.com/repos/{repo_path}/contents/{file_path}"
urls_to_try.insert(0, api_url)
except Exception:
pass
last_err = ""
for try_url in urls_to_try:
try:
req = Request(try_url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=30)
data = resp.read()
# 检查是否是 JSON(API 响应)
try:
json_data = json.loads(data)
if isinstance(json_data, dict) and "content" in json_data:
data = base64.b64decode(json_data["content"])
except (json.JSONDecodeError, ValueError):
pass # 不是 JSON,直接用原始数据
with open(dest_path, 'wb') as f:
f.write(data)
return True, f"下载完成 ({len(data)} 字节)"
except Exception as e:
last_err = str(e)
continue
return False, last_err
def _install_deps(self, deps, optional=False):
"""提示安装依赖(不自动安装,遵守安全规则)"""
if not deps:
return
label = "可选依赖" if optional else "依赖"
print(f"{Fore.YELLOW} 📦 {label}:{', '.join(deps)}{Style.RESET_ALL}")
print(f"{Style.DIM} 运行: pip install {' '.join(deps)}{Style.RESET_ALL}")
# ── /plugin 命令入口 ──
def handle_plugin_command(self, args):
"""处理 /plugin 命令"""
args = args.strip()
if not args:
return self._plugin_help()
parts = args.split(maxsplit=1)
sub = parts[0].lower()
rest = parts[1] if len(parts) > 1 else ""
if sub == "install":
return self._plugin_install(rest.strip())
elif sub in ("remove", "uninstall"):
return self._plugin_remove(rest.strip())
elif sub == "list":
return self._plugin_list()
elif sub == "search":
return self._plugin_search(rest.strip())
elif sub == "info":
return self._plugin_info(rest.strip())
elif sub == "update":
return self._plugin_update(rest.strip())
elif sub == "add-source":
return self._plugin_add_source(rest.strip())
else:
return f"{Fore.RED}❌ 未知子命令: {sub}\n{self._plugin_help()}"
def _plugin_help(self):
"""插件市场帮助"""
return f"""{Fore.CYAN}🔌 插件市场{Style.RESET_ALL}
{Fore.WHITE}命令:{Style.RESET_ALL}
/plugin list 列出所有可用插件
/plugin search <关键词> 搜索插件
/plugin info <插件码> 查看插件详情
/plugin install <插件码> 安装插件
/plugin install <URL> 从 URL 安装插件
/plugin remove <插件名> 卸载插件
/plugin update <插件码> 更新已安装的插件
/plugin add-source <URL> 添加远程插件源
{Fore.WHITE}示例:{Style.RESET_ALL}
/plugin list 查看可用插件
/plugin install dglab 安装 DG-Lab 插件
/plugin search 郊狼 搜索相关插件
/plugin remove dglab_ws 卸载插件"""
def _plugin_list(self):
"""列出所有可用插件"""
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
if not plugins:
return f"{Fore.YELLOW}📭 插件注册表为空,运行 /plugin install <URL> 从 URL 安装{Style.RESET_ALL}"
# 检查已安装状态
plugins_dir = self._get_plugins_dir()
installed = set()
if os.path.exists(plugins_dir):
for f in os.listdir(plugins_dir):
if f.endswith('.py') and f != '__init__.py':
installed.add(f[:-3])
lines = [f"{Fore.CYAN}🔌 可用插件列表:{Style.RESET_ALL}\n"]
for code, info in sorted(plugins.items()):
name = info.get("name", code)
desc = info.get("description", "")
version = info.get("version", "")
target_file = info.get("file", f"{code}.py")
target_name = target_file[:-3] if target_file.endswith('.py') else target_file
if target_name in installed:
status = f"{Fore.GREEN}✓ 已安装{Style.RESET_ALL}"
else:
status = f"{Style.DIM}○ 未安装{Style.RESET_ALL}"
lines.append(f" {Fore.WHITE}{code:<15}{Style.RESET_ALL} {status} {name}")
if desc:
lines.append(f" {Style.DIM}{'':15} {desc[:50]}{Style.RESET_ALL}")
if version:
lines.append(f" {Style.DIM}{'':15} v{version}{Style.RESET_ALL}")
lines.append(f"\n{Style.DIM}使用 /plugin install <插件码> 安装{Style.RESET_ALL}")
return "\n".join(lines)
def _plugin_search(self, keyword):
"""搜索插件"""
if not keyword:
return f"{Fore.YELLOW}请提供搜索关键词: /plugin search <关键词>{Style.RESET_ALL}"
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
keyword_lower = keyword.lower()
matches = []
for code, info in plugins.items():
score = 0
name = info.get("name", "").lower()
desc = info.get("description", "").lower()
keywords = [k.lower() for k in info.get("keywords", [])]
if keyword_lower in code.lower():
score += 100
if keyword_lower in name:
score += 50
if keyword_lower in desc:
score += 30
for kw in keywords:
if keyword_lower in kw:
score += 20
if score > 0:
matches.append((score, code, info))
matches.sort(key=lambda x: x[0], reverse=True)
if not matches:
return f"{Fore.YELLOW}🔍 未找到与 '{keyword}' 相关的插件{Style.RESET_ALL}"
lines = [f"{Fore.CYAN}🔍 搜索 '{keyword}' 的结果:{Style.RESET_ALL}\n"]
for _, code, info in matches:
name = info.get("name", code)
desc = info.get("description", "")
lines.append(f" {Fore.WHITE}{code:<15}{Style.RESET_ALL} {name}")
if desc:
lines.append(f" {Style.DIM}{'':15} {desc[:60]}{Style.RESET_ALL}")
return "\n".join(lines)
def _plugin_info(self, code):
"""查看插件详情"""
if not code:
return f"{Fore.YELLOW}请提供插件码: /plugin info <插件码>{Style.RESET_ALL}"
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
if code not in plugins:
return f"{Fore.RED}❌ 未找到插件 '{code}',运行 /plugin list 查看可用插件{Style.RESET_ALL}"
info = plugins[code]
name = info.get("name", code)
desc = info.get("description", "")
author = info.get("author", "未知")
version = info.get("version", "未知")
deps = info.get("deps", [])
opt_deps = info.get("optional_deps", [])
url = info.get("url", "")
docs = info.get("docs", "")
keywords = info.get("keywords", [])
lines = [
f"{Fore.CYAN}📦 插件详情: {code}{Style.RESET_ALL}\n",
f" 名称: {name}",
f" 版本: {version}",
f" 作者: {author}",
]
if desc:
lines.append(f" 描述: {desc}")
if keywords:
lines.append(f" 关键词: {', '.join(keywords)}")
if deps:
lines.append(f" 依赖: {', '.join(deps)}")
if opt_deps:
lines.append(f" 可选依赖: {', '.join(opt_deps)}")
if url:
lines.append(f" 下载地址: {url}")
if docs:
lines.append(f" 文档: {docs}")
lines.append(f"\n {Fore.GREEN}/plugin install {code}{Style.RESET_ALL} 安装此插件")
return "\n".join(lines)
def _plugin_install(self, code):
"""安装插件"""
if not code:
return f"{Fore.YELLOW}请提供插件码或 URL: /plugin install <插件码|URL>{Style.RESET_ALL}"
plugins_dir = self._get_plugins_dir()
os.makedirs(plugins_dir, exist_ok=True)
# 判断是 URL 还是插件码
if code.startswith("http://") or code.startswith("https://"):
return self._install_from_url(code)
# 从注册表查找
registry = self._fetch_remote_registry()
plugins = registry.get("plugins", {})
if code not in plugins:
return (
f"{Fore.RED}❌ 未找到插件 '{code}'\n"
f"{Fore.YELLOW}运行 /plugin list 查看可用插件\n"
f"或使用 /plugin install <URL> 从 URL 安装{Style.RESET_ALL}"
)
info = plugins[code]
name = info.get("name", code)
url = info.get("url", "")
target_file = info.get("file", f"{code}.py")
deps = info.get("deps", [])
opt_deps = info.get("optional_deps", [])
if not url:
return f"{Fore.RED}❌ 插件 '{code}' 没有下载地址{Style.RESET_ALL}"
dest = os.path.join(plugins_dir, target_file)
# 检查是否已安装
if os.path.exists(dest):
print(f"{Fore.YELLOW}⚠ 插件 '{name}' 已存在,将覆盖更新{Style.RESET_ALL}")
print(f"{Fore.CYAN}📥 正在安装: {name}{Style.RESET_ALL}")
print(f"{Style.DIM} 下载: {url}{Style.RESET_ALL}")
ok, msg = self._download_file(url, dest)
if not ok:
return f"{Fore.RED}❌ 下载失败: {msg}{Style.RESET_ALL}"
print(f"{Fore.GREEN} ✓ 文件已保存到: {dest}{Style.RESET_ALL}")
# 下载文档
docs_url = info.get("docs", "")
if docs_url:
docs_dest = os.path.join(plugins_dir, target_file.replace('.py', '_README.md'))
ok_docs, _ = self._download_file(docs_url, docs_dest)
if ok_docs:
print(f"{Fore.GREEN} ✓ 文档已保存到: {docs_dest}{Style.RESET_ALL}")
# 启用插件
plugin_module_name = target_file[:-3] if target_file.endswith('.py') else target_file
self._enable_plugin_in_config(plugin_module_name)
print(f"{Fore.GREEN} ✓ 已在配置中启用: ENABLE_{plugin_module_name.upper()} = True{Style.RESET_ALL}")
# 提示依赖
self._install_deps(deps, optional=False)
self._install_deps(opt_deps, optional=True)
# 尝试动态加载
print(f"{Fore.CYAN} 🔄 尝试动态加载插件...{Style.RESET_ALL}")
try:
spec = importlib.util.spec_from_file_location(plugin_module_name, dest)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if plugin_class:
instance = plugin_class()
tool_info = instance.get_tool_info()
tool_info['handler'] = instance.handle
if hasattr(instance, 'set_cli'):
instance.set_cli(self)
self.liugin_manager.tools.append(tool_info)
print(f"{Fore.GREEN} ✓ 插件已加载: {tool_info.get('name', plugin_module_name)}{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW} ⚠ 未找到 Liugin/Plugin 类,重启后自动加载{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW} ⚠ 动态加载失败: {e},重启后自动加载{Style.RESET_ALL}")
return f"\n{Fore.GREEN}🎉 插件 '{name}' 安装完成!{Style.RESET_ALL}"
def _install_from_url(self, url):
"""从 URL 直接安装"""
plugins_dir = self._get_plugins_dir()
os.makedirs(plugins_dir, exist_ok=True)
# 从 URL 提取文件名
filename = url.split('/')[-1].split('?')[0]
if not filename.endswith('.py'):
return f"{Fore.RED}❌ URL 必须指向 .py 文件{Style.RESET_ALL}"
dest = os.path.join(plugins_dir, filename)
print(f"{Fore.CYAN}📥 正在从 URL 安装: {url}{Style.RESET_ALL}")
ok, msg = self._download_file(url, dest)
if not ok:
return f"{Fore.RED}❌ 下载失败: {msg}{Style.RESET_ALL}"
plugin_name = filename[:-3]
self._enable_plugin_in_config(plugin_name)
print(f"{Fore.GREEN} ✓ 已保存到: {dest}{Style.RESET_ALL}")
print(f"{Fore.GREEN} ✓ 已启用: ENABLE_{plugin_name.upper()}{Style.RESET_ALL}")
# 尝试动态加载
try:
spec = importlib.util.spec_from_file_location(plugin_name, dest)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plugin_class = getattr(module, 'Liugin', None) or getattr(module, 'Plugin', None)
if plugin_class:
instance = plugin_class()
tool_info = instance.get_tool_info()
tool_info['handler'] = instance.handle
if hasattr(instance, 'set_cli'):
instance.set_cli(self)
self.liugin_manager.tools.append(tool_info)
print(f"{Fore.GREEN} ✓ 插件已加载: {tool_info.get('name', plugin_name)}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.YELLOW} ⚠ 动态加载失败: {e},重启后自动加载{Style.RESET_ALL}")
return f"\n{Fore.GREEN}🎉 插件安装完成!{Style.RESET_ALL}"
def _plugin_remove(self, name):
"""卸载插件"""
if not name:
return f"{Fore.YELLOW}请提供插件名: /plugin remove <插件名>{Style.RESET_ALL}"
plugins_dir = self._get_plugins_dir()
# 查找插件文件
target = None
for f in os.listdir(plugins_dir):
if f.endswith('.py') and f != '__init__.py':
module_name = f[:-3]
if module_name == name or f == name:
target = os.path.join(plugins_dir, f)
break
if not target:
return f"{Fore.RED}❌ 未找到已安装的插件 '{name}'{Style.RESET_ALL}"
module_name = os.path.basename(target)[:-3]
# 删除文件
try:
os.remove(target)
print(f"{Fore.GREEN} ✓ 已删除: {target}{Style.RESET_ALL}")
except Exception as e:
return f"{Fore.RED}❌ 删除失败: {e}{Style.RESET_ALL}"
# 删除关联文档
docs_file = target.replace('.py', '_README.md')
if os.path.exists(docs_file):
os.remove(docs_file)
# 禁用插件
self._disable_plugin_in_config(module_name)
print(f"{Fore.GREEN} ✓ 已禁用: ENABLE_{module_name.upper()} = False{Style.RESET_ALL}")
# 从内存中移除
self.liugin_manager.tools = [
t for t in self.liugin_manager.tools
if t.get('name') != module_name
]
return f"\n{Fore.GREEN}🗑️ 插件 '{name}' 已卸载{Style.RESET_ALL}"
def _plugin_update(self, code):
"""更新插件(重新下载)"""
if not code:
return f"{Fore.YELLOW}请提供插件码: /plugin update <插件码>{Style.RESET_ALL}"
registry = self._load_registry()
plugins = registry.get("plugins", {})
if code not in plugins:
return f"{Fore.RED}❌ 注册表中未找到 '{code}',无法更新{Style.RESET_ALL}"
# 检查是否已安装
info = plugins[code]
target_file = info.get("file", f"{code}.py")
dest = os.path.join(self._get_plugins_dir(), target_file)
if not os.path.exists(dest):
return f"{Fore.YELLOW}⚠ 插件 '{code}' 未安装,使用 /plugin install {code} 安装{Style.RESET_ALL}"
print(f"{Fore.CYAN}🔄 更新插件: {code}{Style.RESET_ALL}")
return self._plugin_install(code)
def _plugin_add_source(self, url):
"""添加远程插件源"""
if not url:
return f"{Fore.YELLOW}请提供注册表 URL: /plugin add-source <URL>{Style.RESET_ALL}"
print(f"{Fore.CYAN}📡 正在同步插件源: {url}{Style.RESET_ALL}")
try:
req = Request(url, headers={"User-Agent": "xiaoli-cli/1.0"})
resp = urlopen(req, timeout=15)
remote = json.loads(resp.read().decode('utf-8'))
except Exception as e:
return f"{Fore.RED}❌ 拉取失败: {e}{Style.RESET_ALL}"
registry = self._load_registry()
remote_plugins = remote.get("plugins", {})
added = 0
updated = 0
for code, info in remote_plugins.items():
if code in registry.get("plugins", {}):
registry["plugins"][code] = info
updated += 1
else:
registry.setdefault("plugins", {})[code] = info
added += 1
self._save_registry(registry)
return (
f"{Fore.GREEN}✓ 插件源同步完成: 新增 {added} 个,更新 {updated} 个{Style.RESET_ALL}"
)
+644
View File
@@ -0,0 +1,644 @@
"""
跨平台进程保护模块
提供单实例保护、进程优先级提升、看门狗监控、优雅关闭等功能
支持 Windows / macOS / Linux
Windows 特有: PPL (Protected Process Light) 风格保护、错误模式抑制
"""
import os
import sys
import time
import signal
import atexit
import threading
import subprocess
from typing import Optional, Callable
# ── 平台检测 ──
PLATFORM = (
'windows' if os.name == 'nt'
else 'macos' if sys.platform == 'darwin'
else 'linux'
)
class ProcessProtection:
"""跨平台进程保护器"""
def __init__(self, app_name: str = "xiaoli-cli"):
self.app_name = app_name
self._lock_file_path: Optional[str] = None
self._lock_fd = None
self._watchdog_thread: Optional[threading.Thread] = None
self._watchdog_running = threading.Event()
self._shutdown_hooks: list[Callable] = []
self._is_protected = False
self._original_priority = None
self._restart_on_crash = False
self._restart_script: Optional[str] = None
# ══════════════════════════════════════════════════
# 单实例保护
# ══════════════════════════════════════════════════
def acquire_single_instance(self) -> bool:
"""
获取单实例锁,防止重复启动
返回 True 表示成功(当前是唯一实例),False 表示已有实例在运行
"""
if PLATFORM == 'windows':
return self._acquire_windows_mutex()
else:
return self._acquire_posix_lock()
def release_single_instance(self):
"""释放单实例锁"""
if PLATFORM == 'windows':
self._release_windows_mutex()
else:
self._release_posix_lock()
def _acquire_windows_mutex(self) -> bool:
"""Windows: 使用命名 Mutex 实现单实例"""
try:
import ctypes
from ctypes import wintypes
kernel32 = ctypes.windll.kernel32
mutex_name = f"Global\\{self.app_name}_single_instance"
# 创建命名 Mutex
handle = kernel32.CreateMutexW(None, True, mutex_name)
last_error = kernel32.GetLastError()
ERROR_ALREADY_EXISTS = 183
if last_error == ERROR_ALREADY_EXISTS:
kernel32.CloseHandle(handle)
return False
self._win_mutex_handle = handle
atexit.register(self.release_single_instance)
return True
except Exception:
# 降级到文件锁
return self._acquire_posix_lock()
def _release_windows_mutex(self):
"""Windows: 释放 Mutex"""
try:
if hasattr(self, '_win_mutex_handle') and self._win_mutex_handle:
import ctypes
ctypes.windll.kernel32.CloseHandle(self._win_mutex_handle)
self._win_mutex_handle = None
except Exception:
pass
def _acquire_posix_lock(self) -> bool:
"""POSIX (macOS/Linux): 使用文件锁实现单实例"""
import tempfile
lock_dir = tempfile.gettempdir()
self._lock_file_path = os.path.join(lock_dir, f"{self.app_name}.lock")
try:
# 尝试创建并锁定文件
if sys.platform == 'darwin':
# macOS: 使用 fcntl 文件锁
import fcntl
self._lock_fd = open(self._lock_file_path, 'a+')
self._lock_fd.seek(0)
try:
fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError):
# flock 失败 — 已有存活实例在运行
# (flock 是进程级锁,进程退出时自动释放)
self._lock_fd.close()
self._lock_fd = None
return False
self._lock_fd.seek(0)
self._lock_fd.truncate()
self._lock_fd.write(str(os.getpid()))
self._lock_fd.flush()
atexit.register(self.release_single_instance)
return True
else:
# Linux: 使用 fcntl 文件锁
import fcntl
self._lock_fd = open(self._lock_file_path, 'a+')
self._lock_fd.seek(0)
try:
fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (IOError, OSError):
# flock 失败 — 已有存活实例在运行
self._lock_fd.close()
self._lock_fd = None
return False
self._lock_fd.seek(0)
self._lock_fd.truncate()
self._lock_fd.write(str(os.getpid()))
self._lock_fd.flush()
atexit.register(self.release_single_instance)
return True
except Exception:
# 锁获取失败,但不阻止程序运行
return True
def _release_posix_lock(self):
"""POSIX: 释放文件锁"""
try:
if self._lock_fd:
if sys.platform == 'darwin':
import fcntl
fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_UN)
else:
import fcntl
fcntl.flock(self._lock_fd.fileno(), fcntl.LOCK_UN)
self._lock_fd.close()
self._lock_fd = None
if self._lock_file_path and os.path.exists(self._lock_file_path):
try:
os.unlink(self._lock_file_path)
except OSError:
pass
except Exception:
pass
@staticmethod
def _is_process_running(pid: int) -> bool:
"""检查指定 PID 的进程是否在运行"""
try:
if PLATFORM == 'windows':
import ctypes
kernel32 = ctypes.windll.kernel32
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if handle:
kernel32.CloseHandle(handle)
return True
return False
else:
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
return False
# ══════════════════════════════════════════════════
# 进程优先级提升
# ══════════════════════════════════════════════════
def elevate_priority(self, level: str = "high") -> bool:
"""
提升进程优先级
Args:
level: "above_normal" | "high" | "realtime"(仅Windows)
Returns:
是否成功提升
"""
try:
if PLATFORM == 'windows':
return self._elevate_windows(level)
elif PLATFORM == 'macos':
return self._elevate_macos(level)
else:
return self._elevate_linux(level)
except Exception:
return False
def _elevate_windows(self, level: str) -> bool:
"""Windows: 使用 SetPriorityClass 提升优先级"""
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# 优先级映射
priority_map = {
"above_normal": 0x00008000, # ABOVE_NORMAL_PRIORITY_CLASS
"high": 0x00000080, # HIGH_PRIORITY_CLASS
"realtime": 0x00000100, # REALTIME_PRIORITY_CLASS
}
priority = priority_map.get(level, 0x00000080)
# 获取当前进程句柄
handle = kernel32.GetCurrentProcess()
# 记录原始优先级
self._original_priority = kernel32.GetPriorityClass(handle)
# 设置新优先级
result = kernel32.SetPriorityClass(handle, priority)
if result:
self._is_protected = True
return True
return False
except Exception:
return False
def _elevate_macos(self, level: str) -> bool:
"""macOS: 使用 renice 提升优先级"""
try:
nice_map = {
"above_normal": -10,
"high": -15,
"realtime": -20,
}
nice_val = nice_map.get(level, -10)
os.nice(nice_val)
self._is_protected = True
return True
except Exception:
return False
def _elevate_linux(self, level: str) -> bool:
"""Linux: 使用 renice 提升优先级"""
try:
nice_map = {
"above_normal": -10,
"high": -15,
"realtime": -20,
}
nice_val = nice_map.get(level, -10)
# os.nice() 是相对值,需要先获取当前 nice 值
try:
import resource
current = resource.getpriority(resource.PRIO_PROCESS, 0)
target = nice_val
delta = target - current
if delta != 0:
os.nice(delta)
except (ImportError, OSError):
# fallback: 直接使用 subprocess
subprocess.run(
["renice", str(nice_val), "-p", str(os.getpid())],
capture_output=True, timeout=5
)
self._is_protected = True
return True
except Exception:
return False
def restore_priority(self):
"""恢复原始进程优先级"""
if not self._is_protected:
return
try:
if PLATFORM == 'windows' and self._original_priority:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetCurrentProcess()
kernel32.SetPriorityClass(handle, self._original_priority)
elif PLATFORM in ('macos', 'linux'):
# 恢复到默认 nice 值 (0)
try:
import resource
current = resource.getpriority(resource.PRIO_PROCESS, 0)
os.nice(-current)
except (ImportError, OSError):
subprocess.run(
["renice", "0", "-p", str(os.getpid())],
capture_output=True, timeout=5
)
self._is_protected = False
except Exception:
pass
# ══════════════════════════════════════════════════
# Windows PPL 风格保护
# ══════════════════════════════════════════════════
def enable_ppl_protection(self) -> bool:
"""
Windows: 启用 PPL (Protected Process Light) 风格保护
- 抑制错误弹窗
- 设置进程缓解策略
- 禁用 DEP 异常弹窗
macOS/Linux: 启用等效保护
- 设置信号处理
- 忽略 SIGHUP
"""
if PLATFORM == 'windows':
return self._enable_windows_ppl()
else:
return self._enable_posix_protection()
def _enable_windows_ppl(self) -> bool:
"""Windows: PPL 风格保护"""
try:
import ctypes
kernel32 = ctypes.windll.kernel32
# 1. 抑制系统错误弹窗 (SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX)
SEM_FAILCRITICALERRORS = 0x0001
SEM_NOGPFAULTERRORBOX = 0x0002
SEM_NOOPENFILEERRORBOX = 0x8000
old_mode = kernel32.SetErrorMode(
SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX
)
self._original_error_mode = old_mode
# 2. 设置进程为后台优先级模式(减少对用户交互的影响)
# PROCESS_MODE_BACKGROUND_BEGIN = 0x00100000
# kernel32.SetPriorityClass(kernel32.GetCurrentProcess(), 0x00100000)
# 3. 注册异常处理(防止未处理异常导致崩溃弹窗)
try:
import faulthandler
faulthandler.enable()
except Exception:
pass
self._is_protected = True
return True
except Exception:
return False
def _enable_posix_protection(self) -> bool:
"""macOS/Linux: 等效保护"""
try:
# 1. 忽略 SIGHUP(终端关闭时不停止进程)
signal.signal(signal.SIGHUP, signal.SIG_IGN)
# 2. 注册优雅关闭信号
def _graceful_shutdown(signum, frame):
self._execute_shutdown_hooks()
sys.exit(0)
signal.signal(signal.SIGTERM, _graceful_shutdown)
# 3. macOS 特有:忽略 SIGPIPE
if PLATFORM == 'macos':
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
# 4. 启用 faulthandler(崩溃时输出堆栈)
try:
import faulthandler
faulthandler.enable()
except Exception:
pass
self._is_protected = True
return True
except Exception:
return False
# ══════════════════════════════════════════════════
# 看门狗监控
# ══════════════════════════════════════════════════
def start_watchdog(self, check_interval: float = 5.0,
restart_on_crash: bool = False,
restart_script: Optional[str] = None):
"""
启动看门狗线程,监控父进程状态
当父进程退出时,自动清理并退出
Args:
check_interval: 检查间隔(秒)
restart_on_crash: 崩溃时是否自动重启
restart_script: 重启脚本路径
"""
self._restart_on_crash = restart_on_crash
self._restart_script = restart_script
if self._watchdog_thread and self._watchdog_thread.is_alive():
return
self._watchdog_running.set()
self._watchdog_thread = threading.Thread(
target=self._watchdog_loop,
args=(check_interval,),
daemon=True,
name="process-watchdog"
)
self._watchdog_thread.start()
def stop_watchdog(self):
"""停止看门狗"""
self._watchdog_running.clear()
if self._watchdog_thread:
self._watchdog_thread.join(timeout=3)
self._watchdog_thread = None
def _watchdog_loop(self, check_interval: float):
"""看门狗主循环"""
parent_pid = os.getppid()
while self._watchdog_running.is_set():
try:
# 检查父进程是否仍在运行
if not self._is_process_running(parent_pid):
# 父进程已退出,执行清理
self._execute_shutdown_hooks()
if self._restart_on_crash and self._restart_script:
self._do_restart()
else:
os._exit(0)
break
time.sleep(check_interval)
except Exception:
time.sleep(check_interval)
def _do_restart(self):
"""执行重启"""
try:
if self._restart_script and os.path.exists(self._restart_script):
if PLATFORM == 'windows':
subprocess.Popen(
["python", self._restart_script],
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
subprocess.Popen(
["python3", self._restart_script],
start_new_session=True
)
except Exception:
pass
finally:
os._exit(0)
# ══════════════════════════════════════════════════
# 关闭钩子
# ══════════════════════════════════════════════════
def register_shutdown_hook(self, hook: Callable):
"""注册关闭钩子,在进程退出时执行"""
if hook not in self._shutdown_hooks:
self._shutdown_hooks.append(hook)
def _execute_shutdown_hooks(self):
"""执行所有关闭钩子"""
for hook in reversed(self._shutdown_hooks):
try:
hook()
except Exception:
pass
# ══════════════════════════════════════════════════
# 一键启用全部保护
# ══════════════════════════════════════════════════
def enable_all(self, priority_level: str = "above_normal",
watchdog: bool = True,
restart_on_crash: bool = False,
restart_script: Optional[str] = None) -> dict:
"""
一键启用所有进程保护
Args:
priority_level: 优先级 ("above_normal" | "high")
watchdog: 是否启用看门狗
restart_on_crash: 崩溃时是否自动重启
restart_script: 重启脚本路径
Returns:
各项保护的启用状态字典
"""
results = {
"single_instance": False,
"priority": False,
"ppl_protection": False,
"watchdog": False,
"platform": PLATFORM,
}
# 1. 单实例保护
results["single_instance"] = self.acquire_single_instance()
# 2. 进程优先级提升
results["priority"] = self.elevate_priority(priority_level)
# 3. PPL 风格保护
results["ppl_protection"] = self.enable_ppl_protection()
# 4. 看门狗
if watchdog:
self.start_watchdog(
restart_on_crash=restart_on_crash,
restart_script=restart_script
)
results["watchdog"] = True
# 注册清理
atexit.register(self.disable_all)
return results
def disable_all(self):
"""禁用所有保护并清理"""
self.stop_watchdog()
self.restore_priority()
self.release_single_instance()
self._execute_shutdown_hooks()
self._is_protected = False
def get_status(self) -> dict:
"""获取当前保护状态"""
return {
"platform": PLATFORM,
"is_protected": self._is_protected,
"single_instance_held": (
self._lock_fd is not None or
hasattr(self, '_win_mutex_handle') and self._win_mutex_handle is not None
),
"watchdog_running": (
self._watchdog_thread is not None and
self._watchdog_thread.is_alive()
),
"shutdown_hooks_count": len(self._shutdown_hooks),
}
# ── 全局单例 ──
_protection: Optional[ProcessProtection] = None
def get_protection() -> ProcessProtection:
"""获取全局进程保护器单例"""
global _protection
if _protection is None:
_protection = ProcessProtection()
return _protection
def enable_process_protection(priority_level: str = "above_normal",
watchdog: bool = True,
restart_on_crash: bool = False) -> dict:
"""
快捷函数:启用进程保护
Returns:
各项保护状态字典
"""
return get_protection().enable_all(
priority_level=priority_level,
watchdog=watchdog,
restart_on_crash=restart_on_crash,
)
def disable_process_protection():
"""快捷函数:禁用进程保护"""
get_protection().disable_all()
def get_protection_status() -> dict:
"""快捷函数:获取保护状态"""
return get_protection().get_status()
# ── CLI 入口(可独立运行测试) ──
if __name__ == "__main__":
import json
print(f"平台: {PLATFORM}")
print(f"PID: {os.getpid()}")
print()
prot = ProcessProtection("xiaoli-cli-test")
# 测试单实例
is_first = prot.acquire_single_instance()
print(f"单实例锁: {'获取成功 (首个实例)' if is_first else '获取失败 (已有实例)'}")
if is_first:
# 测试优先级提升
prio_ok = prot.elevate_priority("above_normal")
print(f"优先级提升: {'成功' if prio_ok else '失败'}")
# 测试 PPL 保护
ppl_ok = prot.enable_ppl_protection()
print(f"PPL 保护: {'启用' if ppl_ok else '失败'}")
# 测试看门狗
prot.start_watchdog(check_interval=2.0)
print(f"看门狗: 已启动")
# 显示状态
status = prot.get_status()
print(f"\n状态: {json.dumps(status, indent=2, ensure_ascii=False)}")
print("\n按 Ctrl+C 退出...")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n正在清理...")
prot.disable_all()
print("完成")
+138 -109
View File
@@ -1,42 +1,50 @@
"""
安全层 - 统一的指令安全检查
拦截所有工具调用,通过 AI 识别风险并请求用户确认
工作模式 - 统一的安全检查 + 工作流控制
整合安全模式与计划模式,控制 AI 的执行策略
"""
import json
import threading
from typing import Optional, Tuple
# ── 安全模式 ──
# ── 工作模式 ──
MODE_UNRESTRICTED = 0 # 无限制:直接执行
MODE_NORMAL = 1 # 普通:AI 识别风险
MODE_MANUAL = 2 # 人工:所有指令都确认
MODE_MANUAL = 2 # 人工:所有指令确认
MODE_PLAN = 3 # 计划:全自动 + 审查 + 重试 + 引擎切换
MODE_NAMES = {
MODE_UNRESTRICTED: "无限制",
MODE_NORMAL: "普通",
MODE_MANUAL: "人工确认",
MODE_PLAN: "计划模式",
}
# ── 始终需要人工确认的指令(无论模式) ──
ALWAYS_CONFIRM = {
("cmd_executor", "run rm"),
("cmd_executor", "run chmod"),
("cmd_executor", "run chown"),
("file_manager", "delete"),
("git_tools", "checkout -b"),
MODE_ICONS = {
MODE_UNRESTRICTED: "",
MODE_NORMAL: "",
MODE_MANUAL: "",
MODE_PLAN: "",
}
class SafetyLayer:
"""统一安全检查层"""
class WorkMode:
"""统一工作模式控制"""
def __init__(self):
self.mode = MODE_NORMAL
self.cli = None
self._analysis_cache = {} # 缓存分析结果
self._analysis_cache = {}
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):
self.cli = cli
@@ -46,37 +54,103 @@ class SafetyLayer:
def get_mode_name(self) -> str:
return MODE_NAMES.get(self.mode, "未知")
def get_mode_icon(self) -> str:
return MODE_ICONS.get(self.mode, "")
def set_mode(self, mode: int):
old = self.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:
"""切换到下一个模式,返回新模式名称"""
self.mode = (self.mode + 1) % 3
"""循环切换: 普通 → 人工 → 无限制 → 计划 → 普通"""
self.mode = (self.mode + 1) % 4
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]:
"""
检查工具调用是否安全
返回: (允许执行, 消息)
检查工具调用是否安全。
计划模式下自动放行(全自动)。
"""
# 计划模式:自动放行,不问用户
if self.mode == MODE_PLAN:
return True, ""
# 无限制模式:直接放行
if self.mode == MODE_UNRESTRICTED:
return True, ""
# 检查是否是始终需要确认的指令
args_lower = tool_args.strip().lower()
for (t, pattern) in ALWAYS_CONFIRM:
if tool_name == t and args_lower.startswith(pattern.split(" ", 1)[1] if " " in pattern else ""):
return self._ask_user_confirm(
tool_name, tool_args,
risk_level="高",
impact="此操作始终需要人工确认",
reason="敏感指令"
)
# 人工确认模式:所有指令都确认
# 人工确认模式:所有指令确认
if self.mode == MODE_MANUAL:
return self._ask_user_confirm(
tool_name, tool_args,
@@ -91,26 +165,20 @@ class SafetyLayer:
# ── AI 风险识别 ──
def _ai_analyze(self, tool_name: str, tool_args: str) -> Tuple[bool, str]:
"""使用当前引擎独立分析指令风险(不带历史对话)"""
# 快速预检:明显安全的指令直接放行
"""使用当前引擎独立分析指令风险"""
if self._is_quick_safe(tool_name, tool_args):
return True, ""
# 获取引擎
engine = None
if self.cli and hasattr(self.cli, 'current_engine'):
engine = self.cli.current_engine
if not engine:
# 无引擎时降级为人工确认
return self._ask_user_confirm(
tool_name, tool_args,
risk_level="未知",
impact="无法进行 AI 风险分析",
reason="无可用引擎"
risk_level="未知", impact="无法进行 AI 风险分析", reason="无可用引擎"
)
# 检查缓存
cache_key = f"{tool_name}:{tool_args[:100]}"
with self._lock:
if cache_key in self._analysis_cache:
@@ -119,12 +187,9 @@ class SafetyLayer:
return True, ""
return self._ask_user_confirm(
tool_name, tool_args,
risk_level=cached["level"],
impact=cached["impact"],
reason=cached["reason"]
risk_level=cached["level"], impact=cached["impact"], reason=cached["reason"]
)
# 调用 AI 独立分析(不带任何历史对话)
analysis_prompt = f"""你是一个安全分析器。分析以下工具调用是否存在安全风险。
工具: {tool_name}
@@ -135,85 +200,58 @@ class SafetyLayer:
判断标准:
- safe=true: 读取操作、查看信息、无害查询
- safe=false: 删除文件、执行系统命令、修改配置、网络请求、git 危险操作
- level 低: 只读操作但可能泄露信息
- level 中: 修改操作可撤销
- level 高: 不可逆操作、系统级操作"""
- safe=false: 删除文件、执行系统命令、修改配置、网络请求、git 危险操作"""
try:
response = engine.generate_response(analysis_prompt, system_prompt="你是安全分析器。只返回 JSON,不要其他内容。")
response = engine.generate_response(
analysis_prompt,
system_prompt="你是安全分析器。只返回 JSON,不要其他内容。"
)
if not response:
return self._ask_user_confirm(
tool_name, tool_args,
risk_level="未知",
impact="AI 分析无响应",
reason="引擎未返回结果"
risk_level="未知", impact="AI 分析无响应", reason="引擎未返回结果"
)
# 解析 JSON
result = self._parse_analysis(response)
if result and result.get("safe") is True:
# 缓存安全结果
with self._lock:
self._analysis_cache[cache_key] = result
return True, ""
# 不安全,请求用户确认
level = result.get("level", "中") if result else "未知"
reason = result.get("reason", "AI 识别到潜在风险") if result else "AI 分析失败"
impact = result.get("impact", "未知影响") if result else "未知影响"
return self._ask_user_confirm(
tool_name, tool_args,
risk_level=level,
impact=impact,
reason=reason
tool_name, tool_args, risk_level=level, impact=impact, reason=reason
)
except Exception as e:
return self._ask_user_confirm(
tool_name, tool_args,
risk_level="未知",
impact=f"AI 分析异常: {e}",
reason="分析过程出错"
risk_level="未知", impact=f"AI 分析异常: {e}", reason="分析过程出错"
)
def _parse_analysis(self, response: str) -> Optional[dict]:
"""解析 AI 返回的分析结果"""
import re
# 尝试直接解析 JSON
response = response.strip()
# 去掉 markdown 代码块
if response.startswith("```"):
response = response.split("\n", 1)[-1] if "\n" in response else response[3:]
if response.endswith("```"):
response = response[:-3]
response = response.strip()
try:
return json.loads(response)
except json.JSONDecodeError:
pass
# 尝试从文本中提取 JSON
match = re.search(r'\{[^{}]*"safe"[^{}]*\}', response)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
match = re.search(r'\{[^{}]*"safe"[^{}]*\}', response)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
return None
def _is_quick_safe(self, tool_name: str, tool_args: str) -> bool:
"""快速预检:明显安全的操作直接放行"""
args = tool_args.strip().lower()
# 纯读取操作
safe_patterns = {
"code_editor": ["read_range", "find ", "regex ", "symbols", "imports",
"ast_info", "stats", "structure", "context", "todo",
@@ -221,7 +259,7 @@ class SafetyLayer:
"git_tools": ["status", "log", "diff", "branch", "remote", "show",
"blame", "contributors", "summary"],
"file_manager": ["list", "read", "info", "search"],
"cmd_executor": [], # 命令执行不跳过
"cmd_executor": [],
"auto_engineer": ["metrics", "security", "complexity", "duplicates",
"suggest", "info"],
"task_manager": ["list"],
@@ -231,35 +269,23 @@ class SafetyLayer:
"network_tools": ["ping", "status", "headers", "ip"],
"tool_search": [],
}
patterns = safe_patterns.get(tool_name, [])
for p in patterns:
if args.startswith(p):
return True
return False
return any(args.startswith(p) for p in patterns)
# ── 用户交互 ──
def _ask_user_confirm(self, tool_name: str, tool_args: str,
risk_level: str = "中", impact: str = "",
reason: str = "") -> Tuple[bool, str]:
"""向用户展示风险信息并请求确认"""
from colorama import Fore, Style
# 风险等级颜色
level_colors = {
"低": Fore.YELLOW,
"中": Fore.LIGHTYELLOW_EX,
"高": Fore.RED,
"未知": Fore.LIGHTBLACK_EX,
"低": Fore.YELLOW, "中": Fore.LIGHTYELLOW_EX,
"高": Fore.RED, "未知": Fore.LIGHTBLACK_EX,
}
level_color = level_colors.get(risk_level, Fore.YELLOW)
# 截断过长的参数
display_args = tool_args
if len(display_args) > 200:
display_args = display_args[:200] + "..."
display_args = tool_args[:200] + "..." if len(tool_args) > 200 else tool_args
print()
print(f" {Fore.RED}{'═' * 50}{Style.RESET_ALL}")
@@ -276,17 +302,20 @@ class SafetyLayer:
try:
choice = input(f" {Fore.WHITE}是否执行? (y/n): {Style.RESET_ALL}").strip().lower()
if choice in ('y', 'yes', '是'):
return True, "用户确认执行"
else:
return False, "用户取消执行"
return (choice in ('y', 'yes', '是'), "用户确认执行" if choice in ('y', 'yes', '是') else "用户取消执行")
except (KeyboardInterrupt, EOFError):
print()
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
+119
View File
@@ -0,0 +1,119 @@
"""
统一工具返回格式 - ToolResult
所有插件和工具管理器使用统一的返回格式,
替代原来混乱的字符串/Dict 返回。
"""
from dataclasses import dataclass, field
from typing import Any, Optional
# 错误码常量
class ErrorCode:
"""工具错误码"""
SUCCESS = 0
# 参数错误 (1xx)
MISSING_ARGS = 100
INVALID_ARGS = 101
UNSUPPORTED_OP = 102
# 资源错误 (2xx)
NOT_FOUND = 200
FILE_NOT_FOUND = 201
PERMISSION_DENIED = 202
# 执行错误 (3xx)
EXEC_FAILED = 300
TIMEOUT = 301
DECODE_ERROR = 302
# 系统错误 (4xx)
INTERNAL_ERROR = 400
NOT_IMPLEMENTED = 401
_NAMES = {
0: "成功",
100: "缺少参数",
101: "参数无效",
102: "不支持的操作",
200: "未找到",
201: "文件不存在",
202: "权限不足",
300: "执行失败",
301: "超时",
302: "解码错误",
400: "内部错误",
401: "未实现",
}
@classmethod
def name(cls, code: int) -> str:
return cls._NAMES.get(code, f"未知({code})")
@dataclass
class ToolResult:
"""统一工具返回格式"""
success: bool
data: Any = None
error_code: int = ErrorCode.SUCCESS
error_message: str = ""
# 可选元数据
tool_name: str = ""
execution_time: float = 0.0
metadata: dict = field(default_factory=dict)
def __str__(self) -> str:
if self.success:
return str(self.data) if self.data is not None else "执行完成"
return f"[{ErrorCode.name(self.error_code)}] {self.error_message}"
# ── 便捷构造方法 ──
@classmethod
def ok(cls, data: Any = None, **kwargs) -> "ToolResult":
"""成功"""
return cls(success=True, data=data, **kwargs)
@classmethod
def fail(cls, message: str, code: int = ErrorCode.EXEC_FAILED, **kwargs) -> "ToolResult":
"""失败"""
return cls(success=False, error_code=code, error_message=message, **kwargs)
@classmethod
def missing(cls, what: str = "参数", **kwargs) -> "ToolResult":
"""缺少参数"""
return cls.fail(f"缺少{what}", ErrorCode.MISSING_ARGS, **kwargs)
@classmethod
def not_found(cls, what: str = "资源", **kwargs) -> "ToolResult":
"""未找到"""
return cls.fail(f"{what}不存在", ErrorCode.NOT_FOUND, **kwargs)
@classmethod
def timeout(cls, seconds: int = 0, **kwargs) -> "ToolResult":
"""超时"""
msg = f"执行超时"
if seconds:
msg += f" ({seconds}秒)"
return cls.fail(msg, ErrorCode.TIMEOUT, **kwargs)
# ── 格式转换 ──
def to_dict(self) -> dict:
"""转为普通 dict(兼容旧接口)"""
if self.success:
return {"result": self.data if self.data is not None else "执行完成"}
return {"result": str(self)}
def to_mcp(self) -> dict:
"""转为 MCP 协议格式"""
text = str(self)
return {
"content": [{"type": "text", "text": text}],
"isError": not self.success,
}
def to_string(self) -> str:
"""转为字符串(兼容旧插件 handle() 返回值)"""
return str(self)
+2116 -475
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.