Merge pull request #13 from z060142/Temporary-solution

Temporary solution
This commit is contained in:
z060142 2025-05-17 03:52:52 +08:00 committed by GitHub
commit 2c8a9e4588
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 599 additions and 79 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
llm_debug.log
config.py
config.py.bak
simple_bubble_dedup.json
__pycache__/
debug_screenshots/
chat_logs/

View File

@ -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`

View File

@ -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

View File

@ -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)

34
main.py
View File

@ -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) ---")

155
simple_bubble_dedup.py Normal file
View File

@ -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}")

BIN
templates/chat_option.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -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. ---")