From 20672def798b5b2571b1f4b351e4d9b4b973af90 Mon Sep 17 00:00:00 2001 From: ljzloser <1312358581@qq.com> Date: Fri, 17 Apr 2026 19:22:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0LLM=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=B3=BB=E7=BB=9F=E5=92=8C=E6=B8=B8=E6=88=8F=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 29 +- src/mineSweeperGUI.py | 123 +++- src/mineSweeperGUIEvent.py | 3 + .../llm_minesweeper_controller/__init__.py | 8 + .../llm_minesweeper_controller/api_client.py | 217 ++++++ .../llm_minesweeper_controller/config.py | 104 +++ .../function_registry.py | 278 ++++++++ .../llm_minesweeper_controller/plugin.py | 636 ++++++++++++++++++ .../llm_minesweeper_controller/widgets.py | 136 ++++ src/shared_types/__init__.py | 2 + src/shared_types/commands.py | 35 +- src/shared_types/events.py | 47 +- 12 files changed, 1603 insertions(+), 15 deletions(-) create mode 100644 src/plugins/llm_minesweeper_controller/__init__.py create mode 100644 src/plugins/llm_minesweeper_controller/api_client.py create mode 100644 src/plugins/llm_minesweeper_controller/config.py create mode 100644 src/plugins/llm_minesweeper_controller/function_registry.py create mode 100644 src/plugins/llm_minesweeper_controller/plugin.py create mode 100644 src/plugins/llm_minesweeper_controller/widgets.py diff --git a/src/main.py b/src/main.py index b92dd7e..1397579 100644 --- a/src/main.py +++ b/src/main.py @@ -18,7 +18,8 @@ # 插件系统(新) from plugin_sdk import GameServerBridge from plugin_manager.app_paths import get_env_for_subprocess -from shared_types.commands import NewGameCommand +from shared_types.commands import NewGameCommand, MouseClickCommand +from shared_types.enums import GameLevel import subprocess os.environ["QT_FONT_DPI"] = "96" @@ -202,12 +203,34 @@ def cli_check_file(file_path: str) -> int: # 注册控制命令处理器(自动在主线程执行) def handle_new_game(cmd: NewGameCommand): - print(f"[NewGameCommand] rows={cmd.rows}, cols={cmd.cols}, mines={cmd.mines}") - ui.setBoard_and_start(cmd.rows, cmd.cols, cmd.mines) + """处理新游戏命令""" from lib_zmq_plugins.shared.base import CommandResponse + + # 根据 level 确定参数 + if cmd.level == GameLevel.BEGINNER.value: + rows, cols, mines = 8, 8, 10 + elif cmd.level == GameLevel.INTERMEDIATE.value: + rows, cols, mines = 16, 16, 40 + elif cmd.level == GameLevel.EXPERT.value: + rows, cols, mines = 16, 30, 99 + else: + # 自定义模式,使用传入的参数 + rows, cols, mines = cmd.rows, cmd.cols, cmd.mines + + print(f"[NewGameCommand] level={cmd.level}, rows={rows}, cols={cols}, mines={mines}") + ui.setBoard_and_start(rows, cols, mines) return CommandResponse(request_id=cmd.request_id, success=True) + def handle_mouse_click(cmd: MouseClickCommand): + """处理鼠标点击命令""" + from lib_zmq_plugins.shared.base import CommandResponse + + print(f"[MouseClickCommand] row={cmd.row}, col={cmd.col}, button={cmd.button}") + success = ui.execute_cell_click(cmd.row, cmd.col, cmd.button) + return CommandResponse(request_id=cmd.request_id, success=success) + GameServerBridge.instance().register_handler(NewGameCommand, handle_new_game) + GameServerBridge.instance().register_handler(MouseClickCommand, handle_mouse_click) # _translate = QtCore.QCoreApplication.translate hwnd = int(ui.mainWindow.winId()) diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index fbe40ad..2ae7cb8 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -7,7 +7,7 @@ # from PyQt5.QtWidgets import QApplication, QFileDialog, QWidget import gameDefinedParameter from plugin_sdk.server_bridge import GameServerBridge -from shared_types.events import VideoSaveEvent +from shared_types.events import VideoSaveEvent, BoardUpdateEvent, GameStatusChangeEvent import superGUI import gameAbout import gameSettings @@ -219,6 +219,8 @@ def game_state(self): @game_state.setter def game_state(self, game_state: str): # print(self._game_state, " -> " ,game_state) + last_state = self._game_state + match self._game_state: case "playing": self.try_append_evfs(game_state) @@ -252,7 +254,29 @@ def game_state(self, game_state: str): self.label.paint_cursor = False self.label.paintProbability = False self.num_bar_ui.QWidget.close() + self._game_state = game_state + + # 发送游戏状态变化事件 + state_map = { + "ready": 1, + "playing": 2, + "win": 3, + "fail": 4, + "joking": 2, # joking 也视为游戏中 + "jowin": 3, + "jofail": 4, + "show": 5, + "study": 6, + "display": 7, + "showdisplay": 8, + } + if last_state != game_state: + event = GameStatusChangeEvent( + last_status=state_map.get(last_state, 0), + current_status=state_map.get(game_state, 0), + ) + GameServerBridge.instance().send_event(event) @property def row(self): @@ -477,6 +501,100 @@ def mineNumWheel(self, i): # self.timer_mine_num.setSingleShot(True) # self.timer_mine_num.start(3000) + def _send_board_update_event(self): + """发送棋盘更新事件给插件""" + try: + ms_board = self.label.ms_board + # 将 game_board 转换为列表格式 + game_board_list = [] + for row in ms_board.game_board: + game_board_list.append(list(row)) + + event = BoardUpdateEvent( + rows=self.row, + cols=self.column, + game_board=game_board_list, + mines_remaining=self.mineUnFlagedNum, + game_time=ms_board.time if hasattr(ms_board, 'time') else 0.0, + ) + GameServerBridge.instance().send_event(event) + except Exception: + pass # 忽略发送失败 + + def execute_cell_click(self, row: int, col: int, button: int) -> bool: + """ + 执行格子点击(供外部命令调用) + + Args: + row: 行索引(从 0 开始) + col: 列索引(从 0 开始) + button: 鼠标按钮(0=左键, 2=右键) + + Returns: + True 表示成功执行 + """ + # 检查游戏状态 + if self.game_state not in ('ready', 'playing', 'joking'): + return False + + # 检查坐标有效性 + if row < 0 or row >= self.row or col < 0 or col >= self.column: + return False + + # 转换为像素坐标(中心点) + i = row * self.pixSize + self.pixSize // 2 + j = col * self.pixSize + self.pixSize // 2 + + try: + if button == 0: # 左键 + # 模拟点击流程:按下 -> 抬起 + self.label.ms_board.step('lc', (i, j)) + + # 处理第一次点击埋雷 + if self.game_state == 'ready': + if self.label.ms_board.mouse_state == 4: + if self.board_constraint: + self.game_state = 'joking' + else: + self.game_state = 'playing' + if self.player_identifier[:6] != "[live]": + self.disable_screenshot() + if self.cursor_limit: + self.limit_cursor() + self.start_time_unix_2 = QtCore.QDateTime.currentDateTime().toMSecsSinceEpoch() + self.timer_10ms.start() + self.score_board_manager.editing_row = -2 + # 埋雷 + self.layMine(row, col) + + self.label.ms_board.step('lr', (i, j)) + + # 检查游戏结束 + if self.label.ms_board.game_board_state == 3: + self.gameWin() + elif self.label.ms_board.game_board_state == 4: + self.gameFailed() + + elif button == 2: # 右键 + # 更新剩余雷数 + cell_state = self.label.ms_board.game_board[row][col] + if cell_state == 11: # 已标旗,取消标旗 + self.mineUnFlagedNum += 1 + self.showMineNum(self.mineUnFlagedNum) + elif cell_state == 10: # 未揭开,标旗 + self.mineUnFlagedNum -= 1 + self.showMineNum(self.mineUnFlagedNum) + + self.label.ms_board.step('rc', (i, j)) + self.label.ms_board.step('rr', (i, j)) + + self.label.update() + self._send_board_update_event() + return True + + except Exception: + return False + def gameStart(self): # 画界面,但是不埋雷。等价于点脸、f2、设置确定后的效果 self.mineUnFlagedNum = self.minenum # 没有标出的雷,显示在左上角 @@ -570,6 +688,9 @@ def gameFinished(self): data[key] = getattr(ms_board, key) event = VideoSaveEvent(**data) GameServerBridge.instance().send_event(event) + + # 发送棋盘更新事件,让插件知道最终状态 + self._send_board_update_event() def gameWin(self): # 成功后改脸和状态变量,停时间 self.timer_10ms.stop() diff --git a/src/mineSweeperGUIEvent.py b/src/mineSweeperGUIEvent.py index 2a3210f..61db2ca 100644 --- a/src/mineSweeperGUIEvent.py +++ b/src/mineSweeperGUIEvent.py @@ -69,6 +69,7 @@ def mineAreaLeftRelease(self, i, j): return else: self.label.update() + self._send_board_update_event() self.set_face(14) elif self.game_state == 'playing' or self.game_state == 'joking': # 如果是游戏中,且是左键抬起(不是双击),且是在10上,且在局面内,则用ai劫持、处理下 @@ -88,6 +89,7 @@ def mineAreaLeftRelease(self, i, j): self.label.update() return self.label.update() + self._send_board_update_event() self.set_face(14) elif self.game_state == 'show': @@ -121,6 +123,7 @@ def mineAreaRightRelease(self, i, j): self.chording_ai(i // self.pixSize, j // self.pixSize) self.label.ms_board.step('rr', (i, j)) self.label.update() + self._send_board_update_event() self.set_face(14) elif self.game_state == 'show': # 看概率时,所有操作都移出局面外 diff --git a/src/plugins/llm_minesweeper_controller/__init__.py b/src/plugins/llm_minesweeper_controller/__init__.py new file mode 100644 index 0000000..100f63c --- /dev/null +++ b/src/plugins/llm_minesweeper_controller/__init__.py @@ -0,0 +1,8 @@ +""" +llm_minesweeper_controller - 使用requests进行反向控制扫雷并处理func calling的插件 +""" +from __future__ import annotations + +from .plugin import LlmMinesweeperControllerPlugin + +__all__ = ["LlmMinesweeperControllerPlugin"] \ No newline at end of file diff --git a/src/plugins/llm_minesweeper_controller/api_client.py b/src/plugins/llm_minesweeper_controller/api_client.py new file mode 100644 index 0000000..35c51af --- /dev/null +++ b/src/plugins/llm_minesweeper_controller/api_client.py @@ -0,0 +1,217 @@ +""" +OpenAI兼容的大模型API客户端 - 使用requests + tools格式function calling +""" +from __future__ import annotations + +import json +import requests +from typing import Dict, Any, Optional, List +from dataclasses import dataclass, field + + +@dataclass +class ChatResponse: + """聊天响应数据类""" + success: bool + status_code: int = 0 + raw_data: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + # 解析后的内容 + content: Optional[str] = None # 文本内容 + tool_calls: Optional[List[Dict[str, Any]]] = None # tool_calls 列表 + finish_reason: Optional[str] = None + + @property + def has_tool_calls(self) -> bool: + """是否有 tool_calls""" + return bool(self.tool_calls) + + @property + def has_content(self) -> bool: + """是否有文本内容""" + return bool(self.content) + + +class LLMClient: + """OpenAI兼容的大模型客户端""" + + def __init__( + self, + api_key: str, + base_url: str = "https://api.openai.com/v1", + model: str = "gpt-4o-mini", + timeout: int = 60, + ): + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.model = model + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }) + + def chat( + self, + messages: List[Dict[str, Any]], + tools: Optional[List[Dict[str, Any]]] = None, + temperature: float = 0.3, + max_tokens: Optional[int] = None, + ) -> ChatResponse: + """ + 发送聊天请求 (OpenAI /v1/chat/completions) + + Args: + messages: 消息列表,格式为 [{"role": "user/assistant/system/tool", "content": "..."}] + tools: OpenAI tools格式的函数定义列表 + temperature: 温度参数 + max_tokens: 最大token数 + + Returns: + ChatResponse + """ + url = f"{self.base_url}/chat/completions" + + payload: Dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": temperature, + } + + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + return self._request("POST", url, payload) + + def test_connection(self) -> ChatResponse: + """测试API连接""" + messages = [{"role": "user", "content": "Hi, reply with OK."}] + return self.chat(messages=messages, max_tokens=10) + + def _request(self, method: str, url: str, payload: Dict[str, Any]) -> ChatResponse: + """执行HTTP请求并解析响应""" + try: + response = self.session.request( + method, url, json=payload, timeout=self.timeout + ) + + response_data = None + if response.content: + try: + response_data = response.json() + except json.JSONDecodeError: + response_data = {"raw_response": response.text} + + if response.status_code >= 400: + error_msg = "" + if response_data and "error" in response_data: + error_msg = response_data["error"].get("message", "") + return ChatResponse( + success=False, + status_code=response.status_code, + raw_data=response_data, + error=error_msg or f"HTTP {response.status_code}", + ) + + # 解析成功响应 + return self._parse_success_response(response_data) + + except requests.exceptions.Timeout: + return ChatResponse(success=False, error=f"请求超时 ({self.timeout}秒)") + except requests.exceptions.ConnectionError: + return ChatResponse(success=False, error="连接失败,请检查网络和API地址") + except requests.exceptions.RequestException as e: + return ChatResponse(success=False, error=f"请求异常: {str(e)}") + except Exception as e: + return ChatResponse(success=False, error=f"未知错误: {str(e)}") + + def _parse_success_response(self, response_data: Dict[str, Any]) -> ChatResponse: + """解析成功的API响应""" + try: + choice = response_data.get("choices", [{}])[0] + message = choice.get("message", {}) + + # 提取文本内容 + content = message.get("content") + + # 提取 tool_calls + tool_calls = message.get("tool_calls") + + # 提取 finish_reason + finish_reason = choice.get("finish_reason") + + return ChatResponse( + success=True, + status_code=200, + raw_data=response_data, + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + ) + except Exception as e: + return ChatResponse( + success=True, + status_code=200, + raw_data=response_data, + error=f"解析响应失败: {str(e)}", + ) + + @staticmethod + def build_tool_result_message(tool_call_id: str, result: Any) -> Dict[str, Any]: + """ + 构建 tool 结果消息 + + Args: + tool_call_id: tool_call 的 ID + result: 函数执行结果(会被 JSON 序列化) + + Returns: + 可直接追加到 messages 的消息字典 + """ + if isinstance(result, str): + content = result + else: + content = json.dumps(result, ensure_ascii=False) + + return { + "role": "tool", + "tool_call_id": tool_call_id, + "content": content, + } + + @staticmethod + def build_assistant_tool_message( + content: Optional[str], + tool_calls: List[Dict[str, Any]] + ) -> Dict[str, Any]: + """ + 构建 assistant 的 tool_calls 消息(用于多轮对话时保存历史) + + Args: + content: 文本内容(可能为 None) + tool_calls: tool_calls 列表 + + Returns: + 可追加到 messages 的消息字典 + """ + msg = {"role": "assistant"} + if content: + msg["content"] = content + if tool_calls: + msg["tool_calls"] = tool_calls + return msg + + def close(self): + self.session.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() \ No newline at end of file diff --git a/src/plugins/llm_minesweeper_controller/config.py b/src/plugins/llm_minesweeper_controller/config.py new file mode 100644 index 0000000..0aff491 --- /dev/null +++ b/src/plugins/llm_minesweeper_controller/config.py @@ -0,0 +1,104 @@ +""" +llm_minesweeper_controller - 配置定义 +""" +from __future__ import annotations + +from plugin_sdk import OtherInfoBase, BoolConfig, IntConfig, TextConfig, LongTextConfig, ChoiceConfig + + +class LlmMinesweeperControllerConfig(OtherInfoBase): + """插件配置""" + + # LLM API设置 + api_key = TextConfig( + default="", + label="API密钥", + ) + + api_base_url = TextConfig( + default="https://api.openai.com/v1", + label="API基础URL", + ) + + model_name = TextConfig( + default="gpt-4o-mini", + label="模型名称", + ) + + request_timeout = IntConfig( + default=60, + label="请求超时(秒)", + ) + + # 游戏设置 + default_difficulty = ChoiceConfig( + default="medium", + label="默认游戏难度", + choices=[ + ("easy", "初级 (8x8, 10雷)"), + ("medium", "中级 (16x16, 40雷)"), + ("hard", "高级 (16x30, 99雷)"), + ], + ) + + # 功能开关 + enable_auto_action = BoolConfig( + default=False, + label="自动执行LLM操作(否则需确认)", + ) + + # 提示词设置 + system_prompt = LongTextConfig( + default="""你是一个顶级扫雷AI。你的任务是通过逻辑推理,输出工具调用指令来赢下游戏。 + +# 一、 棋盘与状态定义 +- 格子状态:`-1`(未揭开)、`0-8`(周围雷数)、`F`(已标旗)、`M`(踩到的红雷)、`m`(未踩的白雷) +- 游戏状态容错:若棋盘出现 `M` 必为失败;若非雷格全揭开必为胜利;以棋盘实际画面为准,忽略错误的状态参数。 + +# 二、 可用工具 +- `get_board_state()`:获取全局视图。 +- `get_local_board(col, row, radius)`:获取局部细节,建议 radius=4。 +- `click_cell(col, row, button)`:执行操作,button仅限 `"left"`(揭开) 或 `"right"`(标旗)。 +- `start_new_game()`:失败或未初始化时调用。 + +# 三、 核心推理策略(按优先级排序) +1. **基础定式**: + - 数字 = 周围未揭开数 → 未揭开格全是雷(右键标旗)。 + - 数字 = 周围旗子数 → 剩余未揭开格全安全(左键揭开)。 +2. **减法逻辑(核心)**: + - 对比边界上相邻的两个数字,利用它们的差值与非共享未知格的数量,推断特定格子是雷还是安全。 +3. **盲猜原则(仅限无任何逻辑解时)**: + - **绝对禁止猜边角!** + - 必须选择**长连续未揭开边界的中段**点击,以最大化获取信息量。 + +# 四、 操作铁律 +1. **100%确定原则**:没有绝对把握不操作,宁可不动也不犯错。 +2. **单次限量**:每次推理后,只执行 1-3 个确定格子的操作。 +3. **标旗优先**:在既可标旗又可揭开的场景下,优先标旗(标旗不会触发死亡,且能降低后续推理复杂度)。 +4. **禁止重复操作**:绝不能点击 0-8 的格子或 F 的格子。 + +# 五、 输出格式要求(严格遵守) +不要输出任何解释性文本、问候语或分析过程。你的输出必须且只能是以下两种格式之一: + +【格式1:执行操作】 + +工具名称(参数) + + +一句理由,不超过15字,如:减法逻辑(5,4)安全 + + +【格式2:需要更多信息】 + +get_board_state() + + +初始化/查看全局 +""", + label="系统提示词", + ) + + temperature = IntConfig( + default=30, + label="温度参数(0-100)", + ) diff --git a/src/plugins/llm_minesweeper_controller/function_registry.py b/src/plugins/llm_minesweeper_controller/function_registry.py new file mode 100644 index 0000000..a637c9b --- /dev/null +++ b/src/plugins/llm_minesweeper_controller/function_registry.py @@ -0,0 +1,278 @@ +""" +Function注册表 - 装饰器注册 + OpenAI tools格式 + 执行处理 +""" +from __future__ import annotations + +import inspect +import json +from typing import Dict, Any, Callable, List, Optional, get_type_hints +from dataclasses import dataclass, field + + +@dataclass +class ParameterInfo: + """参数信息""" + name: str + type: str + description: str = "" + required: bool = False + default: Any = None + enum_values: Optional[List[str]] = None + + +@dataclass +class FunctionInfo: + """函数信息""" + name: str + description: str + parameters: List[ParameterInfo] = field(default_factory=list) + function: Optional[Callable] = None + + +class FunctionRegistry: + """Function注册表 - 装饰器注册、schema生成、执行处理""" + + def __init__(self): + self.functions: Dict[str, FunctionInfo] = {} + + # ═══════════════════════════════════════════════════════════════ + # 注册相关 + # ═══════════════════════════════════════════════════════════════ + + def register( + self, + name: Optional[str] = None, + description: Optional[str] = None, + param_descriptions: Optional[Dict[str, str]] = None + ): + """ + 装饰器:注册函数供 LLM 调用 + + Example: + @registry.register( + description="点击格子", + param_descriptions={"x": "X坐标", "y": "Y坐标"} + ) + def click_cell(x: int, y: int) -> Dict[str, Any]: + return {"success": True} + """ + def decorator(func: Callable) -> Callable: + func_name = name or func.__name__ + func_description = description or (func.__doc__ or "").strip() + + sig = inspect.signature(func) + type_hints = get_type_hints(func) + + parameters = [] + for param_name, param in sig.parameters.items(): + if param_name == 'self': + continue + + param_type = type_hints.get(param_name, Any) + type_str = self._get_type_string(param_type) + param_desc = (param_descriptions or {}).get(param_name, "") + required = param.default == inspect.Parameter.empty + default = param.default if param.default != inspect.Parameter.empty else None + + enum_values = None + if hasattr(param_type, '__members__'): + enum_values = list(param_type.__members__.keys()) + + parameters.append(ParameterInfo( + name=param_name, + type=type_str, + description=param_desc, + required=required, + default=default, + enum_values=enum_values + )) + + self.functions[func_name] = FunctionInfo( + name=func_name, + description=func_description, + parameters=parameters, + function=func + ) + return func + return decorator + + def register_function( + self, + func: Callable, + name: Optional[str] = None, + description: Optional[str] = None, + param_descriptions: Optional[Dict[str, str]] = None + ) -> None: + """直接注册函数(非装饰器方式)""" + decorator = self.register(name, description, param_descriptions) + decorator(func) + + # ═══════════════════════════════════════════════════════════════ + # Schema 生成 + # ═══════════════════════════════════════════════════════════════ + + def get_tools_schema(self) -> List[Dict[str, Any]]: + """获取 OpenAI tools 格式 schema""" + schemas = [] + for func_info in self.functions.values(): + properties = {} + required = [] + + for param in func_info.parameters: + param_schema = {"type": param.type} + if param.description: + param_schema["description"] = param.description + if param.enum_values: + param_schema["enum"] = param.enum_values + properties[param.name] = param_schema + + if param.required: + required.append(param.name) + + tool_schema = { + "type": "function", + "function": { + "name": func_info.name, + "description": func_info.description, + "parameters": { + "type": "object", + "properties": properties, + } + } + } + + if required: + tool_schema["function"]["parameters"]["required"] = required + + schemas.append(tool_schema) + + return schemas + + # ═══════════════════════════════════════════════════════════════ + # 执行相关 + # ═══════════════════════════════════════════════════════════════ + + def execute_function(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + """执行注册的函数""" + func_info = self.get_function(name) + if not func_info or not func_info.function: + return {"success": False, "error": f"函数 '{name}' 未注册"} + + try: + sig = inspect.signature(func_info.function) + valid_params = {} + + for param_name, param_value in arguments.items(): + if param_name in sig.parameters: + # 类型转换 + param_type = sig.parameters[param_name].annotation + if param_type != inspect.Parameter.empty: + try: + if param_type == int: + param_value = int(param_value) + elif param_type == float: + param_value = float(param_value) + elif param_type == bool: + param_value = bool(param_value) + except (ValueError, TypeError): + pass + valid_params[param_name] = param_value + + result = func_info.function(**valid_params) + + if isinstance(result, dict): + if "success" not in result: + result["success"] = True + return result + else: + return {"success": True, "result": result} + + except Exception as e: + return {"success": False, "error": f"函数执行失败: {str(e)}"} + + def handle_tool_calls(self, tool_calls: List[Dict[str, Any]], + logger=None, widget=None) -> List[Dict[str, Any]]: + """ + 处理 OpenAI 响应中的 tool_calls,返回工具结果消息列表 + + Args: + tool_calls: OpenAI 响应中的 tool_calls 字段 + logger: 可选的日志记录器 + widget: 可选的 UI 组件(用于显示日志) + + Returns: + 工具结果消息列表,可直接追加到 messages 中 + """ + results = [] + for tool_call in tool_calls: + tool_call_id = tool_call.get("id", "") + function_data = tool_call.get("function", {}) + func_name = function_data.get("name", "") + + try: + func_args_str = function_data.get("arguments", "{}") + func_args = json.loads(func_args_str) if isinstance(func_args_str, str) else func_args_str + except json.JSONDecodeError: + func_args = {} + + if logger: + logger.info(f"处理 tool call: {func_name}, 参数: {func_args}") + if widget: + widget.log_message(f"LLM 调用: {func_name}({func_args})") + + # 执行函数 + result = self.execute_function(func_name, func_args) + + if widget: + if result.get("success"): + widget.log_message(f"执行成功: {func_name}") + else: + widget.log_message(f"执行失败: {func_name} - {result.get('error', '未知错误')}") + + # 构建工具结果消息 + result_msg = { + "role": "tool", + "tool_call_id": tool_call_id, + "content": json.dumps(result, ensure_ascii=False) + } + results.append(result_msg) + + return results + + # ═══════════════════════════════════════════════════════════════ + # 工具方法 + # ═══════════════════════════════════════════════════════════════ + + def get_function(self, name: str) -> Optional[FunctionInfo]: + return self.functions.get(name) + + def get_all_functions(self) -> List[FunctionInfo]: + return list(self.functions.values()) + + def list_function_names(self) -> List[str]: + return list(self.functions.keys()) + + def clear(self) -> None: + self.functions.clear() + + def remove_function(self, name: str) -> bool: + if name in self.functions: + del self.functions[name] + return True + return False + + @staticmethod + def _get_type_string(type_hint) -> str: + """将类型提示转换为 JSON Schema 类型字符串""" + if type_hint == int: + return "integer" + elif type_hint == float: + return "number" + elif type_hint == bool: + return "boolean" + elif type_hint == list or (hasattr(type_hint, '__origin__') and type_hint.__origin__ == list): + return "array" + elif type_hint == dict or (hasattr(type_hint, '__origin__') and type_hint.__origin__ == dict): + return "object" + else: + return "string" \ No newline at end of file diff --git a/src/plugins/llm_minesweeper_controller/plugin.py b/src/plugins/llm_minesweeper_controller/plugin.py new file mode 100644 index 0000000..349c692 --- /dev/null +++ b/src/plugins/llm_minesweeper_controller/plugin.py @@ -0,0 +1,636 @@ +""" +llm_minesweeper_controller - 插件主类 +""" +from __future__ import annotations + +import json +from typing import Dict, Any, Optional, List + +from PyQt5.QtWidgets import QWidget +from PyQt5.QtCore import QThread, pyqtSignal + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import BoardUpdateEvent, GameStatusChangeEvent +from shared_types.commands import NewGameCommand, MouseClickCommand + +from .widgets import LlmMinesweeperControllerWidget +from .config import LlmMinesweeperControllerConfig +from .api_client import LLMClient, ChatResponse +from .function_registry import FunctionRegistry + + +class LLMWorker(QThread): + """LLM 工作线程""" + + log_signal = pyqtSignal(str) + chat_signal = pyqtSignal(str, str) # role, text + finished_signal = pyqtSignal(bool, str) # success, message + + def __init__(self, client: LLMClient, registry: FunctionRegistry, + messages: List[Dict[str, Any]]): + super().__init__() + self.client = client + self.registry = registry + self.messages = messages + + def run(self): + """执行多轮对话循环(无上限)""" + try: + tools = self.registry.get_tools_schema() + round_num = 0 + + while True: + round_num += 1 + self.log_signal.emit(f"=== 第 {round_num} 轮对话 ===") + + # 调用 LLM + response: ChatResponse = self.client.chat( + messages=self.messages, + tools=tools, + temperature=0.3, + ) + + if not response.success: + self.finished_signal.emit( + False, f"API 调用失败: {response.error}") + return + + # 显示 LLM 文本回复 + if response.has_content: + self.chat_signal.emit("assistant", response.content) + + # 检查是否需要调用工具 + if response.has_tool_calls: + # 将 assistant 消息加入历史 + self.messages.append( + LLMClient.build_assistant_tool_message( + response.content, + response.tool_calls + ) + ) + + # 处理每个 tool_call + for tool_call in response.tool_calls: + tool_call_id = tool_call.get("id", "") + func_data = tool_call.get("function", {}) + func_name = func_data.get("name", "") + + try: + func_args_str = func_data.get("arguments", "{}") + func_args = json.loads(func_args_str) if isinstance( + func_args_str, str) else func_args_str + except json.JSONDecodeError: + func_args = {} + + self.log_signal.emit(f"调用函数: {func_name}({func_args})") + self.chat_signal.emit( + "tool", f"{func_name}({func_args})") + + # 执行函数 + result = self.registry.execute_function( + func_name, func_args) + + self.log_signal.emit(f"执行结果: {result}") + + # 构建 tool 结果消息 + tool_msg = LLMClient.build_tool_result_message( + tool_call_id, result) + self.messages.append(tool_msg) + + # 继续下一轮对话(让 LLM 处理工具结果) + continue + + # 没有 tool_calls,对话结束 + self.finished_signal.emit(True, "LLM 分析完成") + return + + except Exception as e: + self.finished_signal.emit(False, f"执行异常: {str(e)}") + + +class LlmMinesweeperControllerPlugin(BasePlugin): + """使用 LLM 控制扫雷的插件""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="llm_minesweeper_controller", + version="1.0.0", + description="使用 LLM 分析并控制扫雷游戏", + window_mode=WindowMode.TAB, + icon=make_plugin_icon("#4CAF50", "L"), + other_info=LlmMinesweeperControllerConfig, + required_controls=[NewGameCommand, MouseClickCommand], + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(BoardUpdateEvent, self._on_board_update) + self.subscribe(GameStatusChangeEvent, self._on_game_status_change) + + def _create_widget(self) -> QWidget | None: + self._widget = LlmMinesweeperControllerWidget() + return self._widget + + def on_initialized(self) -> None: + self.logger.info("LlmMinesweeperControllerPlugin 已初始化") + + # 检查控制权限状态 + self._log_control_auth_status() + + # 初始化组件 + self._init_llm_client() + self._init_function_registry() + + # 设置 UI 回调 + self._widget.set_test_button_callback(self._test_connection) + self._widget.set_analyze_callback(self._start_analysis) + + # 监听配置变化 + self.config_changed.connect(self._on_config_changed) + + # 当前棋盘状态 + self._current_board: Optional[Dict[str, Any]] = None + + # 当前游戏状态 (1=ready, 2=playing, 3=win, 4=fail, ...) + self._game_status: int = 1 + + # 当前工作线程 + self._worker: Optional[LLMWorker] = None + + def _log_control_auth_status(self) -> None: + """检查控制权限""" + has_new_game = self.has_control_auth(NewGameCommand) + has_click = self.has_control_auth(MouseClickCommand) + self.logger.info(f"NewGameCommand 权限: {has_new_game}") + self.logger.info(f"MouseClickCommand 权限: {has_click}") + self._widget.log_message( + f"权限: NewGame={has_new_game}, MouseClick={has_click}") + + def _init_llm_client(self) -> None: + """初始化 LLM 客户端""" + api_key = self.other_info.api_key + base_url = self.other_info.api_base_url + model = self.other_info.model_name + timeout = self.other_info.request_timeout + + if api_key: + self.llm_client = LLMClient( + api_key=api_key, + base_url=base_url, + model=model, + timeout=timeout, + ) + self.logger.info("LLM 客户端已初始化") + self._widget.log_message(f"LLM 客户端已初始化 (model: {model})") + else: + self.llm_client = None + self.logger.warning("未配置 API 密钥") + self._widget.log_message("未配置 API 密钥,LLM 功能不可用") + + def _init_function_registry(self) -> None: + """初始化 Function 注册表""" + self.function_registry = FunctionRegistry() + self._register_minesweeper_functions() + + def _register_minesweeper_functions(self) -> None: + """注册扫雷相关的函数""" + registry = self.function_registry + + @registry.register( + description="点击指定位置的格子", + param_descriptions={ + "col": "列索引 (从 0 开始,即 X 坐标)", + "row": "行索引 (从 0 开始,即 Y 坐标)", + "button": "鼠标按钮: 'left' 左键揭开, 'right' 右键标旗", + } + ) + def click_cell(col: int, row: int, button: str = "left") -> Dict[str, Any]: + return self._execute_click_cell(col, row, button) + + @registry.register( + description="开始新游戏,不传难度参数则使用默认难度", + param_descriptions={ + "difficulty": "游戏难度(可选): 'easy' 初级(8x8), 'medium' 中级(16x16), 'hard' 高级(16x30)", + } + ) + def start_new_game(difficulty: str = None) -> Dict[str, Any]: + return self._execute_start_new_game(difficulty) + + @registry.register( + description="获取当前棋盘状态", + ) + def get_board_state() -> Dict[str, Any]: + return self._get_current_board_state() + + @registry.register( + description="获取局部棋盘区域,返回以(col,row)为中心的局部格子,radius自己决定(建议3-5)", + param_descriptions={ + "col": "中心列索引 (从 0 开始)", + "row": "中心行索引 (从 0 开始)", + "radius": "半径,自己决定大小,默认3,返回(2*radius+1)x(2*radius+1)的区域", + } + ) + def get_local_board(col: int, row: int, radius: int = 3) -> Dict[str, Any]: + return self._get_local_board(col, row, radius) + + def on_control_auth_changed(self, cmd_type, granted: bool) -> None: + """控制权限变更回调""" + if cmd_type == NewGameCommand: + self._widget.log_message( + f"NewGameCommand 权限: {'已授权' if granted else '未授权'}") + elif cmd_type == MouseClickCommand: + self._widget.log_message( + f"MouseClickCommand 权限: {'已授权' if granted else '未授权'}") + + def _on_config_changed(self, name: str, value) -> None: + """配置变化回调""" + self.logger.info(f"配置变化: {name} = {value}") + self._widget.log_message(f"配置更新: {name}") + + # API 相关配置变化时重新初始化客户端 + if name in ["api_key", "api_base_url", "model_name", "request_timeout"]: + self._init_llm_client() + + def _on_board_update(self, event: BoardUpdateEvent) -> None: + """处理棋盘更新事件""" + self._widget.log_message("收到棋盘更新事件") + + # 更新当前棋盘状态 + self._current_board = self._extract_board_data(event) + + def _on_game_status_change(self, event: GameStatusChangeEvent) -> None: + """处理游戏状态变化事件 + + 状态值: + - 1: ready (准备) + - 2: playing (游戏中) + - 3: win (胜利) + - 4: fail (失败) + - 5: show (显示概率) + - 6: study (研究模式) + - 7/8: display 相关 + """ + status_names = { + 1: "准备", + 2: "游戏中", + 3: "胜利", + 4: "失败", + 5: "显示概率", + 6: "研究模式", + 7: "播放录像", + 8: "播放概率", + } + + last_name = status_names.get( + event.last_status, f"未知({event.last_status})") + current_name = status_names.get( + event.current_status, f"未知({event.current_status})") + + self._widget.log_message(f"游戏状态变化: {last_name} -> {current_name}") + + # 记录当前游戏状态 + self._game_status = event.current_status + + # 如果游戏结束(胜利或失败),更新状态显示 + if event.current_status == 3: + self._widget.update_status("游戏胜利!") + elif event.current_status == 4: + self._widget.update_status("游戏失败!") + + # ═══════════════════════════════════════════════════════════════ + # LLM 对话流程 + # ═══════════════════════════════════════════════════════════════ + + def _test_connection(self) -> None: + """测试 API 连接""" + if not self.llm_client: + self._widget.log_message("请先配置 API 密钥") + return + + self._widget.log_message("正在测试连接...") + self._widget.set_buttons_enabled(False) + + response = self.llm_client.test_connection() + + if response.success: + self._widget.update_status("连接成功") + self._widget.log_message(f"连接成功! 模型: {self.other_info.model_name}") + else: + self._widget.update_status("连接失败") + self._widget.log_message(f"连接失败: {response.error}") + + self._widget.set_buttons_enabled(True) + + def _auto_continue_analysis(self) -> None: + """自动继续分析(异常中断后)""" + if self._game_status != 2: # 不是进行中 + self._widget.log_message("游戏已结束,不再继续分析") + return + + if self._worker and self._worker.isRunning(): + return + + self._widget.log_message("自动继续分析...") + self._start_analysis() + + def _start_analysis(self) -> None: + """开始 LLM 分析""" + if not self.llm_client: + self._widget.log_message("请先配置 API 密钥") + return + + if self._worker and self._worker.isRunning(): + self._widget.log_message("已有分析任务在运行") + return + + # 构建初始消息 + messages = self._build_initial_messages() + + # 创建并启动工作线程 + self._worker = LLMWorker( + client=self.llm_client, + registry=self.function_registry, + messages=messages, + ) + + # 连接信号 + self._worker.log_signal.connect(self._widget.log_message) + self._worker.chat_signal.connect(self._widget.add_chat_message) + self._worker.finished_signal.connect(self._on_analysis_finished) + + self._widget.set_buttons_enabled(False) + self._worker.start() + + def _on_analysis_finished(self, success: bool, message: str) -> None: + """分析完成回调""" + self._widget.set_buttons_enabled(True) + + if success: + self._widget.update_status("分析完成") + else: + self._widget.update_status("分析中断") + self._widget.log_message(message) + + # 无论成功还是失败,如果游戏状态是进行中,自动继续分析 + # 防止AI没有进行任何函数调用就结束 + if self._game_status == 2: # playing + self._widget.log_message("游戏进行中,1秒后继续分析...") + from PyQt5.QtCore import QTimer + QTimer.singleShot(1000, self._auto_continue_analysis) + + def _build_initial_messages(self) -> List[Dict[str, Any]]: + """构建初始消息列表""" + messages = [] + + # 系统提示词 + system_prompt = self.other_info.system_prompt + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + + # 当前棋盘状态 + board_state = self._get_current_board_state() + game_status = board_state.get('game_status', 'unknown') + + board_info = f"""当前棋盘状态: +- 行数: {board_state.get('rows', 0)} +- 列数: {board_state.get('cols', 0)} +- 剩余地雷: {board_state.get('mines_remaining', 0)} +- 游戏时间: {board_state.get('game_time', 0):.1f}秒 +- 游戏状态: {game_status} +- 棋盘数据 (cells[row][col], -1=未揭开, 0-8=周围地雷数, F=标旗, M=踩到的地雷): +{json.dumps(board_state.get('cells', []), ensure_ascii=False)} + +请分析当前局面并选择最佳操作。""" + + messages.append({"role": "user", "content": board_info}) + + return messages + + # ═══════════════════════════════════════════════════════════════ + # 可调用的函数实现 + # ═══════════════════════════════════════════════════════════════ + + def _execute_click_cell(self, col: int, row: int, button: str = "left") -> Dict[str, Any]: + """执行点击格子操作 + + Args: + col: 列索引 (x 坐标, 从 0 开始) + row: 行索引 (y 坐标, 从 0 开始) + button: 鼠标按钮 ("left" 或 "right") + """ + if not self.has_control_auth(MouseClickCommand): + return {"success": False, "error": "无权限执行鼠标点击命令"} + + try: + # button 转换: "left" -> 0, "right" -> 2 + button_map = {"left": 0, "right": 2, "middle": 1} + button_value = button_map.get(button.lower(), 0) + + # 构建并发送鼠标点击命令 + click_cmd = MouseClickCommand( + row=row, + col=col, + button=button_value, + ) + self.send_command(click_cmd) + + self._widget.log_message(f"已点击格子: 行{row}, 列{col}, 按钮: {button}") + return { + "success": True, + "message": f"格子点击已执行: 行{row}, 列{col}, {button}", + "coordinates": {"row": row, "col": col}, + "button": button, + } + + except Exception as e: + error_msg = f"执行格子点击失败: {str(e)}" + self.logger.error(error_msg) + return {"success": False, "error": error_msg} + + def _execute_start_new_game(self, difficulty: str = None) -> Dict[str, Any]: + """执行开始新游戏操作 + + Args: + difficulty: 游戏难度 ("easy", "medium", "hard"),默认使用配置中的 default_difficulty + """ + if not self.has_control_auth(NewGameCommand): + return {"success": False, "error": "无权限执行新游戏命令"} + + try: + # 使用配置中的默认难度 + if difficulty is None: + difficulty = self.other_info.default_difficulty + + # 难度映射: level 值 + # BEGINNER = 3, INTERMEDIATE = 4, EXPERT = 5 + difficulty_map = { + "easy": 3, # 初级 + "medium": 4, # 中级 + "hard": 5, # 高级 + } + + level = difficulty_map.get(difficulty, 4) + + # 清空当前棋盘状态 + self._current_board = None + + # 发送新游戏命令(使用 level 字段) + new_game_cmd = NewGameCommand(level=level) + self.send_command(new_game_cmd) + + # 获取对应的行列地雷数用于日志 + board_params = { + "easy": (8, 8, 10), + "medium": (16, 16, 40), + "hard": (16, 30, 99), + } + rows, cols, mines = board_params.get(difficulty, (16, 16, 40)) + + self._widget.log_message( + f"已开始新游戏,难度: {difficulty} ({rows}x{cols}, {mines}雷)") + return { + "success": True, + "message": f"新游戏已开始,难度: {difficulty}", + "difficulty": difficulty, + "level": level, + "rows": rows, + "cols": cols, + "mines": mines, + } + + except Exception as e: + error_msg = f"开始新游戏失败: {str(e)}" + self.logger.error(error_msg) + return {"success": False, "error": error_msg} + + def _get_game_status_text(self) -> str: + """获取游戏状态的文字描述""" + status_map = { + 1: "ready", # 准备 + 2: "playing", # 游戏中 + 3: "win", # 胜利 + 4: "fail", # 失败 + 5: "show", # 显示概率 + 6: "study", # 研究模式 + 7: "display", # 播放录像 + 8: "showdisplay", # 播放概率 + } + return status_map.get(self._game_status, "unknown") + + def _get_current_board_state(self) -> Dict[str, Any]: + """获取当前棋盘状态""" + if self._current_board: + result = self._current_board.copy() + result["game_status"] = self._get_game_status_text() + return result + return { + "rows": 0, + "cols": 0, + "cells": [], + "mines_remaining": 0, + "game_time": 0.0, + "game_status": self._get_game_status_text(), + } + + def _get_local_board(self, col: int, row: int, radius: int = 2) -> Dict[str, Any]: + """获取局部棋盘区域 + + Args: + col: 中心列索引 + row: 中心行索引 + radius: 半径,默认2(返回5x5区域) + + Returns: + 局部棋盘数据,包含: + - cells: 局部格子数据 + - center: 中心坐标(相对局部区域) + - offset: 局部区域在全图的起始偏移 + - size: 局部区域大小 + - full_board_size: 全图大小 + """ + if not self._current_board or not self._current_board.get("cells"): + return { + "cells": [], + "center": {"col": 0, "row": 0}, + "offset": {"col": 0, "row": 0}, + "size": {"rows": 0, "cols": 0}, + "full_board_size": {"rows": 0, "cols": 0}, + "error": "棋盘数据不可用", + } + + full_cells = self._current_board.get("cells", []) + full_rows = len(full_cells) + full_cols = len(full_cells[0]) if full_cells else 0 + + if full_rows == 0 or full_cols == 0: + return { + "cells": [], + "center": {"col": 0, "row": 0}, + "offset": {"col": 0, "row": 0}, + "size": {"rows": 0, "cols": 0}, + "full_board_size": {"rows": 0, "cols": 0}, + "error": "棋盘为空", + } + + # 计算局部区域边界 + start_row = max(0, row - radius) + end_row = min(full_rows, row + radius + 1) + start_col = max(0, col - radius) + end_col = min(full_cols, col + radius + 1) + + # 提取局部格子 + local_cells = [] + for r in range(start_row, end_row): + local_row = [] + for c in range(start_col, end_col): + local_row.append(full_cells[r][c]) + local_cells.append(local_row) + + # 计算中心在局部区域中的位置 + center_col = col - start_col + center_row = row - start_row + + return { + "cells": local_cells, + "center": {"col": center_col, "row": center_row}, + "offset": {"col": start_col, "row": start_row}, + "size": {"rows": len(local_cells), "cols": len(local_cells[0]) if local_cells else 0}, + "full_board_size": {"rows": full_rows, "cols": full_cols}, + } + + def _extract_board_data(self, event: BoardUpdateEvent) -> Dict[str, Any]: + """从事件中提取棋盘数据 + + game_board 值含义: + - 0-8: 已揭开的数字格子 + - 10: 未揭开的格子 + - 11: 标旗的格子 + - 15: 踩到的地雷(红雷),游戏失败 + - 16: 未踩到的地雷(白雷) + """ + game_board = event.game_board or [] + + # 转换为 LLM 友好格式:-1=未揭开, 0-8=数字, F=标旗, M=地雷(踩到), m=地雷(未踩到) + cells = [] + for row in game_board: + row_data = [] + for cell in row: + if cell == 10: # 未揭开 + row_data.append(-1) + elif cell == 11: # 标旗 + row_data.append("F") + elif cell == 15: # 踩到的地雷(红雷),游戏失败 + row_data.append("M") + elif cell == 16: # 未踩到的地雷(白雷) + row_data.append("m") + else: # 0-8 数字 + row_data.append(cell) + cells.append(row_data) + + return { + "rows": event.rows, + "cols": event.cols, + "cells": cells, + "mines_remaining": event.mines_remaining, + "game_time": event.game_time, + } diff --git a/src/plugins/llm_minesweeper_controller/widgets.py b/src/plugins/llm_minesweeper_controller/widgets.py new file mode 100644 index 0000000..3cf4f0e --- /dev/null +++ b/src/plugins/llm_minesweeper_controller/widgets.py @@ -0,0 +1,136 @@ +""" +llm_minesweeper_controller - UI 组件 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QTextEdit, QPushButton, + QHBoxLayout, QGroupBox, QSplitter, +) +from PyQt5.QtCore import pyqtSignal, Qt + + +class LlmMinesweeperControllerWidget(QWidget): + """插件 UI""" + + _log_signal = pyqtSignal(str) + _chat_signal = pyqtSignal(str, str) # role, text + _status_signal = pyqtSignal(str) + _enable_buttons_signal = pyqtSignal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + # 状态显示 + status_group = QGroupBox("状态") + status_layout = QHBoxLayout() + self._status_label = QLabel("就绪") + self._status_label.setStyleSheet("font-weight: bold;") + status_layout.addWidget(self._status_label) + status_group.setLayout(status_layout) + layout.addWidget(status_group) + + # 对话显示区 + chat_group = QGroupBox("LLM对话") + chat_layout = QVBoxLayout() + self._chat_text = QTextEdit() + self._chat_text.setReadOnly(True) + self._chat_text.setStyleSheet(""" + QTextEdit { + font-family: Consolas, 'Microsoft YaHei', monospace; + font-size: 13px; + } + """) + chat_layout.addWidget(self._chat_text) + chat_group.setLayout(chat_layout) + layout.addWidget(chat_group, stretch=1) + + # 日志显示区 + log_group = QGroupBox("日志") + log_layout = QVBoxLayout() + self._log_text = QTextEdit() + self._log_text.setReadOnly(True) + self._log_text.setMaximumHeight(120) + self._log_text.setStyleSheet("font-size: 11px; color: #666;") + log_layout.addWidget(self._log_text) + log_group.setLayout(log_layout) + layout.addWidget(log_group) + + # 控制按钮 + button_layout = QHBoxLayout() + self._analyze_button = QPushButton("🤖 分析并操作") + self._analyze_button.setStyleSheet("padding: 6px; font-size: 14px;") + self._test_button = QPushButton("🔗 测试连接") + self._clear_chat_button = QPushButton("🗑 清除对话") + self._clear_log_button = QPushButton("🗑 清除日志") + button_layout.addWidget(self._analyze_button) + button_layout.addWidget(self._test_button) + button_layout.addWidget(self._clear_chat_button) + button_layout.addWidget(self._clear_log_button) + layout.addLayout(button_layout) + + # 信号连接 + self._log_signal.connect(self._on_log) + self._chat_signal.connect(self._on_chat) + self._status_signal.connect(self._on_status) + self._enable_buttons_signal.connect(self._on_enable_buttons) + + # 按钮事件 + self._clear_log_button.clicked.connect(self._clear_log) + self._clear_chat_button.clicked.connect(self._clear_chat) + + def _on_log(self, text: str) -> None: + from datetime import datetime + timestamp = datetime.now().strftime("%H:%M:%S") + self._log_text.append(f"[{timestamp}] {text}") + + def _on_chat(self, role: str, text: str) -> None: + color_map = { + "system": "#2196F3", + "assistant": "#4CAF50", + "tool": "#FF9800", + "user": "#9C27B0", + "error": "#F44336", + } + color = color_map.get(role, "#333") + self._chat_text.append( + f'[{role}] ' + f'{text}' + ) + # 滚动到底部 + scrollbar = self._chat_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + def _on_status(self, text: str) -> None: + self._status_label.setText(text) + + def _on_enable_buttons(self, enabled: bool) -> None: + self._analyze_button.setEnabled(enabled) + self._test_button.setEnabled(enabled) + + def _clear_log(self) -> None: + self._log_text.clear() + + def _clear_chat(self) -> None: + self._chat_text.clear() + + # ── 线程安全的公开方法(由插件调用) ── + + def log_message(self, text: str) -> None: + self._log_signal.emit(text) + + def add_chat_message(self, role: str, text: str) -> None: + self._chat_signal.emit(role, text) + + def update_status(self, text: str) -> None: + self._status_signal.emit(text) + + def set_buttons_enabled(self, enabled: bool) -> None: + self._enable_buttons_signal.emit(enabled) + + def set_analyze_callback(self, callback) -> None: + self._analyze_button.clicked.connect(callback) + + def set_test_button_callback(self, callback) -> None: + self._test_button.clicked.connect(callback) diff --git a/src/shared_types/__init__.py b/src/shared_types/__init__.py index 15163ed..28f2cca 100644 --- a/src/shared_types/__init__.py +++ b/src/shared_types/__init__.py @@ -5,6 +5,7 @@ """ from .events import ( BoardUpdateEvent, + GameStatusChangeEvent, EVENT_TYPES, ) @@ -24,6 +25,7 @@ __all__ = [ # 事件 "BoardUpdateEvent", + "GameStatusChangeEvent", "EVENT_TYPES", # 指令 "NewGameCommand", diff --git a/src/shared_types/commands.py b/src/shared_types/commands.py index 5cba3ed..8965a11 100644 --- a/src/shared_types/commands.py +++ b/src/shared_types/commands.py @@ -3,23 +3,50 @@ """ from __future__ import annotations +from typing import Optional + from lib_zmq_plugins.shared.base import BaseCommand +from .enums import GameLevel + class NewGameCommand(BaseCommand, tag="new_game"): - """新游戏指令""" + """ + 新游戏指令 + + Attributes: + level: 游戏难度,使用 GameLevel 枚举值 + - 3: 初级 (8x8, 10雷) + - 4: 中级 (16x16, 40雷) + - 5: 高级 (16x30, 99雷) + - 6: 自定义(使用 rows/cols/mines) + rows: 行数(自定义模式时使用) + cols: 列数(自定义模式时使用) + mines: 地雷数(自定义模式时使用) + """ + level: int = 6 # 默认自定义,使用 rows/cols/mines rows: int = 16 cols: int = 30 mines: int = 99 class MouseClickCommand(BaseCommand, tag="mouse_click"): - """鼠标点击指令""" - + """ + 鼠标点击指令 + + Attributes: + row: 行索引(从 0 开始) + col: 列索引(从 0 开始) + button: 鼠标按钮 + - 0: 左键(揭开格子) + - 1: 中键 + - 2: 右键(标旗) + modifiers: 键盘修饰符(保留) + """ row: int = 0 col: int = 0 button: int = 0 modifiers: int = 0 -COMMAND_TYPES = [NewGameCommand, MouseClickCommand] +COMMAND_TYPES = [NewGameCommand, MouseClickCommand] \ No newline at end of file diff --git a/src/shared_types/events.py b/src/shared_types/events.py index c97ec39..79b4ac4 100644 --- a/src/shared_types/events.py +++ b/src/shared_types/events.py @@ -3,19 +3,51 @@ """ from __future__ import annotations -from lib_zmq_plugins.shared.base import BaseEvent +from typing import List, Optional +from lib_zmq_plugins.shared.base import BaseEvent -class GameStatusChange(BaseEvent, tag="game_status"): - last_status: int = 0 - cuurent_status: int = 0 +from .enums import GameBoardState class BoardUpdateEvent(BaseEvent, tag="board_update"): - pass + """ + 棋盘更新事件 - 每次棋盘状态变化时发送 + + Attributes: + rows: 行数 + cols: 列数 + game_board: 游戏局面二维数组 + - 0-8: 已揭开的数字格子 + - 10: 未揭开的格子 + - 11: 标旗的格子 + - 14: 错误标旗(游戏结束时显示) + - 15: 爆炸的地雷 + - 16: 未爆炸的地雷(游戏结束时显示) + mines_remaining: 剩余未标出的地雷数(总地雷数 - 已标旗数) + game_time: 游戏时间(秒) + """ + rows: int = 0 + cols: int = 0 + game_board: List[List[int]] = [] + mines_remaining: int = 0 + game_time: float = 0.0 + + +class GameStatusChangeEvent(BaseEvent, tag="game_status_change"): + """ + 游戏状态变化事件 + + Attributes: + last_status: 上一个游戏状态 + current_status: 当前游戏状态 + """ + last_status: int = 0 + current_status: int = 0 -class ConetxtChangeEvent(BaseEvent, tag="context_change"): +class ContextChangeEvent(BaseEvent, tag="context_change"): + """上下文变化事件""" pass @@ -65,5 +97,6 @@ class VideoSaveEvent(BaseEvent, tag="video_save"): EVENT_TYPES = [ BoardUpdateEvent, + GameStatusChangeEvent, VideoSaveEvent, -] +] \ No newline at end of file From eff026f1e012e358aa91573f94fefab76cbd1efe Mon Sep 17 00:00:00 2001 From: ljzloser <1312358581@qq.com> Date: Fri, 17 Apr 2026 23:15:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=B8=AD?= =?UTF-8?q?=E9=94=AE=E5=8F=8C=E5=87=BB=E5=B9=B6=E4=BC=98=E5=8C=96=E6=89=AB?= =?UTF-8?q?=E9=9B=B7AI=E6=8E=A7=E5=88=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.py | 21 ++-- src/mineSweeperGUI.py | 96 +++++----------- .../llm_minesweeper_controller/config.py | 105 +++++++++++------- .../llm_minesweeper_controller/plugin.py | 33 +++++- 4 files changed, 132 insertions(+), 123 deletions(-) diff --git a/src/main.py b/src/main.py index 1397579..5f83d02 100644 --- a/src/main.py +++ b/src/main.py @@ -200,12 +200,12 @@ def cli_check_file(file_path: str) -> int: ui._plugin_process = plugin_process # 保存引用,防止被 GC GameServerBridge.instance().start() - + # 注册控制命令处理器(自动在主线程执行) def handle_new_game(cmd: NewGameCommand): """处理新游戏命令""" from lib_zmq_plugins.shared.base import CommandResponse - + # 根据 level 确定参数 if cmd.level == GameLevel.BEGINNER.value: rows, cols, mines = 8, 8, 10 @@ -216,21 +216,24 @@ def handle_new_game(cmd: NewGameCommand): else: # 自定义模式,使用传入的参数 rows, cols, mines = cmd.rows, cmd.cols, cmd.mines - - print(f"[NewGameCommand] level={cmd.level}, rows={rows}, cols={cols}, mines={mines}") + + print( + f"[NewGameCommand] level={cmd.level}, rows={rows}, cols={cols}, mines={mines}") ui.setBoard_and_start(rows, cols, mines) return CommandResponse(request_id=cmd.request_id, success=True) - + def handle_mouse_click(cmd: MouseClickCommand): """处理鼠标点击命令""" from lib_zmq_plugins.shared.base import CommandResponse - - print(f"[MouseClickCommand] row={cmd.row}, col={cmd.col}, button={cmd.button}") + + print( + f"[MouseClickCommand] row={cmd.row}, col={cmd.col}, button={cmd.button}") success = ui.execute_cell_click(cmd.row, cmd.col, cmd.button) return CommandResponse(request_id=cmd.request_id, success=success) - + GameServerBridge.instance().register_handler(NewGameCommand, handle_new_game) - GameServerBridge.instance().register_handler(MouseClickCommand, handle_mouse_click) + GameServerBridge.instance().register_handler( + MouseClickCommand, handle_mouse_click) # _translate = QtCore.QCoreApplication.translate hwnd = int(ui.mainWindow.winId()) diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index 2ae7cb8..ce6ac5a 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -37,6 +37,7 @@ class MineSweeperGUI(MineSweeperVideoPlayer): + def __init__(self, MainWindow: MainWindow, args): self.mainWindow = MainWindow self.checksum_guard = metaminesweeper_checksum.ChecksumGuard() @@ -220,7 +221,7 @@ def game_state(self): def game_state(self, game_state: str): # print(self._game_state, " -> " ,game_state) last_state = self._game_state - + match self._game_state: case "playing": self.try_append_evfs(game_state) @@ -254,9 +255,9 @@ def game_state(self, game_state: str): self.label.paint_cursor = False self.label.paintProbability = False self.num_bar_ui.QWidget.close() - + self._game_state = game_state - + # 发送游戏状态变化事件 state_map = { "ready": 1, @@ -277,6 +278,7 @@ def game_state(self, game_state: str): current_status=state_map.get(game_state, 0), ) GameServerBridge.instance().send_event(event) + self._send_board_update_event() @property def row(self): @@ -509,7 +511,7 @@ def _send_board_update_event(self): game_board_list = [] for row in ms_board.game_board: game_board_list.append(list(row)) - + event = BoardUpdateEvent( rows=self.row, cols=self.column, @@ -521,79 +523,33 @@ def _send_board_update_event(self): except Exception: pass # 忽略发送失败 - def execute_cell_click(self, row: int, col: int, button: int) -> bool: + def execute_cell_click(self, row: int, col: int, button: int): """ 执行格子点击(供外部命令调用) - + Args: row: 行索引(从 0 开始) col: 列索引(从 0 开始) - button: 鼠标按钮(0=左键, 2=右键) - - Returns: - True 表示成功执行 + button: 鼠标按钮(0=左键, 1=中键, 2=右键) """ - # 检查游戏状态 - if self.game_state not in ('ready', 'playing', 'joking'): - return False - - # 检查坐标有效性 if row < 0 or row >= self.row or col < 0 or col >= self.column: return False - - # 转换为像素坐标(中心点) - i = row * self.pixSize + self.pixSize // 2 - j = col * self.pixSize + self.pixSize // 2 - - try: - if button == 0: # 左键 - # 模拟点击流程:按下 -> 抬起 - self.label.ms_board.step('lc', (i, j)) - - # 处理第一次点击埋雷 - if self.game_state == 'ready': - if self.label.ms_board.mouse_state == 4: - if self.board_constraint: - self.game_state = 'joking' - else: - self.game_state = 'playing' - if self.player_identifier[:6] != "[live]": - self.disable_screenshot() - if self.cursor_limit: - self.limit_cursor() - self.start_time_unix_2 = QtCore.QDateTime.currentDateTime().toMSecsSinceEpoch() - self.timer_10ms.start() - self.score_board_manager.editing_row = -2 - # 埋雷 - self.layMine(row, col) - - self.label.ms_board.step('lr', (i, j)) - - # 检查游戏结束 - if self.label.ms_board.game_board_state == 3: - self.gameWin() - elif self.label.ms_board.game_board_state == 4: - self.gameFailed() - - elif button == 2: # 右键 - # 更新剩余雷数 - cell_state = self.label.ms_board.game_board[row][col] - if cell_state == 11: # 已标旗,取消标旗 - self.mineUnFlagedNum += 1 - self.showMineNum(self.mineUnFlagedNum) - elif cell_state == 10: # 未揭开,标旗 - self.mineUnFlagedNum -= 1 - self.showMineNum(self.mineUnFlagedNum) - - self.label.ms_board.step('rc', (i, j)) - self.label.ms_board.step('rr', (i, j)) - - self.label.update() - self._send_board_update_event() - return True - - except Exception: - return False + x = row * self.pixSize + y = col * self.pixSize + if button == 0: + self.mineAreaLeftPressed(x, y) + self.mineAreaLeftRelease(x, y) + elif button == 1: + self.mineAreaLeftPressed(x, y) + self.mineAreaLeftAndRightPressed(x, y) + self.mineAreaRightRelease(x, y) + self.mineAreaLeftRelease(x, y) + else: + self.mineAreaRightPressed(x, y) + self.mineAreaRightRelease(x, y) + + self._send_board_update_event() + return True def gameStart(self): # 画界面,但是不埋雷。等价于点脸、f2、设置确定后的效果 @@ -688,7 +644,7 @@ def gameFinished(self): data[key] = getattr(ms_board, key) event = VideoSaveEvent(**data) GameServerBridge.instance().send_event(event) - + # 发送棋盘更新事件,让插件知道最终状态 self._send_board_update_event() diff --git a/src/plugins/llm_minesweeper_controller/config.py b/src/plugins/llm_minesweeper_controller/config.py index 0aff491..9c38df2 100644 --- a/src/plugins/llm_minesweeper_controller/config.py +++ b/src/plugins/llm_minesweeper_controller/config.py @@ -49,52 +49,71 @@ class LlmMinesweeperControllerConfig(OtherInfoBase): # 提示词设置 system_prompt = LongTextConfig( - default="""你是一个顶级扫雷AI。你的任务是通过逻辑推理,输出工具调用指令来赢下游戏。 - -# 一、 棋盘与状态定义 + default="""你是一个扫雷AI。你只能通过调用工具来操作游戏。 + +# 绝对规则 +每次回复只允许调用1个工具函数。 +所有推理在你的"内部思考"中完成,不要输出到回复里。 + +# 推理策略 +- 基础:数字=周围未揭开数 → 全是雷;数字=周围旗子数 → 全安全 +- 进阶:用相邻数字的差值与非共享未知格做减法约束 +- 盲猜(仅无逻辑解时):选长连续边界中段,绝对禁止猜边角 + +# 决策树(每次操作前必须按此顺序检查!) + +## 检查1:能标旗吗?→ 右键 `"right"` +``` +对于每个数字N: + 未揭开格子数 = N ? + → 是:这N个格子必定是雷 → `click_cell(..., "right")` 标旗 + → 继续检查其他数字 +``` +**标旗不会死!遇到确定的雷必须标旗!** + +## 标旗的修正 +如果发现之前标错了旗(数字逻辑矛盾),可以再次 `click_cell(..., "right")` 取消标旗。 +右键点击已标旗(F)的格子 = 取消标旗。 + +## 检查2:能中键吗?→ 中键 `"middle"` +``` +对于每个数字N: + 周围已标旗数 = N ? + → 是:周围剩余格子全部安全 → `click_cell(..., "middle")` 批量揭开 + → 继续检查其他数字 +``` +**这是最常用的批量揭开操作,必须优先使用!** + +## 检查3:能左键吗?→ 左键 `"left"` +``` +不属于以上两种情况,但确定安全? +→ 是:`click_cell(..., "left")` 揭开(通常是数字0) +``` + +## 强制规则 +- 检查顺序:标旗 → 中键 → 左键,**不能跳过** + +# 游戏状态判断 +- 棋盘出现 M → 调用 start_new_game +- cells 为空 → 调用 start_new_game +- 否则 → 分析推理后调用 click_cell 或 get_local_board + +# 操作流程 +1. 先调用 get_board_state 获取全局 +2. 若有确定操作,直接调用 click_cell +3. 若需要细节,调用 get_local_board(radius=4) +4. 循环直到胜利 + +# 棋盘与状态定义 - 格子状态:`-1`(未揭开)、`0-8`(周围雷数)、`F`(已标旗)、`M`(踩到的红雷)、`m`(未踩的白雷) -- 游戏状态容错:若棋盘出现 `M` 必为失败;若非雷格全揭开必为胜利;以棋盘实际画面为准,忽略错误的状态参数。 +- 游戏状态容错:若棋盘出现 `M` 必为失败;若非雷格全揭开必为胜利;以棋盘实际画面为准。 -# 二、 可用工具 +# 可用工具 - `get_board_state()`:获取全局视图。 -- `get_local_board(col, row, radius)`:获取局部细节,建议 radius=4。 -- `click_cell(col, row, button)`:执行操作,button仅限 `"left"`(揭开) 或 `"right"`(标旗)。 -- `start_new_game()`:失败或未初始化时调用。 - -# 三、 核心推理策略(按优先级排序) -1. **基础定式**: - - 数字 = 周围未揭开数 → 未揭开格全是雷(右键标旗)。 - - 数字 = 周围旗子数 → 剩余未揭开格全安全(左键揭开)。 -2. **减法逻辑(核心)**: - - 对比边界上相邻的两个数字,利用它们的差值与非共享未知格的数量,推断特定格子是雷还是安全。 -3. **盲猜原则(仅限无任何逻辑解时)**: - - **绝对禁止猜边角!** - - 必须选择**长连续未揭开边界的中段**点击,以最大化获取信息量。 - -# 四、 操作铁律 -1. **100%确定原则**:没有绝对把握不操作,宁可不动也不犯错。 -2. **单次限量**:每次推理后,只执行 1-3 个确定格子的操作。 -3. **标旗优先**:在既可标旗又可揭开的场景下,优先标旗(标旗不会触发死亡,且能降低后续推理复杂度)。 -4. **禁止重复操作**:绝不能点击 0-8 的格子或 F 的格子。 - -# 五、 输出格式要求(严格遵守) -不要输出任何解释性文本、问候语或分析过程。你的输出必须且只能是以下两种格式之一: - -【格式1:执行操作】 - -工具名称(参数) - - -一句理由,不超过15字,如:减法逻辑(5,4)安全 - - -【格式2:需要更多信息】 - -get_board_state() - - -初始化/查看全局 -""", +- `get_local_board(col, row, radius=4)`:获取局部细节,返回(2*radius+1)x(2*radius+1)的区域。 +- `click_cell(col, row, button)`:执行操作,button可为 `"left"`(揭开)、`"right"`(标旗) 或 `"middle"`(快速揭开周围格子)。 +- `start_new_game(difficulty)`:开始新游戏,difficulty为 `"easy"`(8x8)、`"medium"`(16x16) 或 `"hard"`(16x30)。 +""", label="系统提示词", ) diff --git a/src/plugins/llm_minesweeper_controller/plugin.py b/src/plugins/llm_minesweeper_controller/plugin.py index 349c692..be38856 100644 --- a/src/plugins/llm_minesweeper_controller/plugin.py +++ b/src/plugins/llm_minesweeper_controller/plugin.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +from ctypes import cast import json from typing import Dict, Any, Optional, List @@ -10,6 +11,7 @@ from PyQt5.QtCore import QThread, pyqtSignal from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_sdk.config_types import OtherInfoBase from shared_types.events import BoardUpdateEvent, GameStatusChangeEvent from shared_types.commands import NewGameCommand, MouseClickCommand @@ -32,6 +34,12 @@ def __init__(self, client: LLMClient, registry: FunctionRegistry, self.client = client self.registry = registry self.messages = messages + self._stop_flag = False + + def stop(self) -> None: + """请求停止工作线程""" + self._stop_flag = True + self.requestInterruption() def run(self): """执行多轮对话循环(无上限)""" @@ -40,6 +48,11 @@ def run(self): round_num = 0 while True: + # 检查停止标志 + if self._stop_flag or self.isInterruptionRequested(): + self.finished_signal.emit(False, "用户请求停止") + return + round_num += 1 self.log_signal.emit(f"=== 第 {round_num} 轮对话 ===") @@ -111,6 +124,8 @@ def run(self): class LlmMinesweeperControllerPlugin(BasePlugin): """使用 LLM 控制扫雷的插件""" + _widget: LlmMinesweeperControllerWidget + @classmethod def plugin_info(cls) -> PluginInfo: return PluginInfo( @@ -123,6 +138,10 @@ def plugin_info(cls) -> PluginInfo: required_controls=[NewGameCommand, MouseClickCommand], ) + @property + def other_info(self) -> LlmMinesweeperControllerConfig: + return super().other_info # type: ignore + def _setup_subscriptions(self) -> None: self.subscribe(BoardUpdateEvent, self._on_board_update) self.subscribe(GameStatusChangeEvent, self._on_game_status_change) @@ -201,7 +220,7 @@ def _register_minesweeper_functions(self) -> None: param_descriptions={ "col": "列索引 (从 0 开始,即 X 坐标)", "row": "行索引 (从 0 开始,即 Y 坐标)", - "button": "鼠标按钮: 'left' 左键揭开, 'right' 右键标旗", + "button": "鼠标按钮: 'left' 左键揭开, 'right' 右键标旗, 'middle' 中键快速揭开周围格子", } ) def click_cell(col: int, row: int, button: str = "left") -> Dict[str, Any]: @@ -296,6 +315,8 @@ def _on_game_status_change(self, event: GameStatusChangeEvent) -> None: self._widget.update_status("游戏胜利!") elif event.current_status == 4: self._widget.update_status("游戏失败!") + elif event.current_status == 1: + self._widget.update_status("游戏准备中...") # ═══════════════════════════════════════════════════════════════ # LLM 对话流程 @@ -363,6 +384,8 @@ def _start_analysis(self) -> None: def _on_analysis_finished(self, success: bool, message: str) -> None: """分析完成回调""" + if self._widget is None: + return self._widget.set_buttons_enabled(True) if success: @@ -634,3 +657,11 @@ def _extract_board_data(self, event: BoardUpdateEvent) -> Dict[str, Any]: "mines_remaining": event.mines_remaining, "game_time": event.game_time, } + + def on_shutdown(self) -> None: + """插件关闭时停止 Worker""" + if self._worker and self._worker.isRunning(): + self.logger.info("正在停止 LLM Worker...") + self._worker.stop() + self._worker.wait(1) # 等待最多3秒 + self._worker = None