diff --git a/.gitignore b/.gitignore index 694f3f6..dc8f9d5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ llm_debug.log config.py config.py.bak +simple_bubble_dedup.json __pycache__/ debug_screenshots/ chat_logs/ diff --git a/ClaudeCode.md b/ClaudeCode.md index 6ce842d..f99d976 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -124,7 +124,14 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 * **計算頭像座標**:根據**新**找到的氣泡左上角座標,應用特定偏移量 (`AVATAR_OFFSET_X_REPLY`, `AVATAR_OFFSET_Y_REPLY`) 計算頭像點擊位置。 * **互動(含重試)**:點擊計算出的頭像位置,檢查是否成功進入個人資料頁面 (`Profile_page.png`)。若失敗,最多重試 3 次(每次重試前會再次重新定位氣泡)。若成功,則繼續導航菜單複製用戶名稱。 * **原始偏移量**:原始的 `-55` 像素水平偏移量 (`AVATAR_OFFSET_X`) 仍保留,用於 `remove_user_position` 等其他功能。 -5. **防重複處理 (Duplicate Prevention)**:使用最近處理過的文字內容歷史 (`recent_texts`) 防止對相同訊息重複觸發。 +5. **防重複處理 (Duplicate Prevention)**: + * **基於圖像哈希的去重 (Image Hash Deduplication)**: 新增 `simple_bubble_dedup.py` 模塊,實現基於圖像感知哈希 (Perceptual Hash) 的去重系統。 + * **原理**: 系統會計算最近處理過的氣泡圖像的感知哈希值,並保存最近的 N 個 (預設 5 個) 氣泡的哈希。當偵測到新氣泡時,會計算其哈希並與保存的哈希進行比對。如果哈希差異小於設定的閾值 (預設 5),則認為是重複氣泡並跳過處理。 + * **實現**: 在 `ui_interaction.py` 的 `run_ui_monitoring_loop` 函數中初始化 `SimpleBubbleDeduplication` 實例,並在偵測到關鍵字並截取氣泡快照後,調用 `is_duplicate` 方法進行檢查。 + * **狀態管理**: 使用 `simple_bubble_dedup.json` 文件持久化保存最近的氣泡哈希記錄。 + * **清理**: F7 (`clear_history`) 和 F8 (`reset_state`) 功能已擴展,會同時清除圖像去重系統中的記錄。 + * **發送者信息更新**: 在成功處理並將氣泡信息放入隊列後,會嘗試更新去重記錄中對應氣泡的發送者名稱。 + * **文字內容歷史 (已棄用)**: 原有的基於 `recent_texts` 的文字內容重複檢查邏輯已**移除或註解**,圖像哈希去重成為主要的去重機制。 #### LLM 整合 @@ -644,6 +651,10 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 - 使用回調函數 (`callback`) 與調用者(即 `Setup.py`)通信,例如在遊戲重啟完成時。 - 保留了獨立運行模式,以便在直接執行時仍能工作(主要用於測試或舊版兼容)。 - 程式碼註解和日誌訊息已更新為英文。 + - **新增遊戲崩潰自動恢復 (2025-05-15)**: + - 在 `_monitor_loop` 方法中,優先檢查遊戲進程 (`_is_game_running`) 是否仍在運行。 + - 如果進程消失,會記錄警告並嘗試重新啟動遊戲 (`_start_game_process`)。 + - 新增 `_is_game_running` 方法,使用 `psutil` 檢查具有指定進程名稱的遊戲是否正在運行。 - **`Setup.py` (修改)**: - 導入 `game_manager`。 - 在 `WolfChatSetup` 類的 `__init__` 方法中初始化 `self.game_monitor = None`。 diff --git a/Setup.py b/Setup.py index 502a5a7..520d02d 100644 --- a/Setup.py +++ b/Setup.py @@ -69,6 +69,7 @@ keep_monitoring_flag.set() # logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') # Setup logger instance. This can be configured further if needed. logger = logging.getLogger(__name__) +logger.propagate = False if not logger.handlers: # Avoid adding multiple handlers if script is reloaded handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -568,8 +569,8 @@ class WolfChatSetup(tk.Tk): def __init__(self): super().__init__() self.title(f"Wolf Chat Setup v{VERSION}") - self.geometry("800x600") - self.minsize(750, 550) + self.geometry("900x600") + self.minsize(900, 600) # Load existing data self.env_data = load_env_file() @@ -2783,8 +2784,8 @@ else: # HAS_SOCKETIO is False # =============================================================== if __name__ == "__main__": # Setup main logger for the application if not already done - if not logging.getLogger().handlers: # Check root logger - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + #if not logging.getLogger().handlers: # Check root logger + # logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') app = WolfChatSetup() app.protocol("WM_DELETE_WINDOW", app.on_closing) # Handle window close button diff --git a/game_manager.py b/game_manager.py index fd5dcfe..1e0fb34 100644 --- a/game_manager.py +++ b/game_manager.py @@ -72,6 +72,10 @@ class GameMonitor: self.monitor_thread = None self.stop_event = threading.Event() + # Add these tracking variables + self.last_focus_failure_count = 0 + self.last_successful_foreground = time.time() + self.logger.info(f"GameMonitor initialized. Game window: '{self.window_title}', Process: '{self.game_process_name}'") self.logger.info(f"Position: ({self.window_x}, {self.window_y}), Size: {self.window_width}x{self.window_height}") self.logger.info(f"Scheduled Restart: {'Enabled' if self.enable_restart else 'Disabled'}, Interval: {self.restart_interval} minutes") @@ -128,6 +132,17 @@ class GameMonitor: while not self.stop_event.is_set(): try: + # Add to _monitor_loop method - just 7 lines that matter + if not self._is_game_running(): + self.logger.warning("Game process disappeared - restarting") + time.sleep(2) # Let resources release + if self._start_game_process(): + self.logger.info("Game restarted successfully") + else: + self.logger.error("Game restart failed") + time.sleep(self.monitor_interval) # Wait before next check after a restart attempt + continue + # Check for scheduled restart if self.next_restart_time and time.time() >= self.next_restart_time: self.logger.info("Scheduled restart time reached. Performing restart...") @@ -160,51 +175,41 @@ class GameMonitor: if current_pos != target_pos or current_size != target_size: window.moveTo(target_pos[0], target_pos[1]) window.resizeTo(target_size[0], target_size[1]) - # Verify if move and resize were successful time.sleep(0.1) - window.activate() # Try activating to ensure changes apply + window.activate() time.sleep(0.1) + # Check if changes were successful new_pos = (window.left, window.top) new_size = (window.width, window.height) if new_pos == target_pos and new_size == target_size: - current_message += f"Adjusted game window to position ({target_pos[0]},{target_pos[1]}) size {target_size[0]}x{target_size[1]}. " + current_message += f"Adjusted window position/size. " adjustment_made = True - else: - self.logger.warning(f"Attempted to adjust window pos/size, but result mismatch. Target: {target_pos}/{target_size}, Actual: {new_pos}/{new_size}") - - # 2. Check and bring to foreground + # 2. Check and bring to foreground using enhanced method current_foreground_hwnd = win32gui.GetForegroundWindow() - if current_foreground_hwnd != hwnd: - try: - # Use HWND_TOP to bring window to top, not HWND_TOPMOST - win32gui.SetWindowPos(hwnd, win32con.HWND_TOP, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) - - # Set as foreground window (gain focus) - win32gui.SetForegroundWindow(hwnd) - - # Verify if window is active - time.sleep(0.1) - foreground_hwnd = win32gui.GetForegroundWindow() - - if foreground_hwnd == hwnd: - current_message += "Brought game window to foreground and set focus. " - adjustment_made = True - else: - # Use fallback method - self.logger.warning("SetForegroundWindow failed, trying fallback window.activate()") - try: - window.activate() - time.sleep(0.1) - if win32gui.GetForegroundWindow() == hwnd: - current_message += "Set game window focus using fallback method. " - adjustment_made = True - except Exception as activate_err: - self.logger.warning(f"Fallback method window.activate() failed: {activate_err}") - except Exception as focus_err: - self.logger.warning(f"Error setting window focus: {focus_err}") + # Use enhanced forceful focus method + success, method_used = self._force_window_foreground(hwnd, window) + if success: + current_message += f"Focused window using {method_used}. " + adjustment_made = True + if not hasattr(self, 'last_focus_failure_count'): + self.last_focus_failure_count = 0 + self.last_focus_failure_count = 0 + else: + # Increment failure counter + if not hasattr(self, 'last_focus_failure_count'): + self.last_focus_failure_count = 0 + self.last_focus_failure_count += 1 + + # Log warning with consecutive failure count + self.logger.warning(f"Window focus failed (attempt {self.last_focus_failure_count}): {method_used}") + + # Restart game after too many failures + if self.last_focus_failure_count >= 15: + self.logger.warning("Excessive focus failures, restarting game...") + self._perform_restart() + self.last_focus_failure_count = 0 else: # Use basic functions on non-Windows platforms current_pos = (window.left, window.top) @@ -225,7 +230,7 @@ class GameMonitor: adjustment_made = True except Exception as activate_err: self.logger.warning(f"Error activating window: {activate_err}") - + except Exception as e: self.logger.error(f"Unexpected error while monitoring game window: {e}") @@ -245,6 +250,17 @@ class GameMonitor: self.logger.info("Game window monitoring loop finished") + def _is_game_running(self): + """Check if game is running""" + if not HAS_PSUTIL: + self.logger.warning("_is_game_running: psutil not available, cannot check process status.") + return True # Assume running if psutil is not available to avoid unintended restarts + try: + return any(p.name().lower() == self.game_process_name.lower() for p in psutil.process_iter(['name'])) + except Exception as e: + self.logger.error(f"Error checking game process: {e}") + return False # Assume not running on error + def _find_game_window(self): """Find the game window with the specified title""" try: @@ -255,27 +271,181 @@ class GameMonitor: self.logger.debug(f"Error finding game window: {e}") return None - def _find_game_process(self): - """Find the game process""" - if not HAS_PSUTIL: - self.logger.warning("psutil is not available, cannot perform process lookup") + def _force_window_foreground(self, hwnd, window): + """Aggressive window focus implementation""" + if not HAS_WIN32: + return False, "win32 modules unavailable" + + success = False + methods_tried = [] + + # Method 1: HWND_TOPMOST strategy + methods_tried.append("HWND_TOPMOST") + try: + win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) + time.sleep(0.1) + win32gui.SetWindowPos(hwnd, win32con.HWND_TOP, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) + + win32gui.SetForegroundWindow(hwnd) + time.sleep(0.2) + if win32gui.GetForegroundWindow() == hwnd: + return True, "HWND_TOPMOST" + except Exception as e: + self.logger.debug(f"Method 1 failed: {e}") + + # Method 2: Minimize/restore cycle + methods_tried.append("MinimizeRestore") + try: + win32gui.ShowWindow(hwnd, win32con.SW_MINIMIZE) + time.sleep(0.3) + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + time.sleep(0.2) + win32gui.SetForegroundWindow(hwnd) + + if win32gui.GetForegroundWindow() == hwnd: + return True, "MinimizeRestore" + except Exception as e: + self.logger.debug(f"Method 2 failed: {e}") + + # Method 3: Thread input attach + methods_tried.append("ThreadAttach") + try: + import win32process + import win32api + + current_thread_id = win32api.GetCurrentThreadId() + window_thread_id = win32process.GetWindowThreadProcessId(hwnd)[0] + + if current_thread_id != window_thread_id: + win32process.AttachThreadInput(current_thread_id, window_thread_id, True) + try: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + + time.sleep(0.2) + if win32gui.GetForegroundWindow() == hwnd: + return True, "ThreadAttach" + finally: + win32process.AttachThreadInput(current_thread_id, window_thread_id, False) + except Exception as e: + self.logger.debug(f"Method 3 failed: {e}") + + # Method 4: Flash + Window messages + methods_tried.append("Flash+Messages") + try: + # First flash to get attention + win32gui.FlashWindow(hwnd, True) + time.sleep(0.2) + + # Then send specific window messages + win32gui.SendMessage(hwnd, win32con.WM_SETREDRAW, 0, 0) + win32gui.SendMessage(hwnd, win32con.WM_SETREDRAW, 1, 0) + win32gui.RedrawWindow(hwnd, None, None, + win32con.RDW_FRAME | win32con.RDW_INVALIDATE | + win32con.RDW_UPDATENOW | win32con.RDW_ALLCHILDREN) + + win32gui.PostMessage(hwnd, win32con.WM_SYSCOMMAND, win32con.SC_RESTORE, 0) + win32gui.PostMessage(hwnd, win32con.WM_ACTIVATE, win32con.WA_ACTIVE, 0) + + time.sleep(0.2) + if win32gui.GetForegroundWindow() == hwnd: + return True, "Flash+Messages" + except Exception as e: + self.logger.debug(f"Method 4 failed: {e}") + + # Method 5: Hide/Show cycle + methods_tried.append("HideShow") + try: + win32gui.ShowWindow(hwnd, win32con.SW_HIDE) + time.sleep(0.2) + win32gui.ShowWindow(hwnd, win32con.SW_SHOW) + time.sleep(0.2) + win32gui.SetForegroundWindow(hwnd) + + if win32gui.GetForegroundWindow() == hwnd: + return True, "HideShow" + except Exception as e: + self.logger.debug(f"Method 5 failed: {e}") + + return False, f"All methods failed: {', '.join(methods_tried)}" + + def _find_game_process_by_window(self): + """Find process using both window title and process name""" + if not HAS_PSUTIL or not HAS_WIN32: return None try: - for proc in psutil.process_iter(['pid', 'name', 'exe']): - try: - proc_info = proc.info - proc_name = proc_info.get('name') + window = self._find_game_window() + if not window: + return None - if proc_name and proc_name.lower() == self.game_process_name.lower(): - self.logger.info(f"Found game process '{proc_name}' (PID: {proc.pid})") + hwnd = window._hWnd + window_pid = None + try: + import win32process + _, window_pid = win32process.GetWindowThreadProcessId(hwnd) + except Exception: + return None + + if window_pid: + try: + proc = psutil.Process(window_pid) + proc_name = proc.name() + + if proc_name.lower() == self.game_process_name.lower(): + self.logger.info(f"Found game process '{proc_name}' (PID: {proc.pid}) with window title '{self.window_title}'") return proc + else: + self.logger.debug(f"Window process name mismatch: expected '{self.game_process_name}', got '{proc_name}'") + return proc # Returning proc even if name mismatches, as per user's code. + except Exception: + pass + + # Fallback to name-based search if window-based fails or PID doesn't match process name. + # The user's provided code implies a fallback to _find_game_process_by_name() + # This will be handled by the updated _find_game_process method. + # For now, if the window PID didn't lead to a matching process name, we return None here. + # The original code had "return self._find_game_process_by_name()" here, + # but that would create a direct dependency. The new _find_game_process handles the fallback. + # So, if we reach here, it means the window was found, PID was obtained, but process name didn't match. + # The original code returns `proc` even on mismatch, so I'll keep that. + # If `window_pid` was None or `psutil.Process(window_pid)` failed, it would have returned None or passed. + # The logic "return self._find_game_process_by_name()" was in the original snippet, + # I will include it here as per the snippet, but note that the overall _find_game_process will also call it. + return self._find_game_process_by_name() # As per user snippet + + except Exception as e: + self.logger.error(f"Process-by-window lookup error: {e}") + return None + + def _find_game_process(self): + """Find game process with combined approach""" + # Try window-based process lookup first + proc = self._find_game_process_by_window() + if proc: + return proc + + # Fall back to name-only lookup + # This is the original _find_game_process logic, now as a fallback. + if not HAS_PSUTIL: + self.logger.debug("psutil not available for name-only process lookup fallback.") # Changed to debug as primary is window based + return None + try: + for p_iter in psutil.process_iter(['pid', 'name', 'exe']): + try: + proc_info = p_iter.info + proc_name = proc_info.get('name') + if proc_name and proc_name.lower() == self.game_process_name.lower(): + self.logger.info(f"Found game process by name '{proc_name}' (PID: {p_iter.pid}) as fallback") + return p_iter except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): continue except Exception as e: - self.logger.error(f"Error finding game process: {e}") - - self.logger.info(f"Game process '{self.game_process_name}' not found") + self.logger.error(f"Error in name-only game process lookup: {e}") + + self.logger.info(f"Game process '{self.game_process_name}' not found by name either.") return None def _perform_restart(self): @@ -298,7 +468,7 @@ class GameMonitor: self.logger.error("Failed to start game") # 4. Wait for game to launch - restart_wait_time = 30 # seconds + restart_wait_time = 45 # seconds, increased from 30 self.logger.info(f"Waiting for game to start ({restart_wait_time} seconds)...") time.sleep(restart_wait_time) diff --git a/main.py b/main.py index 81081be..edc90fd 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,8 @@ from mcp import ClientSession, StdioServerParameters, types # --- Keyboard Imports --- import threading import time +# Import MessageDeduplication from ui_interaction +from ui_interaction import MessageDeduplication try: import keyboard # Needs pip install keyboard except ImportError: @@ -103,16 +105,14 @@ def handle_f8(): except Exception as e: print(f"Error sending pause command (F8): {e}") else: - print("\n--- F8 pressed: Resuming script, resetting state, and resuming UI monitoring ---") - reset_command = {'action': 'reset_state'} + print("\n--- F8 pressed: Resuming script and UI monitoring ---") resume_command = {'action': 'resume'} try: - main_loop.call_soon_threadsafe(command_queue.put_nowait, reset_command) # Add a small delay? Let's try without first. # time.sleep(0.05) # Short delay between commands if needed main_loop.call_soon_threadsafe(command_queue.put_nowait, resume_command) except Exception as e: - print(f"Error sending reset/resume commands (F8): {e}") + print(f"Error sending resume command (F8): {e}") def handle_f9(): """Handles F9 press: Initiates script shutdown.""" @@ -483,9 +483,12 @@ async def run_main_with_exit_stack(): # 5. Start UI Monitoring in a separate thread print("\n--- Starting UI monitoring thread ---") - # Use the new monitoring loop function, passing both queues + # 5c. Create MessageDeduplication instance + deduplicator = MessageDeduplication(expiry_seconds=3600) # Default 1 hour + + # Use the new monitoring loop function, passing both queues and the deduplicator monitor_task = loop.create_task( - asyncio.to_thread(ui_interaction.run_ui_monitoring_loop, trigger_queue, command_queue), # Pass command_queue + asyncio.to_thread(ui_interaction.run_ui_monitoring_loop, trigger_queue, command_queue, deduplicator), # Pass command_queue and deduplicator name="ui_monitor" ) ui_monitor_task = monitor_task # Store task reference for shutdown @@ -493,6 +496,25 @@ async def run_main_with_exit_stack(): # 5b. Game Window Monitoring is now handled by Setup.py + # 5d. Start Periodic Cleanup Timer for Deduplicator + def periodic_cleanup(): + if not shutdown_requested: # Only run if not shutting down + print("Main Thread: Running periodic deduplicator cleanup...") + deduplicator.purge_expired() + # Reschedule the timer + cleanup_timer = threading.Timer(600, periodic_cleanup) # 10 minutes + cleanup_timer.daemon = True + cleanup_timer.start() + else: + print("Main Thread: Shutdown requested, not rescheduling deduplicator cleanup.") + + print("\n--- Starting periodic deduplicator cleanup timer (10 min interval) ---") + initial_cleanup_timer = threading.Timer(600, periodic_cleanup) + initial_cleanup_timer.daemon = True + initial_cleanup_timer.start() + # Note: This timer will run in a separate thread. + # Ensure it's handled correctly on shutdown if it holds resources. + # Since it's a daemon thread and reschedules itself, it should exit when the main program exits. # 6. Start the main processing loop (non-blocking check on queue) print("\n--- Wolfhart chatbot has started (waiting for triggers) ---") diff --git a/simple_bubble_dedup.py b/simple_bubble_dedup.py new file mode 100644 index 0000000..8c75c5b --- /dev/null +++ b/simple_bubble_dedup.py @@ -0,0 +1,155 @@ +import os +import json +import collections +import threading +from PIL import Image +import imagehash +import numpy as np +import io + +class SimpleBubbleDeduplication: + def __init__(self, storage_file="simple_bubble_dedup.json", max_bubbles=5, threshold=5, hash_size=16): + self.storage_file = storage_file + self.max_bubbles = max_bubbles # Keep the most recent 5 bubbles + self.threshold = threshold # Hash difference threshold (lower values are more strict) + self.hash_size = hash_size # Hash size + self.lock = threading.Lock() + + # Use OrderedDict to maintain order + self.recent_bubbles = collections.OrderedDict() + # Load stored bubble hashes + self._load_storage() + + def _load_storage(self): + """Load processed bubble hash values from file""" + if os.path.exists(self.storage_file): + try: + with open(self.storage_file, 'r') as f: + data = json.load(f) + + # Convert stored data to OrderedDict and load + self.recent_bubbles.clear() + # Use loaded_count to track loaded items, ensuring we don't exceed max_bubbles + loaded_count = 0 + for bubble_id, bubble_data in data.items(): + if loaded_count >= self.max_bubbles: + break + self.recent_bubbles[bubble_id] = { + 'hash': imagehash.hex_to_hash(bubble_data['hash']), + 'sender': bubble_data.get('sender', 'Unknown') + } + loaded_count += 1 + + print(f"Loaded {len(self.recent_bubbles)} bubble hash records") + except Exception as e: + print(f"Failed to load bubble hash records: {e}") + self.recent_bubbles.clear() + + def _save_storage(self): + """Save bubble hashes to file""" + try: + # Create temporary dictionary for saving + data_to_save = {} + for bubble_id, bubble_data in self.recent_bubbles.items(): + data_to_save[bubble_id] = { + 'hash': str(bubble_data['hash']), + 'sender': bubble_data.get('sender', 'Unknown') + } + + with open(self.storage_file, 'w') as f: + json.dump(data_to_save, f, indent=2) + print(f"Saved {len(data_to_save)} bubble hash records") + except Exception as e: + print(f"Failed to save bubble hash records: {e}") + + def compute_image_hash(self, bubble_snapshot): + """Calculate perceptual hash of bubble image""" + try: + # If bubble_snapshot is a PIL.Image object + if isinstance(bubble_snapshot, Image.Image): + img = bubble_snapshot + # If bubble_snapshot is a PyAutoGUI screenshot + elif hasattr(bubble_snapshot, 'save'): + img = bubble_snapshot + # If it's bytes or BytesIO + elif isinstance(bubble_snapshot, (bytes, io.BytesIO)): + img = Image.open(io.BytesIO(bubble_snapshot) if isinstance(bubble_snapshot, bytes) else bubble_snapshot) + # If it's a numpy array + elif isinstance(bubble_snapshot, np.ndarray): + img = Image.fromarray(bubble_snapshot) + else: + print(f"Unrecognized image format: {type(bubble_snapshot)}") + return None + + # Calculate perceptual hash + phash = imagehash.phash(img, hash_size=self.hash_size) + return phash + except Exception as e: + print(f"Failed to calculate image hash: {e}") + return None + + def generate_bubble_id(self, bubble_region): + """Generate ID based on bubble region""" + return f"bubble_{bubble_region[0]}_{bubble_region[1]}_{bubble_region[2]}_{bubble_region[3]}" + + def is_duplicate(self, bubble_snapshot, bubble_region, sender_name=""): + """Check if bubble is a duplicate""" + with self.lock: + if bubble_snapshot is None: + return False + + # Calculate hash of current bubble + current_hash = self.compute_image_hash(bubble_snapshot) + if current_hash is None: + print("Unable to calculate bubble hash, cannot perform deduplication") + return False + + # Generate ID for current bubble + bubble_id = self.generate_bubble_id(bubble_region) + + # Check if similar to any known bubbles + for stored_id, bubble_data in self.recent_bubbles.items(): + stored_hash = bubble_data['hash'] + hash_diff = current_hash - stored_hash + + if hash_diff <= self.threshold: + print(f"Detected duplicate bubble (ID: {stored_id}, Hash difference: {hash_diff})") + if sender_name: + print(f"Sender: {sender_name}, Recorded sender: {bubble_data.get('sender', 'Unknown')}") + return True + + # Not a duplicate, add to recent bubbles list + self.recent_bubbles[bubble_id] = { + 'hash': current_hash, + 'sender': sender_name + } + + # If exceeding maximum count, remove oldest item + while len(self.recent_bubbles) > self.max_bubbles: + self.recent_bubbles.popitem(last=False) # Remove first item (oldest) + + self._save_storage() + return False + + def clear_all(self): + """Clear all records""" + with self.lock: + count = len(self.recent_bubbles) + self.recent_bubbles.clear() + self._save_storage() + print(f"Cleared all {count} bubble records") + return count + + def save_debug_image(self, bubble_snapshot, bubble_id, hash_value): + """Save debug image (optional feature)""" + try: + debug_dir = "bubble_debug" + if not os.path.exists(debug_dir): + os.makedirs(debug_dir) + + # Save original image + img_path = os.path.join(debug_dir, f"{bubble_id}_{hash_value}.png") + bubble_snapshot.save(img_path) + print(f"Saved debug image: {img_path}") + except Exception as e: + print(f"Failed to save debug image: {e}") \ No newline at end of file diff --git a/templates/chat_option.png b/templates/chat_option.png new file mode 100644 index 0000000..943862a Binary files /dev/null and b/templates/chat_option.png differ diff --git a/templates/update_confirm.png b/templates/update_confirm.png new file mode 100644 index 0000000..95cd87b Binary files /dev/null and b/templates/update_confirm.png differ diff --git a/ui_interaction.py b/ui_interaction.py index 1b47c2f..65f0713 100644 --- a/ui_interaction.py +++ b/ui_interaction.py @@ -18,6 +18,81 @@ import queue from typing import List, Tuple, Optional, Dict, Any import threading # Import threading for Lock if needed, or just use a simple flag import math # Added for distance calculation in dual method +import time # Ensure time is imported for MessageDeduplication +from simple_bubble_dedup import SimpleBubbleDeduplication +import difflib # Added for text similarity + +class MessageDeduplication: + def __init__(self, expiry_seconds=3600): # 1 hour expiry time + self.processed_messages = {} # {message_key: timestamp} + self.expiry_seconds = expiry_seconds + + def is_duplicate(self, sender, content): + """Check if the message is a duplicate within the expiry period using text similarity.""" + if not sender or not content: + return False # Missing necessary info, treat as new message + + current_time = time.time() + + # 遍歷所有已處理的消息 + for key, timestamp in list(self.processed_messages.items()): + # 檢查是否過期 + if current_time - timestamp >= self.expiry_seconds: + # 從 processed_messages 中移除過期的項目,避免集合在迭代時改變大小 + # 但由於我們使用了 list(self.processed_messages.items()),所以這裡可以安全地 continue + # 或者,如果希望立即刪除,則需要不同的迭代策略或在 purge_expired 中處理 + continue # 繼續檢查下一個,過期項目由 purge_expired 處理 + + # 解析之前儲存的發送者和內容 + stored_sender, stored_content = key.split(":", 1) + + # 檢查發送者是否相同 + if sender.lower() == stored_sender.lower(): + # Calculate text similarity + similarity = difflib.SequenceMatcher(None, content, stored_content).ratio() + if similarity >= 0.95: # Use 0.95 as threshold + print(f"Deduplicator: Detected similar message (similarity: {similarity:.2f}): {sender} - {content[:20]}...") + return True + + # 不是重複消息,儲存它 + # 注意:這裡儲存的 content 是原始 content,不是 clean_content + message_key = f"{sender.lower()}:{content}" + self.processed_messages[message_key] = current_time + return False + + # create_key 方法已不再需要,可以移除 + # def create_key(self, sender, content): + # """Create a standardized composite key.""" + # # Thoroughly standardize text - remove all whitespace and punctuation, lowercase + # clean_content = ''.join(c.lower() for c in content if c.isalnum()) + # clean_sender = ''.join(c.lower() for c in sender if c.isalnum()) + + # # Truncate content to first 100 chars to prevent overly long keys + # if len(clean_content) > 100: + # clean_content = clean_content[:100] + + # return f"{clean_sender}:{clean_content}" + + def purge_expired(self): + """Remove expired message records.""" + current_time = time.time() + expired_keys = [k for k, t in self.processed_messages.items() + if current_time - t >= self.expiry_seconds] + + for key in expired_keys: + del self.processed_messages[key] + + if expired_keys: # Log only if something was purged + print(f"Deduplicator: Purged {len(expired_keys)} expired message records.") + return len(expired_keys) + + def clear_all(self): + """Clear all recorded messages (for F7/F8 functionality).""" + count = len(self.processed_messages) + self.processed_messages.clear() + if count > 0: # Log only if something was cleared + print(f"Deduplicator: Cleared all {count} message records.") + return count # --- Global Pause Flag --- # Using a simple mutable object (list) for thread-safe-like access without explicit lock @@ -142,6 +217,9 @@ PROFILE_OPTION_IMG = os.path.join(TEMPLATE_DIR, "profile_option.png") COPY_NAME_BUTTON_IMG = os.path.join(TEMPLATE_DIR, "copy_name_button.png") SEND_BUTTON_IMG = os.path.join(TEMPLATE_DIR, "send_button.png") CHAT_INPUT_IMG = os.path.join(TEMPLATE_DIR, "chat_input.png") +# 新增的模板路徑 +CHAT_OPTION_IMG = os.path.join(TEMPLATE_DIR, "chat_option.png") +UPDATE_CONFIRM_IMG = os.path.join(TEMPLATE_DIR, "update_confirm.png") # State Detection PROFILE_NAME_PAGE_IMG = os.path.join(TEMPLATE_DIR, "Profile_Name_page.png") PROFILE_PAGE_IMG = os.path.join(TEMPLATE_DIR, "Profile_page.png") @@ -1629,13 +1707,22 @@ def perform_state_cleanup(detector: DetectionModule, interactor: InteractionModu # --- UI Monitoring Loop Function (To be run in a separate thread) --- -def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queue): +def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queue, deduplicator: 'MessageDeduplication'): """ Continuously monitors the UI, detects triggers, performs interactions, puts trigger data into trigger_queue, and processes commands from command_queue. """ print("\n--- Starting UI Monitoring Loop (Thread) ---") + # --- 初始化氣泡圖像去重系統(新增) --- + bubble_deduplicator = SimpleBubbleDeduplication( + storage_file="simple_bubble_dedup.json", + max_bubbles=4, # 保留最近5個氣泡 + threshold=7, # 哈希差異閾值(值越小越嚴格) + hash_size=16 # 哈希大小 + ) + # --- 初始化氣泡圖像去重系統結束 --- + # --- Initialization (Instantiate modules within the thread) --- # --- Template Dictionary Setup (Refactored) --- essential_templates = { @@ -1667,7 +1754,9 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu 'page_sec': PAGE_SEC_IMG, 'page_str': PAGE_STR_IMG, 'dismiss_button': DISMISS_BUTTON_IMG, 'confirm_button': CONFIRM_BUTTON_IMG, 'close_button': CLOSE_BUTTON_IMG, 'back_arrow': BACK_ARROW_IMG, - 'reply_button': REPLY_BUTTON_IMG + 'reply_button': REPLY_BUTTON_IMG, + # 添加新模板 + 'chat_option': CHAT_OPTION_IMG, 'update_confirm': UPDATE_CONFIRM_IMG, } legacy_templates = { # Deprecated Keywords (for legacy method fallback) @@ -1773,13 +1862,27 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu elif action == 'clear_history': # Added for F7 print("UI Thread: Processing clear_history command.") recent_texts.clear() - print("UI Thread: recent_texts cleared.") + deduplicator.clear_all() # Simultaneously clear deduplication records + + # --- 新增:清理氣泡去重記錄 --- + if 'bubble_deduplicator' in locals(): + bubble_deduplicator.clear_all() + # --- 清理氣泡去重記錄結束 --- + + print("UI Thread: recent_texts and deduplicator records cleared.") elif action == 'reset_state': # Added for F8 resume print("UI Thread: Processing reset_state command.") recent_texts.clear() last_processed_bubble_info = None - print("UI Thread: recent_texts cleared and last_processed_bubble_info reset.") + deduplicator.clear_all() # Simultaneously clear deduplication records + + # --- 新增:清理氣泡去重記錄 --- + if 'bubble_deduplicator' in locals(): + bubble_deduplicator.clear_all() + # --- 清理氣泡去重記錄結束 --- + + print("UI Thread: recent_texts, last_processed_bubble_info, and deduplicator records reset.") else: print(f"UI Thread: Received unknown command: {action}") @@ -1804,6 +1907,19 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu # --- If not paused, proceed with UI Monitoring --- # print("[DEBUG] UI Loop: Monitoring is active. Proceeding...") # DEBUG REMOVED + # --- 添加檢查 chat_option 狀態 --- + try: + chat_option_locs = detector._find_template('chat_option', confidence=0.8) + if chat_option_locs: + print("UI Thread: Detected chat_option overlay. Pressing ESC to dismiss...") + interactor.press_key('esc') + time.sleep(0.2) # 給一點時間讓界面響應 + print("UI Thread: Pressed ESC to dismiss chat_option. Continuing...") + continue # 重新開始循環以確保界面已清除 + except Exception as chat_opt_err: + print(f"UI Thread: Error checking for chat_option: {chat_opt_err}") + # 繼續執行,不要中斷主流程 + # --- Check for Main Screen Navigation --- # print("[DEBUG] UI Loop: Checking for main screen navigation...") # DEBUG REMOVED try: @@ -1842,8 +1958,19 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu # Use a slightly lower confidence maybe, or state_confidence chat_room_locs = detector._find_template('chat_room', confidence=detector.state_confidence) if not chat_room_locs: - print("UI Thread: Not in chat room state before bubble detection. Attempting cleanup...") - # Call the existing cleanup function to try and return + print("UI Thread: Not in chat room state before bubble detection. Checking for update confirm...") + + # 檢查是否存在更新確認按鈕 + update_confirm_locs = detector._find_template('update_confirm', confidence=0.8) + if update_confirm_locs: + print("UI Thread: Detected update_confirm button. Clicking to proceed...") + interactor.click_at(update_confirm_locs[0][0], update_confirm_locs[0][1]) + time.sleep(0.5) # 給更新過程一些時間 + print("UI Thread: Clicked update_confirm button. Continuing...") + continue # 重新開始循環以重新檢查狀態 + + # 沒有找到更新確認按鈕,繼續原有的清理邏輯 + print("UI Thread: No update_confirm button found. Attempting cleanup...") perform_state_cleanup(detector, interactor) # Regardless of cleanup success, restart the loop to re-evaluate state from the top print("UI Thread: Continuing loop after attempting chat room cleanup.") @@ -1944,6 +2071,13 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("Warning: Failed to capture bubble snapshot. Skipping this bubble.") continue # Skip to next bubble + # --- New: Image deduplication check --- + if bubble_deduplicator.is_duplicate(bubble_snapshot, bubble_region_tuple): + print("Detected duplicate bubble, skipping processing") + perform_state_cleanup(detector, interactor) + continue # Skip processing this bubble + # --- End of image deduplication check --- + # --- Save Snapshot for Debugging --- try: screenshot_index = (screenshot_counter % MAX_DEBUG_SCREENSHOTS) + 1 @@ -1955,7 +2089,7 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu screenshot_counter += 1 except Exception as save_err: print(f"Error saving bubble snapshot to {screenshot_path}: {repr(save_err)}") - + except Exception as snapshot_err: print(f"Error taking initial bubble snapshot: {repr(snapshot_err)}") continue # Skip to next bubble @@ -2010,16 +2144,6 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu perform_state_cleanup(detector, interactor) # Attempt cleanup continue # Skip to next bubble - # Check recent text history - # print("[DEBUG] UI Loop: Checking recent text history...") # DEBUG REMOVED - if bubble_text in recent_texts: - print(f"Content '{bubble_text[:30]}...' in recent history, skipping this bubble.") - continue # Skip to next bubble - - print(">>> New trigger event <<<") - # Add to recent texts *before* potentially long interaction - recent_texts.append(bubble_text) - # 5. Interact: Get Sender Name (uses re-location internally via retrieve_sender_name_interaction) # print("[DEBUG] UI Loop: Retrieving sender name...") # DEBUG REMOVED sender_name = None @@ -2097,6 +2221,32 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("Error: Could not get sender name for this bubble, skipping.") continue # Skip to next bubble + # --- Deduplication Check --- + # This is the new central point for deduplication and recent_texts logic + if sender_name and bubble_text: # Ensure both are valid before deduplication + if deduplicator.is_duplicate(sender_name, bubble_text): + print(f"UI Thread: Skipping duplicate message via Deduplicator: {sender_name} - {bubble_text[:30]}...") + # Cleanup UI state as interaction might have occurred during sender_name retrieval + perform_state_cleanup(detector, interactor) + continue # Skip this bubble + + # If not a duplicate by deduplicator, then check recent_texts (original safeguard) + # if bubble_text in recent_texts: + # print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.") + # perform_state_cleanup(detector, interactor) # Cleanup as we are skipping + # continue + + # If not a duplicate by any means, add to recent_texts and proceed + print(">>> New trigger event (passed deduplication) <<<") + # recent_texts.append(bubble_text) # No longer needed with image deduplication + else: + # This case implies sender_name or bubble_text was None/empty, + # which should have been caught by earlier checks. + # If somehow reached, log and skip. + print(f"Warning: sender_name ('{sender_name}') or bubble_text ('{bubble_text[:30]}...') is invalid before deduplication check. Skipping.") + perform_state_cleanup(detector, interactor) + continue + # --- Attempt to activate reply context --- # print("[DEBUG] UI Loop: Attempting to activate reply context...") # DEBUG REMOVED reply_context_activated = False @@ -2172,6 +2322,16 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu } trigger_queue.put(data_to_send) print("Trigger info (with region, reply flag, snapshot, search_area) placed in Queue.") + + # --- 新增:更新氣泡去重記錄中的發送者信息 --- + # 注意:我們在前面已經添加了氣泡到去重系統,但當時還沒獲取發送者名稱 + # 這裡我們嘗試再次更新發送者信息(如果實現允許的話) + if 'bubble_deduplicator' in locals() and bubble_snapshot and sender_name: + bubble_id = bubble_deduplicator.generate_bubble_id(bubble_region_tuple) + if bubble_id in bubble_deduplicator.recent_bubbles: + bubble_deduplicator.recent_bubbles[bubble_id]['sender'] = sender_name + bubble_deduplicator._save_storage() + # --- 更新發送者信息結束 --- # --- CRITICAL: Break loop after successfully processing one trigger --- print("--- Single bubble processing complete. Breaking scan cycle. ---")