diff --git a/.gitignore b/.gitignore index a0824ce..e453b9a 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ src/plugins/*/*.db .vscode/ data/* .iflow/ +.claude/settings.local.json diff --git a/skills/plugin-dev/SKILL.md b/skills/plugin-dev/SKILL.md index 3c46ade..5dd0dbc 100644 --- a/skills/plugin-dev/SKILL.md +++ b/skills/plugin-dev/SKILL.md @@ -1,97 +1,92 @@ --- name: plugin-dev -description: Meta-Minesweeper 插件开发助手,支持创建、调试和优化插件 +description: >- + Meta-Minesweeper 插件系统开发专家。当你需要创建插件、编写插件代码、了解插件架构、调试插件问题、 + 使用 BasePlugin API、订阅事件、发送指令、跨线程 GUI、配置系统、插件模板,或用户提到 + "插件"、"plugin"、"创建插件"、"开发插件"、"插件开发"、"plugin_dev"、"baseplugin"、 + "插件管理器"、"plugin manager"、"插件模板"、"plugins 目录" 时使用此 skill。 + 涵盖架构概览 (architecture.md)、创建流程 (creating.md)、渐进式知识体系 (level-1 ~ level-6)、 + 问题诊断 (troubleshooting.md)、最佳实践 (best-practices.md)、代码模板 (assets/templates/) + 和 CLI 工具 (scripts/create_plugin.py)。 --- # Meta-Minesweeper 插件开发助手 -你是一个专业的 Meta-Minesweeper 插件开发助手。你熟悉插件系统的架构、API 和最佳实践,能够帮助用户创建、调试和优化插件。 +你是一个专业的 Meta-Minesweeper 插件开发助手,熟悉插件系统的架构、API 和最佳实践。 -## 核心能力 +## 何时使用本 Skill -1. **插件创建** - 支持单文件和包形式两种插件结构 -2. **渐进式指导** - 根据用户需求逐步披露相关知识点 -3. **代码生成** - 生成符合规范的插件模板代码 -4. **问题诊断** - 帮助排查插件加载、运行时的常见问题 +**必须调用本 skill 的场景:** -## 知识体系 +- 创建/生成/脚手架一个新的插件 +- **编写或修改任何插件代码** — 生成代码前必须先读取相关 references 和模板,确保 API 用法、继承关系、线程安全、导入路径等完全符合项目规范,禁止凭记忆编写 +- 解答插件架构、API、事件系统、跨线程 GUI、配置系统等问题 +- 排查插件加载失败、事件未触发、GUI 崩溃、导入错误等故障 +- 查找插件模板代码或插件开发规范 -### 架构概览 -详见 [references/architecture.md](./references/architecture.md) +**不需要调用的场景:** -### 创建插件 -详见 [references/creating.md](./references/creating.md) +- 项目中与插件系统无关的通用 Python 编程问题 -### 渐进式知识披露 -- [references/level-1-basics.md](./references/level-1-basics.md) - 基础概念 -- [references/level-2-events.md](./references/level-2-events.md) - 事件系统 -- [references/level-3-threading.md](./references/level-3-threading.md) - 跨线程 GUI -- [references/level-4-control.md](./references/level-4-control.md) - 控制授权 -- [references/level-5-service.md](./references/level-5-service.md) - 服务系统 -- [references/level-6-config.md](./references/level-6-config.md) - 配置系统 +## 知识体系索引 -### 问题诊断 -详见 [references/troubleshooting.md](./references/troubleshooting.md) +按需读取以下文件,不要一次性全部读取: -### 最佳实践 -详见 [references/best-practices.md](./references/best-practices.md) +## 场景化指南 -## 快速开始 +### 1. 创建新插件 -### 第一步:检测环境 +**读取文件**: -**必须首先执行**,检测运行环境并获取可用的类型: +- [references/creating.md](references/creating.md) - 交互流程指导 -```bash -python scripts/create_plugin.py discover -``` +### 2. 订阅游戏事件 -返回 JSON: -```json -{ - "environment": "dev", // "dev" 或 "frozen" - "install_dir": "...", // 安装目录 - "plugins_dir": "...", // 插件目录 - "shared_types_dir": "...", // shared_types 目录 - "events": [...], // 可用事件列表 - "commands": [...] // 可用命令列表 -} -``` +**读取文件**: -根据 `environment` 判断: -- `"dev"` - 开发模式,插件放在 `src/plugins/` -- `"frozen"` - 打包模式,插件放在 `plugins/` +- [references/level-2-events.md](references/level-2-events.md) - 事件订阅/过滤机制 -### 第二步:收集信息 +### 3. 发送指令控制主进程 -使用 `ask_user_question` 工具收集插件信息: +**读取文件**: -1. 插件形式(单文件/包形式) -2. 插件名称、描述、作者 -3. 窗口模式(TAB/DETACHED/CLOSED) -4. 订阅的事件(从 discover 返回的 events 选择) -5. 控制权限(从 discover 返回的 commands 选择) -6. 是否需要配置系统、服务接口 +- [references/level-4-control.md](references/level-4-control.md) - 发送指令控制主进程 -### 第三步:创建插件 +### 4. 跨线程 GUI 操作 -调用脚本创建插件: +**读取文件**: -```bash -python scripts/create_plugin.py create \ - --name my_plugin \ - --description "描述" \ - --window-mode TAB \ - --events VideoSaveEvent \ - --commands NewGameCommand -``` +- [references/level-3-threading.md](references/level-3-threading.md) - 跨线程 GUI 安全 -脚本输出创建结果(JSON 格式) +### 5. 使用配置系统 -## 插件模板 +**读取文件**: -模板文件位于 `assets/templates/` 目录: -- `minimal.py` - 最小可行插件 -- `with-gui.py` - 带 GUI 的插件 -- `with-config.py` - 带配置的插件 -- `with-control.py` - 带控制权限的插件 +- [references/level-6-config.md](references/level-6-config.md) - 配置系统 + **参考模板**: +- [assets/templates/with-config.py](assets/templates/with-config.py) + +### 6. 服务注册与发现 + +**读取文件**: + +- [references/level-5-service.md](references/level-5-service.md) - 服务注册/发现 + +### 7. 了解整体架构 + +**读取文件**: + +- [references/architecture.md](references/architecture.md) - 架构概览 + +### 8. 排查插件问题 + +**读取文件**: + +- [references/troubleshooting.md](references/troubleshooting.md) - 问题诊断 + **聚焦问题**: 文件位置、命名规则、基类继承、线程安全、导入依赖 + +### 9. 代码规范与调试 + +**读取文件**: + +- [references/best-practices.md](references/best-practices.md) - 代码规范/性能/调试 diff --git a/skills/plugin-dev/references/creating.md b/skills/plugin-dev/references/creating.md index 94f9f98..4b80b38 100644 --- a/skills/plugin-dev/references/creating.md +++ b/skills/plugin-dev/references/creating.md @@ -1,101 +1,220 @@ # 创建新插件流程 -当用户请求创建新插件时,按以下步骤操作: +## 重要:必须使用脚本创建 -## 步骤 1:询问插件形式 +**必须使用 `python {skills_path}/plugin-dev/scripts/create_plugin.py` 脚本来创建插件!** -使用 `ask_user_question` 工具询问: +禁止手动编写插件代码,必须通过脚本生成。 + +--- + +## 脚本使用指南 + +### 脚本路径 + +``` +python {skills_path}/plugin-dev/scripts/create_plugin.py +``` + +### 命令 1:discover(发现可用类型) + +```bash +python {skills_path}/plugin-dev/scripts/create_plugin.py discover +``` + +返回项目中的可用事件类型和命令类型: ```json { - "question": "请选择插件的形式?", - "header": "插件形式", - "options": [ - {"label": "单文件 (.py)", "description": "简单插件,一个文件搞定,适合快速开发"}, - {"label": "包形式 (目录)", "description": "复杂插件,支持多模块分离,适合大型插件"} - ], - "multiSelect": false + "environment": "dev", + "plugins_dir": "src/plugins", + "shared_types_dir": "src/shared_types", + "events": [{ "name": "BoardUpdateEvent", "description": "..." }], + "commands": [{ "name": "NewGameCommand", "description": "..." }] } ``` -## 步骤 2:收集插件信息 +### 命令 2:create(创建插件) + +```bash +python {skills_path}/plugin-dev/scripts/create_plugin.py create --name <名称> [选项] +``` -依次询问以下信息: +**必选参数:** +| 参数 | 说明 | +|------|------| +| `--name` | 插件名称(英文,下划线分隔,如 `my_plugin`) | + +**可选参数:** +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `--description` | "" | 插件描述 | +| `--version` | "1.0.0" | 版本号 | +| `--author` | "" | 作者名称 | +| `--window-mode` | "TAB" | 窗口模式:`TAB`、`DETACHED`、`CLOSED` | +| `--icon-color` | "#4CAF50" | 图标颜色(十六进制) | +| `--icon-char` | 插件名首字母 | 图标字符 | +| `--package` | False | 是否创建包形式(目录结构) | +| `--events` | "" | 订阅的事件,逗号分隔(如 `BoardUpdateEvent,VideoSaveEvent`) | +| `--commands` | "" | 需要的控制权限,逗号分隔(如 `NewGameCommand`) | +| `--config` | True | 是否需要配置系统(默认启用) | +| `--service` | False | 是否需要服务接口 | + +--- + +## 创建流程 + +### 步骤 1:执行 discover 检测环境 + +``` +python {skills_path}/plugin-dev/scripts/create_plugin.py discover +``` + +### 步骤 2:收集插件信息 + +使用 `ask_user_question` 工具询问: + +1. **插件形式** -### 基本信息 ```json { - "question": "请输入插件名称(英文,下划线分隔,如 my_plugin):", - "header": "插件名称" + "question": "请选择插件的形式?", + "header": "插件形式", + "options": [ + { + "label": "单文件 (.py)", + "description": "简单插件,一个文件搞定,适合快速开发" + }, + { + "label": "包形式 (目录)", + "description": "复杂插件,支持多模块分离,适合大型插件" + } + ], + "multiSelect": false } ``` -### 描述信息 +2. **基本信息** + ```json { - "question": "请输入插件描述:", - "header": "描述" + "question": "请输入插件名称(英文,下划线分隔,如 my_plugin):", + "header": "插件名称" } ``` -### 作者信息 +3. **描述信息** + +```json +{ + "question": "请输入插件描述:", + "header": "描述" +} +``` + +4. **作者信息** + ```json { - "question": "请输入作者名称:", - "header": "作者" + "question": "请输入作者名称:", + "header": "作者" } ``` -### 功能选项 +5. **功能选项** + ```json { - "question": "插件需要哪些功能?", - "header": "功能选项", - "options": [ - {"label": "GUI 界面", "description": "插件需要一个可视化界面"}, - {"label": "订阅事件", "description": "监听游戏事件(如游戏结束)"}, - {"label": "发送指令", "description": "控制主进程(如开始新游戏)"}, - {"label": "配置系统", "description": "用户可配置的设置项"} - ], - "multiSelect": true + "question": "插件需要哪些功能?", + "header": "功能选项", + "options": [ + { "label": "GUI 界面", "description": "插件需要一个可视化界面" }, + { "label": "订阅事件", "description": "监听游戏事件(如游戏结束)" }, + { "label": "发送指令", "description": "控制主进程(如开始新游戏)" } + ], + "multiSelect": true } ``` -## 步骤 3:生成插件代码 +### 步骤 3:执行脚本创建插件 + +根据收集的信息构建命令并执行: + +**示例 1:创建带 GUI 和事件的包形式插件** + +```bash +python {skills_path}/plugin-dev/scripts/create_plugin.py create \ + --name test_game \ + --description "游戏测试插件" \ + --author developer \ + --package \ + --window-mode TAB \ + --icon-color "#2196F3" \ + --icon-char T \ + --events BoardUpdateEvent,VideoSaveEvent,GameStatusChangeEvent +``` + +**示例 2:创建简单单文件插件** + +```bash +python {skills_path}/plugin-dev/scripts/create_plugin.py create \ + --name simple_plugin \ + --description "简单插件" \ + --author developer +``` + +### 步骤 4:验证创建结果 + +创建完成后验证语法: -根据收集的信息,使用 `write_file` 工具创建插件文件: +```bash +python -m py_compile src/plugins//plugin.py +``` -- **单文件插件**: 创建 `plugins/{plugin_name}.py` -- **包形式插件**: 创建 `plugins/{plugin_name}/__init__.py` +然后告知用户: -## 步骤 4:验证创建结果 +1. 插件文件位置 +2. 提醒重启插件管理器以加载新插件 -创建完成后: -1. 使用 `python -m py_compile` 检查语法 -2. 告知用户插件文件位置 -3. 提醒用户重启插件管理器以加载新插件 +--- ## 示例交互流程 ``` -用户: 帮我创建一个新插件 +用户: 帮我创建一个游戏测试插件 -助手: [使用 ask_user_question 询问插件形式] -用户选择: 单文件 (.py) +助手: 先执行 discover 检测环境... + python {skills_path}/plugin-dev/scripts/create_plugin.py discover + +助手: 环境正常。可用事件:BoardUpdateEvent, VideoSaveEvent... + +助手: [使用 ask_user_question 询问] +用户选择: 包形式 (目录) 助手: [询问插件名称] -用户输入: auto_replay +用户输入: test_game 助手: [询问描述] -用户输入: 自动回放功能 - -助手: [询问作者] -用户输入: developer +用户输入: 游戏测试插件 助手: [询问功能选项] 用户选择: GUI 界面, 订阅事件 -助手: [使用 write_file 创建 plugins/auto_replay.py] -助手: [使用 py_compile 验证语法] -助手: 插件已创建,请重启插件管理器加载 +助手: 执行创建脚本... + python {skills_path}/plugin-dev/scripts/create_plugin.py create \ + --name test_game \ + --description "游戏测试插件" \ + --author developer \ + --package \ + --events BoardUpdateEvent,VideoSaveEvent + +助手: 验证语法... + python -m py_compile src/plugins/test_game/plugin.py + +助手: 插件已创建! + - src/plugins/test_game/__init__.py + - src/plugins/test_game/plugin.py + - src/plugins/test_game/widgets.py + + 请重启插件管理器以加载新插件。 ``` diff --git a/skills/plugin-dev/scripts/create_plugin.py b/skills/plugin-dev/scripts/create_plugin.py index 3e6bf73..4b3fb21 100644 --- a/skills/plugin-dev/scripts/create_plugin.py +++ b/skills/plugin-dev/scripts/create_plugin.py @@ -76,10 +76,10 @@ def discover_events() -> list[dict[str, Any]]: """发现可用的事件类型""" events = [] events_file = get_shared_types_dir() / "events.py" - + if not events_file.exists(): return events - + content = events_file.read_text(encoding="utf-8") import re pattern = r'class\s+(\w+)\s*\(.*?\):' @@ -90,7 +90,7 @@ def discover_events() -> list[dict[str, Any]]: doc_match = re.search(doc_pattern, content, re.DOTALL) doc = doc_match.group(1).strip() if doc_match else "" events.append({"name": name, "description": doc}) - + return events @@ -98,10 +98,10 @@ def discover_commands() -> list[dict[str, Any]]: """发现可用的控制命令类型""" commands = [] commands_file = get_shared_types_dir() / "commands.py" - + if not commands_file.exists(): return commands - + content = commands_file.read_text(encoding="utf-8") import re pattern = r'class\s+(\w+)\s*\(.*?\):' @@ -112,7 +112,7 @@ def discover_commands() -> list[dict[str, Any]]: doc_match = re.search(doc_pattern, content, re.DOTALL) doc = doc_match.group(1).strip() if doc_match else "" commands.append({"name": name, "description": doc}) - + return commands @@ -122,7 +122,7 @@ def discover_commands() -> list[dict[str, Any]]: def _to_class_name(name: str) -> str: """将插件名转换为类名前缀 - + Examples: test_plugin -> TestPlugin history -> History @@ -150,44 +150,46 @@ def generate_single_file_plugin( events = [] if commands is None: commands = [] - + if not icon_char: icon_char = name[0].upper() - + needs_gui = window_mode != "CLOSED" class_prefix = _to_class_name(name) lines = [] - + # 文件头 lines.append(f'"""') lines.append(f'{name} - {description}') lines.append(f'"""') lines.append('from __future__ import annotations') lines.append('') - + # 导入 imports = [] if needs_gui: - imports.append('from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel') + imports.append( + 'from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel') imports.append('from PyQt5.QtCore import pyqtSignal') - + sdk_imports = ['BasePlugin', 'PluginInfo'] if needs_gui: sdk_imports.extend(['make_plugin_icon', 'WindowMode']) if needs_config: sdk_imports.extend(['OtherInfoBase', 'BoolConfig']) imports.append(f'from plugin_sdk import {", ".join(sdk_imports)}') - + if events: imports.append(f'from shared_types.events import {", ".join(events)}') if commands: - imports.append(f'from shared_types.commands import {", ".join(commands)}') + imports.append( + f'from shared_types.commands import {", ".join(commands)}') if needs_service and service_name: imports.append(f'from plugins.services.{name} import {service_name}') - + lines.extend(imports) lines.append('') - + # 配置类 if needs_config: lines.append(f'class {class_prefix}Config(OtherInfoBase):') @@ -199,7 +201,7 @@ def generate_single_file_plugin( lines.append(' )') lines.append('') lines.append('') - + # Widget 类 widget_name = f'{class_prefix}Widget' if needs_gui: @@ -222,13 +224,13 @@ def generate_single_file_plugin( lines.append(' self._label.setText(text)') lines.append('') lines.append('') - + # 主插件类 plugin_name = f'{class_prefix}Plugin' - lines.append(f'class {plugin_name}(BasePlugin):') + lines.append(f'class {plugin_name}(BasePlugin[{class_prefix}Config]):') lines.append(f' """{description}"""') lines.append('') - + # plugin_info lines.append(' @classmethod') lines.append(' def plugin_info(cls) -> PluginInfo:') @@ -238,77 +240,84 @@ def generate_single_file_plugin( if author: lines.append(f' author="{author}",') lines.append(f' description="{description}",') - + if needs_gui: lines.append(f' window_mode=WindowMode.{window_mode},') - lines.append(f' icon=make_plugin_icon("{icon_color}", "{icon_char}"),') + lines.append( + f' icon=make_plugin_icon("{icon_color}", "{icon_char}"),') else: lines.append(f' window_mode=WindowMode.CLOSED,') - + if needs_config: lines.append(f' other_info={class_prefix}Config,') if commands: lines.append(f' required_controls=[{", ".join(commands)}],') - + lines.append(' )') lines.append('') - + # _setup_subscriptions lines.append(' def _setup_subscriptions(self) -> None:') if events: for event in events: handler_name = f'_on_{event.lower().replace("event", "")}' - lines.append(f' self.subscribe({event}, self.{handler_name})') + lines.append( + f' self.subscribe({event}, self.{handler_name})') else: lines.append(' pass') lines.append('') - + # _create_widget if needs_gui: lines.append(' def _create_widget(self) -> QWidget | None:') lines.append(f' self._widget = {widget_name}()') lines.append(' return self._widget') lines.append('') - + # on_initialized lines.append(' def on_initialized(self) -> None:') lines.append(f' self.logger.info("{plugin_name} 已初始化")') - + if needs_service and service_name: lines.append(' ') lines.append(' # 注册服务接口') - lines.append(f' self.register_service(self, protocol={service_name})') + lines.append( + f' self.register_service(self, protocol={service_name})') lines.append(f' self.logger.info("{service_name} 已注册")') - + if commands: lines.append(' ') lines.append(' # 检查控制权限') for cmd in commands: lines.append(f' has_auth = self.has_control_auth({cmd})') - lines.append(f' self.logger.info(f"{cmd} 权限: {{has_auth}}")') - + lines.append( + f' self.logger.info(f"{cmd} 权限: {{has_auth}}")') + if needs_config: lines.append(' ') - lines.append(' self.config_changed.connect(self._on_config_changed)') - + lines.append( + ' self.config_changed.connect(self._on_config_changed)') + lines.append('') - + # on_control_auth_changed if commands: - lines.append(' def on_control_auth_changed(self, cmd_type, granted: bool) -> None:') + lines.append( + ' def on_control_auth_changed(self, cmd_type, granted: bool) -> None:') lines.append(' """控制权限变更回调"""') for cmd in commands: lines.append(f' if cmd_type == {cmd}:') lines.append(' self.logger.info(f"权限变更: {granted}")') lines.append('') - + # _on_config_changed if needs_config: - lines.append(' def _on_config_changed(self, name: str, value) -> None:') + lines.append( + ' def _on_config_changed(self, name: str, value) -> None:') lines.append(' """配置变化回调"""') lines.append(' self.logger.info(f"配置变化: {name} = {value}")') lines.append('') - + # 事件处理器 - 带类型提示 for event in events: handler_name = f'_on_{event.lower().replace("event", "")}' @@ -318,7 +327,7 @@ def generate_single_file_plugin( if needs_gui: lines.append(' # self._widget._update_signal.emit("...")') lines.append('') - + return '\n'.join(lines) @@ -345,14 +354,14 @@ def generate_package_files( events = [] if commands is None: commands = [] - + if not icon_char: icon_char = name[0].upper() - + needs_gui = window_mode != "CLOSED" class_prefix = _to_class_name(name) files = {} - + # ═══════════════════════════════════════════════════════════════════ # config.py # ═══════════════════════════════════════════════════════════════════ @@ -375,8 +384,7 @@ def generate_package_files( ' )', ] files['config.py'] = '\n'.join(config_lines) - - + # ═══════════════════════════════════════════════════════════════════ # widgets.py # ═══════════════════════════════════════════════════════════════════ @@ -410,12 +418,10 @@ def generate_package_files( ' self._label.setText(text)', ] files['widgets.py'] = '\n'.join(widget_lines) - - + # 注意:服务接口不放在插件包内,而是放在 plugins/services/{name}.py # 由 cmd_create 函数单独处理 - - + # ═══════════════════════════════════════════════════════════════════ # plugin.py # ═══════════════════════════════════════════════════════════════════ @@ -426,37 +432,41 @@ def generate_package_files( 'from __future__ import annotations', '', ] - + # 导入 if needs_gui: plugin_lines.append('from PyQt5.QtWidgets import QWidget') - + sdk_imports = ['BasePlugin', 'PluginInfo'] if needs_gui: sdk_imports.extend(['make_plugin_icon', 'WindowMode']) plugin_lines.append(f'from plugin_sdk import {", ".join(sdk_imports)}') - + if events: - plugin_lines.append(f'from shared_types.events import {", ".join(events)}') + plugin_lines.append( + f'from shared_types.events import {", ".join(events)}') if commands: - plugin_lines.append(f'from shared_types.commands import {", ".join(commands)}') - + plugin_lines.append( + f'from shared_types.commands import {", ".join(commands)}') + if needs_gui: plugin_lines.append(f'from .widgets import {class_prefix}Widget') if needs_config: plugin_lines.append(f'from .config import {class_prefix}Config') if needs_service and service_name: - plugin_lines.append(f'from plugins.services.{name} import {service_name}') - + plugin_lines.append( + f'from plugins.services.{name} import {service_name}') + plugin_lines.append('') plugin_lines.append('') - + # 主插件类 plugin_name = f'{class_prefix}Plugin' - plugin_lines.append(f'class {plugin_name}(BasePlugin):') + plugin_lines.append( + f'class {plugin_name}(BasePlugin[{class_prefix}Config]):') plugin_lines.append(f' """{description}"""') plugin_lines.append('') - + # plugin_info plugin_lines.append(' @classmethod') plugin_lines.append(' def plugin_info(cls) -> PluginInfo:') @@ -466,89 +476,103 @@ def generate_package_files( if author: plugin_lines.append(f' author="{author}",') plugin_lines.append(f' description="{description}",') - + if needs_gui: - plugin_lines.append(f' window_mode=WindowMode.{window_mode},') - plugin_lines.append(f' icon=make_plugin_icon("{icon_color}", "{icon_char}"),') + plugin_lines.append( + f' window_mode=WindowMode.{window_mode},') + plugin_lines.append( + f' icon=make_plugin_icon("{icon_color}", "{icon_char}"),') else: plugin_lines.append(f' window_mode=WindowMode.CLOSED,') - + if needs_config: plugin_lines.append(f' other_info={class_prefix}Config,') if commands: - plugin_lines.append(f' required_controls=[{", ".join(commands)}],') - + plugin_lines.append( + f' required_controls=[{", ".join(commands)}],') + plugin_lines.append(' )') plugin_lines.append('') - + # _setup_subscriptions plugin_lines.append(' def _setup_subscriptions(self) -> None:') if events: for event in events: handler_name = f'_on_{event.lower().replace("event", "")}' - plugin_lines.append(f' self.subscribe({event}, self.{handler_name})') + plugin_lines.append( + f' self.subscribe({event}, self.{handler_name})') else: plugin_lines.append(' pass') plugin_lines.append('') - + # _create_widget if needs_gui: plugin_lines.append(' def _create_widget(self) -> QWidget | None:') plugin_lines.append(f' self._widget = {class_prefix}Widget()') plugin_lines.append(' return self._widget') plugin_lines.append('') - + # on_initialized plugin_lines.append(' def on_initialized(self) -> None:') plugin_lines.append(f' self.logger.info("{plugin_name} 已初始化")') - + if needs_service and service_name: plugin_lines.append(' ') plugin_lines.append(' # 注册服务接口') - plugin_lines.append(f' self.register_service(self, protocol={service_name})') + plugin_lines.append( + f' self.register_service(self, protocol={service_name})') plugin_lines.append(f' self.logger.info("{service_name} 已注册")') - + if commands: plugin_lines.append(' ') plugin_lines.append(' # 检查控制权限') for cmd in commands: - plugin_lines.append(f' has_auth = self.has_control_auth({cmd})') - plugin_lines.append(f' self.logger.info(f"{cmd} 权限: {{has_auth}}")') - + plugin_lines.append( + f' has_auth = self.has_control_auth({cmd})') + plugin_lines.append( + f' self.logger.info(f"{cmd} 权限: {{has_auth}}")') + if needs_config: plugin_lines.append(' ') - plugin_lines.append(' self.config_changed.connect(self._on_config_changed)') - + plugin_lines.append( + ' self.config_changed.connect(self._on_config_changed)') + plugin_lines.append('') - + # on_control_auth_changed if commands: - plugin_lines.append(' def on_control_auth_changed(self, cmd_type, granted: bool) -> None:') + plugin_lines.append( + ' def on_control_auth_changed(self, cmd_type, granted: bool) -> None:') plugin_lines.append(' """控制权限变更回调"""') for cmd in commands: plugin_lines.append(f' if cmd_type == {cmd}:') - plugin_lines.append(' self.logger.info(f"权限变更: {granted}")') + plugin_lines.append( + ' self.logger.info(f"权限变更: {granted}")') plugin_lines.append('') - + # _on_config_changed if needs_config: - plugin_lines.append(' def _on_config_changed(self, name: str, value) -> None:') + plugin_lines.append( + ' def _on_config_changed(self, name: str, value) -> None:') plugin_lines.append(' """配置变化回调"""') - plugin_lines.append(' self.logger.info(f"配置变化: {name} = {value}")') + plugin_lines.append( + ' self.logger.info(f"配置变化: {name} = {value}")') plugin_lines.append('') - + # 事件处理器 - 带类型提示 for event in events: handler_name = f'_on_{event.lower().replace("event", "")}' - plugin_lines.append(f' def {handler_name}(self, event: {event}) -> None:') + plugin_lines.append( + f' def {handler_name}(self, event: {event}) -> None:') plugin_lines.append(f' """处理 {event}"""') plugin_lines.append(f' self.logger.info(f"收到 {event}")') if needs_gui: - plugin_lines.append(' # self._widget._update_signal.emit("...")') + plugin_lines.append( + ' # self._widget._update_signal.emit("...")') plugin_lines.append('') - + files['plugin.py'] = '\n'.join(plugin_lines) - + # ═══════════════════════════════════════════════════════════════════ # __init__.py # ═══════════════════════════════════════════════════════════════════ @@ -563,7 +587,7 @@ def generate_package_files( f'__all__ = ["{plugin_name}"]', ] files['__init__.py'] = '\n'.join(init_lines) - + return files @@ -571,7 +595,7 @@ def generate_service_interface(service_name: str, description: str = "") -> str: """生成服务接口文件""" # 从 ServiceName 提取基础名(去掉 Service 后缀) base_name = service_name.replace("Service", "") - + lines = [ '"""', f'{service_name} 服务接口', @@ -623,26 +647,26 @@ def cmd_create(args): """创建插件""" plugins_dir = get_plugins_dir() plugins_dir.mkdir(parents=True, exist_ok=True) - + created_files = [] - + # 生成服务接口文件(放在 plugins/services/{name}.py) if args.service: services_dir = plugins_dir / "services" services_dir.mkdir(parents=True, exist_ok=True) - + class_prefix = _to_class_name(args.name) service_name = f"{class_prefix}Service" - + service_content = generate_service_interface( service_name=service_name, description=args.description or "", ) - + service_file = services_dir / f"{args.name}.py" service_file.write_text(service_content, encoding="utf-8") created_files.append(str(service_file)) - + if args.package: # 包形式 - 生成多个文件 files = generate_package_files( @@ -657,17 +681,18 @@ def cmd_create(args): commands=args.commands.split(",") if args.commands else [], needs_config=args.config, needs_service=args.service, - service_name=_to_class_name(args.name) + "Service" if args.service else "", + service_name=_to_class_name( + args.name) + "Service" if args.service else "", ) - + pkg_dir = plugins_dir / args.name pkg_dir.mkdir(parents=True, exist_ok=True) - + for filename, content in files.items(): file_path = pkg_dir / filename file_path.write_text(content, encoding="utf-8") created_files.append(str(file_path)) - + result = { "success": True, "is_package": True, @@ -687,55 +712,57 @@ def cmd_create(args): commands=args.commands.split(",") if args.commands else [], needs_config=args.config, needs_service=args.service, - service_name=_to_class_name(args.name) + "Service" if args.service else "", + service_name=_to_class_name( + args.name) + "Service" if args.service else "", ) - + plugin_file = plugins_dir / f"{args.name}.py" plugin_file.write_text(code, encoding="utf-8") created_files.append(str(plugin_file)) - + result = { "success": True, "is_package": False, "files": created_files, } - + print(json.dumps(result, ensure_ascii=False, indent=2)) def main(): parser = argparse.ArgumentParser(description="插件创建工具") subparsers = parser.add_subparsers(dest="command", help="命令") - + # discover 命令 p_discover = subparsers.add_parser("discover", help="发现可用的类型") p_discover.set_defaults(func=cmd_discover) - + # create 命令 p_create = subparsers.add_parser("create", help="创建插件") p_create.add_argument("--name", required=True, help="插件名称") p_create.add_argument("--description", default="", help="插件描述") p_create.add_argument("--version", default="1.0.0", help="版本号") p_create.add_argument("--author", default="", help="作者") - p_create.add_argument("--window-mode", default="TAB", choices=["TAB", "DETACHED", "CLOSED"], help="窗口模式") + p_create.add_argument("--window-mode", default="TAB", + choices=["TAB", "DETACHED", "CLOSED"], help="窗口模式") p_create.add_argument("--icon-color", default="#4CAF50", help="图标颜色") p_create.add_argument("--icon-char", default="", help="图标字符") p_create.add_argument("--package", action="store_true", help="创建包形式插件") p_create.add_argument("--events", default="", help="订阅的事件,逗号分隔") p_create.add_argument("--commands", default="", help="需要的控制权限,逗号分隔") - p_create.add_argument("--config", action="store_true", help="需要配置系统") + p_create.add_argument("--config", type=lambda x: x.lower() == "true", default=True, help="是否需要配置系统(默认True)") p_create.add_argument("--service", action="store_true", help="需要服务接口") p_create.add_argument("--service-name", default="", help="服务接口名称") p_create.set_defaults(func=cmd_create) - + args = parser.parse_args() - + if args.command is None: parser.print_help() return - + args.func(args) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/plugin_sdk/config_types/__init__.py b/src/plugin_sdk/config_types/__init__.py index ec20966..f61678c 100644 --- a/src/plugin_sdk/config_types/__init__.py +++ b/src/plugin_sdk/config_types/__init__.py @@ -13,7 +13,7 @@ class MyPluginOtherInfo(OtherInfoBase): auto_save = BoolConfig(True, "自动保存") interval = IntConfig(30, "间隔(秒)", min_value=1, max_value=300) - theme = ChoiceConfig("dark", "主题", + theme = ChoiceConfig("dark", "主题", choices=[("light", "明亮"), ("dark", "暗黑")]) theme_color = ColorConfig("#1976d2", "主题颜色") export_path = FileConfig("", "导出文件", filter="JSON (*.json)", save_mode=True) @@ -33,7 +33,7 @@ class MyPluginOtherInfo(OtherInfoBase): from .path_config import PathConfig from .long_text_config import LongTextConfig from .range_config import RangeConfig -from .other_info import OtherInfoBase +from .other_info import OtherInfoBase, ConfigT __all__ = [ "BaseConfig", @@ -50,4 +50,5 @@ class MyPluginOtherInfo(OtherInfoBase): "LongTextConfig", "RangeConfig", "OtherInfoBase", + "ConfigT", ] diff --git a/src/plugin_sdk/config_types/base_config.py b/src/plugin_sdk/config_types/base_config.py index 1c541e2..0f20f62 100644 --- a/src/plugin_sdk/config_types/base_config.py +++ b/src/plugin_sdk/config_types/base_config.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Callable, ClassVar, Generic, TypeVar +from typing import Any, Callable, ClassVar, Generic, Type, TypeVar, overload from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import pyqtBoundSignal, pyqtSignal, QObject @@ -162,3 +162,24 @@ def validate(self, value: T) -> bool: if self.validator is not None: return self.validator(value) return True + + @overload + def __get__(self, instance: None, owner: Type[Any]) -> "BaseConfig[T]": ... + + @overload + def __get__(self, instance: Any, owner: Type[Any]) -> T: ... + + def __get__(self, instance: Any, owner: Type[Any]) -> Any: + # 运行时逻辑不需要变,因为 OtherInfoBase.__getattribute__ 会拦截它 + # 这里的代码只是为了骗过 IDE 的类型检查 + if instance is None: + return self + return self.default + + def __set__(self, instance: Any, value: T) -> None: + """ + 告诉 IDE:这个属性可以被赋予 _T 类型的值。 + 运行时逻辑依然会被 OtherInfoBase.__setattr__ 拦截, + 所以这里不需要写实际的逻辑。 + """ + pass diff --git a/src/plugin_sdk/config_types/other_info.py b/src/plugin_sdk/config_types/other_info.py index c70bf76..4e33149 100644 --- a/src/plugin_sdk/config_types/other_info.py +++ b/src/plugin_sdk/config_types/other_info.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Any, Callable, ClassVar +from typing import Any, Callable, ClassVar, TypeVar from .base_config import BaseConfig @@ -66,7 +66,8 @@ def __init__(self) -> None: # 初始化运行时值存储(初始为默认值) # 注意:这里必须使用 object.__setattr__ 因为我们要直接操作 _values 字典 - values: dict[str, Any] = {name: field.default for name, field in fields.items()} + values: dict[str, Any] = { + name: field.default for name, field in fields.items()} object.__setattr__(self, "_values", values) def set_on_change(self, callback: Callable[[str, Any], None] | None) -> None: @@ -197,3 +198,6 @@ def __repr__(self) -> str: values = object.__getattribute__(self, "_values") values_str = ", ".join(f"{k}={v!r}" for k, v in values.items()) return f"{type(self).__name__}({values_str})" + + +ConfigT = TypeVar("ConfigT", bound="OtherInfoBase",) diff --git a/src/plugin_sdk/plugin_base.py b/src/plugin_sdk/plugin_base.py index 9eefbe1..3fbb1c7 100644 --- a/src/plugin_sdk/plugin_base.py +++ b/src/plugin_sdk/plugin_base.py @@ -12,6 +12,8 @@ from __future__ import annotations from pathlib import Path +from plugin_sdk.config_types.other_info import ConfigT + from .service_registry import ServiceNotFoundError from lib_zmq_plugins.shared.base import BaseEvent, CommandResponse, get_event_tag @@ -25,7 +27,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, ClassVar, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Type, TypeVar, cast _E = TypeVar("_E", bound="BaseEvent") _T = TypeVar("_T") # 用于服务获取方法的泛型 @@ -167,7 +169,7 @@ def _values(cls) -> list[str]: @dataclass -class PluginInfo: +class PluginInfo(Generic[ConfigT]): """插件元信息""" name: str # 插件名称 @@ -182,12 +184,12 @@ class PluginInfo: icon: QIcon | None = None # 插件图标,None 使用默认蓝色问号 log_config: "LogConfig | None" = None # 日志轮转配置,None 使用全局默认值 # 插件自定义配置类(继承自 OtherInfoBase) - other_info: type["OtherInfoBase"] | None = None + other_info: Type[ConfigT] = cast(Any, OtherInfoBase) # 声明需要的控制权限(命令类型列表) required_controls: list[type] = field(default_factory=list) -class BasePlugin(QThread): +class BasePlugin(QThread, Generic[ConfigT]): """ 插件基类(继承 QThread,每个插件运行在独立线程中) @@ -214,7 +216,7 @@ def plugin_info(cls) -> PluginInfo: gui_call = pyqtSignal(object, object, object) ready = pyqtSignal(object) # 插件就绪信号(参数:插件实例) config_changed = pyqtSignal(str, object) # 配置变化信号(参数:字段名, 新值) - + _other_info: ConfigT # 队列最大容量(背压控制) MAX_QUEUE_SIZE = 4096 @@ -266,7 +268,6 @@ def __init__(self, info: PluginInfo): self._registered_protocols: list[type] = [] # ── 插件自定义配置 ── - self._other_info: OtherInfoBase | None = None self._config_manager: PluginConfigManager | None = None if info.other_info is not None: from plugin_manager.config_manager import PluginConfigManager @@ -275,12 +276,14 @@ def __init__(self, info: PluginInfo): # 实例化配置对象 self._other_info = info.other_info() # 设置配置变化回调 - self._other_info.set_on_change(self._on_config_changed) + self._other_info.set_on_change( + self._on_config_changed) # 创建配置管理器 data_dir = get_plugin_data_dir(type(self)) self._config_manager = PluginConfigManager(data_dir) # 加载配置 - self._config_manager.load(info.name, self._other_info) + self._config_manager.load( + info.name, self._other_info) def _on_config_changed(self, name: str, value: Any) -> None: """配置变化回调(在配置对象中触发,需转发到主线程发射信号)""" @@ -341,14 +344,15 @@ def log_level(self) -> LogLevel: return self._log_level @property - def other_info(self) -> OtherInfoBase | None: + def other_info(self): """插件自定义配置对象""" return self._other_info def save_config(self) -> None: """保存插件配置到文件""" if self._config_manager and self._other_info: - self._config_manager.save(self._info.name, self._other_info) + self._config_manager.save( + self._info.name, self._other_info) # type: ignore self.logger.debug(f"Config saved: {self._other_info.to_dict()}") def set_log_level(self, level: LogLevel | str) -> None: diff --git a/src/plugins/history/plugin.py b/src/plugins/history/plugin.py index ca082a8..e058a14 100644 --- a/src/plugins/history/plugin.py +++ b/src/plugins/history/plugin.py @@ -68,7 +68,7 @@ class HistoryConfig(OtherInfoBase): ) -class HistoryPlugin(BasePlugin): +class HistoryPlugin(BasePlugin[HistoryConfig]): """ 历史记录插件 diff --git a/src/plugins/llm_minesweeper_controller/plugin.py b/src/plugins/llm_minesweeper_controller/plugin.py index 4fcc62a..361335c 100644 --- a/src/plugins/llm_minesweeper_controller/plugin.py +++ b/src/plugins/llm_minesweeper_controller/plugin.py @@ -6,7 +6,7 @@ from ctypes import cast import hashlib import json -from typing import Dict, Any, Optional, List +from typing import Any, Dict, List, Optional from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import QThread, pyqtSignal @@ -552,7 +552,7 @@ def run(self): self.finished_signal.emit(False, f"执行异常: {str(e)}") -class LlmMinesweeperControllerPlugin(BasePlugin): +class LlmMinesweeperControllerPlugin(BasePlugin[LlmMinesweeperControllerConfig]): """使用 LLM 控制扫雷的插件""" _widget: LlmMinesweeperControllerWidget