diff --git a/.gitignore b/.gitignore index 4ada49d1a..0fdff1d18 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,7 @@ celerybeat.pid # Environments .venv +.venv311/ env/ venv/ ENV/ diff --git a/bot.py b/bot.py index da2391a0d..93b22c307 100644 --- a/bot.py +++ b/bot.py @@ -215,6 +215,16 @@ class ShutdownManager: logger.info("正在优雅关闭麦麦...") start_time = time.time() + # 停止 WebUI 开发服务(如果在运行) + try: + # WebUIManager 可能在后文定义,这里只在运行阶段引用 + await WebUIManager.stop_dev_server() # type: ignore[name-defined] + except NameError: + # 若未定义(例如异常提前退出),忽略 + pass + except Exception as e: + logger.warning(f"停止WebUI开发服务时出错: {e}") + # 停止异步任务 tasks_stopped = await TaskManager.stop_async_tasks() @@ -355,6 +365,174 @@ class EasterEgg: logger.info(rainbow_text) +class WebUIManager: + """WebUI 开发服务器管理""" + + _process = None + _drain_task = None + + @staticmethod + def _resolve_webui_dir() -> Path | None: + """解析 webui 目录,优先使用同级目录 MoFox_Bot/webui,其次回退到上级目录 ../webui。 + + 也支持通过环境变量 WEBUI_DIR/WEBUI_PATH 指定绝对或相对路径。 + """ + try: + env_dir = os.getenv("WEBUI_DIR") or os.getenv("WEBUI_PATH") + if env_dir: + p = Path(env_dir).expanduser() + if not p.is_absolute(): + p = (Path(__file__).resolve().parent / p).resolve() + if p.exists(): + return p + script_dir = Path(__file__).resolve().parent + candidates = [ + script_dir / "webui", # MoFox_Bot/webui(优先) + script_dir.parent / "webui", # 上级目录/webui(兼容最初需求) + ] + for c in candidates: + if c.exists(): + return c + return None + except Exception: + return None + + @staticmethod + async def start_dev_server(timeout: float = 60.0) -> bool: + """启动 `npm run dev` 并在超时内检测是否成功。 + + 返回 True 表示检测到成功信号;False 表示失败/超时/进程退出。 + """ + try: + webui_dir = WebUIManager._resolve_webui_dir() + if not webui_dir: + logger.error("未找到 webui 目录(可在 .env 使用 WEBUI_DIR 指定路径)") + return False + + if WebUIManager._process and WebUIManager._process.returncode is None: + logger.info("WebUI 开发服务器已在运行,跳过重复启动") + return True + + logger.info(f"正在启动 WebUI 开发服务器: npm run dev (cwd={webui_dir})") + npm_exe = "npm.cmd" if platform.system().lower() == "windows" else "npm" + proc = await asyncio.create_subprocess_exec( + npm_exe, + "run", + "dev", + cwd=str(webui_dir), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + WebUIManager._process = proc + + success_keywords = [ + "compiled successfully", + "ready in", + "local:", + "listening on", + "running at:", + "started server", + "app running at:", + "ready - started server", + "vite v", # Vite 一般会输出版本与 ready in + ] + failure_keywords = [ + "err!", + "error", + "eaddrinuse", + "address already in use", + "syntaxerror", + "fatal", + "npm ERR!", + ] + + start_ts = time.time() + detected_success = False + + while True: + if proc.returncode is not None: + if proc.returncode != 0: + logger.error(f"WebUI 进程提前退出,退出码: {proc.returncode}") + else: + logger.warning("WebUI 进程已退出") + break + + try: + line = await asyncio.wait_for(proc.stdout.readline(), timeout=1.0) # type: ignore[arg-type] + except asyncio.TimeoutError: + line = b"" + + if line: + text = line.decode(errors="ignore").rstrip() + logger.info(f"[webui] {text}") + low = text.lower() + if any(k in low for k in success_keywords): + detected_success = True + break + if any(k in low for k in failure_keywords): + detected_success = False + break + + if time.time() - start_ts > timeout: + logger.warning("WebUI 启动检测超时") + break + + # 后台继续读取剩余输出,避免缓冲区阻塞 + async def _drain_rest(): + try: + while True: + line = await proc.stdout.readline() # type: ignore[union-attr] + if not line: + break + text = line.decode(errors="ignore").rstrip() + logger.info(f"[webui] {text}") + except Exception as e: + logger.debug(f"webui 日志读取停止: {e}") + + WebUIManager._drain_task = asyncio.create_task(_drain_rest()) + return bool(detected_success) + + except FileNotFoundError: + logger.error("未找到 npm,请确认已安装 Node.js 并将 npm 加入 PATH") + return False + except Exception as e: + logger.error(f"启动 WebUI 开发服务器失败: {e}") + return False + + @staticmethod + async def stop_dev_server(timeout: float = 5.0) -> bool: + """停止 WebUI 开发服务器(如果在运行)。""" + proc = WebUIManager._process + if not proc: + return True + try: + if proc.returncode is None: + try: + proc.terminate() + except ProcessLookupError: + pass + except Exception as e: + logger.debug(f"发送终止信号失败: {e}") + + try: + await asyncio.wait_for(proc.wait(), timeout=timeout) + except asyncio.TimeoutError: + try: + proc.kill() + except Exception: + pass + if WebUIManager._drain_task and not WebUIManager._drain_task.done(): + WebUIManager._drain_task.cancel() + try: + await WebUIManager._drain_task + except Exception: + pass + logger.info("WebUI 开发服务器已停止") + return True + finally: + WebUIManager._process = None + WebUIManager._drain_task = None + class MaiBotMain: """麦麦机器人主程序类""" @@ -455,6 +633,13 @@ async def main_async(): # 确保环境文件存在 ConfigManager.ensure_env_file() + # 启动 WebUI 开发服务器(成功/失败都继续后续步骤) + webui_ok = await WebUIManager.start_dev_server(timeout=60) + if webui_ok: + logger.info("WebUI 启动成功,继续下一步骤") + else: + logger.error("WebUI 启动失败,继续下一步骤") + # 创建主程序实例并执行初始化 maibot = MaiBotMain() main_system = await maibot.run_sync_init() diff --git a/src/chat/message_manager/message_manager.py b/src/chat/message_manager/message_manager.py index b6ad48d4e..437b62b90 100644 --- a/src/chat/message_manager/message_manager.py +++ b/src/chat/message_manager/message_manager.py @@ -332,14 +332,13 @@ class MessageManager: async def _check_and_handle_interruption(self, chat_stream: ChatStream | None = None): """检查并处理消息打断""" - if not global_config.chat.interruption_enabled: + if not global_config.chat.interruption_enabled or not chat_stream: return - # 检查是否有正在进行的处理任务 - if ( - chat_stream.context_manager.context.processing_task - and not chat_stream.context_manager.context.processing_task.done() - ): + # 从 chatter_manager 检查是否有正在进行的处理任务 + processing_task = self.chatter_manager.get_processing_task(chat_stream.stream_id) + + if processing_task and not processing_task.done(): # 计算打断概率 interruption_probability = chat_stream.context_manager.context.calculate_interruption_probability( global_config.chat.interruption_max_limit, global_config.chat.interruption_probability_factor @@ -357,11 +356,11 @@ class MessageManager: logger.info(f"聊天流 {chat_stream.stream_id} 触发消息打断,打断概率: {interruption_probability:.2f}") # 取消现有任务 - chat_stream.context_manager.context.processing_task.cancel() + processing_task.cancel() try: - await chat_stream.context_manager.context.processing_task + await processing_task except asyncio.CancelledError: - pass + logger.debug(f"消息打断成功取消任务: {chat_stream.stream_id}") # 增加打断计数并应用afc阈值降低 await chat_stream.context_manager.context.increment_interruption_count()