Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa759665d | ||
|
|
ef8de43693 | ||
|
|
88ddd18ded | ||
|
|
810a057d25 | ||
|
|
3f7df28423 | ||
|
|
75194a9da7 | ||
|
|
a6f6962184 | ||
|
|
346f5fdcb4 | ||
|
|
d336b04b61 | ||
|
|
125e00a485 | ||
|
|
5ea19b75ae | ||
|
|
dfe21298b9 |
No files matched your search
@@ -1,5 +1,76 @@
|
||||
# 更新日志
|
||||
|
||||
## 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/)。
|
||||
|
||||
---
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-5.4.1-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/version-6.0.0-blue" alt="version">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-green" alt="python">
|
||||
<img src="https://img.shields.io/badge/license-MIT-orange" alt="license">
|
||||
<img src="https://img.shields.io/badge/tests-150+%20passed-brightgreen" alt="tests">
|
||||
<img src="https://img.shields.io/badge/plugins-19-blueviolet" alt="plugins">
|
||||
<img src="https://img.shields.io/badge/plugins-24-blueviolet" alt="plugins">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -34,9 +34,9 @@
|
||||
- **远程通信** — PC 与手机 WebSocket 双向通信
|
||||
- **定时任务** — 支持相对时间、绝对时间、重复任务,到期自动激活 AI
|
||||
- **系统通知** — 跨平台任务完成通知(Windows / macOS / Linux)
|
||||
- **MCP 协议** — 19 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
|
||||
- **MCP 协议** — 18 个插件全部实现 MCP 定义,支持 Function Calling 原生调用
|
||||
- **进程保护** — 跨平台单实例保护 + 进程优先级提升 + Windows PPL 风格保护 + 看门狗监控 + 优雅关闭
|
||||
- **19 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
|
||||
- **18 个插件 161+ 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、图像生成、记忆等
|
||||
- **三模式界面** — TUI 图形化 + 传统命令行 + Web UI 浏览器界面
|
||||
- **150+ 个自动化测试** — pytest 覆盖全部插件和核心模块
|
||||
|
||||
@@ -94,7 +94,7 @@ launcher.exe
|
||||
}
|
||||
```
|
||||
|
||||
## 插件列表 (19 个)
|
||||
## 插件列表 (23 个)
|
||||
|
||||
> 更多社区插件请访问 [xiaoli-cli-plugins](https://gitee.com/shuiyu1123/xiaoli-cli-plugins),使用 `/plugin install <插件码>` 一键安装。
|
||||
|
||||
@@ -114,24 +114,27 @@ launcher.exe
|
||||
| 插件 | 操作数 | 说明 |
|
||||
|------|--------|------|
|
||||
| `sub_agent` | 8 | 子 Agent 系统。spawn/list/status/result/send/kill,多 Agent 并行协作 |
|
||||
| `browser_auto` | 28 | 浏览器自动化。导航/点击/填写/截图/JS执行/PDF/cookies(基于 Playwright) |
|
||||
| `gui_auto` | 20 | 桌面 GUI 自动化。基于 Windows UIA,屏幕元素识别/点击/输入/截图(仅 Windows) |
|
||||
| `ai_image` | — | AI 生图。基于 ai.2x.nz 自然语言生图,异步生成 + WebSocket 进度推送 |
|
||||
| `image_generator` | 7 | 图像生成。基于 ComfyUI 在线服务,自然语言/Tag 双模式,28 个角色工作流 |
|
||||
| `memory_plugin` | 11 | 记忆系统。AI 写日记/搜索记忆/管理聊天记录/长期记忆 |
|
||||
| `scheduler` | 4 | 定时任务。支持相对时间/绝对时间/重复任务,到期自动激活 AI |
|
||||
| `tool_search` | — | 工具搜索。按关键词发现可用插件 |
|
||||
| `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` | 远程模式下发图片到手机 |
|
||||
| `json_toolkit` | JSON 工具箱。格式化/压缩/校验/路径查询/差异比较 |
|
||||
| `regex_tester` | 正则表达式测试器。匹配/提取/替换/解释 |
|
||||
| `snippet_manager` | 代码片段管理器。保存/搜索/复用常用代码 |
|
||||
| `markdown_tool` | Markdown 工具。TOC/统计/表格生成/待办提取 |
|
||||
| `id_generator` | ID 生成器。UUID/NanoID/短ID/雪花ID/ULID |
|
||||
|
||||
## 记忆系统
|
||||
|
||||
@@ -195,7 +198,7 @@ memory_plugin chat_search 图像生成
|
||||
|
||||
## MCP 协议支持
|
||||
|
||||
所有 19 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议:
|
||||
所有 18 个插件均实现 MCP (Model Context Protocol) 定义,支持三种工具调用协议:
|
||||
|
||||
| 协议 | 说明 |
|
||||
|------|------|
|
||||
@@ -266,7 +269,7 @@ xiaoli-cli/
|
||||
├── ai_engines/ # AI 引擎
|
||||
│ ├── ollama_engine.py
|
||||
│ └── openai_engine.py
|
||||
├── plugins/ # 插件 (19 个)
|
||||
├── plugins/ # 插件 (18 个)
|
||||
│ ├── code_editor.py
|
||||
│ ├── git_tools.py
|
||||
│ ├── cmd_executor.py
|
||||
@@ -285,7 +288,7 @@ xiaoli-cli/
|
||||
│ ├── ai_search.py
|
||||
│ ├── frontend_tester.py
|
||||
│ ├── speech_recognition.py
|
||||
│ └── send_image.py
|
||||
│ └── terminal.py
|
||||
├── skills/ # 技能
|
||||
├── image_engine/ # 图像识别引擎
|
||||
├── tests/ # 测试套件 (150+ 用例)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
**产品标识**
|
||||
- **产品名称**:小狸智能体 增强-终端(Xiaoli Pro-CLI)
|
||||
- **内部版本号**:20260428
|
||||
- **发布版本**:发行版5.1.3
|
||||
- **发布版本**:发行版6.0
|
||||
|
||||
**技术规格**
|
||||
- **主要语言**:简体中文(UTF-8)
|
||||
|
||||
+141
-5
@@ -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
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+29
-6
@@ -10,10 +10,22 @@
|
||||
"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", "脉冲"]
|
||||
"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 实现",
|
||||
@@ -22,9 +34,20 @@
|
||||
"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"],
|
||||
"deps": [
|
||||
"websocket-client"
|
||||
],
|
||||
"optional_deps": [],
|
||||
"keywords": ["onebot", "qq", "机器人", "bot", "go-cqhttp", "lagrange", "群聊", "私聊"]
|
||||
"keywords": [
|
||||
"onebot",
|
||||
"qq",
|
||||
"机器人",
|
||||
"bot",
|
||||
"go-cqhttp",
|
||||
"lagrange",
|
||||
"群聊",
|
||||
"私聊"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
"""
|
||||
AI 生图插件 - 基于 ai.2x.nz 自然语言生图接口
|
||||
支持自然语言描述、直接 Tag、工作流切换、画风选择等
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
import requests
|
||||
REQUESTS_AVAILABLE = True
|
||||
except ImportError:
|
||||
REQUESTS_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import websocket
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""AI 生图插件 - 调用 ai.2x.nz 接口生成图片"""
|
||||
|
||||
BASE_URL = "https://ai.2x.nz"
|
||||
WS_URL = "wss://ai.2x.nz/ws/run"
|
||||
API_URL = "https://ai.2x.nz/api"
|
||||
|
||||
# 保存目录
|
||||
DEFAULT_SAVE_DIR = "generated_images"
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """AI 生图工具 - 基于自然语言描述生成图片
|
||||
|
||||
基本用法:
|
||||
generate <描述> - 用自然语言描述生成图片
|
||||
tag <直接Tag> - 用 Danbooru Tag 生成图片
|
||||
both <Tag> <<<>>> <描述> - 同时使用 Tag 和自然语言
|
||||
|
||||
高级用法:
|
||||
workflows - 列出所有可用工作流
|
||||
switch <工作流名> - 切换工作流
|
||||
width <数值> - 设置图片宽度 (最大1344)
|
||||
height <数值> - 设置图片高度 (最大1344)
|
||||
size <宽> <高> - 同时设置宽高
|
||||
rewrite on|off - 改写模式开关
|
||||
override on|off - 覆写模式开关
|
||||
status - 查看当前状态和配置
|
||||
|
||||
示例:
|
||||
ai_image generate 一个可爱的猫耳少女,樱花背景
|
||||
ai_image tag 1girl, cat_ears, cherry_blossoms, smile
|
||||
ai_image both 1girl, cat_ears <<<>>> 一个可爱的猫耳少女在樱花树下微笑
|
||||
ai_image workflows
|
||||
ai_image switch WAI - 37.json
|
||||
ai_image size 1024 1024
|
||||
ai_image rewrite on
|
||||
"""
|
||||
self.cli = None
|
||||
self.save_dir = self.DEFAULT_SAVE_DIR
|
||||
|
||||
# 配置状态
|
||||
self.current_workflow = None
|
||||
self.direct_prompt = ""
|
||||
self.nl_prompt = ""
|
||||
self.rewrite = False
|
||||
self.override = False
|
||||
self.width = None
|
||||
self.height = None
|
||||
self.workflows_cache = None
|
||||
self.is_generating = False
|
||||
|
||||
def set_cli(self, cli):
|
||||
"""设置 CLI 实例引用"""
|
||||
self.cli = cli
|
||||
# 注册插件命令
|
||||
if hasattr(cli, 'register_liugin_command'):
|
||||
cli.register_liugin_command('ai_image', self.command_handler)
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "ai_image",
|
||||
"description": "AI 生图工具 — 基于 ai.2x.nz 接口,支持自然语言描述和 Danbooru Tag 生成图片。当用户想要生成图片、画图、AI绘画、生图时使用此工具。",
|
||||
"keywords": [
|
||||
"生图", "画图", "图片", "AI绘画", "生成图片", "image", "generate",
|
||||
"draw", "绘画", "插画", "二次元", "动漫", "Danbooru", "tag",
|
||||
"工作流", "workflow", "ComfyUI"
|
||||
],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "ai_image",
|
||||
"description": "AI 生图工具,基于 ai.2x.nz 接口生成图片",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["generate", "tag", "both", "workflows", "switch", "size", "status"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "生成提示词"
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
prompt = arguments.get("prompt", "")
|
||||
return f"{op} {prompt}"
|
||||
|
||||
def command_handler(self, args):
|
||||
"""处理 /ai_image 命令"""
|
||||
return self.handle(args)
|
||||
|
||||
def handle(self, args):
|
||||
"""处理 AI 生图请求"""
|
||||
if not REQUESTS_AVAILABLE:
|
||||
return "❌ 缺少 requests 库,请运行: pip install requests"
|
||||
|
||||
args = args.strip()
|
||||
if not args:
|
||||
return self.usage
|
||||
|
||||
# 解析操作
|
||||
parts = args.split(maxsplit=1)
|
||||
operation = parts[0].lower()
|
||||
remaining = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
try:
|
||||
if operation in ["generate", "gen", "g"]:
|
||||
return self._cmd_generate(remaining)
|
||||
elif operation in ["tag", "t", "direct", "d"]:
|
||||
return self._cmd_tag(remaining)
|
||||
elif operation in ["both", "b"]:
|
||||
return self._cmd_both(remaining)
|
||||
elif operation in ["workflows", "wf", "list"]:
|
||||
return self._cmd_workflows()
|
||||
elif operation in ["switch", "sw"]:
|
||||
return self._cmd_switch(remaining)
|
||||
elif operation in ["width", "w"]:
|
||||
return self._cmd_set_width(remaining)
|
||||
elif operation in ["height", "h"]:
|
||||
return self._cmd_set_height(remaining)
|
||||
elif operation in ["size", "s"]:
|
||||
return self._cmd_set_size(remaining)
|
||||
elif operation in ["rewrite", "rw"]:
|
||||
return self._cmd_set_rewrite(remaining)
|
||||
elif operation in ["override", "ov"]:
|
||||
return self._cmd_set_override(remaining)
|
||||
elif operation in ["status", "st"]:
|
||||
return self._cmd_status()
|
||||
elif operation in ["help", "?"]:
|
||||
return self.usage
|
||||
else:
|
||||
# 尝试当作自然语言描述处理
|
||||
return self._cmd_generate(args)
|
||||
except Exception as e:
|
||||
return f"❌ 操作失败: {str(e)}"
|
||||
|
||||
# ── 命令实现 ──
|
||||
|
||||
def _cmd_generate(self, prompt):
|
||||
"""自然语言生成图片"""
|
||||
if not prompt.strip():
|
||||
return "❌ 请提供描述内容。示例: ai_image generate 一个可爱的猫耳少女"
|
||||
|
||||
self.nl_prompt = prompt.strip()
|
||||
self.direct_prompt = ""
|
||||
self.override = False
|
||||
return self._run_generation()
|
||||
|
||||
def _cmd_tag(self, tags):
|
||||
"""使用直接 Tag 生成图片"""
|
||||
if not tags.strip():
|
||||
return "❌ 请提供 Tag 内容。示例: ai_image tag 1girl, cat_ears, smile"
|
||||
|
||||
self.direct_prompt = tags.strip()
|
||||
self.nl_prompt = ""
|
||||
self.override = True
|
||||
return self._run_generation()
|
||||
|
||||
def _cmd_both(self, args):
|
||||
"""同时使用 Tag 和自然语言"""
|
||||
if "<<<>>>" in args:
|
||||
parts = args.split("<<<>>>", 1)
|
||||
self.direct_prompt = parts[0].strip()
|
||||
self.nl_prompt = parts[1].strip()
|
||||
else:
|
||||
# 尝试用 || 分隔
|
||||
if "||" in args:
|
||||
parts = args.split("||", 1)
|
||||
self.direct_prompt = parts[0].strip()
|
||||
self.nl_prompt = parts[1].strip()
|
||||
else:
|
||||
return "❌ 格式错误。用法: ai_image both <Tag> <<<>>> <描述>"
|
||||
|
||||
self.override = False
|
||||
return self._run_generation()
|
||||
|
||||
def _cmd_workflows(self):
|
||||
"""列出所有工作流"""
|
||||
workflows = self._fetch_workflows()
|
||||
if not workflows:
|
||||
return "❌ 获取工作流列表失败"
|
||||
|
||||
result = f"📂 共 {len(workflows)} 个工作流:\n\n"
|
||||
for i, wf in enumerate(workflows, 1):
|
||||
name = wf.get("path", "未知")
|
||||
current = " ← [当前]" if name == self.current_workflow else ""
|
||||
result += f" {i:2d}. {name}{current}\n"
|
||||
|
||||
result += f"\n💡 使用 'ai_image switch <工作流名>' 切换工作流"
|
||||
return result
|
||||
|
||||
def _cmd_switch(self, name):
|
||||
"""切换工作流"""
|
||||
if not name.strip():
|
||||
return "❌ 请提供工作流名称。先用 'ai_image workflows' 查看列表"
|
||||
|
||||
# 模糊匹配
|
||||
workflows = self._fetch_workflows()
|
||||
if not workflows:
|
||||
return "❌ 获取工作流列表失败"
|
||||
|
||||
matched = None
|
||||
name_lower = name.strip().lower()
|
||||
for wf in workflows:
|
||||
wf_path = wf.get("path", "")
|
||||
if name_lower == wf_path.lower():
|
||||
matched = wf_path
|
||||
break
|
||||
if name_lower in wf_path.lower():
|
||||
matched = wf_path
|
||||
|
||||
if not matched:
|
||||
# 尝试数字索引
|
||||
try:
|
||||
idx = int(name.strip()) - 1
|
||||
if 0 <= idx < len(workflows):
|
||||
matched = workflows[idx].get("path", "")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not matched:
|
||||
return f"❌ 未找到匹配的工作流: {name}\n💡 先用 'ai_image workflows' 查看列表"
|
||||
|
||||
self.current_workflow = matched
|
||||
return f"✅ 已切换工作流: {matched}"
|
||||
|
||||
def _cmd_set_width(self, val):
|
||||
"""设置宽度"""
|
||||
try:
|
||||
w = int(val.strip())
|
||||
if w < 64 or w > 1344:
|
||||
return "❌ 宽度范围: 64 ~ 1344"
|
||||
self.width = w
|
||||
return f"✅ 宽度已设置为: {w}"
|
||||
except ValueError:
|
||||
return "❌ 请输入有效数字"
|
||||
|
||||
def _cmd_set_height(self, val):
|
||||
"""设置高度"""
|
||||
try:
|
||||
h = int(val.strip())
|
||||
if h < 64 or h > 1344:
|
||||
return "❌ 高度范围: 64 ~ 1344"
|
||||
self.height = h
|
||||
return f"✅ 高度已设置为: {h}"
|
||||
except ValueError:
|
||||
return "❌ 请输入有效数字"
|
||||
|
||||
def _cmd_set_size(self, val):
|
||||
"""同时设置宽高"""
|
||||
parts = val.strip().split()
|
||||
if len(parts) < 2:
|
||||
return "❌ 用法: ai_image size <宽> <高>"
|
||||
try:
|
||||
w, h = int(parts[0]), int(parts[1])
|
||||
if w < 64 or w > 1344 or h < 64 or h > 1344:
|
||||
return "❌ 宽高范围: 64 ~ 1344"
|
||||
self.width = w
|
||||
self.height = h
|
||||
return f"✅ 尺寸已设置为: {w} x {h}"
|
||||
except ValueError:
|
||||
return "❌ 请输入有效数字"
|
||||
|
||||
def _cmd_set_rewrite(self, val):
|
||||
"""设置改写模式"""
|
||||
val = val.strip().lower()
|
||||
if val in ["on", "开", "1", "true", "yes"]:
|
||||
self.rewrite = True
|
||||
return "✅ 改写模式已开启 — LLM 将智能重写 prompt"
|
||||
elif val in ["off", "关", "0", "false", "no"]:
|
||||
self.rewrite = False
|
||||
return "✅ 改写模式已关闭 — 普通翻译/追加模式"
|
||||
else:
|
||||
self.rewrite = not self.rewrite
|
||||
status = "开启" if self.rewrite else "关闭"
|
||||
return f"✅ 改写模式已{status}"
|
||||
|
||||
def _cmd_set_override(self, val):
|
||||
"""设置覆写模式"""
|
||||
val = val.strip().lower()
|
||||
if val in ["on", "开", "1", "true", "yes"]:
|
||||
self.override = True
|
||||
return "✅ 覆写模式已开启 — 忽略工作流内置 prompt"
|
||||
elif val in ["off", "关", "0", "false", "no"]:
|
||||
self.override = False
|
||||
return "✅ 覆写模式已关闭 — 保留工作流内置 prompt"
|
||||
else:
|
||||
self.override = not self.override
|
||||
status = "开启" if self.override else "关闭"
|
||||
return f"✅ 覆写模式已{status}"
|
||||
|
||||
def _cmd_status(self):
|
||||
"""查看当前状态"""
|
||||
result = " AI 生图状态:\n\n"
|
||||
result += f" 工作流: {self.current_workflow or '(默认)'}\n"
|
||||
result += f" 直接 Tag: {self.direct_prompt or '(未设置)'}\n"
|
||||
result += f" 自然语言: {self.nl_prompt or '(未设置)'}\n"
|
||||
result += f" 改写模式: {'✅ 开启' if self.rewrite else '❌ 关闭'}\n"
|
||||
result += f" 覆写模式: {'✅ 开启' if self.override else '❌ 关闭'}\n"
|
||||
result += f" 宽度: {self.width or '(默认)'}\n"
|
||||
result += f" 高度: {self.height or '(默认)'}\n"
|
||||
result += f" 保存目录: {self.save_dir}\n"
|
||||
result += f" 生成中: {'是' if self.is_generating else '否'}\n"
|
||||
return result
|
||||
|
||||
# ── 核心生图逻辑 ──
|
||||
|
||||
def _fetch_workflows(self):
|
||||
"""获取工作流列表"""
|
||||
if self.workflows_cache:
|
||||
return self.workflows_cache
|
||||
|
||||
try:
|
||||
resp = requests.get(f"{self.API_URL}/workflows", timeout=15)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
self.workflows_cache = data.get("workflows", [])
|
||||
return self.workflows_cache
|
||||
except Exception as e:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _run_generation(self):
|
||||
"""执行图片生成"""
|
||||
if self.is_generating:
|
||||
return "⚠️ 已有生成任务在进行中,请等待完成"
|
||||
|
||||
if not self.direct_prompt and not self.nl_prompt:
|
||||
return "❌ 请提供至少一种 prompt(直接 Tag 或自然语言描述)"
|
||||
|
||||
self.is_generating = True
|
||||
result_lines = []
|
||||
image_paths = []
|
||||
|
||||
try:
|
||||
# 获取默认工作流(如果未指定则使用列表中第一个)
|
||||
workflow = self.current_workflow
|
||||
if not workflow:
|
||||
workflows = self._fetch_workflows()
|
||||
if workflows:
|
||||
# 优先选 "无Lora" 工作流,否则用第一个
|
||||
for wf in workflows:
|
||||
if "无lora" in wf.get("path", "").lower():
|
||||
workflow = wf["path"]
|
||||
break
|
||||
if not workflow:
|
||||
workflow = workflows[0].get("path", "")
|
||||
|
||||
# 构建 payload
|
||||
payload = {
|
||||
"workflow_path": workflow or "",
|
||||
"inline_workflow": None,
|
||||
"direct_prompt": self.direct_prompt,
|
||||
"nl_prompt": self.nl_prompt,
|
||||
"rewrite": self.rewrite,
|
||||
"override": self.override,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
result_lines.append(" 正在连接 ai.2x.nz ...")
|
||||
result_lines.append(f" 直接 Tag: {self.direct_prompt[:80] or '(无)'}")
|
||||
result_lines.append(f" 自然语言: {self.nl_prompt[:80] or '(无)'}")
|
||||
result_lines.append(f" 工作流: {workflow or '(默认)'}")
|
||||
result_lines.append("")
|
||||
|
||||
# 使用同步方式通过 REST + 轮询获取结果
|
||||
# 由于 websocket-client 可能未安装,提供降级方案
|
||||
if WEBSOCKET_AVAILABLE:
|
||||
images = self._generate_via_ws(payload, result_lines)
|
||||
else:
|
||||
# 降级:尝试使用 requests + SSE 或提示安装
|
||||
result_lines.append("⚠️ websocket-client 未安装,尝试安装...")
|
||||
images = self._generate_via_ws_fallback(payload, result_lines)
|
||||
|
||||
if images:
|
||||
image_paths = images
|
||||
result_lines.append(f"\n✅ 生成完成!共 {len(images)} 张图片")
|
||||
for i, path in enumerate(images, 1):
|
||||
result_lines.append(f" {i}. {path}")
|
||||
else:
|
||||
result_lines.append("\n❌ 未生成任何图片")
|
||||
|
||||
except Exception as e:
|
||||
result_lines.append(f"\n❌ 生成失败: {str(e)}")
|
||||
finally:
|
||||
self.is_generating = False
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
def _generate_via_ws(self, payload, result_lines):
|
||||
"""通过 WebSocket 生成图片"""
|
||||
images = []
|
||||
final_prompt = ""
|
||||
error_msg = None
|
||||
|
||||
ws_result = {"images": [], "error": None, "final_prompt": "", "done": False}
|
||||
|
||||
def on_message(ws, message):
|
||||
try:
|
||||
m = json.loads(message)
|
||||
msg_type = m.get("type", "")
|
||||
|
||||
if msg_type == "log":
|
||||
result_lines.append(f" {m.get('message', '')}")
|
||||
elif msg_type == "llm_start":
|
||||
result_lines.append(" LLM 正在处理 prompt...")
|
||||
elif msg_type == "llm_chunk":
|
||||
pass # 流式输出,不逐块显示
|
||||
elif msg_type == "llm_done":
|
||||
result_lines.append(" LLM 处理完成")
|
||||
elif msg_type == "progress":
|
||||
node = m.get("node", "")
|
||||
value = m.get("value", 0)
|
||||
max_val = m.get("max", 0)
|
||||
if max_val and max_val > 1:
|
||||
pct = int(value * 100 / max_val)
|
||||
result_lines.append(f"⏳ 进度: {node} {value}/{max_val} ({pct}%)")
|
||||
else:
|
||||
result_lines.append(f"⏳ 执行: {node}")
|
||||
elif msg_type == "prompt_id":
|
||||
fp = m.get("final_prompt", "")
|
||||
if fp:
|
||||
ws_result["final_prompt"] = fp
|
||||
result_lines.append(f"\n 最终 Prompt:\n{fp[:300]}{'...' if len(fp) > 300 else ''}\n")
|
||||
elif msg_type == "image":
|
||||
url = m.get("url", "")
|
||||
filename = m.get("filename", "")
|
||||
if url:
|
||||
# 下载图片
|
||||
saved = self._download_image(url, filename)
|
||||
if saved:
|
||||
ws_result["images"].append(saved)
|
||||
result_lines.append(f" 收到图片: {filename}")
|
||||
elif msg_type == "done":
|
||||
count = m.get("count", 0)
|
||||
result_lines.append(f"✅ 服务端完成,共 {count} 张")
|
||||
ws_result["done"] = True
|
||||
ws.close()
|
||||
elif msg_type == "error":
|
||||
ws_result["error"] = m.get("message", "未知错误")
|
||||
result_lines.append(f"❌ 错误: {ws_result['error']}")
|
||||
ws.close()
|
||||
except Exception as e:
|
||||
result_lines.append(f"⚠️ 解析消息出错: {e}")
|
||||
|
||||
def on_error(ws, error):
|
||||
ws_result["error"] = str(error)
|
||||
result_lines.append(f"❌ WebSocket 错误: {error}")
|
||||
|
||||
def on_close(ws, close_status_code, close_msg):
|
||||
ws_result["done"] = True
|
||||
|
||||
def on_open(ws):
|
||||
ws.send(json.dumps(payload))
|
||||
|
||||
try:
|
||||
ws = websocket.WebSocketApp(
|
||||
self.WS_URL,
|
||||
on_message=on_message,
|
||||
on_error=on_error,
|
||||
on_close=on_close,
|
||||
on_open=on_open
|
||||
)
|
||||
|
||||
# 在独立线程中运行 WebSocket
|
||||
ws_thread = threading.Thread(target=ws.run_forever, kwargs={"ping_timeout": 10})
|
||||
ws_thread.daemon = True
|
||||
ws_thread.start()
|
||||
|
||||
# 等待完成(最多 5 分钟)
|
||||
timeout = 300
|
||||
start = time.time()
|
||||
while not ws_result["done"] and (time.time() - start) < timeout:
|
||||
time.sleep(1)
|
||||
|
||||
if ws_result["error"]:
|
||||
raise Exception(ws_result["error"])
|
||||
|
||||
if not ws_result["done"]:
|
||||
ws.close()
|
||||
raise Exception("生成超时(超过 5 分钟)")
|
||||
|
||||
return ws_result["images"]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"WebSocket 连接失败: {e}")
|
||||
|
||||
def _generate_via_ws_fallback(self, payload, result_lines):
|
||||
"""降级方案:尝试自动安装 websocket-client"""
|
||||
try:
|
||||
import subprocess
|
||||
result_lines.append(" 正在安装 websocket-client ...")
|
||||
subprocess.run(
|
||||
["pip", "install", "websocket-client"],
|
||||
capture_output=True, timeout=60
|
||||
)
|
||||
import importlib
|
||||
global websocket
|
||||
websocket = importlib.import_module("websocket")
|
||||
global WEBSOCKET_AVAILABLE
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
result_lines.append("✅ websocket-client 安装成功")
|
||||
return self._generate_via_ws(payload, result_lines)
|
||||
except Exception as e:
|
||||
result_lines.append(f"❌ 无法安装 websocket-client: {e}")
|
||||
result_lines.append("💡 请手动运行: pip install websocket-client")
|
||||
return []
|
||||
|
||||
def _download_image(self, url, filename):
|
||||
"""下载图片到本地"""
|
||||
try:
|
||||
# 确保 URL 是完整的
|
||||
if url.startswith("/"):
|
||||
url = f"{self.BASE_URL}{url}"
|
||||
elif not url.startswith("http"):
|
||||
url = f"{self.BASE_URL}/api/output/file?path={quote(url)}"
|
||||
|
||||
# 创建保存目录
|
||||
os.makedirs(self.save_dir, exist_ok=True)
|
||||
|
||||
# 生成文件名
|
||||
if not filename:
|
||||
filename = f"img_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
|
||||
# 清理文件名
|
||||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
save_path = os.path.join(self.save_dir, filename)
|
||||
|
||||
# 如果文件已存在,添加序号
|
||||
if os.path.exists(save_path):
|
||||
name, ext = os.path.splitext(filename)
|
||||
for i in range(1, 100):
|
||||
new_path = os.path.join(self.save_dir, f"{name}_{i}{ext}")
|
||||
if not os.path.exists(new_path):
|
||||
save_path = new_path
|
||||
break
|
||||
|
||||
# 下载
|
||||
resp = requests.get(url, timeout=60, stream=True)
|
||||
if resp.status_code == 200:
|
||||
with open(save_path, 'wb') as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
return save_path
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
# 兼容 Liugin 类名
|
||||
@@ -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
|
||||
@@ -1,489 +0,0 @@
|
||||
"""
|
||||
书签管理器插件 - 管理开发相关链接和资源
|
||||
支持分类、标签、搜索、导入导出,JSON 持久化
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""书签管理器 - 开发资源和链接管理"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """书签管理器
|
||||
|
||||
操作:
|
||||
add <URL> -n <名称> [-c 分类] [-t 标签1,标签2] [-d 描述]
|
||||
- 添加书签
|
||||
list [-c 分类] [-t 标签] - 列出书签
|
||||
open <名称或ID> - 获取书签 URL
|
||||
search <关键词> - 搜索书签
|
||||
delete <名称或ID> - 删除书签
|
||||
edit <名称或ID> [-n 名称] [-d 描述] [-c 分类] [-t 标签]
|
||||
- 编辑书签
|
||||
categories - 列出所有分类
|
||||
tags - 列出所有标签
|
||||
export [文件] - 导出书签
|
||||
import <文件> - 导入书签
|
||||
stats - 统计信息
|
||||
recent [-n 数量] - 最近添加的书签
|
||||
check - 检查书签是否可访问
|
||||
favicon <URL> - 获取网站 favicon URL
|
||||
|
||||
示例:
|
||||
bookmark_manager add https://github.com -n GitHub -c 开发 -t 代码,托管 -d 代码托管平台
|
||||
bookmark_manager list -c 开发
|
||||
bookmark_manager search github
|
||||
bookmark_manager open GitHub
|
||||
bookmark_manager categories
|
||||
"""
|
||||
self.cli = None
|
||||
self.bookmarks_file = "bookmarks.json"
|
||||
self.bookmarks = []
|
||||
self._load_bookmarks()
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "bookmark_manager",
|
||||
"description": "书签管理器 - 管理开发相关链接和资源,支持分类、标签、搜索",
|
||||
"keywords": ["书签", "链接", "收藏", "bookmark", "网址", "资源", "网站", "URL"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "bookmark_manager",
|
||||
"description": "书签管理器,支持添加、搜索、获取、删除书签",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["add", "list", "open", "search", "delete",
|
||||
"edit", "categories", "tags", "export",
|
||||
"import", "stats", "recent", "check"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"url": {"type": "string", "description": "URL"},
|
||||
"name": {"type": "string", "description": "书签名称"},
|
||||
"category": {"type": "string", "description": "分类"},
|
||||
"tags": {"type": "string", "description": "标签(逗号分隔)"},
|
||||
"description": {"type": "string", "description": "描述"},
|
||||
"keyword": {"type": "string", "description": "搜索关键词"},
|
||||
"file": {"type": "string", "description": "文件路径"},
|
||||
"count": {"type": "integer", "description": "数量"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
name = arguments.get("name", "")
|
||||
return f"{op} {name}".strip()
|
||||
|
||||
def _load_bookmarks(self):
|
||||
try:
|
||||
if os.path.exists(self.bookmarks_file):
|
||||
with open(self.bookmarks_file, 'r', encoding='utf-8') as f:
|
||||
self.bookmarks = json.load(f)
|
||||
except Exception:
|
||||
self.bookmarks = []
|
||||
|
||||
def _save_bookmarks(self):
|
||||
try:
|
||||
with open(self.bookmarks_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.bookmarks, f, ensure_ascii=False, indent=2)
|
||||
return None
|
||||
except Exception as e:
|
||||
return f"保存失败: {e}"
|
||||
|
||||
def _gen_id(self, url: str) -> str:
|
||||
return hashlib.md5(f"{url}{time.time()}".encode()).hexdigest()[:8]
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return self._op_list([])
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"add": self._op_add,
|
||||
"list": self._op_list,
|
||||
"open": self._op_open,
|
||||
"search": self._op_search,
|
||||
"delete": self._op_delete,
|
||||
"edit": self._op_edit,
|
||||
"categories": self._op_categories,
|
||||
"tags": self._op_tags,
|
||||
"export": self._op_export,
|
||||
"import": self._op_import,
|
||||
"stats": self._op_stats,
|
||||
"recent": self._op_recent,
|
||||
"check": self._op_check,
|
||||
"favicon": self._op_favicon,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"书签管理错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _op_add(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 URL"
|
||||
url = args[0]
|
||||
name, category, tags, desc = "", "", [], ""
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "-n" and i + 1 < len(args):
|
||||
name = args[i + 1]; i += 1
|
||||
elif args[i] == "-c" and i + 1 < len(args):
|
||||
category = args[i + 1]; i += 1
|
||||
elif args[i] == "-t" and i + 1 < len(args):
|
||||
tags = [t.strip() for t in args[i + 1].split(",") if t.strip()]; i += 1
|
||||
elif args[i] == "-d" and i + 1 < len(args):
|
||||
desc = args[i + 1]; i += 1
|
||||
i += 1
|
||||
|
||||
if not name:
|
||||
# 从 URL 推断名称
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url if '://' in url else 'https://' + url)
|
||||
name = parsed.netloc.replace('www.', '').split('.')[0].capitalize()
|
||||
url = parsed.geturl()
|
||||
|
||||
# 检查重复
|
||||
for b in self.bookmarks:
|
||||
if b["url"] == url:
|
||||
return f"错误:URL 已存在 (书签: {b['name']})"
|
||||
|
||||
bookmark = {
|
||||
"id": self._gen_id(url),
|
||||
"name": name,
|
||||
"url": url,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"description": desc,
|
||||
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"access_count": 0
|
||||
}
|
||||
self.bookmarks.append(bookmark)
|
||||
err = self._save_bookmarks()
|
||||
if err:
|
||||
return err
|
||||
return f"✅ 已添加书签 '{name}'\n URL: {url}\n 分类: {category or '未分类'}"
|
||||
|
||||
def _op_list(self, args: List[str]) -> str:
|
||||
cat_filter, tag_filter = None, None
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "-c" and i + 1 < len(args):
|
||||
cat_filter = args[i + 1].lower(); i += 1
|
||||
elif args[i] == "-t" and i + 1 < len(args):
|
||||
tag_filter = args[i + 1].lower(); i += 1
|
||||
i += 1
|
||||
|
||||
filtered = self.bookmarks
|
||||
if cat_filter:
|
||||
filtered = [b for b in filtered if b.get("category", "").lower() == cat_filter]
|
||||
if tag_filter:
|
||||
filtered = [b for b in filtered if any(tag_filter in t.lower() for t in b.get("tags", []))]
|
||||
|
||||
if not filtered:
|
||||
return "📭 没有找到书签"
|
||||
|
||||
# 按分类分组
|
||||
grouped = {}
|
||||
for b in filtered:
|
||||
cat = b.get("category", "未分类")
|
||||
grouped.setdefault(cat, []).append(b)
|
||||
|
||||
result = f"\n 书签列表 (共 {len(filtered)} 个)\n"
|
||||
result += "═" * 55 + "\n"
|
||||
|
||||
for cat, items in sorted(grouped.items()):
|
||||
result += f"\n {cat} ({len(items)} 个)\n"
|
||||
result += "─" * 45 + "\n"
|
||||
for b in items:
|
||||
tags = ", ".join(b.get("tags", [])) if b.get("tags") else ""
|
||||
result += f" [{b['id']}] {b['name']}\n"
|
||||
result += f" {b['url']}\n"
|
||||
if tags:
|
||||
result += f" ️ {tags}\n"
|
||||
if b.get("description"):
|
||||
result += f" {b['description'][:50]}\n"
|
||||
result += "\n"
|
||||
|
||||
return result.rstrip()
|
||||
|
||||
def _op_open(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供书签名称或 ID"
|
||||
key = args[0]
|
||||
bookmark = self._find_bookmark(key)
|
||||
if not bookmark:
|
||||
return f"❌ 未找到书签: {key}"
|
||||
|
||||
bookmark["access_count"] = bookmark.get("access_count", 0) + 1
|
||||
self._save_bookmarks()
|
||||
|
||||
return (f" {bookmark['name']}\n"
|
||||
f" URL: {bookmark['url']}\n"
|
||||
f" 访问次数: {bookmark.get('access_count', 0)}")
|
||||
|
||||
def _op_search(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供搜索关键词"
|
||||
keyword = " ".join(args).lower()
|
||||
|
||||
results = []
|
||||
for b in self.bookmarks:
|
||||
searchable = f"{b['name']} {b['url']} {b.get('description', '')} {' '.join(b.get('tags', []))} {b.get('category', '')}".lower()
|
||||
if keyword in searchable:
|
||||
results.append(b)
|
||||
|
||||
if not results:
|
||||
return f" 未找到匹配 '{keyword}' 的书签"
|
||||
|
||||
output = f" 搜索结果 ({len(results)} 个):\n"
|
||||
output += "─" * 50 + "\n"
|
||||
for b in results:
|
||||
output += f" [{b['id']}] {b['name']} - {b['url']}\n"
|
||||
if b.get("description"):
|
||||
output += f" {b['description'][:40]}\n"
|
||||
return output.rstrip()
|
||||
|
||||
def _op_delete(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供书签名称或 ID"
|
||||
key = args[0]
|
||||
for i, b in enumerate(self.bookmarks):
|
||||
if b["name"].lower() == key.lower() or b["id"] == key:
|
||||
removed = self.bookmarks.pop(i)
|
||||
self._save_bookmarks()
|
||||
return f" ️ 已删除书签: {removed['name']} ({removed['url']})"
|
||||
return f"❌ 未找到书签: {key}"
|
||||
|
||||
def _op_edit(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供书签名称或 ID"
|
||||
key = args[0]
|
||||
bookmark = self._find_bookmark(key)
|
||||
if not bookmark:
|
||||
return f"❌ 未找到书签: {key}"
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "-n" and i + 1 < len(args):
|
||||
bookmark["name"] = args[i + 1]; i += 1
|
||||
elif args[i] == "-d" and i + 1 < len(args):
|
||||
bookmark["description"] = args[i + 1]; i += 1
|
||||
elif args[i] == "-c" and i + 1 < len(args):
|
||||
bookmark["category"] = args[i + 1]; i += 1
|
||||
elif args[i] == "-t" and i + 1 < len(args):
|
||||
bookmark["tags"] = [t.strip() for t in args[i + 1].split(",") if t.strip()]; i += 1
|
||||
i += 1
|
||||
|
||||
bookmark["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self._save_bookmarks()
|
||||
return f"✅ 已更新书签: {bookmark['name']}"
|
||||
|
||||
def _op_categories(self, args: List[str]) -> str:
|
||||
cat_count = {}
|
||||
for b in self.bookmarks:
|
||||
cat = b.get("category", "未分类")
|
||||
cat_count[cat] = cat_count.get(cat, 0) + 1
|
||||
|
||||
if not cat_count:
|
||||
return " 暂无分类"
|
||||
|
||||
result = " 分类列表:\n"
|
||||
for cat, count in sorted(cat_count.items(), key=lambda x: -x[1]):
|
||||
bar = "█" * min(count, 20)
|
||||
result += f" {cat:15s} {bar} ({count})\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_tags(self, args: List[str]) -> str:
|
||||
tag_count = {}
|
||||
for b in self.bookmarks:
|
||||
for t in b.get("tags", []):
|
||||
tag_count[t] = tag_count.get(t, 0) + 1
|
||||
|
||||
if not tag_count:
|
||||
return " ️ 暂无标签"
|
||||
|
||||
result = " ️ 标签列表:\n"
|
||||
for tag, count in sorted(tag_count.items(), key=lambda x: -x[1]):
|
||||
result += f" {tag} ({count})\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_export(self, args: List[str]) -> str:
|
||||
output_file = args[0] if args else "bookmarks_export.json"
|
||||
try:
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.bookmarks, f, ensure_ascii=False, indent=2)
|
||||
return f"✅ 已导出 {len(self.bookmarks)} 个书签到: {output_file}"
|
||||
except Exception as e:
|
||||
return f"❌ 导出失败: {e}"
|
||||
|
||||
def _op_import(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供导入文件路径"
|
||||
file_path = args[0]
|
||||
if not os.path.isfile(file_path):
|
||||
return f"❌ 文件不存在: {file_path}"
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
imported = json.load(f)
|
||||
if not isinstance(imported, list):
|
||||
return "❌ 文件格式错误:需要 JSON 数组"
|
||||
|
||||
existing_urls = {b["url"] for b in self.bookmarks}
|
||||
added = 0
|
||||
for b in imported:
|
||||
if isinstance(b, dict) and b.get("url") and b["url"] not in existing_urls:
|
||||
b["id"] = self._gen_id(b["url"])
|
||||
self.bookmarks.append(b)
|
||||
existing_urls.add(b["url"])
|
||||
added += 1
|
||||
|
||||
self._save_bookmarks()
|
||||
return f"✅ 已导入 {added} 个书签 (跳过 {len(imported) - added} 个重复)"
|
||||
except Exception as e:
|
||||
return f"❌ 导入失败: {e}"
|
||||
|
||||
def _op_stats(self, args: List[str]) -> str:
|
||||
total = len(self.bookmarks)
|
||||
if not total:
|
||||
return "📊 暂无书签"
|
||||
|
||||
cat_count = {}
|
||||
total_access = 0
|
||||
for b in self.bookmarks:
|
||||
cat = b.get("category", "未分类")
|
||||
cat_count[cat] = cat_count.get(cat, 0) + 1
|
||||
total_access += b.get("access_count", 0)
|
||||
|
||||
most_accessed = max(self.bookmarks, key=lambda b: b.get("access_count", 0))
|
||||
|
||||
result = f"📊 书签统计\n"
|
||||
result += "─" * 35 + "\n"
|
||||
result += f" 总数: {total}\n"
|
||||
result += f" 总访问: {total_access} 次\n"
|
||||
result += f" 分类数: {len(cat_count)}\n"
|
||||
result += f" 最常访问: {most_accessed['name']} ({most_accessed.get('access_count', 0)} 次)\n"
|
||||
result += f"\n 分类分布:\n"
|
||||
for cat, count in sorted(cat_count.items(), key=lambda x: -x[1]):
|
||||
result += f" {cat}: {count}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_recent(self, args: List[str]) -> str:
|
||||
count = 5
|
||||
for i, a in enumerate(args):
|
||||
if a == "-n" and i + 1 < len(args):
|
||||
count = int(args[i + 1])
|
||||
|
||||
recent = sorted(self.bookmarks, key=lambda b: b.get("created_at", ""), reverse=True)[:count]
|
||||
if not recent:
|
||||
return "📭 暂无书签"
|
||||
|
||||
result = f"⏰ 最近 {len(recent)} 个书签:\n"
|
||||
for b in recent:
|
||||
result += f" [{b['id']}] {b['name']} - {b.get('created_at', '')}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_check(self, args: List[str]) -> str:
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
bookmarks = self.bookmarks
|
||||
if args:
|
||||
# 检查指定书签
|
||||
bookmark = self._find_bookmark(args[0])
|
||||
if not bookmark:
|
||||
return f"❌ 未找到书签: {args[0]}"
|
||||
bookmarks = [bookmark]
|
||||
|
||||
result = f" ️ 书签可访问性检查 ({len(bookmarks)} 个):\n"
|
||||
result += "─" * 50 + "\n"
|
||||
|
||||
for b in bookmarks[:20]:
|
||||
url = b["url"]
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = parsed.netloc or parsed.path.split('/')[0]
|
||||
socket.create_connection((host, 80), timeout=3)
|
||||
result += f" {b['name']} ({host})\n"
|
||||
except (socket.timeout, socket.error, OSError):
|
||||
result += f" {b['name']} ({host}) - 不可达\n"
|
||||
|
||||
if len(bookmarks) > 20:
|
||||
result += f" ... 仅检查前 20 个\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_favicon(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供 URL"
|
||||
url = args[0]
|
||||
if '://' not in url:
|
||||
url = 'https://' + url
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
favicon_url = f"{base}/favicon.ico"
|
||||
|
||||
return (f" Favicon:\n"
|
||||
f" 网站: {base}\n"
|
||||
f" Favicon: {favicon_url}\n"
|
||||
f" Google: https://www.google.com/s2/favicons?domain={parsed.netloc}&sz=64")
|
||||
|
||||
def _find_bookmark(self, key: str) -> Optional[dict]:
|
||||
for b in self.bookmarks:
|
||||
if b["name"].lower() == key.lower() or b["id"] == key:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("书签管理器测试:")
|
||||
print(plugin.handle('add https://github.com -n GitHub -c 开发 -t 代码,托管 -d 代码托管平台'))
|
||||
print(plugin.handle('add https://stackoverflow.com -n StackOverflow -c 学习 -t 问答,编程'))
|
||||
print(plugin.handle('list'))
|
||||
print(plugin.handle('search github'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -1,459 +0,0 @@
|
||||
"""
|
||||
颜色工具插件 - 颜色格式转换、调色板、对比度检查
|
||||
支持 HEX/RGB/HSL/HSV/CMYK 格式互转,WCAG 对比度检查
|
||||
"""
|
||||
|
||||
import re
|
||||
import colorsys
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""颜色工具 - 格式转换、调色板、对比度检查"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """颜色工具
|
||||
|
||||
操作:
|
||||
convert <颜色> - 自动检测并转换为所有格式
|
||||
hex <RGB或HSL> - 转换为 HEX
|
||||
rgb <HEX或HSL> - 转换为 RGB
|
||||
hsl <HEX或RGB> - 转换为 HSL
|
||||
hsv <HEX或RGB> - 转换为 HSV
|
||||
cmyk <HEX或RGB> - 转换为 CMYK
|
||||
contrast <颜色1> <颜色2> - WCAG 对比度检查
|
||||
palette <颜色> [-n 数量] - 生成调色板(明暗变体)
|
||||
complementary <颜色> - 互补色
|
||||
analogous <颜色> - 类似色
|
||||
triadic <颜色> - 三色组
|
||||
random [-n 数量] - 随机颜色
|
||||
mix <颜色1> <颜色2> [-w 权重] - 混合两种颜色
|
||||
blind <颜色> <类型> - 色盲模拟
|
||||
named - 列出 CSS 命名颜色
|
||||
|
||||
颜色格式:
|
||||
HEX: #FF6600, #F60, FF6600
|
||||
RGB: rgb(255,102,0) 或 255,102,0
|
||||
HSL: hsl(24,100%,50%)
|
||||
HSV: hsv(24,100%,100%)
|
||||
CMYK: cmyk(0,60,100,0)
|
||||
|
||||
示例:
|
||||
color_picker convert "#3498db"
|
||||
color_picker rgb "hsl(207,62%,54%)"
|
||||
color_picker contrast "#FFFFFF" "#000000"
|
||||
color_picker palette "#e74c3c" -n 9
|
||||
color_picker complementary "#3498db"
|
||||
"""
|
||||
self.cli = None
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "color_picker",
|
||||
"description": "颜色工具 - HEX/RGB/HSL/HSV/CMYK 转换、对比度检查、调色板生成",
|
||||
"keywords": ["颜色", "color", "HEX", "RGB", "HSL", "调色板", "对比度", "色盲", "转换"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "color_picker",
|
||||
"description": "颜色工具,支持格式转换、对比度检查、调色板生成",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["convert", "hex", "rgb", "hsl", "hsv", "cmyk",
|
||||
"contrast", "palette", "complementary", "analogous",
|
||||
"triadic", "random", "mix", "blind"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"color": {"type": "string", "description": "颜色值"},
|
||||
"color2": {"type": "string", "description": "第二个颜色值"},
|
||||
"count": {"type": "integer", "description": "数量"},
|
||||
"weight": {"type": "number", "description": "混合权重 (0-1)"},
|
||||
"blind_type": {"type": "string", "description": "色盲类型"}
|
||||
},
|
||||
"required": ["operation"]
|
||||
}
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
color = arguments.get("color", "")
|
||||
return f"{op} {color}".strip()
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = self._parse_args(args)
|
||||
if not parts:
|
||||
return self._op_random(["1"])
|
||||
|
||||
operation = parts[0].lower()
|
||||
handlers = {
|
||||
"convert": self._op_convert,
|
||||
"hex": self._op_hex,
|
||||
"rgb": self._op_rgb,
|
||||
"hsl": self._op_hsl,
|
||||
"hsv": self._op_hsv,
|
||||
"cmyk": self._op_cmyk,
|
||||
"contrast": self._op_contrast,
|
||||
"palette": self._op_palette,
|
||||
"complementary": self._op_complementary,
|
||||
"analogous": self._op_analogous,
|
||||
"triadic": self._op_triadic,
|
||||
"random": self._op_random,
|
||||
"mix": self._op_mix,
|
||||
"blind": self._op_blind,
|
||||
}
|
||||
|
||||
handler = handlers.get(operation)
|
||||
if not handler:
|
||||
return f"错误:不支持的操作 '{operation}'"
|
||||
return handler(parts[1:])
|
||||
|
||||
except Exception as e:
|
||||
return f"颜色工具错误: {str(e)}"
|
||||
|
||||
def _parse_args(self, args: str) -> List[str]:
|
||||
parts, current, in_quotes, qc = [], "", False, None
|
||||
for ch in args:
|
||||
if ch in ('"', "'") and not in_quotes:
|
||||
in_quotes, qc = True, ch
|
||||
elif ch == qc and in_quotes:
|
||||
in_quotes, qc = False, None
|
||||
elif ch == ' ' and not in_quotes:
|
||||
if current:
|
||||
parts.append(current)
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
if current:
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _parse_opts(self, args: List[str]) -> dict:
|
||||
opts = {}
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i].startswith("-") and i + 1 < len(args):
|
||||
key = args[i].lstrip("-")
|
||||
opts[key] = args[i + 1]
|
||||
i += 1
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
def _parse_color(self, color_str: str) -> Tuple[int, int, int]:
|
||||
"""解析颜色字符串为 RGB (0-255)"""
|
||||
s = color_str.strip().lower()
|
||||
|
||||
# HEX
|
||||
hex_match = re.match(r'^#?([0-9a-f]{3,8})$', s)
|
||||
if hex_match:
|
||||
h = hex_match.group(1)
|
||||
if len(h) == 3:
|
||||
h = h[0]*2 + h[1]*2 + h[2]*2
|
||||
elif len(h) == 8:
|
||||
h = h[:6] # ignore alpha
|
||||
if len(h) == 6:
|
||||
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||
|
||||
# RGB
|
||||
rgb_match = re.match(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)', s)
|
||||
if rgb_match:
|
||||
return (int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3)))
|
||||
|
||||
# plain r,g,b
|
||||
plain_match = re.match(r'^(\d+)\s*,\s*(\d+)\s*,\s*(\d+)$', s)
|
||||
if plain_match:
|
||||
return (int(plain_match.group(1)), int(plain_match.group(2)), int(plain_match.group(3)))
|
||||
|
||||
# HSL
|
||||
hsl_match = re.match(r'hsl\s*\(\s*(\d+)\s*,\s*(\d+)%,\s*(\d+)%\s*\)', s)
|
||||
if hsl_match:
|
||||
h = int(hsl_match.group(1)) / 360
|
||||
sl = int(hsl_match.group(2)) / 100
|
||||
l = int(hsl_match.group(3)) / 100
|
||||
r, g, b = colorsys.hls_to_rgb(h, l, sl)
|
||||
return (int(r * 255), int(g * 255), int(b * 255))
|
||||
|
||||
# HSV/HSB
|
||||
hsv_match = re.match(r'hsv\s*\(\s*(\d+)\s*,\s*(\d+)%,\s*(\d+)%\s*\)', s)
|
||||
if hsv_match:
|
||||
h = int(hsv_match.group(1)) / 360
|
||||
sv = int(hsv_match.group(2)) / 100
|
||||
v = int(hsv_match.group(3)) / 100
|
||||
r, g, b = colorsys.hsv_to_rgb(h, sv, v)
|
||||
return (int(r * 255), int(g * 255), int(b * 255))
|
||||
|
||||
# CMYK
|
||||
cmyk_match = re.match(r'cmyk\s*\(\s*(\d+)%?\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)', s)
|
||||
if cmyk_match:
|
||||
c = int(cmyk_match.group(1)) / 100
|
||||
m = int(cmyk_match.group(2)) / 100
|
||||
y = int(cmyk_match.group(3)) / 100
|
||||
k = int(cmyk_match.group(4)) / 100
|
||||
r = int(255 * (1 - c) * (1 - k))
|
||||
g = int(255 * (1 - m) * (1 - k))
|
||||
b = int(255 * (1 - y) * (1 - k))
|
||||
return (r, g, b)
|
||||
|
||||
raise ValueError(f"无法解析颜色: {color_str}")
|
||||
|
||||
def _rgb_to_hex(self, r: int, g: int, b: int) -> str:
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
|
||||
def _rgb_to_hsl(self, r: int, g: int, b: int) -> Tuple[int, int, int]:
|
||||
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
return (int(h * 360), int(s * 100), int(l * 100))
|
||||
|
||||
def _rgb_to_hsv(self, r: int, g: int, b: int) -> Tuple[int, int, int]:
|
||||
h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255)
|
||||
return (int(h * 360), int(s * 100), int(v * 100))
|
||||
|
||||
def _rgb_to_cmyk(self, r: int, g: int, b: int) -> Tuple[int, int, int, int]:
|
||||
if r == g == b == 0:
|
||||
return (0, 0, 0, 100)
|
||||
c = 1 - r / 255
|
||||
m = 1 - g / 255
|
||||
y = 1 - b / 255
|
||||
k = min(c, m, y)
|
||||
c = int((c - k) / (1 - k) * 100)
|
||||
m = int((m - k) / (1 - k) * 100)
|
||||
y = int((y - k) / (1 - k) * 100)
|
||||
k = int(k * 100)
|
||||
return (c, m, y, k)
|
||||
|
||||
def _luminance(self, r: int, g: int, b: int) -> float:
|
||||
"""计算相对亮度 (WCAG 2.0)"""
|
||||
def linearize(c):
|
||||
c = c / 255
|
||||
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
||||
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
||||
|
||||
def _format_color_block(self, r: int, g: int, b: int) -> str:
|
||||
"""生成颜色预览块"""
|
||||
return f" ■ RGB({r}, {g}, {b})"
|
||||
|
||||
def _op_convert(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色值"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
h, s, l = self._rgb_to_hsl(r, g, b)
|
||||
hh, sv, vv = self._rgb_to_hsv(r, g, b)
|
||||
c, m, y, k = self._rgb_to_cmyk(r, g, b)
|
||||
|
||||
result = f" 颜色转换: {args[0]}\n"
|
||||
result += self._format_color_block(r, g, b) + "\n"
|
||||
result += "─" * 35 + "\n"
|
||||
result += f" HEX: {self._rgb_to_hex(r, g, b)}\n"
|
||||
result += f" RGB: rgb({r}, {g}, {b})\n"
|
||||
result += f" HSL: hsl({h}, {s}%, {l}%)\n"
|
||||
result += f" HSV: hsv({hh}, {sv}%, {vv}%)\n"
|
||||
result += f" CMYK: cmyk({c}%, {m}%, {y}%, {k}%)\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_hex(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色值"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
return f" HEX: {self._rgb_to_hex(r, g, b)}"
|
||||
|
||||
def _op_rgb(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色值"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
return f" RGB: rgb({r}, {g}, {b})"
|
||||
|
||||
def _op_hsl(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色值"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
h, s, l = self._rgb_to_hsl(r, g, b)
|
||||
return f" HSL: hsl({h}, {s}%, {l}%)"
|
||||
|
||||
def _op_hsv(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色值"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
h, s, v = self._rgb_to_hsv(r, g, b)
|
||||
return f" HSV: hsv({h}, {s}%, {v}%)"
|
||||
|
||||
def _op_cmyk(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色值"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
c, m, y, k = self._rgb_to_cmyk(r, g, b)
|
||||
return f" CMYK: cmyk({c}%, {m}%, {y}%, {k}%)"
|
||||
|
||||
def _op_contrast(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:请提供两种颜色"
|
||||
r1, g1, b1 = self._parse_color(args[0])
|
||||
r2, g2, b2 = self._parse_color(args[1])
|
||||
|
||||
l1 = self._luminance(r1, g1, b1)
|
||||
l2 = self._luminance(r2, g2, b2)
|
||||
lighter = max(l1, l2)
|
||||
darker = min(l1, l2)
|
||||
ratio = (lighter + 0.05) / (darker + 0.05)
|
||||
|
||||
aa_large = ratio >= 3
|
||||
aa_normal = ratio >= 4.5
|
||||
aaa_large = ratio >= 4.5
|
||||
aaa_normal = ratio >= 7
|
||||
|
||||
result = f" WCAG 对比度检查\n"
|
||||
result += "─" * 40 + "\n"
|
||||
result += f" 颜色1: {self._rgb_to_hex(r1, g1, b1)} {self._format_color_block(r1, g1, b1)}\n"
|
||||
result += f" 颜色2: {self._rgb_to_hex(r2, g2, b2)} {self._format_color_block(r2, g2, b1)}\n"
|
||||
result += f" 对比度: {ratio:.2f}:1\n"
|
||||
result += "─" * 40 + "\n"
|
||||
result += f" {' ' if aa_normal else '❌'} AA 正文 (≥4.5:1)\n"
|
||||
result += f" {' ' if aa_large else '❌'} AA 大字 (≥3:1)\n"
|
||||
result += f" {' ' if aaa_normal else '❌'} AAA 正文 (≥7:1)\n"
|
||||
result += f" {' ' if aaa_large else '❌'} AAA 大字 (≥4.5:1)\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_palette(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供基础颜色"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
opts = self._parse_opts(args[1:])
|
||||
count = int(opts.get("n", 9))
|
||||
count = max(3, min(count, 15))
|
||||
|
||||
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
|
||||
result = f" 调色板 (基于 {args[0]}):\n"
|
||||
result += "─" * 50 + "\n"
|
||||
|
||||
steps = count
|
||||
for i in range(steps):
|
||||
# 从暗到亮
|
||||
new_l = 0.1 + (0.8 * i / (steps - 1))
|
||||
nr, ng, nb = colorsys.hls_to_rgb(h, new_l, s)
|
||||
nr, ng, nb = int(nr * 255), int(ng * 255), int(nb * 255)
|
||||
hex_val = self._rgb_to_hex(nr, ng, nb)
|
||||
bar = "█" * int(new_l * 20)
|
||||
result += f" {hex_val} {bar} L:{int(new_l*100)}%\n"
|
||||
|
||||
return result.rstrip()
|
||||
|
||||
def _op_complementary(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
# 互补色: 色相旋转180°
|
||||
comp_h = (h + 0.5) % 1.0
|
||||
cr, cg, cb = colorsys.hls_to_rgb(comp_h, l, s)
|
||||
cr, cg, cb = int(cr * 255), int(cg * 255), int(cb * 255)
|
||||
|
||||
result = f" 互补色:\n"
|
||||
result += f" 原色: {self._rgb_to_hex(r, g, b)}\n"
|
||||
result += f" 互补: {self._rgb_to_hex(cr, cg, cb)}\n"
|
||||
return result
|
||||
|
||||
def _op_analogous(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
|
||||
result = f" 类似色:\n"
|
||||
for offset in [-30, -15, 0, 15, 30]:
|
||||
ah = (h + offset / 360) % 1.0
|
||||
ar, ag, ab = colorsys.hls_to_rgb(ah, l, s)
|
||||
ar, ag, ab = int(ar * 255), int(ag * 255), int(ab * 255)
|
||||
marker = " ◀" if offset == 0 else ""
|
||||
result += f" {self._rgb_to_hex(ar, ag, ab)} ({offset:+d}°){marker}\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_triadic(self, args: List[str]) -> str:
|
||||
if not args:
|
||||
return "错误:请提供颜色"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
|
||||
result = f" 三色组:\n"
|
||||
for i, offset in enumerate([0, 120, 240]):
|
||||
th = (h + offset / 360) % 1.0
|
||||
tr, tg, tb = colorsys.hls_to_rgb(th, l, s)
|
||||
tr, tg, tb = int(tr * 255), int(tg * 255), int(tb * 255)
|
||||
result += f" {i+1}. {self._rgb_to_hex(tr, tg, tb)} ({offset}°)\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_random(self, args: List[str]) -> str:
|
||||
opts = self._parse_opts(args)
|
||||
count = int(opts.get("n", 5))
|
||||
count = min(count, 20)
|
||||
|
||||
result = f" 随机颜色 ({count} 个):\n"
|
||||
for _ in range(count):
|
||||
r, g, b = __import__('random').randint(0, 255), __import__('random').randint(0, 255), __import__('random').randint(0, 255)
|
||||
result += f" {self._rgb_to_hex(r, g, b)} rgb({r}, {g}, {b})\n"
|
||||
return result.rstrip()
|
||||
|
||||
def _op_mix(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:请提供两种颜色"
|
||||
r1, g1, b1 = self._parse_color(args[0])
|
||||
r2, g2, b2 = self._parse_color(args[1])
|
||||
opts = self._parse_opts(args[2:])
|
||||
w = float(opts.get("w", 0.5))
|
||||
w = max(0, min(1, w))
|
||||
|
||||
mr = int(r1 * (1 - w) + r2 * w)
|
||||
mg = int(g1 * (1 - w) + g2 * w)
|
||||
mb = int(b1 * (1 - w) + b2 * w)
|
||||
|
||||
result = f" 颜色混合 (权重 {w}):\n"
|
||||
result += f" 颜色1: {self._rgb_to_hex(r1, g1, b1)}\n"
|
||||
result += f" 颜色2: {self._rgb_to_hex(r2, g2, b2)}\n"
|
||||
result += f" 混合: {self._rgb_to_hex(mr, mg, mb)}\n"
|
||||
return result
|
||||
|
||||
def _op_blind(self, args: List[str]) -> str:
|
||||
if len(args) < 2:
|
||||
return "错误:请提供颜色和色盲类型\n类型: protanopia, deuteranopia, tritanopia"
|
||||
r, g, b = self._parse_color(args[0])
|
||||
blind_type = args[1].lower()
|
||||
|
||||
# 简化的色盲模拟矩阵
|
||||
matrices = {
|
||||
"protanopia": [[0.567, 0.433, 0.0], [0.558, 0.442, 0.0], [0.0, 0.242, 0.758]],
|
||||
"deuteranopia": [[0.625, 0.375, 0.0], [0.7, 0.3, 0.0], [0.0, 0.3, 0.7]],
|
||||
"tritanopia": [[0.95, 0.05, 0.0], [0.0, 0.433, 0.567], [0.0, 0.475, 0.525]],
|
||||
}
|
||||
|
||||
matrix = matrices.get(blind_type)
|
||||
if not matrix:
|
||||
return f"错误:未知类型 '{blind_type}'\n支持: protanopia, deuteranopia, tritanopia"
|
||||
|
||||
nr = int(matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b)
|
||||
ng = int(matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b)
|
||||
nb = int(matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b)
|
||||
nr, ng, nb = max(0, min(255, nr)), max(0, min(255, ng)), max(0, min(255, nb))
|
||||
|
||||
result = f" 色盲模拟 ({blind_type}):\n"
|
||||
result += f" 原色: {self._rgb_to_hex(r, g, b)}\n"
|
||||
result += f" 模拟: {self._rgb_to_hex(nr, ng, nb)}\n"
|
||||
return result
|
||||
|
||||
|
||||
def test_plugin():
|
||||
plugin = Liugin()
|
||||
print("颜色工具测试:")
|
||||
print(plugin.handle('convert "#3498db"'))
|
||||
print(plugin.handle('contrast "#FFFFFF" "#000000"'))
|
||||
print(plugin.handle('palette "#e74c3c" -n 7'))
|
||||
print(plugin.handle('complementary "#3498db"'))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_plugin()
|
||||
@@ -1,370 +0,0 @@
|
||||
"""
|
||||
图像生成插件 - 基于 ai.2x.nz 的 ComfyUI 在线服务
|
||||
支持自然语言描述和英文 Tag 两种模式
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Liugin:
|
||||
"""图像生成工具 — 自然语言/Tag 生图,基于 ComfyUI 在线服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.usage = """图像生成工具 — 基于 ai.2x.nz 的 ComfyUI 在线服务
|
||||
|
||||
操作:
|
||||
generate <描述> - 自然语言描述生成图片(中文/英文均可)
|
||||
tag <英文tags> - 直接 Tag 模式(逗号分隔的英文标签)
|
||||
list - 列出可用的工作流/角色
|
||||
status - 查看 GPU 状态
|
||||
|
||||
示例:
|
||||
image_generator generate 一个白发蓝眼的猫耳少女在写代码
|
||||
image_generator tag masterpiece, 1girl, white hair, blue eyes, cat ears, coding
|
||||
image_generator generate 画一个赛博朋克风格的城市夜景 --width 1024 --height 768
|
||||
|
||||
说明:
|
||||
- 自然语言模式会经过 LLM 翻译为 Tag,效果更好
|
||||
- 直接 Tag 模式适合熟悉 Danbooru 标签的用户
|
||||
- 默认分辨率 1024x1344(WAI 推荐)
|
||||
- 最大分辨率 1344x1344
|
||||
- 生成的图片保存在 screenshots/ 目录
|
||||
"""
|
||||
self.cli = None
|
||||
self.api_base = "https://ai.2x.nz"
|
||||
self.ws_url = "wss://ai.2x.nz/ws/run"
|
||||
self.default_workflow = "WAI - 无Lora.json"
|
||||
self.output_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "screenshots")
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "image_generator",
|
||||
"description": "图像生成 — 自然语言/Tag 生图,基于 ComfyUI 在线服务(ai.2x.nz),支持中文描述",
|
||||
"keywords": ["图像", "生成", "画图", "绘图", "AI画", "生图", "image", "generate", "draw", "paint",
|
||||
"diffusion", "comfyui", "stable", "sd", "文生图", "text2img"],
|
||||
"usage": self.usage
|
||||
}
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
try:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return "错误:请提供操作类型\n\n" + self.usage
|
||||
|
||||
op = parts[0].lower()
|
||||
rest = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
handlers = {
|
||||
"generate": self._generate,
|
||||
"tag": self._generate_tag,
|
||||
"gen": self._generate,
|
||||
"g": self._generate,
|
||||
"list": self._list_workflows,
|
||||
"ls": self._list_workflows,
|
||||
"status": self._gpu_status,
|
||||
}
|
||||
|
||||
handler = handlers.get(op)
|
||||
if not handler:
|
||||
# 如果没有指定操作,把整个 args 当作自然语言描述
|
||||
return self._generate(args)
|
||||
|
||||
return handler(rest)
|
||||
|
||||
except Exception as e:
|
||||
return f"图像生成错误: {e}"
|
||||
|
||||
def _generate(self, args: str) -> str:
|
||||
"""自然语言模式生成图片"""
|
||||
# 解析 --width 和 --height 参数
|
||||
width, height = 1024, 1344
|
||||
prompt = args.strip()
|
||||
|
||||
if "--width" in prompt or "--height" in prompt or "--w" in prompt or "--h" in prompt:
|
||||
import re
|
||||
w_match = re.search(r'--w(?:idth)?\s+(\d+)', prompt)
|
||||
h_match = re.search(r'--h(?:eight)?\s+(\d+)', prompt)
|
||||
if w_match:
|
||||
width = int(w_match.group(1))
|
||||
prompt = prompt[:w_match.start()] + prompt[w_match.end():]
|
||||
if h_match:
|
||||
height = int(h_match.group(1))
|
||||
prompt = prompt[:h_match.start()] + prompt[h_match.end():]
|
||||
prompt = prompt.strip()
|
||||
|
||||
if not prompt:
|
||||
return "错误:请提供图片描述\n示例: image_generator generate 一个白发猫耳少女"
|
||||
|
||||
# 限制分辨率
|
||||
width = min(width, 1344)
|
||||
height = min(height, 1344)
|
||||
|
||||
return self._run_generation(prompt, "", width, height, rewrite=True, override=False)
|
||||
|
||||
def _generate_tag(self, args: str) -> str:
|
||||
"""直接 Tag 模式"""
|
||||
width, height = 1024, 1344
|
||||
prompt = args.strip()
|
||||
|
||||
if "--width" in prompt or "--height" in prompt or "--w" in prompt or "--h" in prompt:
|
||||
import re
|
||||
w_match = re.search(r'--w(?:idth)?\s+(\d+)', prompt)
|
||||
h_match = re.search(r'--h(?:eight)?\s+(\d+)', prompt)
|
||||
if w_match:
|
||||
width = int(w_match.group(1))
|
||||
prompt = prompt[:w_match.start()] + prompt[w_match.end():]
|
||||
if h_match:
|
||||
height = int(h_match.group(1))
|
||||
prompt = prompt[:h_match.start()] + prompt[h_match.end():]
|
||||
prompt = prompt.strip()
|
||||
|
||||
if not prompt:
|
||||
return "错误:请提供 Tag\n示例: image_generator tag masterpiece, 1girl, white hair, blue eyes"
|
||||
|
||||
width = min(width, 1344)
|
||||
height = min(height, 1344)
|
||||
|
||||
return self._run_generation("", prompt, width, height, rewrite=False, override=False)
|
||||
|
||||
def _run_generation(self, nl_prompt: str, direct_prompt: str, width: int, height: int,
|
||||
rewrite: bool = False, override: bool = False) -> str:
|
||||
"""执行图像生成(同步包装)"""
|
||||
try:
|
||||
import websocket as ws_lib
|
||||
except ImportError:
|
||||
# 回退:用 urllib 的方式不行,WebSocket 必须用专用库
|
||||
# 尝试用 subprocess 调用 python 脚本
|
||||
return self._run_generation_subprocess(nl_prompt, direct_prompt, width, height, rewrite, override)
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
ws = ws_lib.create_connection(self.ws_url, timeout=180)
|
||||
|
||||
payload = {
|
||||
"workflow_path": self.default_workflow,
|
||||
"inline_workflow": None,
|
||||
"direct_prompt": direct_prompt,
|
||||
"nl_prompt": nl_prompt,
|
||||
"rewrite": rewrite,
|
||||
"override": override,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
ws.send(json.dumps(payload))
|
||||
|
||||
image_path = None
|
||||
final_prompt = ""
|
||||
error_msg = ""
|
||||
|
||||
while True:
|
||||
try:
|
||||
raw = ws.recv()
|
||||
data = json.loads(raw)
|
||||
msg_type = data.get("type", "")
|
||||
|
||||
if msg_type == "image":
|
||||
url = data.get("url", "")
|
||||
filename = data.get("filename", "")
|
||||
if url:
|
||||
image_path = self._download_image(url, filename)
|
||||
elif msg_type == "final_prompt":
|
||||
final_prompt = data.get("final_prompt", "") if isinstance(data.get("final_prompt"), str) else data.get("prompt", "")
|
||||
elif msg_type == "done":
|
||||
break
|
||||
elif msg_type == "error":
|
||||
error_msg = data.get("message", "未知错误")
|
||||
break
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
break
|
||||
|
||||
ws.close()
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if error_msg:
|
||||
return f" 生成失败: {error_msg}"
|
||||
|
||||
if image_path:
|
||||
size = os.path.getsize(image_path)
|
||||
size_str = f"{size/1024:.0f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB"
|
||||
result = f" 图片生成成功!\n"
|
||||
result += f" 路径: {image_path}\n"
|
||||
result += f" 大小: {size_str}\n"
|
||||
result += f" 分辨率: {width}x{height}\n"
|
||||
result += f"⏱ 耗时: {elapsed:.1f}s\n"
|
||||
if final_prompt:
|
||||
result += f" Prompt: {final_prompt[:120]}...\n"
|
||||
result += f"\n💡 提示: 用 'send_image {image_path}' 可以推送到手机"
|
||||
return result
|
||||
else:
|
||||
return " 生成完成但未收到图片数据"
|
||||
|
||||
except Exception as e:
|
||||
return f" 连接失败: {e}\n💡 请检查网络连接或稍后重试"
|
||||
|
||||
def _run_generation_subprocess(self, nl_prompt: str, direct_prompt: str, width: int, height: int,
|
||||
rewrite: bool, override: bool) -> str:
|
||||
"""用子进程执行 WebSocket 生成(无需 websocket 库)"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
script = f'''
|
||||
import json, asyncio, urllib.request, os, sys
|
||||
|
||||
async def main():
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
print("ERROR:需要安装 websockets: pip install websockets")
|
||||
sys.exit(1)
|
||||
|
||||
uri = "wss://ai.2x.nz/ws/run"
|
||||
async with websockets.connect(uri) as ws:
|
||||
payload = {{
|
||||
"workflow_path": "{self.default_workflow}",
|
||||
"inline_workflow": None,
|
||||
"direct_prompt": {json.dumps(direct_prompt)},
|
||||
"nl_prompt": {json.dumps(nl_prompt)},
|
||||
"rewrite": {"true" if rewrite else "false"},
|
||||
"override": {"true" if override else "false"},
|
||||
"width": {width},
|
||||
"height": {height},
|
||||
}}
|
||||
await ws.send(json.dumps(payload))
|
||||
|
||||
image_url = None
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=180)
|
||||
data = json.loads(msg)
|
||||
t = data.get("type", "")
|
||||
if t == "image":
|
||||
image_url = data.get("url", "")
|
||||
elif t == "done":
|
||||
break
|
||||
elif t == "error":
|
||||
print(f"ERROR:{{data.get('message','')}}")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
print("ERROR:超时")
|
||||
break
|
||||
|
||||
if image_url:
|
||||
full_url = f"https://ai.2x.nz{{image_url}}"
|
||||
out_path = os.path.join("{self.output_dir}", f"generated_{{os.path.basename(image_url)}}")
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
urllib.request.urlretrieve(full_url, out_path)
|
||||
print(f"OK:{{out_path}}")
|
||||
|
||||
asyncio.run(main())
|
||||
'''
|
||||
|
||||
script_path = os.path.join(self.output_dir, "_gen_temp.py")
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
with open(script_path, 'w') as f:
|
||||
f.write(script)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["python3", script_path],
|
||||
capture_output=True, text=True, timeout=200
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
|
||||
if output.startswith("OK:"):
|
||||
image_path = output[3:]
|
||||
size = os.path.getsize(image_path)
|
||||
size_str = f"{size/1024:.0f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB"
|
||||
res = f" 图片生成成功!\n"
|
||||
res += f" 路径: {image_path}\n"
|
||||
res += f" 大小: {size_str}\n"
|
||||
res += f" 分辨率: {width}x{height}\n"
|
||||
res += f"\n💡 提示: 用 'send_image {image_path}' 可以推送到手机"
|
||||
return res
|
||||
elif output.startswith("ERROR:"):
|
||||
return f" {output[6:]}"
|
||||
else:
|
||||
return f" 生成异常:\n{result.stdout}\n{result.stderr}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return " 生成超时(>200s),请稍后重试"
|
||||
finally:
|
||||
try:
|
||||
os.remove(script_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _download_image(self, url: str, filename: str) -> str:
|
||||
"""下载生成的图片"""
|
||||
full_url = f"{self.api_base}{url}"
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
out_path = os.path.join(self.output_dir, f"gen_{filename}")
|
||||
urllib.request.urlretrieve(full_url, out_path)
|
||||
return out_path
|
||||
|
||||
def _list_workflows(self, args: str) -> str:
|
||||
"""列出可用的工作流"""
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(f"{self.api_base}/api/workflows")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
|
||||
workflows = data.get("workflows", [])
|
||||
result = f" 可用工作流 ({len(workflows)} 个):\n\n"
|
||||
|
||||
lora_workflows = []
|
||||
no_lora = []
|
||||
for w in workflows:
|
||||
name = w.get("path", "")
|
||||
if "无Lora" in name or "no" in name.lower():
|
||||
no_lora.append(name)
|
||||
else:
|
||||
lora_workflows.append(name)
|
||||
|
||||
if no_lora:
|
||||
result += " 通用(无角色绑定):\n"
|
||||
for w in no_lora:
|
||||
result += f" • {w}\n"
|
||||
|
||||
if lora_workflows:
|
||||
result += "\n 角色工作流:\n"
|
||||
for w in lora_workflows:
|
||||
result += f" • {w}\n"
|
||||
|
||||
result += "\n💡 使用 'image_generator generate <描述>' 自然语言生图"
|
||||
result += "\n💡 使用 'image_generator tag <tags>' 直接 Tag 生图"
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f" 获取工作流列表失败: {e}"
|
||||
|
||||
def _gpu_status(self, args: str) -> str:
|
||||
"""查看 GPU 状态"""
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(f"{self.api_base}/api/gpu")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
|
||||
result = " 服务器 GPU 状态:\n"
|
||||
for gpu in data.get("gpus", []):
|
||||
name = gpu.get("name", "未知")
|
||||
util = gpu.get("utilization", 0)
|
||||
mem_used = gpu.get("memory_used", 0)
|
||||
mem_total = gpu.get("memory_total", 0)
|
||||
temp = gpu.get("temperature", 0)
|
||||
result += f" {name}\n"
|
||||
result += f" GPU 负载: {util}%\n"
|
||||
result += f" 显存: {mem_used}/{mem_total} MiB\n"
|
||||
result += f" 温度: {temp}°C\n"
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f" 获取 GPU 状态失败: {e}"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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} 已关闭"
|
||||
+13
-6
@@ -1,11 +1,10 @@
|
||||
# 小狸插件配置
|
||||
|
||||
# ── 必备生产力 ──
|
||||
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
|
||||
@@ -14,7 +13,15 @@ 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_DGLAB_WS = False
|
||||
ENABLE_TERMINAL = True
|
||||
ENABLE_BROWSER_AUTO = True
|
||||
ENABLE_GUI_AUTO = True
|
||||
ENABLE_SUB_AGENT = True
|
||||
ENABLE_WORKFLOW = True
|
||||
ENABLE_MEMORY_PLUGIN = True
|
||||
ENABLE_JSON_TOOLKIT = True
|
||||
ENABLE_REGEX_TESTER = True
|
||||
ENABLE_SNIPPET_MANAGER = True
|
||||
ENABLE_MARKDOWN_TOOL = True
|
||||
ENABLE_ID_GENERATOR = True
|
||||
ENABLE_IMAGE_READER = True
|
||||
@@ -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 个测试通过!")
|
||||
+135
-9
@@ -12,7 +12,7 @@ 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
|
||||
@@ -152,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 指令来执行操作:
|
||||
|
||||
@@ -219,6 +243,20 @@ multi 操作支持一次修改多处:
|
||||
|
||||
{tool_search_rule}
|
||||
|
||||
【⚠️ 兜底策略 — 遇到能力不足时的解决路径】
|
||||
当你发现现有工具无法完成用户需求时,按以下顺序逐级尝试,不要直接放弃:
|
||||
|
||||
第一级:查插件仓库
|
||||
用 /plugin search <关键词> 搜索社区插件,找到后 /plugin install 安装使用。
|
||||
|
||||
第二级:网上搜索方案
|
||||
用 ai_search 搜索解决方案或命令行工具。
|
||||
|
||||
第三级:自己写插件
|
||||
写 Python 插件放到 plugins/ 目录,实现 Liugin 类,重启加载。
|
||||
|
||||
记住:放弃是最后的选择。能装就装,能搜就搜,能写就写。
|
||||
|
||||
【工具调用格式】
|
||||
{{"action": "use_tool", "tool": "工具名", "args": "参数"}}
|
||||
|
||||
@@ -488,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)
|
||||
|
||||
@@ -827,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 模式:不做动画
|
||||
@@ -1003,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}")
|
||||
@@ -1215,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:
|
||||
@@ -1226,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'):
|
||||
|
||||
+141
-91
@@ -1,32 +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: "计划模式",
|
||||
}
|
||||
|
||||
class SafetyLayer:
|
||||
"""统一安全检查层"""
|
||||
MODE_ICONS = {
|
||||
MODE_UNRESTRICTED: "",
|
||||
MODE_NORMAL: "",
|
||||
MODE_MANUAL: "",
|
||||
MODE_PLAN: "",
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -36,26 +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, ""
|
||||
|
||||
# 人工确认模式:所有指令都确认
|
||||
# 人工确认模式:所有指令确认
|
||||
if self.mode == MODE_MANUAL:
|
||||
return self._ask_user_confirm(
|
||||
tool_name, tool_args,
|
||||
@@ -70,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:
|
||||
@@ -98,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}
|
||||
@@ -114,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",
|
||||
@@ -200,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"],
|
||||
@@ -210,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}")
|
||||
@@ -255,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
|
||||
Reference in new issue
Block a user