mofox-plugin-dev-toolkit 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mofox_plugin_dev_toolkit-0.2.1.dist-info/METADATA +409 -0
- mofox_plugin_dev_toolkit-0.2.1.dist-info/RECORD +43 -0
- mofox_plugin_dev_toolkit-0.2.1.dist-info/WHEEL +5 -0
- mofox_plugin_dev_toolkit-0.2.1.dist-info/entry_points.txt +2 -0
- mofox_plugin_dev_toolkit-0.2.1.dist-info/licenses/LICENSE +674 -0
- mofox_plugin_dev_toolkit-0.2.1.dist-info/top_level.txt +1 -0
- mpdt/__init__.py +15 -0
- mpdt/__main__.py +8 -0
- mpdt/cli.py +314 -0
- mpdt/commands/__init__.py +9 -0
- mpdt/commands/check.py +316 -0
- mpdt/commands/dev.py +550 -0
- mpdt/commands/generate.py +366 -0
- mpdt/commands/init.py +487 -0
- mpdt/dev/bridge_plugin/__init__.py +17 -0
- mpdt/dev/bridge_plugin/discovery_server.py +126 -0
- mpdt/dev/bridge_plugin/plugin.py +258 -0
- mpdt/templates/__init__.py +165 -0
- mpdt/templates/action_template.py +102 -0
- mpdt/templates/adapter_template.py +129 -0
- mpdt/templates/chatter_template.py +103 -0
- mpdt/templates/event_template.py +116 -0
- mpdt/templates/plus_command_template.py +150 -0
- mpdt/templates/prompt_template.py +92 -0
- mpdt/templates/router_template.py +175 -0
- mpdt/templates/tool_template.py +98 -0
- mpdt/utils/__init__.py +10 -0
- mpdt/utils/color_printer.py +99 -0
- mpdt/utils/config_loader.py +171 -0
- mpdt/utils/config_manager.py +297 -0
- mpdt/utils/file_ops.py +203 -0
- mpdt/utils/license_generator.py +980 -0
- mpdt/utils/plugin_parser.py +196 -0
- mpdt/utils/template_engine.py +112 -0
- mpdt/validators/__init__.py +26 -0
- mpdt/validators/auto_fix_validator.py +182 -0
- mpdt/validators/base.py +121 -0
- mpdt/validators/component_validator.py +415 -0
- mpdt/validators/config_validator.py +173 -0
- mpdt/validators/metadata_validator.py +125 -0
- mpdt/validators/structure_validator.py +70 -0
- mpdt/validators/style_validator.py +125 -0
- mpdt/validators/type_validator.py +223 -0
mpdt/commands/dev.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mpdt dev 命令实现
|
|
3
|
+
提供热重载开发模式
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
import websockets
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.text import Text
|
|
21
|
+
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
|
22
|
+
from watchdog.observers import Observer
|
|
23
|
+
|
|
24
|
+
from mpdt.utils.config_manager import MPDTConfig, interactive_config
|
|
25
|
+
from mpdt.utils.plugin_parser import extract_plugin_name, get_plugin_info
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# 发现服务器固定端口
|
|
30
|
+
DISCOVERY_PORT = 12318
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PluginFileWatcher(FileSystemEventHandler):
|
|
34
|
+
"""插件文件监控"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, plugin_path: Path, callback, loop):
|
|
37
|
+
self.plugin_path = plugin_path
|
|
38
|
+
self.callback = callback
|
|
39
|
+
self.loop = loop # 主事件循环
|
|
40
|
+
self.last_modified = {}
|
|
41
|
+
self.debounce_delay = 0.3 # 防抖延迟(秒)
|
|
42
|
+
|
|
43
|
+
def on_modified(self, event: FileSystemEvent):
|
|
44
|
+
if event.is_directory:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# 只监控 Python 文件
|
|
48
|
+
if not event.src_path.endswith(".py"):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# 防抖处理
|
|
52
|
+
now = time.time()
|
|
53
|
+
if event.src_path in self.last_modified:
|
|
54
|
+
if now - self.last_modified[event.src_path] < self.debounce_delay:
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
self.last_modified[event.src_path] = now
|
|
58
|
+
|
|
59
|
+
# 获取相对路径
|
|
60
|
+
rel_path = Path(event.src_path).relative_to(self.plugin_path)
|
|
61
|
+
|
|
62
|
+
# 在主事件循环中调度协程
|
|
63
|
+
asyncio.run_coroutine_threadsafe(self.callback(str(rel_path)), self.loop)
|
|
64
|
+
|
|
65
|
+
def on_created(self, event: FileSystemEvent):
|
|
66
|
+
self.on_modified(event)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DevServer:
|
|
70
|
+
"""开发服务器 - 监控文件并通过 WebSocket 控制主程序"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, plugin_path: Path, config: MPDTConfig, mmc_path: Path | None = None):
|
|
73
|
+
self.plugin_path = plugin_path.absolute()
|
|
74
|
+
self.config = config
|
|
75
|
+
self.mmc_path = mmc_path or config.mmc_path
|
|
76
|
+
|
|
77
|
+
if not self.mmc_path:
|
|
78
|
+
raise ValueError("未配置 mmc 主程序路径")
|
|
79
|
+
|
|
80
|
+
self.plugin_name: str | None = None
|
|
81
|
+
self.process: subprocess.Popen | None = None
|
|
82
|
+
self.websocket: websockets.WebSocketClientProtocol | None = None
|
|
83
|
+
self.observer: Observer | None = None
|
|
84
|
+
self.main_host = "127.0.0.1"
|
|
85
|
+
self.main_port = 8000
|
|
86
|
+
self.running = False
|
|
87
|
+
|
|
88
|
+
async def start(self):
|
|
89
|
+
"""启动开发服务器"""
|
|
90
|
+
try:
|
|
91
|
+
# 1. 解析插件名称
|
|
92
|
+
await self._parse_plugin_info()
|
|
93
|
+
|
|
94
|
+
# 2. 注入 DevBridge 插件
|
|
95
|
+
await self._inject_bridge_plugin()
|
|
96
|
+
|
|
97
|
+
# 3. 启动主程序
|
|
98
|
+
await self._start_main_process()
|
|
99
|
+
|
|
100
|
+
# 4. 等待主程序启动
|
|
101
|
+
await asyncio.sleep(3)
|
|
102
|
+
|
|
103
|
+
# 5. 发现主程序端口
|
|
104
|
+
await self._discover_main_server()
|
|
105
|
+
|
|
106
|
+
# 6. 连接 WebSocket
|
|
107
|
+
await self._connect_websocket()
|
|
108
|
+
|
|
109
|
+
# 7. 等待插件加载通知
|
|
110
|
+
await self._wait_for_plugin_loaded()
|
|
111
|
+
|
|
112
|
+
# 8. 启动文件监控
|
|
113
|
+
await self._start_file_watcher()
|
|
114
|
+
|
|
115
|
+
console.print("\n[bold green]✨ 开发服务器就绪![/bold green]")
|
|
116
|
+
console.print("监控文件变化中... (Ctrl+C 退出)\n")
|
|
117
|
+
|
|
118
|
+
self.running = True
|
|
119
|
+
|
|
120
|
+
# 保持运行
|
|
121
|
+
await self._keep_alive()
|
|
122
|
+
|
|
123
|
+
except KeyboardInterrupt:
|
|
124
|
+
console.print("\n[yellow]正在退出...[/yellow]")
|
|
125
|
+
except Exception as e:
|
|
126
|
+
console.print(f"[red]错误: {e}[/red]")
|
|
127
|
+
import traceback
|
|
128
|
+
|
|
129
|
+
traceback.print_exc()
|
|
130
|
+
finally:
|
|
131
|
+
await self.stop()
|
|
132
|
+
|
|
133
|
+
async def stop(self):
|
|
134
|
+
"""停止开发服务器"""
|
|
135
|
+
self.running = False
|
|
136
|
+
|
|
137
|
+
# 停止文件监控
|
|
138
|
+
if self.observer:
|
|
139
|
+
self.observer.stop()
|
|
140
|
+
self.observer.join()
|
|
141
|
+
|
|
142
|
+
# 关闭 WebSocket
|
|
143
|
+
if self.websocket:
|
|
144
|
+
try:
|
|
145
|
+
await self.websocket.close()
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# 停止主程序 - 确保一定被关闭(包括所有子进程)
|
|
150
|
+
if self.process:
|
|
151
|
+
console.print("[cyan]🛑 正在关闭主程序...[/cyan]")
|
|
152
|
+
try:
|
|
153
|
+
import os
|
|
154
|
+
|
|
155
|
+
# Windows: 使用 taskkill 杀死整个进程树
|
|
156
|
+
if os.name == "nt":
|
|
157
|
+
try:
|
|
158
|
+
# /F 强制终止 /T 终止子进程树 /PID 指定进程ID
|
|
159
|
+
subprocess.run(
|
|
160
|
+
["taskkill", "/F", "/T", "/PID", str(self.process.pid)],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
timeout=5
|
|
163
|
+
)
|
|
164
|
+
console.print("[green]✓ 主程序及所有子进程已关闭[/green]")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
console.print(f"[yellow]taskkill 失败: {e},尝试其他方法...[/yellow]")
|
|
167
|
+
# 降级到常规方法
|
|
168
|
+
self.process.terminate()
|
|
169
|
+
try:
|
|
170
|
+
self.process.wait(timeout=3)
|
|
171
|
+
except subprocess.TimeoutExpired:
|
|
172
|
+
self.process.kill()
|
|
173
|
+
self.process.wait()
|
|
174
|
+
else:
|
|
175
|
+
# Linux/Mac: 尝试优雅终止
|
|
176
|
+
self.process.terminate()
|
|
177
|
+
try:
|
|
178
|
+
self.process.wait(timeout=3)
|
|
179
|
+
console.print("[green]✓ 主程序已优雅关闭[/green]")
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
# 超时则强制杀死进程组
|
|
182
|
+
console.print("[yellow]主程序未响应,强制关闭...[/yellow]")
|
|
183
|
+
try:
|
|
184
|
+
# 杀死整个进程组
|
|
185
|
+
os.killpg(os.getpgid(self.process.pid), 9)
|
|
186
|
+
except Exception:
|
|
187
|
+
self.process.kill()
|
|
188
|
+
self.process.wait()
|
|
189
|
+
console.print("[green]✓ 主程序已强制关闭[/green]")
|
|
190
|
+
except Exception as e:
|
|
191
|
+
console.print(f"[yellow]警告: 关闭主程序时出错: {e}[/yellow]")
|
|
192
|
+
# 最后的尝试:直接 kill
|
|
193
|
+
try:
|
|
194
|
+
self.process.kill()
|
|
195
|
+
self.process.wait()
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# 清理 DevBridge 插件
|
|
200
|
+
await self._cleanup_bridge_plugin()
|
|
201
|
+
|
|
202
|
+
console.print("[green]开发服务器已停止[/green]")
|
|
203
|
+
|
|
204
|
+
async def _parse_plugin_info(self):
|
|
205
|
+
"""解析插件信息"""
|
|
206
|
+
console.print(
|
|
207
|
+
Panel.fit(
|
|
208
|
+
f"[bold cyan]🚀 MoFox Plugin Dev Server[/bold cyan]\n\n"
|
|
209
|
+
f"📂 目录: {self.plugin_path.name}\n"
|
|
210
|
+
f"📍 路径: {self.plugin_path}"
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# 提取插件名称
|
|
215
|
+
self.plugin_name = extract_plugin_name(self.plugin_path)
|
|
216
|
+
|
|
217
|
+
if not self.plugin_name:
|
|
218
|
+
console.print("[red]❌ 无法读取插件名称[/red]")
|
|
219
|
+
console.print("\n请确保 plugin.py 中有:")
|
|
220
|
+
console.print("```python")
|
|
221
|
+
console.print("class YourPlugin(BasePlugin):")
|
|
222
|
+
console.print(' plugin_name = "your_plugin"')
|
|
223
|
+
console.print("```")
|
|
224
|
+
raise ValueError("无法解析插件名称")
|
|
225
|
+
|
|
226
|
+
console.print(f"[green]✓ 插件名: {self.plugin_name}[/green]")
|
|
227
|
+
|
|
228
|
+
async def _inject_bridge_plugin(self):
|
|
229
|
+
"""注入 DevBridge 插件到主程序"""
|
|
230
|
+
console.print("[cyan]🔗 注入开发模式插件...[/cyan]")
|
|
231
|
+
|
|
232
|
+
# DevBridge 插件源路径
|
|
233
|
+
bridge_source = Path(__file__).parent.parent / "dev" / "bridge_plugin"
|
|
234
|
+
|
|
235
|
+
if not bridge_source.exists():
|
|
236
|
+
raise FileNotFoundError(f"DevBridge 插件源不存在: {bridge_source}")
|
|
237
|
+
|
|
238
|
+
# 目标路径
|
|
239
|
+
bridge_target = self.mmc_path / "plugins" / "dev_bridge"
|
|
240
|
+
|
|
241
|
+
# 如果已存在,先删除
|
|
242
|
+
if bridge_target.exists():
|
|
243
|
+
shutil.rmtree(bridge_target)
|
|
244
|
+
|
|
245
|
+
# 复制插件
|
|
246
|
+
shutil.copytree(bridge_source, bridge_target)
|
|
247
|
+
|
|
248
|
+
console.print(f"[green]✓ DevBridge 插件已注入: {bridge_target}[/green]")
|
|
249
|
+
|
|
250
|
+
async def _cleanup_bridge_plugin(self):
|
|
251
|
+
"""清理 DevBridge 插件"""
|
|
252
|
+
bridge_target = self.mmc_path / "plugins" / "dev_bridge"
|
|
253
|
+
|
|
254
|
+
if bridge_target.exists():
|
|
255
|
+
try:
|
|
256
|
+
shutil.rmtree(bridge_target)
|
|
257
|
+
console.print("[cyan]🧹 DevBridge 插件已清理[/cyan]")
|
|
258
|
+
except Exception as e:
|
|
259
|
+
console.print(f"[yellow]警告: 清理 DevBridge 插件失败: {e}[/yellow]")
|
|
260
|
+
|
|
261
|
+
async def _start_main_process(self):
|
|
262
|
+
"""启动主程序"""
|
|
263
|
+
console.print(f"[cyan]🚀 启动主程序: {self.mmc_path / 'bot.py'}[/cyan]")
|
|
264
|
+
|
|
265
|
+
# 获取 Python 命令
|
|
266
|
+
python_cmd = self.config.get_python_command()
|
|
267
|
+
venv_type = self.config.venv_type
|
|
268
|
+
venv_path = self.config.venv_path
|
|
269
|
+
|
|
270
|
+
# 启动进程
|
|
271
|
+
try:
|
|
272
|
+
import os
|
|
273
|
+
import sys
|
|
274
|
+
|
|
275
|
+
# Windows 下打开新窗口
|
|
276
|
+
if os.name == "nt":
|
|
277
|
+
# 根据虚拟环境类型构建启动命令
|
|
278
|
+
if venv_type in ["venv", "uv"] and venv_path:
|
|
279
|
+
# venv/uv: 先激活环境再启动
|
|
280
|
+
activate_script = venv_path / "Scripts" / "activate.bat"
|
|
281
|
+
if activate_script.exists():
|
|
282
|
+
# 使用 cmd /k 保持窗口打开,先设置编码再激活和启动
|
|
283
|
+
cmd = ["cmd", "/k", f"chcp 65001 && cd /d {self.mmc_path} && {activate_script} && python bot.py"]
|
|
284
|
+
console.print(f"[dim]命令: 激活 {venv_type} 环境并启动[/dim]")
|
|
285
|
+
else:
|
|
286
|
+
# 降级到直接使用 Python 可执行文件
|
|
287
|
+
cmd = ["cmd", "/k", f"chcp 65001 && cd /d {self.mmc_path} && {python_cmd[0]} bot.py"]
|
|
288
|
+
console.print("[yellow]警告: 未找到激活脚本,使用直接启动[/yellow]")
|
|
289
|
+
elif venv_type == "conda" and venv_path:
|
|
290
|
+
# conda: 使用 conda activate
|
|
291
|
+
cmd = ["cmd", "/k", f"chcp 65001 && cd /d {self.mmc_path} && conda activate {venv_path} && python bot.py"]
|
|
292
|
+
console.print("[dim]命令: 激活 conda 环境并启动[/dim]")
|
|
293
|
+
elif venv_type == "poetry":
|
|
294
|
+
# poetry: 使用 poetry run
|
|
295
|
+
cmd = ["cmd", "/k", f"chcp 65001 && cd /d {self.mmc_path} && poetry run python bot.py"]
|
|
296
|
+
console.print("[dim]命令: 使用 poetry run 启动[/dim]")
|
|
297
|
+
else:
|
|
298
|
+
# 无虚拟环境或其他情况
|
|
299
|
+
cmd = ["cmd", "/k", f"chcp 65001 && cd /d {self.mmc_path} && python bot.py"]
|
|
300
|
+
console.print("[dim]命令: 使用系统 Python 启动[/dim]")
|
|
301
|
+
|
|
302
|
+
self.process = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE)
|
|
303
|
+
else:
|
|
304
|
+
# Linux/Mac 打开新终端窗口
|
|
305
|
+
if venv_type in ["venv", "uv"] and venv_path:
|
|
306
|
+
# venv/uv: 先激活环境再启动
|
|
307
|
+
activate_script = venv_path / "bin" / "activate"
|
|
308
|
+
if activate_script.exists():
|
|
309
|
+
shell_cmd = f"cd {self.mmc_path} && source {activate_script} && python bot.py; exec $SHELL"
|
|
310
|
+
else:
|
|
311
|
+
# 降级到直接使用 Python 可执行文件
|
|
312
|
+
shell_cmd = f"cd {self.mmc_path} && {python_cmd[0]} bot.py; exec $SHELL"
|
|
313
|
+
console.print("[yellow]警告: 未找到激活脚本,使用直接启动[/yellow]")
|
|
314
|
+
console.print(f"[dim]命令: 激活 {venv_type} 环境并启动[/dim]")
|
|
315
|
+
elif venv_type == "conda" and venv_path:
|
|
316
|
+
# conda: 使用 conda activate
|
|
317
|
+
shell_cmd = f"cd {self.mmc_path} && conda activate {venv_path} && python bot.py; exec $SHELL"
|
|
318
|
+
console.print("[dim]命令: 激活 conda 环境并启动[/dim]")
|
|
319
|
+
elif venv_type == "poetry":
|
|
320
|
+
# poetry: 使用 poetry run
|
|
321
|
+
shell_cmd = f"cd {self.mmc_path} && poetry run python bot.py; exec $SHELL"
|
|
322
|
+
console.print("[dim]命令: 使用 poetry run 启动[/dim]")
|
|
323
|
+
else:
|
|
324
|
+
# 无虚拟环境
|
|
325
|
+
shell_cmd = f"cd {self.mmc_path} && python bot.py; exec $SHELL"
|
|
326
|
+
console.print("[dim]命令: 使用系统 Python 启动[/dim]")
|
|
327
|
+
|
|
328
|
+
# 检测桌面环境并使用相应的终端
|
|
329
|
+
if sys.platform == "darwin":
|
|
330
|
+
# macOS: 使用 osascript 打开 Terminal.app
|
|
331
|
+
cmd = [
|
|
332
|
+
"osascript",
|
|
333
|
+
"-e",
|
|
334
|
+
f'tell application "Terminal" to do script "{shell_cmd}"',
|
|
335
|
+
]
|
|
336
|
+
else:
|
|
337
|
+
# Linux: 尝试常见的终端模拟器
|
|
338
|
+
terminals = [
|
|
339
|
+
("gnome-terminal", ["gnome-terminal", "--", "bash", "-c", shell_cmd]),
|
|
340
|
+
("konsole", ["konsole", "-e", "bash", "-c", shell_cmd]),
|
|
341
|
+
("xfce4-terminal", ["xfce4-terminal", "-e", f"bash -c '{shell_cmd}'"]),
|
|
342
|
+
("xterm", ["xterm", "-e", f"bash -c '{shell_cmd}'"]),
|
|
343
|
+
]
|
|
344
|
+
|
|
345
|
+
cmd = None
|
|
346
|
+
for term_name, term_cmd in terminals:
|
|
347
|
+
# 检查终端是否可用
|
|
348
|
+
if subprocess.run(["which", term_name], capture_output=True).returncode == 0:
|
|
349
|
+
cmd = term_cmd
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
if cmd is None:
|
|
353
|
+
# 降级到不打开新窗口
|
|
354
|
+
console.print("[yellow]警告: 未找到支持的终端模拟器,使用后台启动[/yellow]")
|
|
355
|
+
cmd = ["bash", "-c", f"cd {self.mmc_path} && source {activate_script} && python bot.py" if venv_type in ["venv", "uv"] and activate_script.exists() else f"cd {self.mmc_path} && python bot.py"]
|
|
356
|
+
self.process = subprocess.Popen(
|
|
357
|
+
cmd,
|
|
358
|
+
stdout=subprocess.PIPE,
|
|
359
|
+
stderr=subprocess.PIPE,
|
|
360
|
+
text=True
|
|
361
|
+
)
|
|
362
|
+
console.print("[green]✓ 主程序已启动(后台)[/green]")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
self.process = subprocess.Popen(cmd)
|
|
366
|
+
console.print("[green]✓ 主程序已启动(新窗口)[/green]")
|
|
367
|
+
except Exception as e:
|
|
368
|
+
raise RuntimeError(f"启动主程序失败: {e}")
|
|
369
|
+
|
|
370
|
+
async def _discover_main_server(self):
|
|
371
|
+
"""通过发现服务器获取主程序端口"""
|
|
372
|
+
console.print("[cyan]⏳ 等待主程序就绪...[/cyan]")
|
|
373
|
+
|
|
374
|
+
max_retries = 10
|
|
375
|
+
retry_delay = 1.0
|
|
376
|
+
|
|
377
|
+
await asyncio.sleep(10)
|
|
378
|
+
for i in range(max_retries):
|
|
379
|
+
try:
|
|
380
|
+
async with aiohttp.ClientSession() as session:
|
|
381
|
+
async with session.get(
|
|
382
|
+
f"http://127.0.0.1:{DISCOVERY_PORT}/api/server-info", timeout=aiohttp.ClientTimeout(total=2)
|
|
383
|
+
) as resp:
|
|
384
|
+
if resp.status == 200:
|
|
385
|
+
data = await resp.json()
|
|
386
|
+
self.main_host = data["host"]
|
|
387
|
+
self.main_port = data["port"]
|
|
388
|
+
console.print(f"[green]✓ 发现主程序: http://{self.main_host}:{self.main_port}[/green]")
|
|
389
|
+
return
|
|
390
|
+
except Exception as e:
|
|
391
|
+
if i < max_retries - 1:
|
|
392
|
+
console.print(f"[dim]重试 {i + 1}/{max_retries}...[/dim]")
|
|
393
|
+
await asyncio.sleep(retry_delay)
|
|
394
|
+
else:
|
|
395
|
+
raise RuntimeError(f"无法连接到发现服务器: {e}")
|
|
396
|
+
|
|
397
|
+
async def _connect_websocket(self):
|
|
398
|
+
"""连接 WebSocket"""
|
|
399
|
+
console.print("[cyan]🔌 连接开发模式接口...[/cyan]")
|
|
400
|
+
|
|
401
|
+
ws_url = f"ws://{self.main_host}:{self.main_port}/plugins/dev_bridge/dev_bridge_router/ws"
|
|
402
|
+
|
|
403
|
+
max_retries = 5
|
|
404
|
+
retry_delay = 1.0
|
|
405
|
+
|
|
406
|
+
for i in range(max_retries):
|
|
407
|
+
try:
|
|
408
|
+
self.websocket = await websockets.connect(ws_url)
|
|
409
|
+
console.print("[green]✓ 已连接到主程序[/green]")
|
|
410
|
+
return
|
|
411
|
+
except Exception as e:
|
|
412
|
+
if i < max_retries - 1:
|
|
413
|
+
console.print(f"[dim]重试 {i + 1}/{max_retries}...[/dim]")
|
|
414
|
+
await asyncio.sleep(retry_delay)
|
|
415
|
+
else:
|
|
416
|
+
raise RuntimeError(f"无法连接到 WebSocket: {e}")
|
|
417
|
+
|
|
418
|
+
async def _wait_for_plugin_loaded(self):
|
|
419
|
+
"""等待插件加载通知"""
|
|
420
|
+
console.print("[cyan]⏳ 等待插件加载...[/cyan]")
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
# 设置超时
|
|
424
|
+
async with asyncio.timeout(10):
|
|
425
|
+
while True:
|
|
426
|
+
message = await self.websocket.recv()
|
|
427
|
+
data = json.loads(message)
|
|
428
|
+
|
|
429
|
+
if data.get("type") == "plugins_loaded":
|
|
430
|
+
loaded = data.get("loaded", [])
|
|
431
|
+
failed = data.get("failed", [])
|
|
432
|
+
|
|
433
|
+
if self.plugin_name in loaded:
|
|
434
|
+
console.print(f"[green]✓ 插件已加载: {self.plugin_name}[/green]")
|
|
435
|
+
return
|
|
436
|
+
elif self.plugin_name in failed:
|
|
437
|
+
console.print(f"[red]❌ 插件加载失败: {self.plugin_name}[/red]")
|
|
438
|
+
raise RuntimeError(f"插件加载失败: {self.plugin_name}")
|
|
439
|
+
else:
|
|
440
|
+
console.print(f"[yellow]⚠️ 插件未找到: {self.plugin_name}[/yellow]")
|
|
441
|
+
raise RuntimeError(f"插件未找到: {self.plugin_name}")
|
|
442
|
+
except asyncio.TimeoutError:
|
|
443
|
+
console.print("[yellow]⚠️ 等待插件加载超时[/yellow]")
|
|
444
|
+
raise RuntimeError("等待插件加载超时")
|
|
445
|
+
|
|
446
|
+
async def _start_file_watcher(self):
|
|
447
|
+
"""启动文件监控"""
|
|
448
|
+
console.print(f"[cyan]👀 开始监控: {self.plugin_path}[/cyan]")
|
|
449
|
+
|
|
450
|
+
handler = PluginFileWatcher(
|
|
451
|
+
self.plugin_path,
|
|
452
|
+
self._on_file_changed,
|
|
453
|
+
asyncio.get_running_loop() # 传递当前事件循环
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
self.observer = Observer()
|
|
457
|
+
self.observer.schedule(handler, str(self.plugin_path), recursive=True)
|
|
458
|
+
self.observer.start()
|
|
459
|
+
|
|
460
|
+
async def _on_file_changed(self, rel_path: str):
|
|
461
|
+
"""文件变化回调"""
|
|
462
|
+
if not self.running or not self.websocket:
|
|
463
|
+
return
|
|
464
|
+
|
|
465
|
+
console.print(f"[yellow]📝 检测到变化: {rel_path}[/yellow]")
|
|
466
|
+
console.print(f"[cyan]🔄 重新加载 {self.plugin_name}...[/cyan]")
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
# 只发送重载命令,不等待响应
|
|
470
|
+
# 响应将由 _keep_alive 统一处理
|
|
471
|
+
await self.websocket.send(json.dumps({"command": "reload", "plugin_name": self.plugin_name}))
|
|
472
|
+
|
|
473
|
+
except Exception as e:
|
|
474
|
+
console.print(f"[red]❌ 发送重载命令失败: {e}[/red]\n")
|
|
475
|
+
|
|
476
|
+
async def _keep_alive(self):
|
|
477
|
+
"""保持运行并处理 WebSocket 消息"""
|
|
478
|
+
try:
|
|
479
|
+
while self.running:
|
|
480
|
+
try:
|
|
481
|
+
# 接收 WebSocket 消息
|
|
482
|
+
message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
|
|
483
|
+
|
|
484
|
+
# 处理消息
|
|
485
|
+
data = json.loads(message)
|
|
486
|
+
msg_type = data.get("type")
|
|
487
|
+
|
|
488
|
+
if msg_type == "reload_result":
|
|
489
|
+
# 重载结果
|
|
490
|
+
plugin_name = data.get("plugin_name")
|
|
491
|
+
if data.get("success"):
|
|
492
|
+
console.print(f"[green]✅ 插件 {plugin_name} 重载成功[/green]\n")
|
|
493
|
+
else:
|
|
494
|
+
console.print(f"[red]❌ 插件重载失败: {data.get('message')}[/red]\n")
|
|
495
|
+
elif msg_type == "plugin_reloaded":
|
|
496
|
+
# 广播的重载消息
|
|
497
|
+
pass
|
|
498
|
+
elif msg_type == "pong":
|
|
499
|
+
# 心跳响应
|
|
500
|
+
pass
|
|
501
|
+
|
|
502
|
+
except TimeoutError:
|
|
503
|
+
# 超时是正常的,继续循环
|
|
504
|
+
continue
|
|
505
|
+
except websockets.exceptions.ConnectionClosed:
|
|
506
|
+
console.print("[red]WebSocket 连接已断开[/red]")
|
|
507
|
+
break
|
|
508
|
+
|
|
509
|
+
except KeyboardInterrupt:
|
|
510
|
+
pass
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def dev_command(
|
|
514
|
+
plugin_path: Path | None = None,
|
|
515
|
+
mmc_path: Path | None = None,
|
|
516
|
+
):
|
|
517
|
+
"""启动开发模式
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
plugin_path: 插件路径,默认为当前目录
|
|
521
|
+
mmc_path: mmc 主程序路径,默认从配置读取
|
|
522
|
+
"""
|
|
523
|
+
# 确定插件路径
|
|
524
|
+
if plugin_path is None:
|
|
525
|
+
plugin_path = Path.cwd()
|
|
526
|
+
|
|
527
|
+
# 加载配置
|
|
528
|
+
config = MPDTConfig()
|
|
529
|
+
|
|
530
|
+
# 如果未配置,运行配置向导
|
|
531
|
+
if not config.is_configured() and mmc_path is None:
|
|
532
|
+
console.print("[yellow]未找到配置,启动配置向导...[/yellow]\n")
|
|
533
|
+
config = interactive_config()
|
|
534
|
+
|
|
535
|
+
# 如果提供了 mmc_path,使用它
|
|
536
|
+
if mmc_path:
|
|
537
|
+
config.mmc_path = mmc_path
|
|
538
|
+
|
|
539
|
+
# 验证配置
|
|
540
|
+
valid, errors = config.validate()
|
|
541
|
+
if not valid:
|
|
542
|
+
console.print("[red]配置验证失败:[/red]")
|
|
543
|
+
for error in errors:
|
|
544
|
+
console.print(f" - {error}")
|
|
545
|
+
console.print("\n请运行 [cyan]mpdt config init[/cyan] 重新配置")
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
# 创建并启动开发服务器
|
|
549
|
+
server = DevServer(plugin_path, config, mmc_path)
|
|
550
|
+
await server.start()
|