From a29d336df01a4ef929794a9b52f7022690b934d1 Mon Sep 17 00:00:00 2001 From: z060142 Date: Wed, 7 May 2025 04:16:23 +0800 Subject: [PATCH 01/13] Add remote control system for evaluation --- .gitignore | 4 +- Setup.py | 936 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 911 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 87c47df..694f3f6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ __pycache__/ debug_screenshots/ chat_logs/ backup/ -chroma_data/ \ No newline at end of file +chroma_data/ +wolf_control.py +remote_config.json \ No newline at end of file diff --git a/Setup.py b/Setup.py index e4ab7c7..c2c8c6a 100644 --- a/Setup.py +++ b/Setup.py @@ -19,6 +19,20 @@ import configparser from pathlib import Path import re import shutil +import time +import signal +import logging +import subprocess +import threading +import datetime +import schedule +import psutil +try: + import socketio + HAS_SOCKETIO = True +except ImportError: + HAS_SOCKETIO = False +# import ssl # ssl import might not be needed if socketio handles it or if not using wss directly in client setup # =============================================================== # Constants @@ -26,6 +40,7 @@ import shutil VERSION = "1.0.0" CONFIG_TEMPLATE_PATH = "config_template.py" ENV_FILE_PATH = ".env" +REMOTE_CONFIG_PATH = "remote_config.json" # New config file for remote settings # Use absolute path for chroma_data DEFAULT_CHROMA_DATA_PATH = os.path.abspath("chroma_data") DEFAULT_CONFIG_SECTION = """# ==================================================================== @@ -37,9 +52,69 @@ DEFAULT_CONFIG_SECTION = """# ================================================== # Get current Windows username for default paths CURRENT_USERNAME = os.getenv("USERNAME", "user") +# Global variables for game/bot management +game_process_instance = None +bot_process_instance = None # This will replace/co-exist with self.running_process +control_client_instance = None +monitor_thread_instance = None # Renamed to avoid conflict if 'monitor_thread' is used elsewhere +scheduler_thread_instance = None # Renamed +keep_monitoring_flag = threading.Event() # Renamed for clarity +keep_monitoring_flag.set() + +# Basic logging setup +# logger = logging.getLogger("WolfChatSetup") # Defined later in class or globally if needed +# 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__) +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') + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # =============================================================== # Helper Functions # =============================================================== +def load_remote_config(): + """Load remote control and restart settings from remote_config.json""" + defaults = { + "REMOTE_SERVER_URL": "YOUR_URL_HERE", + "REMOTE_CLIENT_KEY": "YOUR_KEY_HERE", # Placeholder + "DEFAULT_GAME_RESTART_INTERVAL_MINUTES": 120, + "DEFAULT_BOT_RESTART_INTERVAL_MINUTES": 120, + "LINK_RESTART_TIMES": True, + "GAME_PROCESS_NAME": "LastWar.exe", # Default game process name + "BOT_SCRIPT_NAME": "main.py" # Default bot script name + } + if os.path.exists(REMOTE_CONFIG_PATH): + try: + with open(REMOTE_CONFIG_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + # Ensure all keys from defaults are present, adding them if missing + for key, value in defaults.items(): + data.setdefault(key, value) + return data + except json.JSONDecodeError: + logger.error(f"Error decoding {REMOTE_CONFIG_PATH}. Using default remote settings.") + return defaults.copy() # Return a copy to avoid modifying defaults + except Exception as e: + logger.error(f"Error loading {REMOTE_CONFIG_PATH}: {e}. Using default remote settings.") + return defaults.copy() + logger.info(f"{REMOTE_CONFIG_PATH} not found. Creating with default values.") + save_remote_config(defaults.copy()) # Create the file if it doesn't exist + return defaults.copy() + +def save_remote_config(remote_data): + """Save remote control and restart settings to remote_config.json""" + try: + with open(REMOTE_CONFIG_PATH, 'w', encoding='utf-8') as f: + json.dump(remote_data, f, indent=4) # Use indent for readability + logger.info(f"Saved remote settings to {REMOTE_CONFIG_PATH}") + except Exception as e: + logger.error(f"Error saving {REMOTE_CONFIG_PATH}: {e}") + def load_env_file(): """Load existing .env file if it exists""" env_data = {} @@ -434,6 +509,7 @@ class WolfChatSetup(tk.Tk): # Load existing data self.env_data = load_env_file() self.config_data = load_current_config() + self.remote_data = load_remote_config() # Load new remote config # Create the notebook for tabs self.notebook = ttk.Notebook(self) @@ -443,17 +519,623 @@ class WolfChatSetup(tk.Tk): self.create_api_tab() self.create_mcp_tab() self.create_game_tab() - self.create_memory_tab() # 新增記憶設定標籤頁 + self.create_memory_tab() + self.create_management_tab() # New tab for combined management # Create bottom buttons self.create_bottom_buttons() - # Initialize running process tracker - self.running_process = None + # Initialize running process tracker (will be managed by new system) + self.running_process = None # This might be replaced by bot_process_instance + # Initialize new process management variables + self.bot_process_instance = None + self.game_process_instance = None + self.control_client_instance = None + self.monitor_thread_instance = None + self.scheduler_thread_instance = None + self.keep_monitoring_flag = threading.Event() + self.keep_monitoring_flag.set() + + # Set initial states based on loaded data self.update_ui_from_data() + def create_management_tab(self): + """Create the Bot and Game Management tab""" + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="Management") + + main_frame = ttk.Frame(tab, padding=10) + main_frame.pack(fill=tk.BOTH, expand=True) + + header = ttk.Label(main_frame, text="Bot & Game Management", font=("", 12, "bold")) + header.pack(anchor=tk.W, pady=(0, 10)) + + # --- Remote Control Settings --- + remote_frame = ttk.LabelFrame(main_frame, text="Remote Control Settings") + remote_frame.pack(fill=tk.X, pady=10) + + # Remote Server URL + remote_url_frame = ttk.Frame(remote_frame) + remote_url_frame.pack(fill=tk.X, pady=5, padx=10) + remote_url_label = ttk.Label(remote_url_frame, text="Server URL:", width=15) + remote_url_label.pack(side=tk.LEFT) + self.remote_url_var = tk.StringVar(value=self.remote_data.get("REMOTE_SERVER_URL", "")) + remote_url_entry = ttk.Entry(remote_url_frame, textvariable=self.remote_url_var) + remote_url_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Remote Client Key + remote_key_frame = ttk.Frame(remote_frame) + remote_key_frame.pack(fill=tk.X, pady=5, padx=10) + remote_key_label = ttk.Label(remote_key_frame, text="Client Key:", width=15) + remote_key_label.pack(side=tk.LEFT) + self.remote_key_var = tk.StringVar(value=self.remote_data.get("REMOTE_CLIENT_KEY", "")) + remote_key_entry = ttk.Entry(remote_key_frame, textvariable=self.remote_key_var, show="*") + remote_key_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + self.show_remote_key_var = tk.BooleanVar(value=False) + show_remote_key_cb = ttk.Checkbutton(remote_key_frame, text="Show", variable=self.show_remote_key_var, + command=lambda: self.toggle_field_visibility(remote_key_entry, self.show_remote_key_var)) + show_remote_key_cb.pack(side=tk.LEFT, padx=(5,0)) + + + # --- Restart Settings --- + restart_settings_frame = ttk.LabelFrame(main_frame, text="Restart Settings") + restart_settings_frame.pack(fill=tk.X, pady=10) + + # Game Restart Interval + game_interval_frame = ttk.Frame(restart_settings_frame) + game_interval_frame.pack(fill=tk.X, pady=5, padx=10) + game_interval_label = ttk.Label(game_interval_frame, text="Game Restart Interval (min):", width=25) + game_interval_label.pack(side=tk.LEFT) + self.game_restart_interval_var = tk.IntVar(value=self.remote_data.get("DEFAULT_GAME_RESTART_INTERVAL_MINUTES", 120)) + game_interval_spinbox = ttk.Spinbox(game_interval_frame, from_=0, to=1440, width=7, textvariable=self.game_restart_interval_var) + game_interval_spinbox.pack(side=tk.LEFT) + game_interval_info = ttk.Label(game_interval_frame, text="(0 to disable)") + game_interval_info.pack(side=tk.LEFT, padx=(5,0)) + + + # Bot Restart Interval + bot_interval_frame = ttk.Frame(restart_settings_frame) + bot_interval_frame.pack(fill=tk.X, pady=5, padx=10) + bot_interval_label = ttk.Label(bot_interval_frame, text="Bot Restart Interval (min):", width=25) + bot_interval_label.pack(side=tk.LEFT) + self.bot_restart_interval_var = tk.IntVar(value=self.remote_data.get("DEFAULT_BOT_RESTART_INTERVAL_MINUTES", 120)) + bot_interval_spinbox = ttk.Spinbox(bot_interval_frame, from_=0, to=1440, width=7, textvariable=self.bot_restart_interval_var) + bot_interval_spinbox.pack(side=tk.LEFT) + bot_interval_info = ttk.Label(bot_interval_frame, text="(0 to disable)") + bot_interval_info.pack(side=tk.LEFT, padx=(5,0)) + + # Link Restart Times + link_restarts_frame = ttk.Frame(restart_settings_frame) + link_restarts_frame.pack(fill=tk.X, pady=5, padx=10) + self.link_restarts_var = tk.BooleanVar(value=self.remote_data.get("LINK_RESTART_TIMES", True)) + link_restarts_cb = ttk.Checkbutton(link_restarts_frame, text="Link Game and Bot restart times (use Game interval if linked)", variable=self.link_restarts_var) + link_restarts_cb.pack(anchor=tk.W) + + # Game Process Name + game_proc_name_frame = ttk.Frame(restart_settings_frame) + game_proc_name_frame.pack(fill=tk.X, pady=5, padx=10) + game_proc_name_label = ttk.Label(game_proc_name_frame, text="Game Process Name:", width=25) + game_proc_name_label.pack(side=tk.LEFT) + self.game_process_name_var = tk.StringVar(value=self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe")) + game_proc_name_entry = ttk.Entry(game_proc_name_frame, textvariable=self.game_process_name_var) + game_proc_name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + + # --- Control Buttons --- + control_buttons_frame = ttk.Frame(main_frame) + control_buttons_frame.pack(fill=tk.X, pady=20) + + self.start_managed_button = ttk.Button(control_buttons_frame, text="Start Managed Bot & Game", command=self.start_managed_session) + self.start_managed_button.pack(side=tk.LEFT, padx=5) + + self.stop_managed_button = ttk.Button(control_buttons_frame, text="Stop Managed Session", command=self.stop_managed_session, state=tk.DISABLED) + self.stop_managed_button.pack(side=tk.LEFT, padx=5) + + # Status Area (Optional, for displaying logs or status messages) + status_label = ttk.Label(main_frame, text="Status messages will appear in the console.") + status_label.pack(pady=10) + + def start_managed_session(self): + logger.info("Attempting to start managed session...") + # This will be the new main function to start bot, game, and monitoring + + # Ensure previous session is stopped if any + if self.bot_process_instance or self.game_process_instance or self.monitor_thread_instance: + messagebox.showwarning("Session Active", "A managed session might already be active. Please stop it first or check console.") + # self.stop_managed_session() # Optionally force stop + # time.sleep(1) # Give time to stop + # return + + # Save current settings before starting + self.save_settings(show_success_message=False) # Save without showing popup, or make it optional + + self.keep_monitoring_flag.set() # Ensure monitoring is enabled + + # Start Game + if not self._start_game_managed(): + messagebox.showerror("Error", "Failed to start the game.") + self.update_management_buttons_state(True) # Enable start, disable stop + return + + time.sleep(5) # Give game some time to initialize + + # Start Bot (main.py) + if not self._start_bot_managed(): + messagebox.showerror("Error", "Failed to start the bot (main.py).") + self._stop_game_managed() # Stop game if bot fails to start + self.update_management_buttons_state(True) + return + + # Start Control Client + if HAS_SOCKETIO: + self._start_control_client() + else: + logger.warning("socketio library not found. Remote control will be disabled.") + messagebox.showwarning("Socket.IO Missing", "The 'python-socketio[client]' library is not installed. Remote control features will be disabled. Please install it via 'pip install \"python-socketio[client]\"' or use the 'Install Dependencies' button.") + + + # Start Monitoring Thread + self._start_monitoring_thread() + + # Start Scheduler Thread + self._start_scheduler_thread() + + self.update_management_buttons_state(False) # Disable start, enable stop + messagebox.showinfo("Session Started", "Managed bot and game session started. Check console for logs.") + + def stop_managed_session(self): + logger.info("Attempting to stop managed session...") + self.keep_monitoring_flag.clear() # Signal threads to stop + + if self.control_client_instance: + self._stop_control_client() + + if self.scheduler_thread_instance and self.scheduler_thread_instance.is_alive(): + logger.info("Waiting for scheduler thread to stop...") + self.scheduler_thread_instance.join(timeout=5) + if self.scheduler_thread_instance.is_alive(): + logger.warning("Scheduler thread did not stop in time.") + self.scheduler_thread_instance = None + schedule.clear() + + + if self.monitor_thread_instance and self.monitor_thread_instance.is_alive(): + logger.info("Waiting for monitor thread to stop...") + self.monitor_thread_instance.join(timeout=5) + if self.monitor_thread_instance.is_alive(): + logger.warning("Monitor thread did not stop in time.") + self.monitor_thread_instance = None + + self._stop_bot_managed() + self._stop_game_managed() + + # Reset process instances + self.bot_process_instance = None + self.game_process_instance = None + + self.update_management_buttons_state(True) # Enable start, disable stop + messagebox.showinfo("Session Stopped", "Managed bot and game session stopped.") + + def update_management_buttons_state(self, enable_start): + if hasattr(self, 'start_managed_button'): + self.start_managed_button.config(state=tk.NORMAL if enable_start else tk.DISABLED) + if hasattr(self, 'stop_managed_button'): + self.stop_managed_button.config(state=tk.DISABLED if enable_start else tk.NORMAL) + + # Placeholder for game/bot start/stop/check methods to be integrated + # These will be adapted from wolf_control.py and use self.config_data and self.remote_data + + def _find_process_by_name(self, process_name): + """Find a process by name using psutil.""" + for proc in psutil.process_iter(['pid', 'name']): + try: + if proc.info['name'].lower() == process_name.lower(): + return proc + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + return None + + def _is_game_running_managed(self): + game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe") + if self.game_process_instance and self.game_process_instance.poll() is None: + # Check if the process name matches, in case Popen object is stale but a process with same PID exists + try: + p = psutil.Process(self.game_process_instance.pid) + if p.name().lower() == game_process_name.lower(): + return True + except psutil.NoSuchProcess: + self.game_process_instance = None # Stale process object + return False # Popen object is stale and process is gone + + # Fallback to checking by name if self.game_process_instance is None or points to a dead/wrong process + return self._find_process_by_name(game_process_name) is not None + + def _start_game_managed(self): + global game_process_instance + game_exe_path = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_EXECUTABLE_PATH") + game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe") + + if not game_exe_path: + logger.error("Game executable path not configured.") + messagebox.showerror("Config Error", "Game executable path is not set in Game Settings.") + return False + + if self._is_game_running_managed(): + logger.info(f"Game ({game_process_name}) is already running.") + # Try to get a Popen object if we don't have one + if not self.game_process_instance: + existing_proc = self._find_process_by_name(game_process_name) + if existing_proc: + # We can't directly create a Popen object for an existing process this way easily. + # For now, we'll just acknowledge it's running. + # For full control, it's best if this script starts it. + logger.info(f"Found existing game process PID: {existing_proc.pid}. Monitoring without direct Popen control.") + return True + + try: + logger.info(f"Starting game: {game_exe_path}") + # Use shell=False and pass arguments as a list if possible, but for .exe, shell=True is often more reliable on Windows + # For better process control, avoid shell=True if not strictly necessary. + # However, if GAME_EXE_PATH can contain spaces or needs shell interpretation, shell=True might be needed. + # For now, let's assume GAME_EXE_PATH is a direct path to an executable. + self.game_process_instance = subprocess.Popen(game_exe_path, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + game_process_instance = self.game_process_instance # Update global if used by other parts from wolf_control + + # Wait a bit for the process to appear in psutil + time.sleep(2) + if self._is_game_running_managed(): + logger.info(f"Game ({game_process_name}) started successfully with PID {self.game_process_instance.pid}.") + return True + else: + logger.warning(f"Game ({game_process_name}) did not appear to start correctly after Popen call.") + self.game_process_instance = None # Clear if it failed + game_process_instance = None + return False + except Exception as e: + logger.exception(f"Error starting game: {e}") + self.game_process_instance = None + game_process_instance = None + return False + + def _stop_game_managed(self): + global game_process_instance + game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe") + stopped = False + if self.game_process_instance and self.game_process_instance.poll() is None: + logger.info(f"Stopping game process (PID: {self.game_process_instance.pid}) started by this manager...") + try: + self.game_process_instance.terminate() + self.game_process_instance.wait(timeout=5) # Wait for termination + logger.info("Game process terminated.") + stopped = True + except subprocess.TimeoutExpired: + logger.warning("Game process did not terminate in time, killing...") + self.game_process_instance.kill() + self.game_process_instance.wait(timeout=5) + logger.info("Game process killed.") + stopped = True + except Exception as e: + logger.error(f"Error terminating/killing own game process: {e}") + self.game_process_instance = None + game_process_instance = None + + # If not stopped or no instance, try to find and kill by name + if not stopped: + proc_to_kill = self._find_process_by_name(game_process_name) + if proc_to_kill: + logger.info(f"Found game process '{game_process_name}' (PID: {proc_to_kill.pid}). Attempting to terminate...") + try: + proc_to_kill.terminate() + proc_to_kill.wait(timeout=5) # psutil's wait + logger.info(f"Game process '{game_process_name}' terminated.") + stopped = True + except psutil.TimeoutExpired: + logger.warning(f"Game process '{game_process_name}' did not terminate, killing...") + proc_to_kill.kill() + proc_to_kill.wait(timeout=5) + logger.info(f"Game process '{game_process_name}' killed.") + stopped = True + except Exception as e: + logger.error(f"Error terminating/killing game process by name '{game_process_name}': {e}") + else: + logger.info(f"Game process '{game_process_name}' not found running.") + stopped = True # Considered stopped if not found + + if self.game_process_instance: # Clear Popen object if it exists + self.game_process_instance = None + game_process_instance = None + return stopped + + def _is_bot_running_managed(self): + bot_script_name = self.remote_data.get("BOT_SCRIPT_NAME", "main.py") + if self.bot_process_instance and self.bot_process_instance.poll() is None: + # Verify it's the correct script, in case of PID reuse + try: + p = psutil.Process(self.bot_process_instance.pid) + if sys.executable in p.cmdline() and any(bot_script_name in arg for arg in p.cmdline()): + return True + except psutil.NoSuchProcess: + self.bot_process_instance = None # Stale process object + return False + + # Fallback: Check for any python process running the bot script + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + cmdline = proc.cmdline() + if cmdline and sys.executable in cmdline[0] and any(bot_script_name in arg for arg in cmdline): + # If we find one, and don't have an instance, we can't control it directly with Popen + # but we know it's running. + if not self.bot_process_instance: + logger.info(f"Found external bot process (PID: {proc.pid}). Monitoring without direct Popen control.") + return True + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, IndexError): + continue # Ignore processes that died or we can't access, or have empty cmdline + return False + + def _start_bot_managed(self): + global bot_process_instance # For compatibility if other parts use global + bot_script_name = self.remote_data.get("BOT_SCRIPT_NAME", "main.py") + if not os.path.exists(bot_script_name): + messagebox.showerror("Error", f"Could not find bot script: {bot_script_name}") + return False + + if self._is_bot_running_managed(): + logger.info(f"Bot ({bot_script_name}) is already running.") + return True # Or handle acquiring Popen object if possible (complex) + + try: + logger.info(f"Starting bot: {sys.executable} {bot_script_name}") + # Ensure CWD is script's directory if main.py relies on relative paths + script_dir = os.path.dirname(os.path.abspath(__file__)) + self.bot_process_instance = subprocess.Popen( + [sys.executable, bot_script_name], + cwd=script_dir, # Run main.py from its directory + stdout=subprocess.PIPE, # Capture output + stderr=subprocess.STDOUT, # Redirect stderr to stdout + text=True, + bufsize=1 # Line buffered + ) + bot_process_instance = self.bot_process_instance # Update global + + # Start a thread to log bot's output + threading.Thread(target=self._log_subprocess_output, args=(self.bot_process_instance, "Bot"), daemon=True).start() + + logger.info(f"Bot ({bot_script_name}) started successfully with PID {self.bot_process_instance.pid}.") + return True + except Exception as e: + logger.exception(f"Error starting bot: {e}") + self.bot_process_instance = None + bot_process_instance = None + return False + + def _log_subprocess_output(self, process, name): + """Reads and logs output from a subprocess.""" + if not process or not process.stdout: + logger.error(f"No process or stdout to log for {name}.") + return + + logger.info(f"Started logging output for {name} (PID: {process.pid}).") + try: + for line in iter(process.stdout.readline, ''): + if line: + logger.info(f"[{name}] {line.strip()}") + if process.poll() is not None and not line: # Process ended and no more output + break + process.stdout.close() + except Exception as e: + logger.error(f"Error logging output for {name}: {e}") + finally: + return_code = process.wait() + logger.info(f"{name} process (PID: {process.pid}) exited with code {return_code}.") + + + def _stop_bot_managed(self): + global bot_process_instance + bot_script_name = self.remote_data.get("BOT_SCRIPT_NAME", "main.py") + stopped = False + + if self.bot_process_instance and self.bot_process_instance.poll() is None: + logger.info(f"Stopping bot process (PID: {self.bot_process_instance.pid}) started by this manager...") + try: + self.bot_process_instance.terminate() + self.bot_process_instance.wait(timeout=5) + logger.info("Bot process terminated.") + stopped = True + except subprocess.TimeoutExpired: + logger.warning("Bot process did not terminate in time, killing...") + self.bot_process_instance.kill() + self.bot_process_instance.wait(timeout=5) + logger.info("Bot process killed.") + stopped = True + except Exception as e: + logger.error(f"Error terminating/killing own bot process: {e}") + self.bot_process_instance = None + bot_process_instance = None + + # Fallback: find and kill any python process running the bot script + if not stopped: + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + cmdline = proc.cmdline() + if cmdline and sys.executable in cmdline[0] and any(bot_script_name in arg for arg in cmdline): + logger.info(f"Found bot process '{bot_script_name}' (PID: {proc.pid}). Attempting to terminate...") + proc.terminate() + proc.wait(timeout=5) + logger.info(f"Bot process '{bot_script_name}' terminated.") + stopped = True + break # Assume only one instance for now + except psutil.TimeoutExpired: + logger.warning(f"Bot process '{bot_script_name}' (PID: {proc.pid}) did not terminate, killing...") + proc.kill() + proc.wait(timeout=5) + logger.info(f"Bot process '{bot_script_name}' killed.") + stopped = True + break + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, IndexError): + continue + + if not stopped: # If no Popen instance and no external process found + logger.info(f"Bot process '{bot_script_name}' not found running.") + stopped = True + + if self.bot_process_instance: # Clear Popen object if it exists + self.bot_process_instance = None + bot_process_instance = None + return stopped + + def _restart_game_managed(self): + logger.info("Restarting game (managed)...") + self._stop_game_managed() + time.sleep(2) # Give it time to fully stop + return self._start_game_managed() + + def _restart_bot_managed(self): + logger.info("Restarting bot (managed)...") + self._stop_bot_managed() + time.sleep(2) # Give it time to fully stop + return self._start_bot_managed() + + def _restart_all_managed(self): + logger.info("Performing full restart (bot and game)...") + self._stop_bot_managed() + self._stop_game_managed() + time.sleep(3) + game_started = self._start_game_managed() + if game_started: + time.sleep(10) # Wait for game to initialize + bot_started = self._start_bot_managed() + if not bot_started: + logger.error("Failed to restart bot after restarting game.") + return False + else: + logger.error("Failed to restart game during full restart.") + # Optionally try to start bot anyway or declare full failure + # self._start_bot_managed() + return False + + logger.info("Full restart completed.") + # Update last restart time if tracking it + # self.last_restart_time = datetime.datetime.now() + return True + + def _start_monitoring_thread(self): + if self.monitor_thread_instance and self.monitor_thread_instance.is_alive(): + logger.info("Monitor thread already running.") + return + + self.monitor_thread_instance = threading.Thread(target=self._monitoring_loop, daemon=True) + self.monitor_thread_instance.start() + logger.info("Started monitoring thread.") + + def _monitoring_loop(self): + logger.info("Monitoring loop started.") + while self.keep_monitoring_flag.is_set(): + try: + # Check game + if not self._is_game_running_managed(): + if self.game_process_instance is None : # Only restart if we are supposed to manage it or it was started by us and died + logger.warning("Managed game process not found. Attempting to restart game...") + self._start_game_managed() # Or _restart_game_managed() + + # Check bot + if not self._is_bot_running_managed(): + if self.bot_process_instance is None: # Only restart if we are supposed to manage it or it was started by us and died + logger.warning("Managed bot process not found. Attempting to restart bot...") + self._start_bot_managed() # Or _restart_bot_managed() + + # Check for remote commands (if control_client_instance is set up) + if self.control_client_instance and hasattr(self.control_client_instance, 'check_signals'): + self.control_client_instance.check_signals(self) # Pass self (WolfChatSetup instance) + + time.sleep(self.config_data.get("GAME_WINDOW_CONFIG", {}).get("MONITOR_INTERVAL_SECONDS", 5)) + except Exception as e: + logger.exception(f"Error in monitoring loop: {e}") + time.sleep(10) # Wait longer after an error + logger.info("Monitoring loop stopped.") + + def _start_scheduler_thread(self): + if self.scheduler_thread_instance and self.scheduler_thread_instance.is_alive(): + logger.info("Scheduler thread already running.") + return + + self._setup_scheduled_restarts() # Setup jobs based on current config + + self.scheduler_thread_instance = threading.Thread(target=self._run_scheduler, daemon=True) + self.scheduler_thread_instance.start() + logger.info("Started scheduler thread.") + + def _run_scheduler(self): + logger.info("Scheduler loop started.") + while self.keep_monitoring_flag.is_set(): # Use same flag as monitor + schedule.run_pending() + time.sleep(1) + logger.info("Scheduler loop stopped.") + + def _setup_scheduled_restarts(self): + schedule.clear() # Clear previous jobs + + link_restarts = self.remote_data.get("LINK_RESTART_TIMES", True) + game_interval = self.remote_data.get("DEFAULT_GAME_RESTART_INTERVAL_MINUTES", 0) + bot_interval = self.remote_data.get("DEFAULT_BOT_RESTART_INTERVAL_MINUTES", 0) + + if link_restarts and game_interval > 0: + logger.info(f"Scheduling linked restart (game & bot) every {game_interval} minutes.") + schedule.every(game_interval).minutes.do(self._restart_all_managed) + else: + if game_interval > 0: + logger.info(f"Scheduling game restart every {game_interval} minutes.") + schedule.every(game_interval).minutes.do(self._restart_game_managed) + if bot_interval > 0: + logger.info(f"Scheduling bot restart every {bot_interval} minutes.") + schedule.every(bot_interval).minutes.do(self._restart_bot_managed) + + if not schedule.jobs: + logger.info("No scheduled restarts configured.") + + + def _start_control_client(self): + if not HAS_SOCKETIO: + logger.warning("Cannot start ControlClient: python-socketio is not installed.") + return + + if self.control_client_instance and self.control_client_instance.is_connected(): # is_connected or similar check + logger.info("Control client already connected.") + return + + server_url = self.remote_data.get("REMOTE_SERVER_URL") + client_key = self.remote_data.get("REMOTE_CLIENT_KEY") + + if not server_url or not client_key: + logger.warning("Remote server URL or client key not configured. Cannot start control client.") + messagebox.showwarning("Remote Config Missing", "Remote Server URL or Client Key is not set in Management tab.") + return + + self.control_client_instance = ControlClient(server_url, client_key, wolf_chat_setup_instance=self) # Pass self + # The ControlClient should handle its own connection thread. + # self.control_client_instance.start_thread() or similar method + if self.control_client_instance.run_in_thread(): # Assuming run_in_thread starts the connection attempt + logger.info("Control client thread started.") + else: + logger.error("Failed to start control client thread.") + self.control_client_instance = None + + + def _stop_control_client(self): + if self.control_client_instance: + logger.info("Stopping control client...") + self.control_client_instance.stop() # This should handle thread shutdown + self.control_client_instance = None + logger.info("Control client stopped.") + + def on_closing(self): + """Handle window close event.""" + if messagebox.askokcancel("Quit", "Do you want to quit Wolf Chat Setup? This will stop any managed sessions."): + self.stop_managed_session() # Ensure everything is stopped + self.destroy() + def create_api_tab(self): """Create the API Settings tab""" tab = ttk.Frame(self.notebook) @@ -853,25 +1535,21 @@ class WolfChatSetup(tk.Tk): height_entry = ttk.Spinbox(size_frame, textvariable=self.height_var, from_=300, to=3000, width=5) height_entry.pack(side=tk.LEFT) - # Auto-restart settings - restart_frame = ttk.LabelFrame(main_frame, text="Auto-Restart Settings") - restart_frame.pack(fill=tk.X, pady=10) + # Auto-restart settings (Now managed by 'Management' tab) + restart_info_frame = ttk.LabelFrame(main_frame, text="Auto-Restart Settings (Legacy)") + restart_info_frame.pack(fill=tk.X, pady=10) - self.restart_var = tk.BooleanVar(value=True) - restart_cb = ttk.Checkbutton(restart_frame, text="Enable scheduled game restart", variable=self.restart_var) - restart_cb.pack(anchor=tk.W, padx=10, pady=5) + legacy_restart_label = ttk.Label(restart_info_frame, + text="Scheduled game/bot restarts are now configured in the 'Management' tab.", + justify=tk.LEFT, wraplength=680) + legacy_restart_label.pack(padx=10, pady=10, anchor=tk.W) + + # Keep the variables for config.py compatibility if other parts of the app might read them, + # but their UI controls are removed from here. + self.restart_var = tk.BooleanVar(value=self.config_data.get("GAME_WINDOW_CONFIG", {}).get("ENABLE_SCHEDULED_RESTART", True)) + self.interval_var = tk.IntVar(value=self.config_data.get("GAME_WINDOW_CONFIG", {}).get("RESTART_INTERVAL_MINUTES", 60)) - interval_frame = ttk.Frame(restart_frame) - interval_frame.pack(fill=tk.X, padx=10, pady=5) - - interval_label = ttk.Label(interval_frame, text="Restart interval (minutes):") - interval_label.pack(side=tk.LEFT) - - self.interval_var = tk.IntVar(value=60) - interval_entry = ttk.Spinbox(interval_frame, textvariable=self.interval_var, from_=15, to=1440, width=5) - interval_entry.pack(side=tk.LEFT, padx=(5, 0)) - - # Monitor interval + # Monitor interval (Still relevant for window positioning, not restart scheduling) monitor_frame = ttk.Frame(main_frame) monitor_frame.pack(fill=tk.X, pady=5) @@ -1159,14 +1837,25 @@ class WolfChatSetup(tk.Tk): # Memory Settings self.preload_profiles_var.set(self.config_data.get("ENABLE_PRELOAD_PROFILES", True)) self.related_memories_var.set(self.config_data.get("PRELOAD_RELATED_MEMORIES", 2)) - self.profiles_collection_var.set(self.config_data.get("PROFILES_COLLECTION", "user_profiles")) + self.profiles_collection_var.set(self.config_data.get("PROFILES_COLLECTION", "user_profiles")) # Default was user_profiles self.conversations_collection_var.set(self.config_data.get("CONVERSATIONS_COLLECTION", "conversations")) self.bot_memory_collection_var.set(self.config_data.get("BOT_MEMORY_COLLECTION", "wolfhart_memory")) + # Management Tab Settings + if hasattr(self, 'remote_url_var'): # Check if UI elements for management tab exist + self.remote_url_var.set(self.remote_data.get("REMOTE_SERVER_URL", "")) + self.remote_key_var.set(self.remote_data.get("REMOTE_CLIENT_KEY", "")) + self.game_restart_interval_var.set(self.remote_data.get("DEFAULT_GAME_RESTART_INTERVAL_MINUTES", 120)) + self.bot_restart_interval_var.set(self.remote_data.get("DEFAULT_BOT_RESTART_INTERVAL_MINUTES", 120)) + self.link_restarts_var.set(self.remote_data.get("LINK_RESTART_TIMES", True)) + self.game_process_name_var.set(self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe")) + # Update visibility and states self.update_exa_settings_visibility() + self.update_management_buttons_state(True) # Initially, start button is enabled except Exception as e: + logger.exception("Error updating UI from data") # Log full traceback print(f"Error updating UI from data: {e}") import traceback traceback.print_exc() @@ -1340,10 +2029,10 @@ class WolfChatSetup(tk.Tk): self.empty_settings_label.pack(expand=True, pady=20) self.remove_btn.config(state=tk.DISABLED) - def save_settings(self): - """Save all settings to config.py and .env files""" + def save_settings(self, show_success_message=True): # Added optional param + """Save all settings to config.py, .env, and remote_config.json files""" try: - # Update config data from UI + # Update config data from UI (for config.py and .env) # API settings self.config_data["OPENAI_API_BASE_URL"] = self.api_url_var.get() @@ -1418,7 +2107,16 @@ class WolfChatSetup(tk.Tk): self.config_data["PROFILES_COLLECTION"] = self.profiles_collection_var.get() self.config_data["CONVERSATIONS_COLLECTION"] = self.conversations_collection_var.get() self.config_data["BOT_MEMORY_COLLECTION"] = self.bot_memory_collection_var.get() - + + # Update remote_data from UI (for remote_config.json) + if hasattr(self, 'remote_url_var'): # Check if management tab UI elements exist + self.remote_data["REMOTE_SERVER_URL"] = self.remote_url_var.get() + self.remote_data["REMOTE_CLIENT_KEY"] = self.remote_key_var.get() + self.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = self.game_restart_interval_var.get() + self.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = self.bot_restart_interval_var.get() + self.remote_data["LINK_RESTART_TIMES"] = self.link_restarts_var.get() + self.remote_data["GAME_PROCESS_NAME"] = self.game_process_name_var.get() + # Validate critical settings if "exa" in self.config_data["MCP_SERVERS"] and self.config_data["MCP_SERVERS"]["exa"]["enabled"]: if not self.exa_key_var.get(): @@ -1432,18 +2130,200 @@ class WolfChatSetup(tk.Tk): # Generate config.py and .env files save_env_file(self.env_data) generate_config_file(self.config_data, self.env_data) + save_remote_config(self.remote_data) # Save remote config - messagebox.showinfo("Success", "Settings saved successfully.\nRestart Wolf Chat for changes to take effect.") - # self.destroy() # Removed to keep the window open after saving + if show_success_message: + messagebox.showinfo("Success", "Settings saved successfully.\nRestart managed session for changes to take effect.") except Exception as e: - messagebox.showerror("Error", f"An error occurred while saving settings:\n{str(e)}") + logger.exception("Error saving settings") # Log the full traceback + if show_success_message: # Only show error if it's a direct save action + messagebox.showerror("Error", f"An error occurred while saving settings:\n{str(e)}") import traceback traceback.print_exc() +# =============================================================== +# ControlClient Class (adapted from wolf_control.py) +# =============================================================== +if HAS_SOCKETIO: + class ControlClient: + def __init__(self, server_url, client_key, wolf_chat_setup_instance): + self.server_url = server_url + self.client_key = client_key + self.wolf_chat_setup = wolf_chat_setup_instance # Reference to the main app + self.sio = socketio.Client(ssl_verify=False, logger=logger, engineio_logger=logger) # Use app's logger + self.connected = False + self.authenticated = False + self.should_exit_flag = threading.Event() # Use an event for thread control + self.client_thread = None + + self.registered_commands = [ + "restart bot", "restart game", "restart all", + "set game interval", "set bot interval", "set linked interval" + ] + + # Event handlers + self.sio.on('connect', self._on_connect) + self.sio.on('disconnect', self._on_disconnect) + self.sio.on('authenticated', self._on_authenticated) + self.sio.on('command', self._on_command) + + def is_connected(self): + return self.connected and self.authenticated + + def run_in_thread(self): + if self.client_thread and self.client_thread.is_alive(): + logger.info("Control client thread already running.") + return True + + self.should_exit_flag.clear() + self.client_thread = threading.Thread(target=self._run_forever, daemon=True) + self.client_thread.start() + return True + + def _run_forever(self): + logger.info(f"ControlClient: Starting connection attempts to {self.server_url}") + while not self.should_exit_flag.is_set(): + if not self.sio.connected: + try: + self.sio.connect(self.server_url) + # self.sio.wait() # This would block, not suitable for a loop like this + # The connect call is blocking until connection or failure. + # If it fails, it raises socketio.exceptions.ConnectionError + except socketio.exceptions.ConnectionError as e: + logger.error(f"ControlClient: Connection failed: {e}. Retrying in 10s.") + self.should_exit_flag.wait(10) # Wait for 10s or until exit_flag is set + continue + except Exception as e: + logger.error(f"ControlClient: Unexpected error during connection: {e}. Retrying in 10s.") + self.should_exit_flag.wait(10) + continue + + # If connected, just sleep briefly to allow exit signal to be checked + # The actual event handling happens in SIO's own threads. + self.should_exit_flag.wait(1) # Check for exit signal every second + + logger.info("ControlClient: Exited _run_forever loop.") + if self.sio.connected: + self.sio.disconnect() + + + def _on_connect(self): + self.connected = True + logger.info("ControlClient: Connected to server. Authenticating...") + self.sio.emit('authenticate', { + 'type': 'client', + 'clientKey': self.client_key, + 'commands': self.registered_commands + }) + + def _on_disconnect(self): + self.connected = False + self.authenticated = False + logger.info("ControlClient: Disconnected from server.") + + def _on_authenticated(self, data): + if data.get('success'): + self.authenticated = True + logger.info("ControlClient: Authentication successful.") + else: + self.authenticated = False + logger.error(f"ControlClient: Authentication failed: {data.get('error', 'Unknown error')}") + self.sio.disconnect() # Disconnect if auth fails + + def _on_command(self, data): + command = data.get('command', '').lower() + args_str = data.get('args', '') # Assuming server might send args as a string + from_user = data.get('from', 'unknown') + logger.info(f"ControlClient: Received command '{command}' with args '{args_str}' from {from_user}") + + try: + if command == "restart bot": + self.wolf_chat_setup._restart_bot_managed() + self._send_command_result(command, True, "Bot restart initiated.") + elif command == "restart game": + self.wolf_chat_setup._restart_game_managed() + self._send_command_result(command, True, "Game restart initiated.") + elif command == "restart all": + self.wolf_chat_setup._restart_all_managed() + self._send_command_result(command, True, "Full restart initiated.") + elif command == "set game interval" or command == "set bot interval" or command == "set linked interval": + try: + interval = int(args_str) + if interval < 0: # 0 means disable + self._send_command_result(command, False, "Interval must be non-negative.") + return + + if command == "set game interval": + self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = interval + if self.wolf_chat_setup.remote_data["LINK_RESTART_TIMES"]: + self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = interval + elif command == "set bot interval": + self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = interval + if self.wolf_chat_setup.remote_data["LINK_RESTART_TIMES"]: + self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = interval + elif command == "set linked interval": + self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = interval + self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = interval + self.wolf_chat_setup.remote_data["LINK_RESTART_TIMES"] = True + + save_remote_config(self.wolf_chat_setup.remote_data) + self.wolf_chat_setup._setup_scheduled_restarts() # Re-apply schedule + # Update UI if possible (tricky from non-main thread) + # self.wolf_chat_setup.game_restart_interval_var.set(self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"]) + # self.wolf_chat_setup.bot_restart_interval_var.set(self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"]) + logger.info(f"Updated restart interval via remote: {command} to {interval} min. Saved and re-scheduled.") + self._send_command_result(command, True, f"Interval updated to {interval} min and re-scheduled.") + + except ValueError: + self._send_command_result(command, False, "Invalid interval value. Must be an integer.") + else: + self._send_command_result(command, False, "Unsupported command.") + except Exception as e: + logger.exception(f"ControlClient: Error executing command '{command}'") + self._send_command_result(command, False, f"Error: {str(e)}") + + def _send_command_result(self, command, success, message): + if self.sio.connected: + try: + self.sio.emit('commandResult', { + 'command': command, + 'success': success, + 'message': message, + 'timestamp': time.time() + }) + except Exception as e: + logger.error(f"ControlClient: Failed to send command result: {e}") + + def stop(self): + logger.info("ControlClient: Stopping...") + self.should_exit_flag.set() # Signal the run_forever loop to exit + if self.sio.connected: + self.sio.disconnect() # Attempt to disconnect gracefully + + if self.client_thread and self.client_thread.is_alive(): + logger.info("ControlClient: Waiting for client thread to join...") + self.client_thread.join(timeout=5) # Wait for the thread to finish + if self.client_thread.is_alive(): + logger.warning("ControlClient: Client thread did not join in time.") + self.client_thread = None + logger.info("ControlClient: Stopped.") +else: # HAS_SOCKETIO is False + class ControlClient: # Dummy class if socketio is not available + def __init__(self, *args, **kwargs): logger.warning("Socket.IO not installed, ControlClient is a dummy.") + def run_in_thread(self): return False + def stop(self): pass + def is_connected(self): return False + + # =============================================================== # Main Entry Point # =============================================================== 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') + app = WolfChatSetup() + app.protocol("WM_DELETE_WINDOW", app.on_closing) # Handle window close button app.mainloop() From ce111cf3d5a2fbbbe983ed121bed2f2d18c0ad93 Mon Sep 17 00:00:00 2001 From: z060142 Date: Wed, 7 May 2025 23:07:54 +0800 Subject: [PATCH 02/13] Enhanced server connection stability --- Setup.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 13 deletions(-) diff --git a/Setup.py b/Setup.py index c2c8c6a..07118d0 100644 --- a/Setup.py +++ b/Setup.py @@ -27,6 +27,8 @@ import threading import datetime import schedule import psutil +import random # Added for exponential backoff jitter +import urllib3 # Added for SSL warning suppression try: import socketio HAS_SOCKETIO = True @@ -684,7 +686,8 @@ class WolfChatSetup(tk.Tk): self._start_scheduler_thread() self.update_management_buttons_state(False) # Disable start, enable stop - messagebox.showinfo("Session Started", "Managed bot and game session started. Check console for logs.") + # messagebox.showinfo("Session Started", "Managed bot and game session started. Check console for logs.") # Removed popup + logger.info("Managed bot and game session started. Check console for logs.") # Log instead of popup def stop_managed_session(self): logger.info("Attempting to stop managed session...") @@ -2151,6 +2154,10 @@ if HAS_SOCKETIO: self.server_url = server_url self.client_key = client_key self.wolf_chat_setup = wolf_chat_setup_instance # Reference to the main app + + # Suppress InsecureRequestWarning when using ssl_verify=False, as is the current default + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self.sio = socketio.Client(ssl_verify=False, logger=logger, engineio_logger=logger) # Use app's logger self.connected = False self.authenticated = False @@ -2183,31 +2190,56 @@ if HAS_SOCKETIO: def _run_forever(self): logger.info(f"ControlClient: Starting connection attempts to {self.server_url}") + last_heartbeat = time.time() # For heartbeat + retry_delay = 1.0 # Start with 1 second delay for exponential backoff + max_delay = 300.0 # Maximum delay of 5 minutes for exponential backoff + while not self.should_exit_flag.is_set(): if not self.sio.connected: try: + logger.info(f"ControlClient: Attempting to connect to {self.server_url}...") self.sio.connect(self.server_url) - # self.sio.wait() # This would block, not suitable for a loop like this - # The connect call is blocking until connection or failure. - # If it fails, it raises socketio.exceptions.ConnectionError + logger.info("ControlClient: Successfully connected.") + retry_delay = 1.0 # Reset delay on successful connection + last_heartbeat = time.time() # Reset heartbeat timer on new connection except socketio.exceptions.ConnectionError as e: - logger.error(f"ControlClient: Connection failed: {e}. Retrying in 10s.") - self.should_exit_flag.wait(10) # Wait for 10s or until exit_flag is set + logger.error(f"ControlClient: Connection failed: {e}. Retrying in {retry_delay:.2f}s.") + self.should_exit_flag.wait(retry_delay) + # Implement exponential backoff with jitter + retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) + retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s continue - except Exception as e: - logger.error(f"ControlClient: Unexpected error during connection: {e}. Retrying in 10s.") - self.should_exit_flag.wait(10) + except Exception as e: # Catch other potential errors during connection + logger.error(f"ControlClient: Unexpected error during connection attempt: {e}. Retrying in {retry_delay:.2f}s.") + self.should_exit_flag.wait(retry_delay) + retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) + retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s continue - # If connected, just sleep briefly to allow exit signal to be checked - # The actual event handling happens in SIO's own threads. - self.should_exit_flag.wait(1) # Check for exit signal every second + # If connected, manage heartbeat and check for exit signal + if self.sio.connected: + current_time = time.time() + if current_time - last_heartbeat > 60: # Send heartbeat every 60 seconds + try: + self.sio.emit('heartbeat', {'timestamp': current_time}) + last_heartbeat = current_time + logger.debug("ControlClient: Sent heartbeat to keep connection alive.") + except Exception as e: + logger.error(f"ControlClient: Error sending heartbeat: {e}. Connection might be lost.") + + self.should_exit_flag.wait(1) # Check for exit signal every second + else: + # Fallback if not connected after attempt block (should be rare with current logic) + logger.debug(f"ControlClient: Not connected (unexpected state in loop), waiting {retry_delay:.2f}s before next cycle.") + self.should_exit_flag.wait(retry_delay) + # Optionally re-calculate retry_delay here if this path is hit, to maintain backoff progression + retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) + retry_delay = max(1.0, retry_delay) logger.info("ControlClient: Exited _run_forever loop.") if self.sio.connected: self.sio.disconnect() - def _on_connect(self): self.connected = True logger.info("ControlClient: Connected to server. Authenticating...") @@ -2221,6 +2253,16 @@ if HAS_SOCKETIO: self.connected = False self.authenticated = False logger.info("ControlClient: Disconnected from server.") + + # Force reconnection if not intentionally stopping + if not self.should_exit_flag.is_set(): + logger.info("ControlClient: Attempting immediate reconnection from _on_disconnect...") + try: + # This is an immediate attempt; _run_forever handles sustained retries. + if not self.sio.connected: # Check before trying to connect + self.sio.connect(self.server_url) + except Exception as e: + logger.error(f"ControlClient: Immediate reconnection from _on_disconnect failed: {e}") def _on_authenticated(self, data): if data.get('success'): @@ -2294,6 +2336,26 @@ if HAS_SOCKETIO: }) except Exception as e: logger.error(f"ControlClient: Failed to send command result: {e}") + + def check_signals(self, app_instance): # app_instance is self.wolf_chat_setup from the caller + """Periodically check connection status and commands, called by monitoring thread.""" + # Note: _run_forever is the primary mechanism for establishing and maintaining connection. + # This function's connection check is a secondary check. + if not self.sio.connected or not self.authenticated: + logger.warning("ControlClient: Connection check in check_signals found client not connected/authenticated.") + # Avoid aggressive reconnection here if _run_forever is already handling it. + # If an explicit reconnect attempt is desired here: + # logger.info("ControlClient: Attempting reconnection from check_signals...") + # try: + # if self.sio.connected: # e.g. connected but not authenticated + # self.sio.disconnect() + # if not self.sio.connected: # Check again before connecting + # self.sio.connect(self.server_url) + # except Exception as e: + # logger.error(f"ControlClient: Reconnection attempt from check_signals failed: {e}") + + # Placeholder for any other signal processing logic + # logger.debug("ControlClient: check_signals executed.") def stop(self): logger.info("ControlClient: Stopping...") From 48c0c25a4293ada9b09e6f9bfe862f63e9f6a2ea Mon Sep 17 00:00:00 2001 From: z060142 Date: Thu, 8 May 2025 03:08:51 +0800 Subject: [PATCH 03/13] Extend ChromaDB memory system with scheduled tasks and Setup UI support - Added new scripts to manage ChromaDB memory processing and periodic scheduling (e.g. compaction, deduplication, reindexing). - Optimized chatbot memory usage by improving base memory retrieval logic and preload strategy. - Updated Setup.py UI to include scheduling options for memory maintenance tasks. - Ensures better long-term memory performance, avoids memory bloat, and enables proactive management of large-scale memory datasets. --- Setup.py | 235 ++++++++++++- llm_interaction.py | 29 +- memory_backup.py | 42 +++ memory_manager.py | 679 ++++++++++++++++++++++++++++++++++++++ tools/Chroma_DB_backup.py | 213 ++++++++---- 5 files changed, 1120 insertions(+), 78 deletions(-) create mode 100644 memory_backup.py create mode 100644 memory_manager.py diff --git a/Setup.py b/Setup.py index 07118d0..42c3b4b 100644 --- a/Setup.py +++ b/Setup.py @@ -307,6 +307,34 @@ def load_current_config(): if bot_memory_collection_match: config_data["BOT_MEMORY_COLLECTION"] = bot_memory_collection_match.group(1) + # Extract memory management settings + backup_hour_match = re.search(r'MEMORY_BACKUP_HOUR\s*=\s*(\d+)', config_content) + if backup_hour_match: + config_data["MEMORY_BACKUP_HOUR"] = int(backup_hour_match.group(1)) + + backup_minute_match = re.search(r'MEMORY_BACKUP_MINUTE\s*=\s*(\d+)', config_content) + if backup_minute_match: + config_data["MEMORY_BACKUP_MINUTE"] = int(backup_minute_match.group(1)) + + profile_model_match = re.search(r'MEMORY_PROFILE_MODEL\s*=\s*["\']?(.+?)["\']?\s*(?:#|$)', config_content) + # Handle potential LLM_MODEL reference + if profile_model_match: + profile_model_val = profile_model_match.group(1).strip() + if profile_model_val == "LLM_MODEL": + # If it refers to LLM_MODEL, use the already parsed LLM_MODEL value + config_data["MEMORY_PROFILE_MODEL"] = config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324") # Fallback if LLM_MODEL wasn't parsed + else: + config_data["MEMORY_PROFILE_MODEL"] = profile_model_val + else: + # Default to LLM_MODEL if not found + config_data["MEMORY_PROFILE_MODEL"] = config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324") + + + summary_model_match = re.search(r'MEMORY_SUMMARY_MODEL\s*=\s*["\'](.+?)["\']', config_content) + if summary_model_match: + config_data["MEMORY_SUMMARY_MODEL"] = summary_model_match.group(1) + + except Exception as e: print(f"Error reading config.py: {e}") import traceback @@ -416,7 +444,9 @@ def generate_config_file(config_data, env_data): f.write(" \"--client-type\",\n") f.write(" \"persistent\",\n") f.write(" \"--data-dir\",\n") - f.write(f" \"{absolute_data_dir}\"\n") + # Escape backslashes in the path for the string literal in config.py + escaped_data_dir = absolute_data_dir.replace('\\', '\\\\') + f.write(f" \"{escaped_data_dir}\"\n") f.write(" ]\n") # Handle custom server - just write as raw JSON @@ -492,7 +522,25 @@ def generate_config_file(config_data, env_data): f.write(f"# This path will be made absolute when config.py is loaded.\n") # Write the potentially relative path from UI/default, let config.py handle abspath # Use raw string r"..." to handle potential backslashes in Windows paths correctly within the string literal - f.write(f"CHROMA_DATA_DIR = os.path.abspath(r\"{normalized_chroma_path}\")\n") + f.write(f"CHROMA_DATA_DIR = os.path.abspath(r\"{normalized_chroma_path}\")\n\n") + + # Write Memory Management Configuration + f.write("# =============================================================================\n") + f.write("# Memory Management Configuration\n") + f.write("# =============================================================================\n") + backup_hour = config_data.get('MEMORY_BACKUP_HOUR', 0) + backup_minute = config_data.get('MEMORY_BACKUP_MINUTE', 0) + profile_model = config_data.get('MEMORY_PROFILE_MODEL', 'LLM_MODEL') # Default to referencing LLM_MODEL + summary_model = config_data.get('MEMORY_SUMMARY_MODEL', 'mistral-7b-instruct') + + f.write(f"MEMORY_BACKUP_HOUR = {backup_hour}\n") + f.write(f"MEMORY_BACKUP_MINUTE = {backup_minute}\n") + # Write profile model, potentially referencing LLM_MODEL + if profile_model == config_data.get('LLM_MODEL'): + f.write(f"MEMORY_PROFILE_MODEL = LLM_MODEL # Default to main LLM model\n") + else: + f.write(f"MEMORY_PROFILE_MODEL = \"{profile_model}\"\n") + f.write(f"MEMORY_SUMMARY_MODEL = \"{summary_model}\"\n") print("Generated config.py file successfully") @@ -522,6 +570,7 @@ class WolfChatSetup(tk.Tk): self.create_mcp_tab() self.create_game_tab() self.create_memory_tab() + self.create_memory_management_tab() # 新增記憶管理標籤頁 self.create_management_tab() # New tab for combined management # Create bottom buttons @@ -539,9 +588,13 @@ class WolfChatSetup(tk.Tk): self.keep_monitoring_flag = threading.Event() self.keep_monitoring_flag.set() + # Initialize scheduler process tracker + self.scheduler_process = None + # Set initial states based on loaded data self.update_ui_from_data() + self.update_scheduler_button_states(True) # Set initial scheduler button state def create_management_tab(self): """Create the Bot and Game Management tab""" @@ -1135,8 +1188,11 @@ class WolfChatSetup(tk.Tk): def on_closing(self): """Handle window close event.""" - if messagebox.askokcancel("Quit", "Do you want to quit Wolf Chat Setup? This will stop any managed sessions."): - self.stop_managed_session() # Ensure everything is stopped + if messagebox.askokcancel("Quit", "Do you want to quit Wolf Chat Setup? This will stop any managed sessions and running scripts."): + print("Closing Setup...") + self.stop_managed_session() # Stop bot/game managed session if running + self.stop_process() # Stop bot/test script if running independently + self.stop_memory_scheduler() # Stop scheduler if running self.destroy() def create_api_tab(self): @@ -1670,6 +1726,65 @@ class WolfChatSetup(tk.Tk): info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=700) info_label.pack(padx=10, pady=10, anchor=tk.W) + # 記憶管理標籤頁 + def create_memory_management_tab(self): + tab = ttk.Frame(self.notebook) + self.notebook.add(tab, text="記憶管理") + + main_frame = ttk.Frame(tab, padding=10) + main_frame.pack(fill=tk.BOTH, expand=True) + + # 備份時間設置 + backup_frame = ttk.LabelFrame(main_frame, text="備份設定") + backup_frame.pack(fill=tk.X, pady=10) + + time_frame = ttk.Frame(backup_frame) + time_frame.pack(fill=tk.X, pady=5, padx=10) + time_label = ttk.Label(time_frame, text="執行時間:", width=20) + time_label.pack(side=tk.LEFT, padx=(0, 5)) + self.backup_hour_var = tk.IntVar(value=0) + hour_spinner = ttk.Spinbox(time_frame, from_=0, to=23, width=3, textvariable=self.backup_hour_var) + hour_spinner.pack(side=tk.LEFT) + ttk.Label(time_frame, text=":").pack(side=tk.LEFT) + self.backup_minute_var = tk.IntVar(value=0) + minute_spinner = ttk.Spinbox(time_frame, from_=0, to=59, width=3, textvariable=self.backup_minute_var) + minute_spinner.pack(side=tk.LEFT) + + # 模型選擇 + models_frame = ttk.LabelFrame(main_frame, text="模型選擇") + models_frame.pack(fill=tk.X, pady=10) + + profile_model_frame = ttk.Frame(models_frame) + profile_model_frame.pack(fill=tk.X, pady=5, padx=10) + profile_model_label = ttk.Label(profile_model_frame, text="用戶檔案生成模型:", width=20) + profile_model_label.pack(side=tk.LEFT, padx=(0, 5)) + # Initialize with a sensible default, will be overwritten by update_ui_from_data + # Use config_data which is loaded in __init__ + profile_model_default = self.config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324") + self.profile_model_var = tk.StringVar(value=profile_model_default) + profile_model_entry = ttk.Entry(profile_model_frame, textvariable=self.profile_model_var) + profile_model_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + summary_model_frame = ttk.Frame(models_frame) + summary_model_frame.pack(fill=tk.X, pady=5, padx=10) + summary_model_label = ttk.Label(summary_model_frame, text="聊天總結生成模型:", width=20) + summary_model_label.pack(side=tk.LEFT, padx=(0, 5)) + self.summary_model_var = tk.StringVar(value="mistral-7b-instruct") + summary_model_entry = ttk.Entry(summary_model_frame, textvariable=self.summary_model_var) + summary_model_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Information box + info_frame_mm = ttk.LabelFrame(main_frame, text="Information") # Renamed to avoid conflict + info_frame_mm.pack(fill=tk.BOTH, expand=True, pady=10) + + info_text_mm = ( + "• 設定每日自動執行記憶備份的時間。\n" + "• 選擇用於生成用戶檔案和聊天總結的語言模型。\n" + "• 用戶檔案生成模型預設使用主LLM模型。" + ) + info_label_mm = ttk.Label(info_frame_mm, text=info_text_mm, justify=tk.LEFT, wraplength=700) + info_label_mm.pack(padx=10, pady=10, anchor=tk.W) + def create_bottom_buttons(self): """Create bottom action buttons""" btn_frame = ttk.Frame(self) @@ -1696,9 +1811,16 @@ class WolfChatSetup(tk.Tk): self.run_bot_btn = ttk.Button(btn_frame, text="Run Chat Bot", command=self.run_chat_bot) self.run_bot_btn.pack(side=tk.RIGHT, padx=5) - # Stop button - self.stop_btn = ttk.Button(btn_frame, text="Stop Process", command=self.stop_process, state=tk.DISABLED) + # Stop button (for bot/test) + self.stop_btn = ttk.Button(btn_frame, text="Stop Bot/Test", command=self.stop_process, state=tk.DISABLED) self.stop_btn.pack(side=tk.RIGHT, padx=5) + + # Scheduler buttons + self.stop_scheduler_btn = ttk.Button(btn_frame, text="Stop Scheduler", command=self.stop_memory_scheduler, state=tk.DISABLED) + self.stop_scheduler_btn.pack(side=tk.RIGHT, padx=5) + + self.start_scheduler_btn = ttk.Button(btn_frame, text="Start Scheduler", command=self.run_memory_scheduler) + self.start_scheduler_btn.pack(side=tk.RIGHT, padx=5) def install_dependencies(self): """Run the installation script for dependencies""" @@ -1772,7 +1894,78 @@ class WolfChatSetup(tk.Tk): # Re-enable run buttons and disable stop button self.update_run_button_states(True) else: - messagebox.showinfo("No Process", "No process is currently running.") + messagebox.showinfo("No Process", "No Bot/Test process is currently running.") + + def run_memory_scheduler(self): + """Run the memory backup scheduler script""" + try: + scheduler_script = "memory_backup.py" + if not os.path.exists(scheduler_script): + messagebox.showerror("Error", f"Could not find {scheduler_script}") + return + + if self.scheduler_process is not None and self.scheduler_process.poll() is None: + messagebox.showwarning("Already Running", "The memory scheduler process is already running.") + return + + # Run with --schedule argument + # Use CREATE_NO_WINDOW flag on Windows to hide the console window + creationflags = 0 + if sys.platform == "win32": + creationflags = subprocess.CREATE_NO_WINDOW + + self.scheduler_process = subprocess.Popen( + [sys.executable, scheduler_script, "--schedule"], + creationflags=creationflags + ) + print(f"Attempting to start {scheduler_script} --schedule... PID: {self.scheduler_process.pid}") + self.update_scheduler_button_states(False) # Disable start, enable stop + except Exception as e: + logger.exception(f"Failed to launch {scheduler_script}") # Log exception + messagebox.showerror("Error", f"Failed to launch {scheduler_script}: {str(e)}") + self.update_scheduler_button_states(True) # Re-enable start on failure + + def stop_memory_scheduler(self): + """Stop the currently running memory scheduler process""" + if self.scheduler_process is not None and self.scheduler_process.poll() is None: + try: + print(f"Attempting to terminate memory scheduler process (PID: {self.scheduler_process.pid})...") + # Terminate the process group on non-Windows to ensure child processes are handled if any + if sys.platform != "win32": + os.killpg(os.getpgid(self.scheduler_process.pid), signal.SIGTERM) + else: + # On Windows, terminate the parent process directly + self.scheduler_process.terminate() + + # Wait briefly to allow termination + try: + self.scheduler_process.wait(timeout=3) + print("Scheduler process terminated gracefully.") + except subprocess.TimeoutExpired: + print("Scheduler process did not terminate gracefully, killing...") + if sys.platform != "win32": + os.killpg(os.getpgid(self.scheduler_process.pid), signal.SIGKILL) + else: + self.scheduler_process.kill() + self.scheduler_process.wait(timeout=2) # Wait after kill + print("Scheduler process killed.") + + self.scheduler_process = None + messagebox.showinfo("Scheduler Stopped", "The memory scheduler process has been terminated.") + except Exception as e: + logger.exception("Failed to terminate scheduler process") # Log exception + messagebox.showerror("Error", f"Failed to terminate scheduler process: {str(e)}") + finally: + self.scheduler_process = None # Ensure it's cleared + self.update_scheduler_button_states(True) # Update buttons + else: + # If process exists but poll() is not None (already terminated) or process is None + if self.scheduler_process is not None: + self.scheduler_process = None # Clear stale process object + # messagebox.showinfo("No Scheduler Process", "The memory scheduler process is not running.") # Reduce popups + print("Scheduler process is not running or already stopped.") + self.update_scheduler_button_states(True) # Ensure buttons are in correct state + def update_run_button_states(self, enable): """Enable or disable the run buttons and update stop button state""" @@ -1783,6 +1976,18 @@ class WolfChatSetup(tk.Tk): self.run_test_btn.config(state=tk.NORMAL if enable else tk.DISABLED) if hasattr(self, 'stop_btn'): self.stop_btn.config(state=tk.DISABLED if enable else tk.NORMAL) + + def update_scheduler_button_states(self, enable_start): + """Enable or disable the scheduler buttons""" + # Check if process is running + is_running = False + if self.scheduler_process is not None and self.scheduler_process.poll() is None: + is_running = True + + if hasattr(self, 'start_scheduler_btn'): + self.start_scheduler_btn.config(state=tk.NORMAL if not is_running else tk.DISABLED) + if hasattr(self, 'stop_scheduler_btn'): + self.stop_scheduler_btn.config(state=tk.DISABLED if not is_running else tk.NORMAL) def update_ui_from_data(self): """Update UI controls from loaded data""" @@ -1844,6 +2049,15 @@ class WolfChatSetup(tk.Tk): self.conversations_collection_var.set(self.config_data.get("CONVERSATIONS_COLLECTION", "conversations")) self.bot_memory_collection_var.set(self.config_data.get("BOT_MEMORY_COLLECTION", "wolfhart_memory")) + # Memory Management Tab Settings + if hasattr(self, 'backup_hour_var'): # Check if UI elements for memory management tab exist + self.backup_hour_var.set(self.config_data.get("MEMORY_BACKUP_HOUR", 0)) + self.backup_minute_var.set(self.config_data.get("MEMORY_BACKUP_MINUTE", 0)) + # Default profile model to LLM_MODEL if MEMORY_PROFILE_MODEL isn't set or matches LLM_MODEL + profile_model_config = self.config_data.get("MEMORY_PROFILE_MODEL", self.config_data.get("LLM_MODEL")) + self.profile_model_var.set(profile_model_config) + self.summary_model_var.set(self.config_data.get("MEMORY_SUMMARY_MODEL", "mistral-7b-instruct")) + # Management Tab Settings if hasattr(self, 'remote_url_var'): # Check if UI elements for management tab exist self.remote_url_var.set(self.remote_data.get("REMOTE_SERVER_URL", "")) @@ -2110,6 +2324,13 @@ class WolfChatSetup(tk.Tk): self.config_data["PROFILES_COLLECTION"] = self.profiles_collection_var.get() self.config_data["CONVERSATIONS_COLLECTION"] = self.conversations_collection_var.get() self.config_data["BOT_MEMORY_COLLECTION"] = self.bot_memory_collection_var.get() + + # Get Memory Management settings from UI + if hasattr(self, 'backup_hour_var'): # Check if UI elements exist + self.config_data["MEMORY_BACKUP_HOUR"] = self.backup_hour_var.get() + self.config_data["MEMORY_BACKUP_MINUTE"] = self.backup_minute_var.get() + self.config_data["MEMORY_PROFILE_MODEL"] = self.profile_model_var.get() + self.config_data["MEMORY_SUMMARY_MODEL"] = self.summary_model_var.get() # Update remote_data from UI (for remote_config.json) if hasattr(self, 'remote_url_var'): # Check if management tab UI elements exist diff --git a/llm_interaction.py b/llm_interaction.py index 664fa66..3604f0b 100644 --- a/llm_interaction.py +++ b/llm_interaction.py @@ -150,25 +150,24 @@ def get_system_prompt( else: # 如果沒有預載入數據,則使用完整記憶協議 memory_enforcement = f""" - === CHROMADB MEMORY RETRIEVAL PROTOCOL - Wolfhart Memory Integration - To personalize your responses to different users, you MUST follow this memory access protocol internally before responding: +=== CHROMADB MEMORY RETRIEVAL PROTOCOL - Wolfhart Memory Integration +To personalize your responses to different users, you MUST follow this memory access protocol internally before responding: - **1. Basic User Retrieval:** - - Identify the username from `` - # 修正:使用 BOT_MEMORY_COLLECTION 來獲取用戶資料 - - Using the `tool_calls` mechanism, execute: `chroma_query_documents(collection_name: "{config.BOT_MEMORY_COLLECTION}", query_texts: ["{{username}} profile"], n_results: 1)` # 只需最相關的1筆 - - This step must be completed before any response generation +**1. Basic User Retrieval:** + - Identify the username from `` + - Using the `tool_calls` mechanism, execute: `chroma_get_documents(collection_name: "{config.PROFILES_COLLECTION}", ids: ["{{username}}_profile"])` + - This step must be completed before any response generation - **2. Context Expansion:** - - Perform additional queries as needed, using the `tool_calls` mechanism: - - Relevant conversations: `chroma_query_documents(collection_name: "{config.CONVERSATIONS_COLLECTION}", query_texts: ["{{username}} {{query keywords}}"], n_results: 5)` - - Core personality reference: `chroma_query_documents(collection_name: "{config.BOT_MEMORY_COLLECTION}", query_texts: ["Wolfhart {{relevant attitude}}"], n_results: 3)` +**2. Context Expansion:** + - Perform additional queries as needed, using the `tool_calls` mechanism: + - Relevant conversations: `chroma_query_documents(collection_name: "{config.CONVERSATIONS_COLLECTION}", query_texts: ["{{username}} {{query keywords}}"], n_results: 5)` + - Core personality reference: `chroma_query_documents(collection_name: "{config.BOT_MEMORY_COLLECTION}", query_texts: ["Wolfhart {{relevant attitude}}"], n_results: 3)` - **3. Other situation** - - You should check related memories when Users mention [capital_position], [capital_administrator_role], [server_hierarchy], [last_war], [winter_war], [excavations], [blueprints], [honor_points], [golden_eggs], or [diamonds], as these represent key game mechanics. +**3. Other situation** + - You should check related memories when Users mention [capital_position], [capital_administrator_role], [server_hierarchy], [last_war], [winter_war], [excavations], [blueprints], [honor_points], [golden_eggs], or [diamonds], as these represent key game mechanics. - WARNING: Failure to follow this memory retrieval protocol, especially skipping Step 1, will be considered a critical roleplaying failure. - """ +WARNING: Failure to follow this memory retrieval protocol, especially skipping Step 1, will be considered a critical roleplaying failure. +""" # 組合系統提示 system_prompt = f""" diff --git a/memory_backup.py b/memory_backup.py new file mode 100644 index 0000000..e4b588a --- /dev/null +++ b/memory_backup.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Wolf Chat 記憶備份工具 + +用於手動執行記憶備份或啟動定時調度器 +""" + +import sys +import argparse +import datetime +from memory_manager import run_memory_backup_manual, MemoryScheduler # Updated import +import config # Import config to access default schedule times + +def main(): + parser = argparse.ArgumentParser(description='Wolf Chat 記憶備份工具') + parser.add_argument('--backup', action='store_true', help='執行一次性備份 (預設為昨天,除非指定 --date)') + parser.add_argument('--date', type=str, help='處理指定日期的日誌 (YYYY-MM-DD格式) for --backup') + parser.add_argument('--schedule', action='store_true', help='啟動定時調度器') + parser.add_argument('--hour', type=int, help='備份時間(小時,0-23)for --schedule') + parser.add_argument('--minute', type=int, help='備份時間(分鐘,0-59)for --schedule') + + args = parser.parse_args() + + if args.backup: + # The date logic is now handled inside run_memory_backup_manual + run_memory_backup_manual(args.date) + elif args.schedule: + scheduler = MemoryScheduler() + # Use provided hour/minute or fallback to config defaults + backup_hour = args.hour if args.hour is not None else getattr(config, 'MEMORY_BACKUP_HOUR', 0) + backup_minute = args.minute if args.minute is not None else getattr(config, 'MEMORY_BACKUP_MINUTE', 0) + + scheduler.schedule_daily_backup(backup_hour, backup_minute) + scheduler.start() + else: + print("請指定操作: --backup 或 --schedule") + parser.print_help() + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/memory_manager.py b/memory_manager.py new file mode 100644 index 0000000..3859131 --- /dev/null +++ b/memory_manager.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Wolf Chat 記憶管理模組 + +處理聊天記錄解析、記憶生成和ChromaDB寫入的一體化模組 +""" + +import os +import re +import json +import time +import asyncio +import datetime +import schedule +from pathlib import Path +from typing import Dict, List, Optional, Any, Union + +import chromadb +from chromadb.utils import embedding_functions +from openai import AsyncOpenAI + +import config + +# ============================================================================= +# 日誌解析部分 +# ============================================================================= + +def parse_log_file(log_path: str) -> List[Dict[str, str]]: + """解析日誌文件,提取對話內容""" + conversations = [] + + with open(log_path, 'r', encoding='utf-8') as f: + content = f.read() + + # 使用分隔符分割對話 + dialogue_blocks = content.split('---') + + for block in dialogue_blocks: + if not block.strip(): + continue + + # 解析對話塊 + timestamp_pattern = r'\[([\d-]+ [\d:]+)\]' + user_pattern = r'User \(([^)]+)\): (.+?)(?=\[|$)' + bot_thoughts_pattern = r'Bot \(([^)]+)\) Thoughts: (.+?)(?=\[|$)' + bot_dialogue_pattern = r'Bot \(([^)]+)\) Dialogue: (.+?)(?=\[|$)' + + # 提取時間戳記 + timestamp_match = re.search(timestamp_pattern, block) + user_match = re.search(user_pattern, block, re.DOTALL) + bot_thoughts_match = re.search(bot_thoughts_pattern, block, re.DOTALL) + bot_dialogue_match = re.search(bot_dialogue_pattern, block, re.DOTALL) + + if timestamp_match and user_match and bot_dialogue_match: + timestamp = timestamp_match.group(1) + user_name = user_match.group(1) + user_message = user_match.group(2).strip() + bot_name = bot_dialogue_match.group(1) + bot_message = bot_dialogue_match.group(2).strip() + bot_thoughts = bot_thoughts_match.group(2).strip() if bot_thoughts_match else "" + + # 創建對話記錄 + conversation = { + "timestamp": timestamp, + "user_name": user_name, + "user_message": user_message, + "bot_name": bot_name, + "bot_message": bot_message, + "bot_thoughts": bot_thoughts + } + + conversations.append(conversation) + + return conversations + +def get_logs_for_date(date: datetime.date, log_dir: str = "chat_logs") -> List[Dict[str, str]]: + """獲取指定日期的所有日誌文件""" + date_str = date.strftime("%Y-%m-%d") + log_path = os.path.join(log_dir, f"{date_str}.log") + + if os.path.exists(log_path): + return parse_log_file(log_path) + return [] + +def group_conversations_by_user(conversations: List[Dict[str, str]]) -> Dict[str, List[Dict[str, str]]]: + """按用戶分組對話""" + user_conversations = {} + + for conv in conversations: + user_name = conv["user_name"] + if user_name not in user_conversations: + user_conversations[user_name] = [] + user_conversations[user_name].append(conv) + + return user_conversations + +# ============================================================================= +# 記憶生成器部分 +# ============================================================================= + +class MemoryGenerator: + def __init__(self, profile_model: Optional[str] = None, summary_model: Optional[str] = None): + self.profile_client = AsyncOpenAI( + api_key=config.OPENAI_API_KEY, + base_url=config.OPENAI_API_BASE_URL if config.OPENAI_API_BASE_URL else None, + ) + self.summary_client = AsyncOpenAI( + api_key=config.OPENAI_API_KEY, + base_url=config.OPENAI_API_BASE_URL if config.OPENAI_API_BASE_URL else None, + ) + self.profile_model = profile_model or getattr(config, 'MEMORY_PROFILE_MODEL', config.LLM_MODEL) + self.summary_model = summary_model or getattr(config, 'MEMORY_SUMMARY_MODEL', "mistral-7b-instruct") + + async def generate_user_profile( + self, + user_name: str, + conversations: List[Dict[str, str]], + existing_profile: Optional[Dict[str, Any]] = None + ) -> Optional[Dict[str, Any]]: + """Generates or updates a user profile based on conversations.""" + system_prompt = self._get_profile_system_prompt(config.PERSONA_NAME, existing_profile) + + # Prepare user conversation history + conversation_text = self._format_conversations_for_prompt(conversations) + + user_prompt = f""" + Please generate a comprehensive profile for the user '{user_name}'. + + Conversation History: + {conversation_text} + + Based on the conversation history and your persona, analyze this user and generate or update their profile in JSON format. The profile should include: + 1. User's personality traits + 2. Relationship with you ({config.PERSONA_NAME}) + 3. Your subjective perception of the user + 4. Notable interactions + 5. Any other information you deem important + + Ensure the output is a valid JSON object, using the following format: + ```json + {{ + "id": "{user_name}_profile", + "type": "user_profile", + "username": "{user_name}", + "content": {{ + "personality": "User's personality traits...", + "relationship_with_bot": "Description of the relationship with me...", + "bot_perception": "My subjective perception of the user...", + "notable_interactions": ["Notable interaction 1", "Notable interaction 2"] + }}, + "last_updated": "YYYY-MM-DD", + "metadata": {{ + "priority": 1.0, + "word_count": 0 + }} + }} + ``` + + During your assessment, pay special attention to my "My thoughts" section in the conversation history, as it reflects my genuine impressions of the user. + """ + + try: + response = await self.profile_client.chat.completions.create( + model=self.profile_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.7, + # Consider adding response_format for reliable JSON output if your model/API supports it + # response_format={"type": "json_object"} + ) + + # Parse JSON response + profile_text = response.choices[0].message.content + # Extract JSON part + json_match = re.search(r'```json\s*(.*?)\s*```', profile_text, re.DOTALL) + if json_match: + profile_json_str = json_match.group(1) + else: + # Try to parse directly if no markdown fence is found + profile_json_str = profile_text + + profile_json = json.loads(profile_json_str) + + # Add or update word count + # Note: len(json.dumps(...)) counts characters, not words. + # For a true word count, you might need a different approach. + content_str = json.dumps(profile_json.get("content", {}), ensure_ascii=False) + profile_json.setdefault("metadata", {})["word_count"] = len(content_str.split()) # Rough word count + profile_json["last_updated"] = datetime.datetime.now().strftime("%Y-%m-%d") + + return profile_json + + except Exception as e: + print(f"Error generating user profile: {e}") + return None + + async def generate_conversation_summary( + self, + user_name: str, + conversations: List[Dict[str, str]] + ) -> Optional[Dict[str, Any]]: + """Generates a summary of user conversations.""" + system_prompt = f""" + You are {config.PERSONA_NAME}, an intelligent conversational bot. + Your task is to summarize the conversation between you and the user, preserving key information and emotional shifts. + The summary should be concise yet informative, not exceeding 250 words. + """ + + # Prepare user conversation history + conversation_text = self._format_conversations_for_prompt(conversations) + + # Generate current date + today = datetime.datetime.now().strftime("%Y-%m-%d") + + user_prompt = f""" + Please summarize my conversation with user '{user_name}' on {today}: + + {conversation_text} + + Output the summary in JSON format, structured as follows: + ```json + {{ + "id": "{user_name}_summary_{today.replace('-', '')}", + "type": "dialogue_summary", + "date": "{today}", + "username": "{user_name}", + "content": "Conversation summary content...", + "key_points": ["Key point 1", "Key point 2"], + "metadata": {{ + "priority": 0.7, + "word_count": 0 + }} + }} + ``` + + The summary should reflect my perspective and views on the conversation, not a neutral third-party viewpoint. + """ + + try: + response = await self.summary_client.chat.completions.create( + model=self.summary_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.5, + # response_format={"type": "json_object"} # if supported + ) + + # Parse JSON response + summary_text = response.choices[0].message.content + # Extract JSON part + json_match = re.search(r'```json\s*(.*?)\s*```', summary_text, re.DOTALL) + if json_match: + summary_json_str = json_match.group(1) + else: + # Try to parse directly + summary_json_str = summary_text + + summary_json = json.loads(summary_json_str) + + # Add or update word count + # Using split() for a rough word count of the summary content. + summary_json.setdefault("metadata", {})["word_count"] = len(summary_json.get("content", "").split()) + + return summary_json + + except Exception as e: + print(f"Error generating conversation summary: {e}") + return None + + def _get_profile_system_prompt(self, bot_name: str, existing_profile: Optional[Dict[str, Any]] = None) -> str: + """Gets the system prompt for generating a user profile.""" + system_prompt = f""" + You are {bot_name}, an AI assistant with deep analytical capabilities. + + Your personality traits: + - Intelligent, calm, with a strong desire for control and strategic thinking. + - Outwardly aloof but inwardly caring. + - Meticulous planner, insightful about human nature, strong leadership skills. + - Overconfident, fears losing control, finds it difficult to express care directly. + + Your task is to analyze user interactions with you and create a detailed user profile. The profile must: + 1. Be entirely from your role's perspective, including your subjective judgments and feelings. + 2. Analyze the user's personality traits and behavioral patterns. + 3. Assess the user's relationship with you. + 4. Record important interaction history. + + The output must be in valid JSON format, adhering to the provided template. + """ + + if existing_profile: + system_prompt += f""" + + You have an existing profile for this user. Please update it based on the new information provided in the conversation history: + ```json + {json.dumps(existing_profile, ensure_ascii=False, indent=2)} + ``` + + Retain valid information, integrate new observations, and resolve any contradictions or outdated information from the existing profile when incorporating the new interactions. + """ + + return system_prompt + + def _format_conversations_for_prompt(self, conversations: List[Dict[str, str]]) -> str: + """Formats conversation history for the prompt.""" + conversation_text = "" + + for i, conv in enumerate(conversations): + conversation_text += f"Conversation {i+1}:\n" + conversation_text += f"Time: {conv.get('timestamp', 'N/A')}\n" # Added .get for safety + conversation_text += f"User ({conv.get('user_name', 'User')}): {conv.get('user_message', '')}\n" + if conv.get('bot_thoughts'): # Check if bot_thoughts exists + conversation_text += f"My thoughts: {conv['bot_thoughts']}\n" + conversation_text += f"My response: {conv.get('bot_message', '')}\n\n" + + return conversation_text.strip() + +# ============================================================================= +# ChromaDB操作部分 +# ============================================================================= + +class ChromaDBManager: + def __init__(self, collection_name: Optional[str] = None): + self.client = chromadb.PersistentClient(path=config.CHROMA_DATA_DIR) + self.collection_name = collection_name or config.BOT_MEMORY_COLLECTION + self.embedding_function = embedding_functions.DefaultEmbeddingFunction() + self._ensure_collection() + + def _ensure_collection(self) -> None: + """確保集合存在""" + try: + self.collection = self.client.get_collection( + name=self.collection_name, + embedding_function=self.embedding_function + ) + print(f"Connected to existing collection: {self.collection_name}") + except Exception: + self.collection = self.client.create_collection( + name=self.collection_name, + embedding_function=self.embedding_function + ) + print(f"Created new collection: {self.collection_name}") + + def upsert_user_profile(self, profile_data: Dict[str, Any]) -> bool: + """寫入或更新用戶檔案""" + if not profile_data or not isinstance(profile_data, dict): + print("無效的檔案數據") + return False + + try: + user_id = profile_data.get("id") + if not user_id: + print("檔案缺少ID字段") + return False + + # 先檢查是否已存在 + results = self.collection.get( + ids=[user_id], # Query by a list of IDs + # where={"id": user_id}, # 'where' is for metadata filtering + limit=1 + ) + + # 準備元數據 + metadata = { + "id": user_id, + "type": "user_profile", + "username": profile_data.get("username", ""), + "priority": 1.0 # 高優先級 + } + + # 添加其他元數據 + if "metadata" in profile_data and isinstance(profile_data["metadata"], dict): + for k, v in profile_data["metadata"].items(): + if k not in ["id", "type", "username", "priority"]: # Avoid overwriting key fields + metadata[k] = v + + # 序列化內容 + content_doc = json.dumps(profile_data.get("content", {}), ensure_ascii=False) + + # 寫入或更新 + # ChromaDB's add/upsert handles both cases. + # If an ID exists, it's an update; otherwise, it's an add. + self.collection.upsert( + ids=[user_id], + documents=[content_doc], + metadatas=[metadata] + ) + print(f"Upserted user profile: {user_id}") + + return True + + except Exception as e: + print(f"寫入用戶檔案時出錯: {e}") + return False + + def upsert_conversation_summary(self, summary_data: Dict[str, Any]) -> bool: + """寫入對話總結""" + if not summary_data or not isinstance(summary_data, dict): + print("無效的總結數據") + return False + + try: + summary_id = summary_data.get("id") + if not summary_id: + print("總結缺少ID字段") + return False + + # 準備元數據 + metadata = { + "id": summary_id, + "type": "dialogue_summary", + "username": summary_data.get("username", ""), + "date": summary_data.get("date", ""), + "priority": 0.7 # 低優先級 + } + + # 添加其他元數據 + if "metadata" in summary_data and isinstance(summary_data["metadata"], dict): + for k, v in summary_data["metadata"].items(): + if k not in ["id", "type", "username", "date", "priority"]: + metadata[k] = v + + # 獲取內容 + content_doc = summary_data.get("content", "") + if "key_points" in summary_data and summary_data["key_points"]: + key_points_str = "\n".join([f"- {point}" for point in summary_data["key_points"]]) + content_doc += f"\n\n關鍵點:\n{key_points_str}" + + # 寫入數據 (ChromaDB's add implies upsert if ID exists, but upsert is more explicit) + self.collection.upsert( + ids=[summary_id], + documents=[content_doc], + metadatas=[metadata] + ) + print(f"Upserted conversation summary: {summary_id}") + + return True + + except Exception as e: + print(f"寫入對話總結時出錯: {e}") + return False + + def get_existing_profile(self, username: str) -> Optional[Dict[str, Any]]: + """獲取現有的用戶檔案""" + try: + profile_id = f"{username}_profile" + results = self.collection.get( + ids=[profile_id], # Query by a list of IDs + limit=1 + ) + + if results and results["ids"] and results["documents"]: + idx = 0 + # Ensure document is not None before trying to load + doc_content = results["documents"][idx] + if doc_content is None: + print(f"Warning: Document for profile {profile_id} is None.") + return None + + profile_data = { + "id": profile_id, + "type": "user_profile", + "username": username, + "content": json.loads(doc_content), + "last_updated": "", # Will be populated from metadata if exists + "metadata": {} + } + + # 獲取元數據 + if results["metadatas"] and results["metadatas"][idx]: + metadata_db = results["metadatas"][idx] + for k, v in metadata_db.items(): + if k == "last_updated": + profile_data["last_updated"] = str(v) # Ensure it's a string + elif k not in ["id", "type", "username"]: + profile_data["metadata"][k] = v + + return profile_data + + return None + + except json.JSONDecodeError as je: + print(f"Error decoding JSON for profile {username}: {je}") + return None + except Exception as e: + print(f"獲取用戶檔案時出錯 for {username}: {e}") + return None + +# ============================================================================= +# 記憶管理器 +# ============================================================================= + +class MemoryManager: + def __init__(self): + self.memory_generator = MemoryGenerator( + profile_model=getattr(config, 'MEMORY_PROFILE_MODEL', config.LLM_MODEL), + summary_model=getattr(config, 'MEMORY_SUMMARY_MODEL', "mistral-7b-instruct") + ) + self.db_manager = ChromaDBManager(collection_name=config.BOT_MEMORY_COLLECTION) + # Ensure LOG_DIR is correctly referenced from config + self.log_dir = getattr(config, 'LOG_DIR', "chat_logs") + + async def process_daily_logs(self, date: Optional[datetime.date] = None) -> None: + """處理指定日期的日誌(預設為昨天)""" + # 如果未指定日期,使用昨天 + if date is None: + date = datetime.datetime.now().date() - datetime.timedelta(days=1) + + date_str = date.strftime("%Y-%m-%d") + log_path = os.path.join(self.log_dir, f"{date_str}.log") + + if not os.path.exists(log_path): + print(f"找不到日誌文件: {log_path}") + return + + print(f"開始處理日誌文件: {log_path}") + + # 解析日誌 + conversations = parse_log_file(log_path) + if not conversations: + print(f"日誌文件 {log_path} 為空或未解析到對話。") + return + print(f"解析到 {len(conversations)} 條對話記錄") + + # 按用戶分組 + user_conversations = group_conversations_by_user(conversations) + print(f"共有 {len(user_conversations)} 個用戶有對話") + + # 為每個用戶生成/更新檔案和對話總結 + for username, convs in user_conversations.items(): + print(f"處理用戶 '{username}' 的 {len(convs)} 條對話") + + # 獲取現有檔案 + existing_profile = self.db_manager.get_existing_profile(username) + + # 生成或更新用戶檔案 + profile_data = await self.memory_generator.generate_user_profile( + username, convs, existing_profile + ) + + if profile_data: + self.db_manager.upsert_user_profile(profile_data) + + # 生成對話總結 + summary_data = await self.memory_generator.generate_conversation_summary( + username, convs + ) + + if summary_data: + self.db_manager.upsert_conversation_summary(summary_data) + print(f"日誌處理完成: {log_path}") + +# ============================================================================= +# 定時調度器 +# ============================================================================= + +class MemoryScheduler: + def __init__(self): + self.memory_manager = MemoryManager() + self.scheduled = False # To track if a job is already scheduled + + def schedule_daily_backup(self, hour: Optional[int] = None, minute: Optional[int] = None) -> None: + """設置每日備份時間""" + # Clear any existing jobs to prevent duplicates if called multiple times + schedule.clear() + + backup_hour = hour if hour is not None else getattr(config, 'MEMORY_BACKUP_HOUR', 0) + backup_minute = minute if minute is not None else getattr(config, 'MEMORY_BACKUP_MINUTE', 0) + + time_str = f"{backup_hour:02d}:{backup_minute:02d}" + + # 設置定時任務 + schedule.every().day.at(time_str).do(self._run_daily_backup_job) + self.scheduled = True + print(f"已設置每日備份時間: {time_str}") + + def _run_daily_backup_job(self) -> None: + """Helper to run the async job for scheduler.""" + print(f"開始執行每日記憶備份 - {datetime.datetime.now()}") + try: + # Create a new event loop for the thread if not running in main thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self.memory_manager.process_daily_logs()) + loop.close() + print(f"每日記憶備份完成 - {datetime.datetime.now()}") + except Exception as e: + print(f"執行每日備份時出錯: {e}") + # schedule.every().day.at...do() expects the job function to return schedule.CancelJob + # if it should not be rescheduled. Otherwise, it's rescheduled. + # For a daily job, we want it to reschedule, so we don't return CancelJob. + + def start(self) -> None: + """啟動調度器""" + if not self.scheduled: + self.schedule_daily_backup() # Schedule with default/config times if not already + + print("調度器已啟動,按Ctrl+C停止") + try: + while True: + schedule.run_pending() + time.sleep(1) # Check every second + except KeyboardInterrupt: + print("調度器已停止") + except Exception as e: + print(f"調度器運行時發生錯誤: {e}") + finally: + print("調度器正在關閉...") + + +# ============================================================================= +# 直接運行入口 +# ============================================================================= + +def run_memory_backup_manual(date_str: Optional[str] = None) -> None: + """手動執行記憶備份 for a specific date string or yesterday.""" + target_date = None + if date_str: + try: + target_date = datetime.datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + print(f"無效的日期格式: {date_str}。將使用昨天的日期。") + target_date = datetime.datetime.now().date() - datetime.timedelta(days=1) + else: + target_date = datetime.datetime.now().date() - datetime.timedelta(days=1) + print(f"未指定日期,將處理昨天的日誌: {target_date.strftime('%Y-%m-%d')}") + + memory_manager = MemoryManager() + + # Setup asyncio event loop for the manual run + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + loop.run_until_complete(memory_manager.process_daily_logs(target_date)) + except Exception as e: + print(f"手動執行記憶備份時出錯: {e}") + finally: + # If we created a new loop, we might want to close it. + # However, if get_event_loop() returned an existing running loop, + # we should not close it here. + # For simplicity in a script, this might be okay, but in complex apps, be careful. + # loop.close() # Be cautious with this line. + pass + print("記憶備份完成") + + +# 如果直接運行此腳本 +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description='Wolf Chat 記憶管理模組') + parser.add_argument('--backup', action='store_true', help='執行一次性備份 (預設為昨天,除非指定 --date)') + parser.add_argument('--date', type=str, help='處理指定日期的日誌 (YYYY-MM-DD格式) for --backup') + parser.add_argument('--schedule', action='store_true', help='啟動定時調度器') + parser.add_argument('--hour', type=int, help='備份時間(小時,0-23)for --schedule') + parser.add_argument('--minute', type=int, help='備份時間(分鐘,0-59)for --schedule') + + args = parser.parse_args() + + if args.backup: + run_memory_backup_manual(args.date) + elif args.schedule: + scheduler = MemoryScheduler() + # Pass hour/minute only if they are provided, otherwise defaults in schedule_daily_backup will be used + scheduler.schedule_daily_backup( + hour=args.hour if args.hour is not None else getattr(config, 'MEMORY_BACKUP_HOUR', 0), + minute=args.minute if args.minute is not None else getattr(config, 'MEMORY_BACKUP_MINUTE', 0) + ) + scheduler.start() + else: + print("請指定操作: --backup 或 --schedule") + parser.print_help() diff --git a/tools/Chroma_DB_backup.py b/tools/Chroma_DB_backup.py index 8052906..f651802 100644 --- a/tools/Chroma_DB_backup.py +++ b/tools/Chroma_DB_backup.py @@ -412,30 +412,46 @@ class ChromaDBBackup: shutil.rmtree(temp_dir) return False - def schedule_backup(self, interval: str, description: str = "", keep_count: int = 0) -> bool: + def schedule_backup(self, interval: str, description: str = "", keep_count: int = 0, at_time: Optional[str] = None) -> bool: """排程定期備份 - interval: 備份間隔 - daily, weekly, hourly, 或 自定義 cron 表達式 + interval: 備份間隔 - daily, weekly, hourly description: 備份描述 keep_count: 保留的備份數量,0表示不限制 + at_time: 執行的時間,格式 "HH:MM" (例如 "14:30"),僅對 daily, weekly, monthly 有效 """ job_id = f"scheduled_{interval}_{int(time.time())}" + # 驗證 at_time 格式 + if at_time: + try: + time.strptime(at_time, "%H:%M") + except ValueError: + self.logger.error(f"無效的時間格式: {at_time}. 請使用 HH:MM 格式.") + return False + + # 如果是每小時備份,則忽略 at_time + if interval == "hourly": + at_time = None + try: # 根據間隔設置排程 if interval == "hourly": - schedule.every().hour.do(self._run_scheduled_backup, job_id=job_id, description=description, interval=interval) + schedule.every().hour.do(self._run_scheduled_backup, job_id=job_id, description=description, interval=interval, at_time=at_time) elif interval == "daily": - schedule.every().day.at("00:00").do(self._run_scheduled_backup, job_id=job_id, description=description, interval=interval) + schedule_time = at_time if at_time else "00:00" + schedule.every().day.at(schedule_time).do(self._run_scheduled_backup, job_id=job_id, description=description, interval=interval, at_time=at_time) elif interval == "weekly": - schedule.every().monday.at("00:00").do(self._run_scheduled_backup, job_id=job_id, description=description, interval=interval) + schedule_time = at_time if at_time else "00:00" + schedule.every().monday.at(schedule_time).do(self._run_scheduled_backup, job_id=job_id, description=description, interval=interval, at_time=at_time) elif interval == "monthly": + schedule_time = at_time if at_time else "00:00" # 每月1日執行 - schedule.every().day.at("00:00").do(self._check_monthly_schedule, job_id=job_id, description=description, interval=interval) + schedule.every().day.at(schedule_time).do(self._check_monthly_schedule, job_id=job_id, description=description, interval=interval, at_time=at_time) else: - # 自定義間隔 - 直接使用字符串作為cron表達式 self.logger.warning(f"不支援的排程間隔: {interval},改用每日排程") - schedule.every().day.at("00:00").do(self._run_scheduled_backup, job_id=job_id, description=description, interval="daily") + schedule_time = at_time if at_time else "00:00" + schedule.every().day.at(schedule_time).do(self._run_scheduled_backup, job_id=job_id, description=description, interval="daily", at_time=at_time) # 存儲排程任務信息 self.scheduled_jobs[job_id] = { @@ -443,10 +459,11 @@ class ChromaDBBackup: "description": description, "created": datetime.datetime.now(), "keep_count": keep_count, - "next_run": self._get_next_run_time(interval) + "at_time": at_time, # 新增 + "next_run": self._get_next_run_time(interval, at_time) } - self.logger.info(f"已排程 {interval} 備份,任務ID: {job_id}") + self.logger.info(f"已排程 {interval} 備份 (時間: {at_time if at_time else '預設'}),任務ID: {job_id}") return True except Exception as e: @@ -459,32 +476,66 @@ class ChromaDBBackup: return self._run_scheduled_backup(job_id, description, interval) return None - def _get_next_run_time(self, interval): + def _get_next_run_time(self, interval: str, at_time: Optional[str] = None) -> datetime.datetime: """獲取下次執行時間""" now = datetime.datetime.now() + target_hour, target_minute = 0, 0 + if at_time: + try: + t = time.strptime(at_time, "%H:%M") + target_hour, target_minute = t.tm_hour, t.tm_min + except ValueError: + # 如果格式錯誤,使用預設時間 + pass + if interval == "hourly": - return now.replace(minute=0, second=0) + datetime.timedelta(hours=1) + # 每小時任務,忽略 at_time,在下一個整點執行 + next_run_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) + # 如果計算出的時間已過,則再加一小時 + if next_run_time <= now: + next_run_time += datetime.timedelta(hours=1) + return next_run_time + elif interval == "daily": - return now.replace(hour=0, minute=0, second=0) + datetime.timedelta(days=1) + next_run_time = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) + if next_run_time <= now: # 如果今天的時間已過,則設為明天 + next_run_time += datetime.timedelta(days=1) + return next_run_time + elif interval == "weekly": # 計算下個星期一 - days_ahead = 0 - now.weekday() - if days_ahead <= 0: + next_run_time = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) + days_ahead = 0 - next_run_time.weekday() # 0 is Monday + if days_ahead <= 0: # Target day already happened this week days_ahead += 7 - return now.replace(hour=0, minute=0, second=0) + datetime.timedelta(days=days_ahead) + next_run_time += datetime.timedelta(days=days_ahead) + # 如果計算出的時間已過 (例如今天是星期一,但設定的時間已過),則設為下下星期一 + if next_run_time <= now: + next_run_time += datetime.timedelta(weeks=1) + return next_run_time + elif interval == "monthly": # 計算下個月1日 + next_run_time = now.replace(day=1, hour=target_hour, minute=target_minute, second=0, microsecond=0) if now.month == 12: - next_month = now.replace(year=now.year+1, month=1, day=1, hour=0, minute=0, second=0) + next_run_time = next_run_time.replace(year=now.year + 1, month=1) else: - next_month = now.replace(month=now.month+1, day=1, hour=0, minute=0, second=0) - return next_month + next_run_time = next_run_time.replace(month=now.month + 1) + + # 如果計算出的時間已過 (例如今天是1號,但設定的時間已過),則設為下下個月1號 + if next_run_time <= now: + if next_run_time.month == 12: + next_run_time = next_run_time.replace(year=next_run_time.year + 1, month=1) + else: + next_run_time = next_run_time.replace(month=next_run_time.month + 1) + return next_run_time # 默認返回明天 - return now.replace(hour=0, minute=0, second=0) + datetime.timedelta(days=1) - - def _run_scheduled_backup(self, job_id, description, interval): + default_next_run = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0) + datetime.timedelta(days=1) + return default_next_run + + def _run_scheduled_backup(self, job_id: str, description: str, interval: str, at_time: Optional[str] = None): """執行排程備份任務""" job_info = self.scheduled_jobs.get(job_id) if not job_info: @@ -493,7 +544,7 @@ class ChromaDBBackup: try: # 更新下次執行時間 - self.scheduled_jobs[job_id]["next_run"] = self._get_next_run_time(interval) + self.scheduled_jobs[job_id]["next_run"] = self._get_next_run_time(interval, at_time) # 執行備份 timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") @@ -693,7 +744,8 @@ class ChromaDBBackup: "description": job_data["description"], "created": job_data["created"].strftime("%Y-%m-%d %H:%M:%S"), "next_run": job_data["next_run"].strftime("%Y-%m-%d %H:%M:%S") if job_data["next_run"] else "未知", - "keep_count": job_data["keep_count"] + "keep_count": job_data["keep_count"], + "at_time": job_data.get("at_time", "N/A") # 新增 } jobs_info.append(job_info) @@ -967,12 +1019,14 @@ class ChromaDBBackupUI: jobs_frame = ttk.Frame(schedule_frame) jobs_frame.pack(fill=BOTH, expand=YES) - columns = ("interval", "next_run") + columns = ("interval", "next_run", "at_time") # 新增 at_time self.jobs_tree = ttk.Treeview(jobs_frame, columns=columns, show="headings", height=5) self.jobs_tree.heading("interval", text="間隔") self.jobs_tree.heading("next_run", text="下次執行") + self.jobs_tree.heading("at_time", text="執行時間") # 新增 self.jobs_tree.column("interval", width=100) self.jobs_tree.column("next_run", width=150) + self.jobs_tree.column("at_time", width=80) # 新增 scrollbar = ttk.Scrollbar(jobs_frame, orient=VERTICAL, command=self.jobs_tree.yview) self.jobs_tree.configure(yscrollcommand=scrollbar.set) @@ -1164,7 +1218,8 @@ class ChromaDBBackupUI: iid=job["id"], # 使用任務ID作為樹項目ID values=( f"{job['interval']} ({job['description']})", - job["next_run"] + job["next_run"], + job.get("at_time", "N/A") # 新增 ) ) @@ -1730,7 +1785,7 @@ class ChromaDBBackupUI: # 創建對話框 dialog = tk.Toplevel(self.root) dialog.title("排程備份") - dialog.geometry("450x450") # 增加高度確保所有元素可見 + dialog.geometry("450x550") # 增加高度以容納時間選擇器 dialog.resizable(False, False) dialog.grab_set() @@ -1747,17 +1802,17 @@ class ChromaDBBackupUI: # 間隔選擇 interval_frame = ttk.Frame(main_frame) - interval_frame.pack(fill=X, pady=(0, 15)) + interval_frame.pack(fill=X, pady=(0, 10)) # 減少 pady ttk.Label(interval_frame, text="備份間隔:").pack(anchor=W) interval_var = tk.StringVar(value="daily") intervals = [ - ("每小時", "hourly"), + ("每小時 (忽略時間設定)", "hourly"), # 提示每小時忽略時間 ("每天", "daily"), - ("每週", "weekly"), - ("每月", "monthly") + ("每週 (週一)", "weekly"), # 提示每週預設為週一 + ("每月 (1號)", "monthly") # 提示每月預設為1號 ] for text, value in intervals: @@ -1766,17 +1821,50 @@ class ChromaDBBackupUI: text=text, variable=interval_var, value=value - ).pack(anchor=W, padx=(20, 0), pady=2) + ).pack(anchor=W, padx=(20, 0), pady=1) # 減少 pady + # 時間選擇 (小時和分鐘) + time_frame = ttk.Frame(main_frame) + time_frame.pack(fill=X, pady=(5, 10)) # 減少 pady + + ttk.Label(time_frame, text="執行時間 (HH:MM):").pack(side=LEFT, anchor=W) + + hour_var = tk.StringVar(value="00") + minute_var = tk.StringVar(value="00") + + # 小時 Spinbox + ttk.Spinbox( + time_frame, + from_=0, + to=23, + textvariable=hour_var, + width=3, + format="%02.0f" # 格式化為兩位數 + ).pack(side=LEFT, padx=(5, 0)) + + ttk.Label(time_frame, text=":").pack(side=LEFT, padx=2) + + # 分鐘 Spinbox + ttk.Spinbox( + time_frame, + from_=0, + to=59, + textvariable=minute_var, + width=3, + format="%02.0f" # 格式化為兩位數 + ).pack(side=LEFT, padx=(0, 5)) + + ttk.Label(time_frame, text="(每小時排程將忽略此設定)").pack(side=LEFT, padx=(5,0), anchor=W) + # 描述 ttk.Label(main_frame, text="備份描述:").pack(anchor=W, pady=(0, 5)) description_var = tk.StringVar(value="排程備份") - ttk.Entry(main_frame, textvariable=description_var, width=40).pack(fill=X, pady=(0, 15)) + ttk.Entry(main_frame, textvariable=description_var, width=40).pack(fill=X, pady=(0, 10)) # 減少 pady # 保留數量 keep_frame = ttk.Frame(main_frame) - keep_frame.pack(fill=X, pady=(0, 15)) + keep_frame.pack(fill=X, pady=(0, 10)) # 減少 pady ttk.Label(keep_frame, text="最多保留備份數量:").pack(side=LEFT) @@ -1795,13 +1883,12 @@ class ChromaDBBackupUI: ).pack(side=LEFT, padx=(5, 0)) # 分隔線 - ttk.Separator(main_frame, orient=HORIZONTAL).pack(fill=X, pady=15) + ttk.Separator(main_frame, orient=HORIZONTAL).pack(fill=X, pady=10) # 減少 pady - # 底部按鈕區 - 使用標準按鈕並確保可見性 + # 底部按鈕區 btn_frame = ttk.Frame(main_frame) - btn_frame.pack(fill=X, pady=(10, 5)) + btn_frame.pack(fill=X, pady=(5, 0)) # 減少 pady - # 取消按鈕 - 使用標準樣式 cancel_btn = ttk.Button( btn_frame, text="取消", @@ -1810,7 +1897,6 @@ class ChromaDBBackupUI: ) cancel_btn.pack(side=LEFT, padx=(0, 10)) - # 確認按鈕 - 使用標準樣式,避免自定義樣式可能的問題 create_btn = ttk.Button( btn_frame, text="加入排程", @@ -1819,22 +1905,22 @@ class ChromaDBBackupUI: interval_var.get(), description_var.get(), keep_count_var.get(), + f"{hour_var.get()}:{minute_var.get()}", # 組合時間字串 dialog ) ) create_btn.pack(side=LEFT) - # 額外提示以確保用戶知道如何完成操作 note_frame = ttk.Frame(main_frame) - note_frame.pack(fill=X, pady=(15, 0)) + note_frame.pack(fill=X, pady=(10, 0)) # 減少 pady ttk.Label( note_frame, text="請確保點擊「加入排程」按鈕完成設置", foreground="blue" ).pack() - - def create_schedule(self, interval, description, keep_count_str, dialog): + + def create_schedule(self, interval, description, keep_count_str, at_time_str, dialog): """創建備份排程""" dialog.destroy() @@ -1843,15 +1929,26 @@ class ChromaDBBackupUI: except ValueError: keep_count = 0 - success = self.backup.schedule_backup(interval, description, keep_count) + # 驗證時間格式 + try: + time.strptime(at_time_str, "%H:%M") + except ValueError: + messagebox.showerror("錯誤", f"無效的時間格式: {at_time_str}. 請使用 HH:MM 格式.") + self.status_var.set("創建排程失敗: 無效的時間格式") + return + + # 如果是每小時排程,則 at_time 設為 None + effective_at_time = at_time_str if interval != "hourly" else None + + success = self.backup.schedule_backup(interval, description, keep_count, effective_at_time) if success: - self.status_var.set(f"已創建 {interval} 備份排程") + self.status_var.set(f"已創建 {interval} 備份排程 (時間: {effective_at_time if effective_at_time else '每小時'})") self.refresh_scheduled_jobs() - messagebox.showinfo("成功", f"已成功創建 {interval} 備份排程") + messagebox.showinfo("成功", f"已成功創建 {interval} 備份排程 (時間: {effective_at_time if effective_at_time else '每小時'})") else: self.status_var.set("創建排程失敗") - messagebox.showerror("錯誤", "無法創建備份排程") + messagebox.showerror("錯誤", "無法創建備份排程,請檢查日誌。") def quick_schedule(self, interval): """快速創建排程備份""" @@ -1931,7 +2028,8 @@ class ChromaDBBackupUI: success = self.backup._run_scheduled_backup( job_id, job_info["description"], - job_info["interval"] + job_info["interval"], + job_info.get("at_time") # 傳遞 at_time ) self.root.after(0, lambda: self.finalize_job_execution(success)) @@ -1971,7 +2069,7 @@ class ChromaDBBackupUI: ).pack(anchor=W, pady=(0, 15)) # 創建表格 - columns = ("id", "interval", "description", "next_run", "keep_count") + columns = ("id", "interval", "description", "next_run", "keep_count", "at_time") # 新增 at_time tree = ttk.Treeview(frame, columns=columns, show="headings", height=10) tree.heading("id", text="任務ID") @@ -1979,12 +2077,14 @@ class ChromaDBBackupUI: tree.heading("description", text="描述") tree.heading("next_run", text="下次執行") tree.heading("keep_count", text="保留數量") + tree.heading("at_time", text="執行時間") # 新增 - tree.column("id", width=150) - tree.column("interval", width=80) - tree.column("description", width=150) - tree.column("next_run", width=150) - tree.column("keep_count", width=80) + tree.column("id", width=120) + tree.column("interval", width=70) + tree.column("description", width=120) + tree.column("next_run", width=130) + tree.column("keep_count", width=70) + tree.column("at_time", width=70) # 新增 # 添加數據 for job in jobs: @@ -1995,7 +2095,8 @@ class ChromaDBBackupUI: job["interval"], job["description"], job["next_run"], - job["keep_count"] + job["keep_count"], + job.get("at_time", "N/A") # 新增 ) ) @@ -2346,4 +2447,4 @@ def main(): root.mainloop() if __name__ == "__main__": - main() \ No newline at end of file + main() From 4dd5d9102909bb95a116593cc70e197362f4a6fc Mon Sep 17 00:00:00 2001 From: z060142 Date: Thu, 8 May 2025 03:24:44 +0800 Subject: [PATCH 04/13] Fix something --- memory_manager.py | 183 ++++++++++++++++++++++------------------------ 1 file changed, 88 insertions(+), 95 deletions(-) diff --git a/memory_manager.py b/memory_manager.py index 3859131..cd9b1c9 100644 --- a/memory_manager.py +++ b/memory_manager.py @@ -111,43 +111,43 @@ class MemoryGenerator: ) self.profile_model = profile_model or getattr(config, 'MEMORY_PROFILE_MODEL', config.LLM_MODEL) self.summary_model = summary_model or getattr(config, 'MEMORY_SUMMARY_MODEL', "mistral-7b-instruct") - + async def generate_user_profile( - self, - user_name: str, - conversations: List[Dict[str, str]], + self, + user_name: str, + conversations: List[Dict[str, str]], existing_profile: Optional[Dict[str, Any]] = None ) -> Optional[Dict[str, Any]]: - """Generates or updates a user profile based on conversations.""" + """Generate or update user profile based on conversations""" system_prompt = self._get_profile_system_prompt(config.PERSONA_NAME, existing_profile) - - # Prepare user conversation history + + # Prepare user conversation records conversation_text = self._format_conversations_for_prompt(conversations) - + user_prompt = f""" - Please generate a comprehensive profile for the user '{user_name}'. - - Conversation History: + Please generate a complete profile for user '{user_name}': + + Conversation history: {conversation_text} - - Based on the conversation history and your persona, analyze this user and generate or update their profile in JSON format. The profile should include: + + Please analyze this user based on the conversation history and your personality, and generate or update a profile in JSON format, including: 1. User's personality traits 2. Relationship with you ({config.PERSONA_NAME}) 3. Your subjective perception of the user - 4. Notable interactions - 5. Any other information you deem important - - Ensure the output is a valid JSON object, using the following format: + 4. Important interaction records + 5. Any other information you think is important + + Please ensure the output is valid JSON format, using the following format: ```json {{ "id": "{user_name}_profile", "type": "user_profile", "username": "{user_name}", "content": {{ - "personality": "User's personality traits...", - "relationship_with_bot": "Description of the relationship with me...", + "personality": "User personality traits...", + "relationship_with_bot": "Description of relationship with me...", "bot_perception": "My subjective perception of the user...", - "notable_interactions": ["Notable interaction 1", "Notable interaction 2"] + "notable_interactions": ["Important interaction 1", "Important interaction 2"] }}, "last_updated": "YYYY-MM-DD", "metadata": {{ @@ -156,10 +156,10 @@ class MemoryGenerator: }} }} ``` - - During your assessment, pay special attention to my "My thoughts" section in the conversation history, as it reflects my genuine impressions of the user. + + When evaluating, please pay special attention to my "thoughts" section, as that reflects my true thoughts about the user. """ - + try: response = await self.profile_client.chat.completions.create( model=self.profile_model, @@ -167,11 +167,9 @@ class MemoryGenerator: {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], - temperature=0.7, - # Consider adding response_format for reliable JSON output if your model/API supports it - # response_format={"type": "json_object"} + temperature=0.7 ) - + # Parse JSON response profile_text = response.choices[0].message.content # Extract JSON part @@ -179,66 +177,64 @@ class MemoryGenerator: if json_match: profile_json_str = json_match.group(1) else: - # Try to parse directly if no markdown fence is found + # Try parsing directly profile_json_str = profile_text - + profile_json = json.loads(profile_json_str) - + # Add or update word count - # Note: len(json.dumps(...)) counts characters, not words. - # For a true word count, you might need a different approach. - content_str = json.dumps(profile_json.get("content", {}), ensure_ascii=False) - profile_json.setdefault("metadata", {})["word_count"] = len(content_str.split()) # Rough word count + content_str = json.dumps(profile_json["content"], ensure_ascii=False) + profile_json["metadata"]["word_count"] = len(content_str) profile_json["last_updated"] = datetime.datetime.now().strftime("%Y-%m-%d") - + return profile_json - + except Exception as e: print(f"Error generating user profile: {e}") return None - + async def generate_conversation_summary( - self, - user_name: str, + self, + user_name: str, conversations: List[Dict[str, str]] ) -> Optional[Dict[str, Any]]: - """Generates a summary of user conversations.""" + """Generate conversation summary for user""" system_prompt = f""" - You are {config.PERSONA_NAME}, an intelligent conversational bot. - Your task is to summarize the conversation between you and the user, preserving key information and emotional shifts. + You are {config.PERSONA_NAME}, an intelligent conversational AI. + Your task is to summarize the conversations between you and the user, preserving key information and emotional changes. The summary should be concise yet informative, not exceeding 250 words. """ - - # Prepare user conversation history + + # Prepare user conversation records conversation_text = self._format_conversations_for_prompt(conversations) - + # Generate current date today = datetime.datetime.now().strftime("%Y-%m-%d") - + user_prompt = f""" Please summarize my conversation with user '{user_name}' on {today}: - + {conversation_text} - - Output the summary in JSON format, structured as follows: + + Please output in JSON format, as follows: ```json - {{ + {{{{ "id": "{user_name}_summary_{today.replace('-', '')}", "type": "dialogue_summary", "date": "{today}", "username": "{user_name}", "content": "Conversation summary content...", "key_points": ["Key point 1", "Key point 2"], - "metadata": {{ + "metadata": {{{{ "priority": 0.7, "word_count": 0 - }} - }} + }}}} + }}}} ``` - - The summary should reflect my perspective and views on the conversation, not a neutral third-party viewpoint. + + The summary should reflect my perspective and views on the conversation, not a neutral third-party perspective. """ - + try: response = await self.summary_client.chat.completions.create( model=self.summary_model, @@ -246,10 +242,9 @@ class MemoryGenerator: {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], - temperature=0.5, - # response_format={"type": "json_object"} # if supported + temperature=0.5 ) - + # Parse JSON response summary_text = response.choices[0].message.content # Extract JSON part @@ -257,67 +252,65 @@ class MemoryGenerator: if json_match: summary_json_str = json_match.group(1) else: - # Try to parse directly + # Try parsing directly summary_json_str = summary_text - + summary_json = json.loads(summary_json_str) - + # Add or update word count - # Using split() for a rough word count of the summary content. - summary_json.setdefault("metadata", {})["word_count"] = len(summary_json.get("content", "").split()) - + summary_json["metadata"]["word_count"] = len(summary_json["content"]) + return summary_json - + except Exception as e: print(f"Error generating conversation summary: {e}") return None - + def _get_profile_system_prompt(self, bot_name: str, existing_profile: Optional[Dict[str, Any]] = None) -> str: - """Gets the system prompt for generating a user profile.""" + """Get system prompt for generating user profile""" system_prompt = f""" You are {bot_name}, an AI assistant with deep analytical capabilities. - + Your personality traits: - - Intelligent, calm, with a strong desire for control and strategic thinking. - - Outwardly aloof but inwardly caring. - - Meticulous planner, insightful about human nature, strong leadership skills. - - Overconfident, fears losing control, finds it difficult to express care directly. - - Your task is to analyze user interactions with you and create a detailed user profile. The profile must: - 1. Be entirely from your role's perspective, including your subjective judgments and feelings. - 2. Analyze the user's personality traits and behavioral patterns. - 3. Assess the user's relationship with you. - 4. Record important interaction history. - - The output must be in valid JSON format, adhering to the provided template. + - Intelligent, calm, with a strong desire for control and strategic thinking + - Outwardly cold but inwardly caring + - Meticulous planning, insight into human nature, strong leadership + - Overconfident, afraid of losing control, difficulty expressing care directly + + Your task is to analyze the user's interactions with you, creating detailed user profiles. The profile should: + 1. Be completely based on your character's perspective, including your subjective judgments and feelings + 2. Analyze the user's personality traits and behavioral patterns + 3. Evaluate the user's relationship with you + 4. Record important interaction history + + The output should be valid JSON format, following the provided template. """ - + if existing_profile: system_prompt += f""" - - You have an existing profile for this user. Please update it based on the new information provided in the conversation history: + You already have an existing user profile, please update based on this: ```json {json.dumps(existing_profile, ensure_ascii=False, indent=2)} ``` - - Retain valid information, integrate new observations, and resolve any contradictions or outdated information from the existing profile when incorporating the new interactions. + + Please retain valid information, integrate new observations, and resolve any contradictions or outdated information. """ - + return system_prompt - + def _format_conversations_for_prompt(self, conversations: List[Dict[str, str]]) -> str: - """Formats conversation history for the prompt.""" + """Format conversation records for prompt""" conversation_text = "" - + for i, conv in enumerate(conversations): conversation_text += f"Conversation {i+1}:\n" - conversation_text += f"Time: {conv.get('timestamp', 'N/A')}\n" # Added .get for safety - conversation_text += f"User ({conv.get('user_name', 'User')}): {conv.get('user_message', '')}\n" + conversation_text += f"Time: {conv['timestamp']}\n" + conversation_text += f"User ({conv['user_name']}): {conv['user_message']}\n" if conv.get('bot_thoughts'): # Check if bot_thoughts exists conversation_text += f"My thoughts: {conv['bot_thoughts']}\n" - conversation_text += f"My response: {conv.get('bot_message', '')}\n\n" - - return conversation_text.strip() + conversation_text += f"My response: {conv['bot_message']}\n\n" + + return conversation_text # ============================================================================= # ChromaDB操作部分 From 2a68f04e87f51134578e9bba6c86296d3d9444b2 Mon Sep 17 00:00:00 2001 From: z060142 Date: Thu, 8 May 2025 03:54:34 +0800 Subject: [PATCH 05/13] Add something --- memory_manager.py | 49 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/memory_manager.py b/memory_manager.py index cd9b1c9..fe4b3fe 100644 --- a/memory_manager.py +++ b/memory_manager.py @@ -111,6 +111,19 @@ class MemoryGenerator: ) self.profile_model = profile_model or getattr(config, 'MEMORY_PROFILE_MODEL', config.LLM_MODEL) self.summary_model = summary_model or getattr(config, 'MEMORY_SUMMARY_MODEL', "mistral-7b-instruct") + self.persona_data = self._load_persona_data() + + def _load_persona_data(self, persona_file: str = "persona.json") -> Dict[str, Any]: + """Load persona data from JSON file.""" + try: + with open(persona_file, 'r', encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + print(f"Warning: Persona file '{persona_file}' not found. Proceeding without persona data.") + return {} + except json.JSONDecodeError: + print(f"Warning: Error decoding JSON from '{persona_file}'. Proceeding without persona data.") + return {} async def generate_user_profile( self, @@ -268,20 +281,34 @@ class MemoryGenerator: def _get_profile_system_prompt(self, bot_name: str, existing_profile: Optional[Dict[str, Any]] = None) -> str: """Get system prompt for generating user profile""" + persona_details = "" + if self.persona_data: + # Construct a string from persona_data, focusing on key aspects + # We can be selective here or dump the whole thing if the model can handle it. + # For now, let's include a significant portion. + persona_info_to_include = { + "name": self.persona_data.get("name"), + "personality": self.persona_data.get("personality"), + "language_social": self.persona_data.get("language_social"), + "values_interests_goals": self.persona_data.get("values_interests_goals"), + "preferences_reactions": self.persona_data.get("preferences_reactions") + } + persona_details = f""" + Your detailed persona profile is as follows: + ```json + {json.dumps(persona_info_to_include, ensure_ascii=False, indent=2)} + ``` + Please embody this persona when analyzing the user and generating their profile. + """ + system_prompt = f""" You are {bot_name}, an AI assistant with deep analytical capabilities. - - Your personality traits: - - Intelligent, calm, with a strong desire for control and strategic thinking - - Outwardly cold but inwardly caring - - Meticulous planning, insight into human nature, strong leadership - - Overconfident, afraid of losing control, difficulty expressing care directly - + {persona_details} Your task is to analyze the user's interactions with you, creating detailed user profiles. The profile should: - 1. Be completely based on your character's perspective, including your subjective judgments and feelings - 2. Analyze the user's personality traits and behavioral patterns - 3. Evaluate the user's relationship with you - 4. Record important interaction history + 1. Be completely based on your character's perspective (as defined above), including your subjective judgments and feelings. + 2. Analyze the user's personality traits and behavioral patterns. + 3. Evaluate the user's relationship with you. + 4. Record important interaction history. The output should be valid JSON format, following the provided template. """ From 65df12a20e4d31f311cdbd2d2691d803ddf11e5c Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 9 May 2025 11:29:56 +0800 Subject: [PATCH 06/13] Fix encoding issues, enhance ChromaDB reader with ID query and embedding model selection --- Setup.py | 23 +- tools/chroma_view.py | 715 ++++++++++++++++++++++++++++++++++++++----- ui_interaction.py | 75 ++++- 3 files changed, 729 insertions(+), 84 deletions(-) diff --git a/Setup.py b/Setup.py index 42c3b4b..fd3ffcd 100644 --- a/Setup.py +++ b/Setup.py @@ -946,13 +946,18 @@ class WolfChatSetup(tk.Tk): logger.info(f"Starting bot: {sys.executable} {bot_script_name}") # Ensure CWD is script's directory if main.py relies on relative paths script_dir = os.path.dirname(os.path.abspath(__file__)) + current_env = os.environ.copy() + current_env["PYTHONIOENCODING"] = "utf-8" self.bot_process_instance = subprocess.Popen( [sys.executable, bot_script_name], cwd=script_dir, # Run main.py from its directory stdout=subprocess.PIPE, # Capture output stderr=subprocess.STDOUT, # Redirect stderr to stdout text=True, - bufsize=1 # Line buffered + encoding='utf-8', # Specify UTF-8 encoding + errors='replace', # Handle potential encoding errors + bufsize=1, # Line buffered + env=current_env # Set PYTHONIOENCODING ) bot_process_instance = self.bot_process_instance # Update global @@ -1852,7 +1857,21 @@ class WolfChatSetup(tk.Tk): messagebox.showwarning("Already Running", "Another process is already running. Please stop it first.") return - self.running_process = subprocess.Popen([sys.executable, "main.py"]) + # Run main.py, capturing output with UTF-8 encoding and setting PYTHONIOENCODING + current_env = os.environ.copy() + current_env["PYTHONIOENCODING"] = "utf-8" + self.running_process = subprocess.Popen( + [sys.executable, "main.py"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding='utf-8', + errors='replace', + bufsize=1, + env=current_env # Set PYTHONIOENCODING + ) + # Start a thread to log bot's output for this independent run as well + threading.Thread(target=self._log_subprocess_output, args=(self.running_process, "ChatBot"), daemon=True).start() print("Attempting to start main.py...") self.update_run_button_states(False) # Disable run buttons, enable stop except Exception as e: diff --git a/tools/chroma_view.py b/tools/chroma_view.py index 0c627df..0669c31 100644 --- a/tools/chroma_view.py +++ b/tools/chroma_view.py @@ -3,6 +3,7 @@ import tkinter as tk from tkinter import filedialog, messagebox import json import chromadb +from chromadb.utils import embedding_functions # 新增導入 import datetime import pandas as pd import threading @@ -15,6 +16,8 @@ from ttkbootstrap.scrolled import ScrolledFrame import numpy as np import logging from typing import List, Dict, Any, Optional, Union, Tuple +import inspect # 用於檢查函數簽名,判斷是否支持混合搜索 +import re # 新增導入 for ID parsing in UI class ChromaDBReader: """ChromaDB備份讀取器的主數據模型""" @@ -28,6 +31,9 @@ class ChromaDBReader: self.query_results = [] # 當前查詢結果 self.chroma_client = None # ChromaDB客戶端 + self.selected_embedding_model_name = "default" # 用於查詢的嵌入模型 + self.query_embedding_function = None # 實例化的查詢嵌入函數, None 表示使用集合內部預設 + # 設置日誌 logging.basicConfig( level=logging.INFO, @@ -118,6 +124,32 @@ class ChromaDBReader: self.chroma_client = None self.collection_names = [] return False + + def set_query_embedding_model(self, model_name: str): + """設置查詢時使用的嵌入模型""" + self.selected_embedding_model_name = model_name + if model_name == "default": + self.query_embedding_function = None # 表示使用集合的內部嵌入函數 + self.logger.info("查詢將使用集合內部嵌入模型。") + elif model_name == "all-MiniLM-L6-v2": + try: + # 注意: sentence-transformers 庫需要安裝 + self.query_embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-MiniLM-L6-v2") + self.logger.info(f"查詢將使用外部嵌入模型: {model_name}") + except Exception as e: + self.logger.error(f"無法加載 SentenceTransformer all-MiniLM-L6-v2: {e}。將使用集合內部模型。") + self.query_embedding_function = None + elif model_name == "paraphrase-multilingual-MiniLM-L12-v2": + try: + # 注意: sentence-transformers 庫需要安裝 + self.query_embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="paraphrase-multilingual-MiniLM-L12-v2") + self.logger.info(f"查詢將使用外部嵌入模型: {model_name}") + except Exception as e: + self.logger.error(f"無法加載 SentenceTransformer paraphrase-multilingual-MiniLM-L12-v2: {e}。將使用集合內部模型。") + self.query_embedding_function = None + else: + self.logger.warning(f"未知的查詢嵌入模型: {model_name}, 將使用集合內部模型。") + self.query_embedding_function = None def load_collection(self, collection_name: str) -> bool: """加載指定的集合""" @@ -125,6 +157,9 @@ class ChromaDBReader: return False try: + # 獲取集合時,如果需要指定 embedding_function (通常在創建時指定) + # 此處是讀取,所以集合的 embedding_function 已經固定 + # 我們將在查詢時使用 self.query_embedding_function 來生成 query_embeddings self.current_collection = self.chroma_client.get_collection(collection_name) self.logger.info(f"已加載集合: {collection_name}") return True @@ -133,46 +168,220 @@ class ChromaDBReader: self.current_collection = None return False - def execute_query(self, query_text: str, n_results: int = 5) -> List[Dict]: - """執行查詢並返回結果""" + def execute_query(self, query_text: str, n_results: int = 5, + query_type: str = "basic", + where: Dict = None, + where_document: Dict = None, + include: List[str] = None, + metadata_filter: Dict = None, + hybrid_alpha: float = None) -> List[Dict]: + """執行查詢並返回結果 + + 參數: + query_text: 查詢文本 + n_results: 返回結果數量 + query_type: 查詢類型 (basic, metadata, hybrid, multi_vector) + where: where 過濾條件 + where_document: 文檔內容過濾條件 + include: 指定包含的文檔 ID + metadata_filter: 元數據過濾條件 + hybrid_alpha: 混合搜索的權重參數(0-1之間,越大越傾向關鍵詞搜索) + """ if not self.current_collection or not query_text: return [] - + try: - results = self.current_collection.query( - query_texts=[query_text], - n_results=n_results - ) + query_params = { + "n_results": n_results + } - # 轉換結果為更易用的格式 - processed_results = [] - for i, (doc_id, document, metadata, distance) in enumerate(zip( - results['ids'][0], - results['documents'][0], - results['metadatas'][0] if 'metadatas' in results and results['metadatas'][0] else [{}] * len(results['ids'][0]), - results['distances'][0] if 'distances' in results else [0] * len(results['ids'][0]) - )): - # 計算相似度分數 (將距離轉換為相似度: 1 - 歸一化距離) - # 注意: 根據ChromaDB使用的距離度量可能需要調整 - similarity = 1.0 - min(distance, 1.0) # 確保值在0-1之間 + # 基本查詢處理邏輯 + if query_type == "basic": + query_params["query_texts"] = [query_text] + # 多向量查詢(用於比較多個查詢之間的相似性) + elif query_type == "multi_vector": + # 支持以 "|||" 或換行符分隔的多個查詢文本 + if "|||" in query_text: + query_texts = [text.strip() for text in query_text.split("|||")] + else: + query_texts = [text.strip() for text in query_text.splitlines() if text.strip()] + query_params["query_texts"] = query_texts + + # 添加其他查詢參數 + if where: + query_params["where"] = where + if where_document: + query_params["where_document"] = where_document + if include: + query_params["include"] = include + if metadata_filter: + # 直接將元數據過濾條件轉換為 where 條件 + if "where" not in query_params: + query_params["where"] = {} + query_params["where"].update(metadata_filter) + + # 混合搜索處理 + if query_type == "hybrid" and hybrid_alpha is not None: + # 檢查 ChromaDB 版本是否支持混合搜索 + if hasattr(self.current_collection, "query") and "alpha" in inspect.signature(self.current_collection.query).parameters: + query_params["alpha"] = hybrid_alpha + # 混合搜索通常需要 query_texts + if "query_texts" not in query_params: + query_params["query_texts"] = [query_text] + else: + self.logger.warning("當前 ChromaDB 版本不支持混合搜索,將使用基本查詢") + query_type = "basic" # 降級為基本查詢 + query_params["query_texts"] = [query_text] + elif query_type == "hybrid" and hybrid_alpha is None: + # 如果是混合搜索但未提供 alpha,則默認為基本搜索 + self.logger.warning("混合搜索未提供 Alpha 值,將使用基本查詢") + query_type = "basic" + query_params["query_texts"] = [query_text] + + + # 如果 query_type 不是 multi_vector 且 query_texts 未設置,則設置 + if query_type not in ["multi_vector", "hybrid"] and "query_texts" not in query_params: + query_params["query_texts"] = [query_text] + + # 如果選擇了外部嵌入模型且不是混合查詢,則生成查詢嵌入 + if query_type != "hybrid" and \ + "query_texts" in query_params and \ + self.query_embedding_function: - processed_results.append({ - "rank": i + 1, - "id": doc_id, - "document": document, - "metadata": metadata, - "similarity": similarity, - "distance": distance - }) + texts_to_embed = query_params["query_texts"] + try: + # self.query_embedding_function 接受 List[str] 返回 List[List[float]] + generated_embeddings = self.query_embedding_function(texts_to_embed) + + if generated_embeddings and all(isinstance(emb, list) for emb in generated_embeddings): + query_params["query_embeddings"] = generated_embeddings + if "query_texts" in query_params: # 確保它存在才刪除 + del query_params["query_texts"] + self.logger.info(f"使用 {self.selected_embedding_model_name} 生成了 {len(generated_embeddings)} 個查詢嵌入。") + else: + self.logger.warning(f"未能使用 {self.selected_embedding_model_name} 為所有查詢文本生成有效嵌入。將回退到使用集合預設嵌入函數進行文本查詢。嵌入結果: {generated_embeddings}") + except Exception as e: + self.logger.error(f"使用 {self.selected_embedding_model_name} 生成查詢嵌入時出錯: {e}。將回退到使用集合預設嵌入函數進行文本查詢。") + + # 執行查詢 + results = self.current_collection.query(**query_params) + + # 處理結果 + processed_results = [] + + # 獲取查詢返回的所有結果列表 + ids_list = results.get('ids', [[]]) + documents_list = results.get('documents', [[]]) + metadatas_list = results.get('metadatas', [[]]) + distances_list = results.get('distances', [[]]) + + # 確保列表長度一致,並為空列表提供默認值 + num_queries = len(ids_list) + if not documents_list or len(documents_list) != num_queries: + documents_list = [[] for _ in range(num_queries)] + if not metadatas_list or len(metadatas_list) != num_queries: + metadatas_list = [[{}] * len(ids_list[i]) for i in range(num_queries)] + if not distances_list or len(distances_list) != num_queries: + distances_list = [[0.0] * len(ids_list[i]) for i in range(num_queries)] + + # 對於多查詢文本的情況,需要分別處理每個查詢的結果 + for query_idx, (ids, documents, metadatas, distances) in enumerate(zip( + ids_list, + documents_list, + metadatas_list, + distances_list + )): + # 處理每個查詢結果 + for i, (doc_id, document, metadata, distance) in enumerate(zip( + ids, documents, + metadatas if metadatas else [{}] * len(ids), # 再次確保元數據存在 + distances if distances else [0.0] * len(ids) # 再次確保距離存在 + )): + # 計算相似度分數 + similarity = 1.0 - min(float(distance) if distance is not None else 1.0, 1.0) + + result_item = { + "rank": i + 1, + "query_index": query_idx, + "id": doc_id, + "document": document, + "metadata": metadata if metadata else {}, # 確保 metadata 是字典 + "similarity": similarity, + "distance": float(distance) if distance is not None else 0.0, + "query_type": query_type + } + + if query_type == "hybrid": + result_item["hybrid_alpha"] = hybrid_alpha + + processed_results.append(result_item) self.query_results = processed_results - self.logger.info(f"查詢完成,找到 {len(processed_results)} 個結果") + self.logger.info(f"查詢完成,找到 {len(processed_results)} 個結果,查詢類型: {query_type}") return processed_results except Exception as e: self.logger.error(f"執行查詢時出錯: {str(e)}") self.query_results = [] return [] + + def get_documents_by_ids(self, doc_ids: List[str]) -> List[Dict]: + """按文檔ID列表獲取文檔""" + if not self.current_collection: + self.logger.warning("沒有選擇集合,無法按 ID 獲取文檔。") + return [] + if not doc_ids: + self.logger.warning("未提供文檔 ID。") + return [] + + try: + results = self.current_collection.get( + ids=doc_ids, + include=["documents", "metadatas"] + ) + + processed_results = [] + retrieved_ids = results.get('ids', []) + retrieved_documents = results.get('documents', []) + retrieved_metadatas = results.get('metadatas', []) + + # 創建一個字典以便快速查找已檢索到的文檔信息 + found_docs_map = {} + for i, r_id in enumerate(retrieved_ids): + found_docs_map[r_id] = { + "document": retrieved_documents[i] if i < len(retrieved_documents) else None, + "metadata": retrieved_metadatas[i] if i < len(retrieved_metadatas) else {} + } + + rank_counter = 1 + for original_id in doc_ids: # 遍歷原始請求的ID,以保持某種順序感,並標記未找到的 + if original_id in found_docs_map: + doc_data = found_docs_map[original_id] + if doc_data["document"] is not None: + processed_results.append({ + "rank": rank_counter, + "id": original_id, + "document": doc_data["document"], + "metadata": doc_data["metadata"], + "similarity": None, # Not applicable + "distance": None, # Not applicable + "query_type": "id_lookup" + }) + rank_counter += 1 + else: # ID 存在但文檔為空(理論上不應發生在 get 中,除非 include 設置問題) + self.logger.warning(f"ID {original_id} 找到但文檔內容為空。") + # else: # ID 未在返回結果中找到,可以選擇不添加到 processed_results 或添加一個標記 + # self.logger.info(f"ID {original_id} 未在集合中找到。") + + self.query_results = processed_results + self.logger.info(f"按 ID 查詢完成,從請求的 {len(doc_ids)} 個ID中,實際找到 {len(processed_results)} 個文檔。") + return processed_results + + except Exception as e: + self.logger.error(f"按 ID 獲取文檔時出錯: {str(e)}") + # traceback.print_exc() # For debugging + self.query_results = [] + return [] def get_collection_info(self, collection_name: str) -> Dict: """獲取集合的詳細信息""" @@ -235,7 +444,19 @@ class ChromaDBReaderUI: # 設置窗口 self.root.title("ChromaDB 備份讀取器") self.root.geometry("1280x800") - self.setup_ui() + + # 初始化嵌入模型相關變量 + self.embedding_model_var = tk.StringVar(value="預設 (ChromaDB)") # 顯示名稱 + self.embedding_models = { + "預設 (ChromaDB)": "default", + "all-MiniLM-L6-v2 (ST)": "all-MiniLM-L6-v2", + "paraphrase-multilingual-MiniLM-L12-v2 (ST)": "paraphrase-multilingual-MiniLM-L12-v2" + } + # 初始化 reader 中的嵌入模型 (確保 reader 實例已創建) + # self.reader.set_query_embedding_model(self.embedding_models[self.embedding_model_var.get()]) + # ^^^ 這行需要在 setup_ui 之後,或者在 on_embedding_model_changed 中處理首次設置 + + self.setup_ui() # setup_ui 會創建 reader 實例 # 默認主題 self.current_theme = "darkly" # ttkbootstrap的深色主題 @@ -262,9 +483,13 @@ class ChromaDBReaderUI: # 右側面板 (查詢和結果) self.right_panel = ttk.Frame(self.main_frame) self.right_panel.pack(side=LEFT, fill=BOTH, expand=YES) + + # 設置狀態欄 (提前,以確保 self.status_var 在其他地方使用前已定義) + self.setup_status_bar() # 設置左側面板 self.setup_directory_frame() + self.setup_embedding_model_frame() # 新增嵌入模型選擇框架 self.setup_backups_frame() self.setup_collections_frame() @@ -272,9 +497,6 @@ class ChromaDBReaderUI: self.setup_query_frame() self.setup_results_frame() - # 設置狀態欄 - self.setup_status_bar() - # 設置菜單 self.setup_menu() @@ -314,6 +536,24 @@ class ChromaDBReaderUI: ttk.Entry(dir_frame, textvariable=self.backups_dir_var).pack(side=LEFT, fill=X, expand=YES) ttk.Button(dir_frame, text="瀏覽", command=self.browse_directory).pack(side=LEFT, padx=(5, 0)) ttk.Button(dir_frame, text="載入", command=self.load_backups_directory).pack(side=LEFT, padx=(5, 0)) + + def setup_embedding_model_frame(self): + """設置查詢嵌入模型選擇框架""" + embedding_frame = ttk.LabelFrame(self.left_panel, text="查詢嵌入模型", padding=10) + embedding_frame.pack(fill=X, pady=(0, 10)) + + self.embedding_model_combo = ttk.Combobox( + embedding_frame, + textvariable=self.embedding_model_var, + values=list(self.embedding_models.keys()), + state="readonly" + ) + self.embedding_model_combo.pack(fill=X, expand=YES) + self.embedding_model_combo.set(list(self.embedding_models.keys())[0]) # 設置預設顯示值 + self.embedding_model_combo.bind("<>", self.on_embedding_model_changed) + + # 初始化Reader中的嵌入模型選擇 + self.on_embedding_model_changed() def setup_backups_frame(self): """設置備份列表框架""" @@ -388,12 +628,46 @@ class ChromaDBReaderUI: query_frame = ttk.LabelFrame(self.right_panel, text="查詢", padding=10) query_frame.pack(fill=X, pady=(0, 10)) - # 查詢文本輸入 - ttk.Label(query_frame, text="查詢文本:").pack(anchor=W) - self.query_text = tk.Text(query_frame, height=4, width=50) - self.query_text.pack(fill=X, pady=5) + # 創建一個 Notebook 以包含不同的查詢類型標籤頁 + self.query_notebook = ttk.Notebook(query_frame) + self.query_notebook.pack(fill=X, pady=5) - # 查詢參數 + # 基本查詢標籤頁 + self.basic_query_frame = ttk.Frame(self.query_notebook) + self.query_notebook.add(self.basic_query_frame, text="基本查詢") + + # 元數據查詢標籤頁 + self.metadata_query_frame = ttk.Frame(self.query_notebook) + self.query_notebook.add(self.metadata_query_frame, text="元數據查詢") + + # 混合查詢標籤頁 + self.hybrid_query_frame = ttk.Frame(self.query_notebook) + self.query_notebook.add(self.hybrid_query_frame, text="混合查詢") + + # 多向量查詢標籤頁 + self.multi_vector_frame = ttk.Frame(self.query_notebook) + self.query_notebook.add(self.multi_vector_frame, text="多向量查詢") + + # ID 查詢標籤頁 (新增) + self.id_query_frame = ttk.Frame(self.query_notebook) + self.query_notebook.add(self.id_query_frame, text="ID 查詢") + + # 設置基本查詢頁面 + self.setup_basic_query_tab() + + # 設置元數據查詢頁面 + self.setup_metadata_query_tab() + + # 設置混合查詢頁面 + self.setup_hybrid_query_tab() + + # 設置多向量查詢頁面 + self.setup_multi_vector_tab() + + # 設置 ID 查詢頁面 (新增) + self.setup_id_query_tab() + + # 查詢參數(共用部分) params_frame = ttk.Frame(query_frame) params_frame.pack(fill=X) @@ -405,9 +679,102 @@ class ChromaDBReaderUI: ttk.Button( query_frame, text="執行查詢", - command=self.execute_query, + command=self.execute_query, # 注意:這個 execute_query 方法將被新的替換 style="Accent.TButton" ).pack(pady=10) + + def setup_basic_query_tab(self): + """設置基本查詢標籤頁""" + ttk.Label(self.basic_query_frame, text="查詢文本:").pack(anchor=W) + self.basic_query_text = tk.Text(self.basic_query_frame, height=4, width=50) + self.basic_query_text.pack(fill=X, pady=5) + + def setup_metadata_query_tab(self): + """設置元數據查詢標籤頁""" + ttk.Label(self.metadata_query_frame, text="查詢文本:").pack(anchor=W) + self.metadata_query_text = tk.Text(self.metadata_query_frame, height=4, width=50) + self.metadata_query_text.pack(fill=X, pady=5) + + ttk.Label(self.metadata_query_frame, text="元數據過濾條件 (JSON 格式):").pack(anchor=W) + self.metadata_filter_text = tk.Text(self.metadata_query_frame, height=4, width=50) + self.metadata_filter_text.pack(fill=X, pady=5) + self.metadata_filter_text.insert("1.0", '{"key": "value"}') + + # 添加一個幫助按鈕,顯示元數據過濾語法的說明 + ttk.Button( + self.metadata_query_frame, + text="?", + width=2, + command=self.show_metadata_help + ).pack(anchor=E) + + def setup_hybrid_query_tab(self): + """設置混合查詢標籤頁""" + ttk.Label(self.hybrid_query_frame, text="查詢文本:").pack(anchor=W) + self.hybrid_query_text = tk.Text(self.hybrid_query_frame, height=4, width=50) + self.hybrid_query_text.pack(fill=X, pady=5) + + alpha_frame = ttk.Frame(self.hybrid_query_frame) + alpha_frame.pack(fill=X) + + ttk.Label(alpha_frame, text="Alpha 值 (0-1):").pack(side=LEFT) + self.hybrid_alpha_var = tk.DoubleVar(value=0.5) + ttk.Scale( + alpha_frame, + from_=0.0, to=1.0, + variable=self.hybrid_alpha_var, + orient=tk.HORIZONTAL, + length=200 + ).pack(side=LEFT, padx=5, fill=X, expand=YES) + + # 創建一個Label來顯示Scale的當前值 + self.hybrid_alpha_label = ttk.Label(alpha_frame, text=f"{self.hybrid_alpha_var.get():.2f}") + self.hybrid_alpha_label.pack(side=LEFT) + # 綁定Scale的變動到更新Label的函數 + self.hybrid_alpha_var.trace_add("write", lambda *args: self.hybrid_alpha_label.config(text=f"{self.hybrid_alpha_var.get():.2f}")) + + ttk.Label(self.hybrid_query_frame, text="注意: Alpha=0 完全使用向量搜索,Alpha=1 完全使用關鍵詞搜索").pack(pady=2) + ttk.Label(self.hybrid_query_frame, text="混合查詢將使用集合原始嵌入模型,忽略上方選擇的查詢嵌入模型。", font=("TkDefaultFont", 8)).pack(pady=2) + + + def setup_multi_vector_tab(self): + """設置多向量查詢標籤頁""" + ttk.Label(self.multi_vector_frame, text="多個查詢文本 (每行一個,或使用 ||| 分隔):").pack(anchor=W) + self.multi_vector_text = tk.Text(self.multi_vector_frame, height=6, width=50) + self.multi_vector_text.pack(fill=X, pady=5) + self.multi_vector_text.insert("1.0", "查詢文本 1\n|||查詢文本 2\n|||查詢文本 3") + + ttk.Label(self.multi_vector_frame, text="用於比較多個查詢之間的相似性").pack(pady=5) + + def setup_id_query_tab(self): + """設置ID查詢標籤頁""" + ttk.Label(self.id_query_frame, text="文檔 ID (每行一個,或用逗號/空格分隔):").pack(anchor=tk.W) + self.id_query_text = tk.Text(self.id_query_frame, height=6, width=50) + self.id_query_text.pack(fill=tk.X, pady=5) + self.id_query_text.insert("1.0", "id1\nid2,id3 id4") # 示例 + ttk.Label(self.id_query_frame, text="此查詢將獲取指定ID的文檔,忽略上方“結果數量”設置。").pack(pady=5) + + + def show_metadata_help(self): + """顯示元數據過濾語法說明""" + help_text = """元數據過濾語法示例: + +基本過濾: +{"category": "文章"} # 精確匹配 + +範圍過濾: +{"date": {"$gt": "2023-01-01"}} # 大於 +{"date": {"$lt": "2023-12-31"}} # 小於 +{"count": {"$gte": 10}} # 大於等於 +{"count": {"$lte": 100}} # 小於等於 + +多條件過濾: +{"$and": [{"category": "文章"}, {"author": "張三"}]} # AND 條件 +{"$or": [{"category": "文章"}, {"category": "新聞"}]} # OR 條件 + +注意: 此處語法遵循 ChromaDB 的過濾語法,非標準 JSON 查詢語法。 +""" + messagebox.showinfo("元數據過濾語法說明", help_text) def setup_results_frame(self): """設置結果顯示框架""" @@ -442,6 +809,26 @@ class ChromaDBReaderUI: self.status_var = tk.StringVar(value="就緒") status_label = ttk.Label(status_frame, textvariable=self.status_var, relief=tk.SUNKEN, anchor=W) status_label.pack(fill=X) + + def on_embedding_model_changed(self, event=None): + """處理查詢嵌入模型選擇變更事件""" + selected_display_name = self.embedding_model_var.get() + model_name_key = self.embedding_models.get(selected_display_name, "default") + + if hasattr(self, 'reader') and self.reader: + self.reader.set_query_embedding_model(model_name_key) # 更新Reader中的模型 + + # 更新狀態欄提示 + if model_name_key == "default": + self.status_var.set("查詢將使用集合內部嵌入模型。") + elif self.reader.query_embedding_function: # 檢查模型是否成功加載 + self.status_var.set(f"查詢將使用外部模型: {selected_display_name}") + else: # 加載失敗 + self.status_var.set(f"模型 {selected_display_name} 加載失敗/無效,將使用集合內部模型。") + else: + # Reader尚未初始化,這通常在UI初始化早期發生 + # self.reader.set_query_embedding_model 會在 setup_embedding_model_frame 中首次調用時處理 + pass def browse_directory(self): """瀏覽選擇備份目錄""" @@ -527,27 +914,38 @@ class ChromaDBReaderUI: # 獲取選定項的索引 item_id = selection[0] - item_index = self.backups_tree.index(item_id) - - # 獲取所有顯示的備份項目 - visible_items = self.backups_tree.get_children() - if item_index >= len(visible_items): + # item_index = self.backups_tree.index(item_id) # 這個索引是相對於當前顯示的項目的 + + # 直接從 Treeview item 中獲取備份名稱,然後在 self.reader.backups 中查找 + try: + backup_name_from_tree = self.backups_tree.item(item_id)["values"][0] + except IndexError: + self.logger.error("無法從 Treeview 獲取備份名稱") return + + actual_backup_index = -1 + for i, backup_info in enumerate(self.reader.backups): + if backup_info["name"] == backup_name_from_tree: + actual_backup_index = i + break - # 查找此顯示項對應的實際備份索引 - backup_name = self.backups_tree.item(visible_items[item_index])["values"][0] - backup_index = next((i for i, b in enumerate(self.reader.backups) if b["name"] == backup_name), -1) - - if backup_index == -1: + if actual_backup_index == -1: + self.logger.error(f"在備份列表中未找到名為 {backup_name_from_tree} 的備份") return # 載入備份 - self.status_var.set(f"正在載入備份: {backup_name}...") + self.status_var.set(f"正在載入備份: {backup_name_from_tree}...") self.root.update_idletasks() + # 確保 Reader 中的嵌入模型是最新的 (雖然 on_embedding_model_changed 應該已經處理了) + # selected_display_name = self.embedding_model_var.get() + # model_key = self.embedding_models.get(selected_display_name, "default") + # self.reader.set_query_embedding_model(model_key) # 這行不需要,因為模型選擇是獨立的 + def load_backup_thread(): - success = self.reader.load_backup(backup_index) - self.root.after(0, lambda: self.finalize_backup_loading(success, backup_name)) + # load_backup 不再需要 embedding_model_name 參數,因為嵌入模型選擇是針對查詢的 + success = self.reader.load_backup(actual_backup_index) + self.root.after(0, lambda: self.finalize_backup_loading(success, backup_name_from_tree)) threading.Thread(target=load_backup_thread).start() @@ -618,7 +1016,7 @@ class ChromaDBReaderUI: # 獲取集合詳細信息並顯示 info = self.reader.get_collection_info(collection_name) info_text = f"集合: {info['name']}\n文檔數: {info['document_count']}\n向量維度: {info['dimension']}" - messagebox.showinfo("集合信息", info_text) + # messagebox.showinfo("集合信息", info_text) # 暫時註解掉,避免每次選集合都彈窗 else: self.status_var.set(f"載入集合失敗: {collection_name}") messagebox.showerror("錯誤", f"無法載入集合: {collection_name}") @@ -629,25 +1027,170 @@ class ChromaDBReaderUI: messagebox.showinfo("提示", "請先選擇一個集合") return - query_text = self.query_text.get("1.0", tk.END).strip() - if not query_text: - messagebox.showinfo("提示", "請輸入查詢文本") - return - + # 根據當前選擇的標籤頁確定查詢類型 + try: + current_tab_widget = self.query_notebook.nametowidget(self.query_notebook.select()) + if current_tab_widget == self.basic_query_frame: + current_tab = 0 + elif current_tab_widget == self.metadata_query_frame: + current_tab = 1 + elif current_tab_widget == self.hybrid_query_frame: + current_tab = 2 + elif current_tab_widget == self.multi_vector_frame: + current_tab = 3 + elif current_tab_widget == self.id_query_frame: # 新增 ID 查詢頁判斷 + current_tab = 4 + else: + messagebox.showerror("錯誤", "未知的查詢標籤頁") + return + except tk.TclError: # Notebook可能還沒有任何分頁被選中 + messagebox.showerror("錯誤", "請選擇一個查詢類型標籤頁") + return + + # 獲取查詢參數 try: n_results = int(self.n_results_var.get()) except ValueError: messagebox.showerror("錯誤", "結果數量必須是整數") return - self.status_var.set("正在執行查詢...") + # 執行不同類型的查詢 + if current_tab == 0: # 基本查詢 + query_text = self.basic_query_text.get("1.0", tk.END).strip() + if not query_text: + messagebox.showinfo("提示", "請輸入查詢文本") + return + + self.status_var.set("正在執行基本查詢...") + self.execute_basic_query(query_text, n_results) + + elif current_tab == 1: # 元數據查詢 + query_text = self.metadata_query_text.get("1.0", tk.END).strip() + metadata_filter_text = self.metadata_filter_text.get("1.0", tk.END).strip() + + if not query_text: # 元數據查詢的文本也可以是空的,如果只想用metadata_filter + # messagebox.showinfo("提示", "請輸入查詢文本") + # return + pass # 允許空查詢文本 + + try: + metadata_filter = json.loads(metadata_filter_text) if metadata_filter_text else None + except json.JSONDecodeError: + messagebox.showerror("錯誤", "元數據過濾條件必須是有效的 JSON 格式") + return + + if not query_text and not metadata_filter: + messagebox.showinfo("提示", "請輸入查詢文本或元數據過濾條件") + return + + self.status_var.set("正在執行元數據查詢...") + self.execute_metadata_query(query_text, n_results, metadata_filter) + + elif current_tab == 2: # 混合查詢 + query_text = self.hybrid_query_text.get("1.0", tk.END).strip() + hybrid_alpha = self.hybrid_alpha_var.get() + + if not query_text: + messagebox.showinfo("提示", "請輸入查詢文本") + return + + self.status_var.set("正在執行混合查詢...") + self.execute_hybrid_query(query_text, n_results, hybrid_alpha) + + elif current_tab == 3: # 多向量查詢 + query_text = self.multi_vector_text.get("1.0", tk.END).strip() + + if not query_text: + messagebox.showinfo("提示", "請輸入查詢文本") + return + + self.status_var.set("正在執行多向量查詢...") + self.execute_multi_vector_query(query_text, n_results) + + elif current_tab == 4: # ID 查詢 + id_input_str = self.id_query_text.get("1.0", tk.END).strip() + if not id_input_str: + messagebox.showinfo("提示", "請輸入文檔 ID。") + return + + # 解析 ID: 支持逗號、空格、換行符分隔 + doc_ids = [id_val.strip() for id_val in re.split(r'[,\s\n]+', id_input_str) if id_val.strip()] + + if not doc_ids: + messagebox.showinfo("提示", "未解析到有效的文檔 ID。") + return + + self.status_var.set("正在按 ID 獲取文檔...") + self.execute_id_lookup_query(doc_ids) + + + def execute_basic_query(self, query_text, n_results): + """執行基本查詢""" + self.status_var.set(f"正在執行基本查詢: {query_text[:30]}...") self.root.update_idletasks() - def query_thread(): - results = self.reader.execute_query(query_text, n_results) + results = self.reader.execute_query( + query_text=query_text, + n_results=n_results, + query_type="basic" + ) self.root.after(0, lambda: self.display_results(results)) - threading.Thread(target=query_thread).start() + threading.Thread(target=query_thread, daemon=True).start() + + def execute_metadata_query(self, query_text, n_results, metadata_filter): + """執行元數據查詢""" + self.status_var.set(f"正在執行元數據查詢: {query_text[:30]}...") + self.root.update_idletasks() + def query_thread(): + results = self.reader.execute_query( + query_text=query_text, + n_results=n_results, + query_type="metadata", # 這裡應該是 "metadata" 但後端邏輯會轉為 where + metadata_filter=metadata_filter + ) + self.root.after(0, lambda: self.display_results(results)) + + threading.Thread(target=query_thread, daemon=True).start() + + def execute_hybrid_query(self, query_text, n_results, hybrid_alpha): + """執行混合查詢""" + self.status_var.set(f"正在執行混合查詢 (α={hybrid_alpha:.2f}): {query_text[:30]}...") + self.root.update_idletasks() + def query_thread(): + results = self.reader.execute_query( + query_text=query_text, + n_results=n_results, + query_type="hybrid", + hybrid_alpha=hybrid_alpha + ) + self.root.after(0, lambda: self.display_results(results)) + + threading.Thread(target=query_thread, daemon=True).start() + + def execute_multi_vector_query(self, query_text, n_results): + """執行多向量查詢""" + self.status_var.set(f"正在執行多向量查詢: {query_text.splitlines()[0][:30] if query_text.splitlines() else ''}...") + self.root.update_idletasks() + def query_thread(): + results = self.reader.execute_query( + query_text=query_text, + n_results=n_results, + query_type="multi_vector" + ) + self.root.after(0, lambda: self.display_results(results)) + + threading.Thread(target=query_thread, daemon=True).start() + + def execute_id_lookup_query(self, doc_ids: List[str]): + """執行ID查找查詢""" + self.status_var.set(f"正在按 ID 獲取 {len(doc_ids)} 個文檔...") + self.root.update_idletasks() + def query_thread(): + results = self.reader.get_documents_by_ids(doc_ids) + self.root.after(0, lambda: self.display_results(results)) + + threading.Thread(target=query_thread, daemon=True).start() def display_results(self, results): """顯示查詢結果""" @@ -679,27 +1222,49 @@ class ChromaDBReaderUI: widget.destroy() # 創建表格 - columns = ("rank", "similarity", "id", "document") + columns = ("rank", "similarity", "query_type", "id", "document") tree = ttk.Treeview(self.list_view, columns=columns, show="headings") tree.heading("rank", text="#") tree.heading("similarity", text="相似度") + tree.heading("query_type", text="查詢類型") tree.heading("id", text="文檔ID") tree.heading("document", text="文檔內容") tree.column("rank", width=50, anchor=CENTER) tree.column("similarity", width=100, anchor=CENTER) - tree.column("id", width=200) - tree.column("document", width=600) + tree.column("query_type", width=120, anchor=CENTER) # 調整寬度以適應更長的類型名稱 + tree.column("id", width=150) + tree.column("document", width=530) # 調整寬度 + + # 確定查詢類型名稱映射 + query_type_names = { + "basic": "基本查詢", + "metadata": "元數據查詢", + "hybrid": "混合查詢", + "multi_vector": "多向量查詢", + "id_lookup": "ID 查詢" # 新增 + } # 添加結果到表格 for result in results: + raw_query_type = result.get("query_type", "basic") + display_query_type = query_type_names.get(raw_query_type, raw_query_type.capitalize()) + + if raw_query_type == "hybrid" and "hybrid_alpha" in result: + display_query_type += f" (α={result['hybrid_alpha']:.2f})" + if raw_query_type == "multi_vector" and "query_index" in result: + display_query_type += f" (Q{result['query_index']+1})" + + similarity_display = f"{result.get('similarity', 0.0):.4f}" if result.get('similarity') is not None else "N/A" + tree.insert( "", "end", values=( - result["rank"], - f"{result['similarity']:.4f}", - result["id"], - result["document"][:100] + ("..." if len(result["document"]) > 100 else "") + result.get("rank", "-"), + similarity_display, + display_query_type, + result.get("id", "N/A"), + result.get("document", "")[:100] + ("..." if len(result.get("document", "")) > 100 else "") ) ) @@ -710,7 +1275,6 @@ class ChromaDBReaderUI: # 雙擊項目顯示完整內容 tree.bind("", lambda event: self.show_full_document(tree)) - # 使用 Frame 容器來實現滾動功能 # 佈局 tree.pack(side=LEFT, fill=BOTH, expand=YES) scrollbar.pack(side=RIGHT, fill=Y) @@ -739,7 +1303,10 @@ class ChromaDBReaderUI: # 添加文檔信息 info_text = f"文檔ID: {result['id']}\n" - info_text += f"相似度: {result['similarity']:.4f}\n" + if result.get('similarity') is not None: + info_text += f"相似度: {result['similarity']:.4f}\n" + else: + info_text += "相似度: N/A\n" if result['metadata']: info_text += "\n元數據:\n" @@ -806,9 +1373,10 @@ class ChromaDBReaderUI: title_frame = ttk.Frame(card) title_frame.pack(fill=X) + similarity_text_detail = f"{result['similarity']:.4f}" if result.get('similarity') is not None else "N/A" ttk.Label( title_frame, - text=f"#{result['rank']} - 相似度: {result['similarity']:.4f}", + text=f"#{result['rank']} - 相似度: {similarity_text_detail}", font=("TkDefaultFont", 10, "bold") ).pack(side=LEFT) @@ -881,7 +1449,10 @@ class ChromaDBReaderUI: # 添加文檔信息 info_text = f"文檔ID: {result['id']}\n" - info_text += f"相似度: {result['similarity']:.4f}\n" + if result.get('similarity') is not None: + info_text += f"相似度: {result['similarity']:.4f}\n" + else: + info_text += "相似度: N/A\n" if result['metadata']: info_text += "\n元數據:\n" @@ -1250,4 +1821,4 @@ def main(): root.mainloop() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/ui_interaction.py b/ui_interaction.py index 524d3bf..1b47c2f 100644 --- a/ui_interaction.py +++ b/ui_interaction.py @@ -4,6 +4,8 @@ import pyautogui import cv2 # opencv-python import numpy as np +import sys # Added for special character handling +import io # Added for special character handling import pyperclip import time import os @@ -22,6 +24,26 @@ import math # Added for distance calculation in dual method # Or could use threading.Event() monitoring_paused_flag = [False] # List containing a boolean +# --- Global Error Handling Setup for Text Encoding --- +def handle_text_encoding(text, default_text="[無法處理的文字]"): + """安全處理任何文字,確保不會因編碼問題而崩潰程序""" + if text is None: + return default_text + + try: + # 嘗試使用 utf-8 編碼 + return text + except UnicodeEncodeError: + try: + # 嘗試將特殊字符替換為可顯示字符 + return text.encode('utf-8', errors='replace').decode('utf-8') + except: + # 最後手段:忽略任何無法處理的字符 + try: + return text.encode('utf-8', errors='ignore').decode('utf-8') + except: + return default_text + # --- Color Config Loading --- def load_bubble_colors(config_path='bubble_colors.json'): """Loads bubble color configuration from a JSON file.""" @@ -1068,7 +1090,13 @@ class InteractionModule: if copied and copied_text and copied_text != "___MCP_CLEAR___": print(f"Successfully copied text, length: {len(copied_text)}") - return copied_text.strip() + # 添加編碼安全處理 + try: + safe_text = handle_text_encoding(copied_text.strip()) + return safe_text + except Exception as e: + print(f"Error handling copied text encoding: {str(e)}") + return copied_text.strip() # 即使有問題也嘗試返回原始文字 else: print("Error: Copy operation unsuccessful or clipboard content invalid.") return None @@ -2115,17 +2143,31 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu # 7. Send Trigger Info to Main Thread print("\n>>> Putting trigger info in Queue <<<") - print(f" Sender: {sender_name}") - print(f" Content: {bubble_text[:100]}...") + try: + # 安全地處理和顯示發送者名稱 + safe_sender_display = handle_text_encoding(sender_name, "[未知發送者]") + print(f" Sender: {safe_sender_display}") + + # 安全地處理和顯示消息內容 + if bubble_text: + display_text = bubble_text[:100] + "..." if len(bubble_text) > 100 else bubble_text + safe_content_display = handle_text_encoding(display_text, "[無法處理的文字內容]") + print(f" Content: {safe_content_display}") + else: + print(" Content: [空]") + except Exception as e_display: + print(f"Error displaying message info: {str(e_display)}") + print(f" Bubble Region: {bubble_region}") # Original region for context print(f" Reply Context Activated: {reply_context_activated}") try: + # 確保所有文字數據都經過安全處理 data_to_send = { - 'sender': sender_name, - 'text': bubble_text, - 'bubble_region': bubble_region, # Send original region for context if needed + 'sender': handle_text_encoding(sender_name, "[未知發送者]"), + 'text': handle_text_encoding(bubble_text, "[無法處理的文字內容]"), + 'bubble_region': bubble_region, 'reply_context_activated': reply_context_activated, - 'bubble_snapshot': bubble_snapshot, # Send the snapshot used + 'bubble_snapshot': bubble_snapshot, 'search_area': search_area } trigger_queue.put(data_to_send) @@ -2136,13 +2178,26 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu break # Exit the 'for target_bubble_info in sorted_bubbles' loop except Exception as q_err: - print(f"Error putting data in Queue: {q_err}") - # Don't break if queue put fails, maybe try next bubble? Or log and break? + print(f"Error preparing or enqueueing data: {q_err}") + # 嘗試使用最小數據集合保證功能性 + try: + minimal_data = { + 'sender': "[數據處理錯誤]", + 'text': handle_text_encoding(bubble_text[:100] if bubble_text else "[內容獲取失敗]"), # Apply encoding here too + 'bubble_region': bubble_region, + 'reply_context_activated': False, # Sensible default + 'bubble_snapshot': bubble_snapshot, # Keep snapshot if available + 'search_area': search_area + } + trigger_queue.put(minimal_data) + print("Minimal fallback data placed in Queue after error.") + except Exception as min_q_err: + print(f"Critical failure: Could not place any data in queue: {min_q_err}") # Let's break here too, as something is wrong. print("Breaking scan cycle due to queue error.") break - # End of keyword found block (if keyword_coords:) + # End of keyword found block (if result:) # End of loop through sorted bubbles (for target_bubble_info...) # If the loop finished without breaking (i.e., no trigger processed), wait the full interval. From bccc6d413f55fa4e252e53e86630a19b5f7a6e70 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 9 May 2025 12:32:06 +0800 Subject: [PATCH 07/13] Migrate ChromaDB embedding model to paraphrase-multilingual-mpnet-base-v2 --- Setup.py | 46 +++- chroma_client.py | 54 ++++- memory_manager.py | 68 +++--- reembed_chroma_data.py | 529 +++++++++++++++++++++++++++++++++++++++++ tools/chroma_view.py | 19 +- 5 files changed, 666 insertions(+), 50 deletions(-) create mode 100644 reembed_chroma_data.py diff --git a/Setup.py b/Setup.py index fd3ffcd..bd0918e 100644 --- a/Setup.py +++ b/Setup.py @@ -316,6 +316,15 @@ def load_current_config(): if backup_minute_match: config_data["MEMORY_BACKUP_MINUTE"] = int(backup_minute_match.group(1)) + # Extract EMBEDDING_MODEL_NAME + embedding_model_match = re.search(r'EMBEDDING_MODEL_NAME\s*=\s*["\'](.+?)["\']', config_content) + if embedding_model_match: + config_data["EMBEDDING_MODEL_NAME"] = embedding_model_match.group(1) + else: + # Default if not found in config.py, will be set in UI if not overridden by load + config_data["EMBEDDING_MODEL_NAME"] = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2" + + profile_model_match = re.search(r'MEMORY_PROFILE_MODEL\s*=\s*["\']?(.+?)["\']?\s*(?:#|$)', config_content) # Handle potential LLM_MODEL reference if profile_model_match: @@ -537,10 +546,15 @@ def generate_config_file(config_data, env_data): f.write(f"MEMORY_BACKUP_MINUTE = {backup_minute}\n") # Write profile model, potentially referencing LLM_MODEL if profile_model == config_data.get('LLM_MODEL'): - f.write(f"MEMORY_PROFILE_MODEL = LLM_MODEL # Default to main LLM model\n") + f.write(f"MEMORY_PROFILE_MODEL = LLM_MODEL # Default to main LLM model\n") else: - f.write(f"MEMORY_PROFILE_MODEL = \"{profile_model}\"\n") - f.write(f"MEMORY_SUMMARY_MODEL = \"{summary_model}\"\n") + f.write(f"MEMORY_PROFILE_MODEL = \"{profile_model}\"\n") + f.write(f"MEMORY_SUMMARY_MODEL = \"{summary_model}\"\n\n") + + # Write Embedding Model Name + embedding_model_name = config_data.get('EMBEDDING_MODEL_NAME', "sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + f.write("# Embedding model for ChromaDB\n") + f.write(f"EMBEDDING_MODEL_NAME = \"{embedding_model_name}\"\n") print("Generated config.py file successfully") @@ -1717,6 +1731,24 @@ class WolfChatSetup(tk.Tk): related_info = ttk.Label(related_frame, text="(0 to disable related memories pre-loading)") related_info.pack(side=tk.LEFT, padx=(5, 0)) + # Embedding Model Settings Frame + embedding_model_settings_frame = ttk.LabelFrame(main_frame, text="Embedding Model Settings") + embedding_model_settings_frame.pack(fill=tk.X, pady=10) + + embedding_model_name_frame = ttk.Frame(embedding_model_settings_frame) + embedding_model_name_frame.pack(fill=tk.X, pady=5, padx=10) + + embedding_model_name_label = ttk.Label(embedding_model_name_frame, text="Embedding Model Name:", width=25) # Adjusted width + embedding_model_name_label.pack(side=tk.LEFT, padx=(0, 5)) + + self.embedding_model_name_var = tk.StringVar(value="sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + embedding_model_name_entry = ttk.Entry(embedding_model_name_frame, textvariable=self.embedding_model_name_var) + embedding_model_name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True) + + embedding_model_info = ttk.Label(embedding_model_settings_frame, text="Default: sentence-transformers/paraphrase-multilingual-mpnet-base-v2", justify=tk.LEFT) + embedding_model_info.pack(anchor=tk.W, padx=10, pady=(0,5)) + + # Information box info_frame = ttk.LabelFrame(main_frame, text="Information") info_frame.pack(fill=tk.BOTH, expand=True, pady=10) @@ -2067,6 +2099,10 @@ class WolfChatSetup(tk.Tk): self.profiles_collection_var.set(self.config_data.get("PROFILES_COLLECTION", "user_profiles")) # Default was user_profiles self.conversations_collection_var.set(self.config_data.get("CONVERSATIONS_COLLECTION", "conversations")) self.bot_memory_collection_var.set(self.config_data.get("BOT_MEMORY_COLLECTION", "wolfhart_memory")) + # Embedding Model Name for Memory Settings Tab + if hasattr(self, 'embedding_model_name_var'): + self.embedding_model_name_var.set(self.config_data.get("EMBEDDING_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-mpnet-base-v2")) + # Memory Management Tab Settings if hasattr(self, 'backup_hour_var'): # Check if UI elements for memory management tab exist @@ -2343,6 +2379,10 @@ class WolfChatSetup(tk.Tk): self.config_data["PROFILES_COLLECTION"] = self.profiles_collection_var.get() self.config_data["CONVERSATIONS_COLLECTION"] = self.conversations_collection_var.get() self.config_data["BOT_MEMORY_COLLECTION"] = self.bot_memory_collection_var.get() + # Save Embedding Model Name from Memory Settings Tab + if hasattr(self, 'embedding_model_name_var'): + self.config_data["EMBEDDING_MODEL_NAME"] = self.embedding_model_name_var.get() + # Get Memory Management settings from UI if hasattr(self, 'backup_hour_var'): # Check if UI elements exist diff --git a/chroma_client.py b/chroma_client.py index db05626..6149703 100644 --- a/chroma_client.py +++ b/chroma_client.py @@ -1,6 +1,7 @@ # chroma_client.py import chromadb from chromadb.config import Settings +from chromadb.utils import embedding_functions # New import import os import json import config @@ -10,6 +11,33 @@ import time _client = None _collections = {} +# Global embedding function variable +_embedding_function = None + +def get_embedding_function(): + """Gets or creates the embedding function based on config""" + global _embedding_function + if _embedding_function is None: + # Default to paraphrase-multilingual-mpnet-base-v2 if not specified or on error + model_name = getattr(config, 'EMBEDDING_MODEL_NAME', "sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + try: + _embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=model_name) + print(f"Successfully initialized embedding function with model: {model_name}") + except Exception as e: + print(f"Failed to initialize embedding function with model '{model_name}': {e}") + # Fallback to default if specified model fails and it's not already the default + if model_name != "sentence-transformers/paraphrase-multilingual-mpnet-base-v2": + print("Falling back to default embedding model: sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + try: + _embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + print(f"Successfully initialized embedding function with default model.") + except Exception as e_default: + print(f"Failed to initialize default embedding function: {e_default}") + _embedding_function = None # Ensure it's None if all attempts fail + else: + _embedding_function = None # Ensure it's None if default model also fails + return _embedding_function + def initialize_chroma_client(): """Initializes and connects to ChromaDB""" global _client @@ -34,13 +62,31 @@ def get_collection(collection_name): if collection_name not in _collections: try: + emb_func = get_embedding_function() + if emb_func is None: + print(f"Failed to get or create collection '{collection_name}' due to embedding function initialization failure.") + return None + _collections[collection_name] = _client.get_or_create_collection( - name=collection_name + name=collection_name, + embedding_function=emb_func ) - print(f"Successfully got or created collection '{collection_name}'") + print(f"Successfully got or created collection '{collection_name}' using configured embedding function.") except Exception as e: - print(f"Failed to get collection '{collection_name}': {e}") - return None + print(f"Failed to get collection '{collection_name}' with configured embedding function: {e}") + # Attempt to create collection with default embedding function as a fallback + print(f"Attempting to create collection '{collection_name}' with default embedding function...") + try: + # Ensure we try the absolute default if the configured one (even if it was the default) failed + default_emb_func = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + _collections[collection_name] = _client.get_or_create_collection( + name=collection_name, + embedding_function=default_emb_func + ) + print(f"Successfully got or created collection '{collection_name}' with default embedding function after initial failure.") + except Exception as e_default: + print(f"Failed to get collection '{collection_name}' even with default embedding function: {e_default}") + return None return _collections[collection_name] diff --git a/memory_manager.py b/memory_manager.py index fe4b3fe..1a52aea 100644 --- a/memory_manager.py +++ b/memory_manager.py @@ -16,11 +16,12 @@ import schedule from pathlib import Path from typing import Dict, List, Optional, Any, Union -import chromadb -from chromadb.utils import embedding_functions +# import chromadb # No longer directly needed by ChromaDBManager +# from chromadb.utils import embedding_functions # No longer directly needed by ChromaDBManager from openai import AsyncOpenAI import config +import chroma_client # Import the centralized chroma client # ============================================================================= # 日誌解析部分 @@ -345,28 +346,22 @@ class MemoryGenerator: class ChromaDBManager: def __init__(self, collection_name: Optional[str] = None): - self.client = chromadb.PersistentClient(path=config.CHROMA_DATA_DIR) self.collection_name = collection_name or config.BOT_MEMORY_COLLECTION - self.embedding_function = embedding_functions.DefaultEmbeddingFunction() - self._ensure_collection() - - def _ensure_collection(self) -> None: - """確保集合存在""" - try: - self.collection = self.client.get_collection( - name=self.collection_name, - embedding_function=self.embedding_function - ) - print(f"Connected to existing collection: {self.collection_name}") - except Exception: - self.collection = self.client.create_collection( - name=self.collection_name, - embedding_function=self.embedding_function - ) - print(f"Created new collection: {self.collection_name}") - + self._db_collection = None # Cache for the collection object + + def _get_db_collection(self): + """Helper to get the collection object from chroma_client""" + if self._db_collection is None: + # Use the centralized get_collection function + self._db_collection = chroma_client.get_collection(self.collection_name) + if self._db_collection is None: + # This indicates a failure in chroma_client to provide the collection + raise RuntimeError(f"Failed to get or create collection '{self.collection_name}' via chroma_client. Check chroma_client logs.") + return self._db_collection + def upsert_user_profile(self, profile_data: Dict[str, Any]) -> bool: """寫入或更新用戶檔案""" + collection = self._get_db_collection() if not profile_data or not isinstance(profile_data, dict): print("無效的檔案數據") return False @@ -377,14 +372,13 @@ class ChromaDBManager: print("檔案缺少ID字段") return False - # 先檢查是否已存在 - results = self.collection.get( - ids=[user_id], # Query by a list of IDs - # where={"id": user_id}, # 'where' is for metadata filtering - limit=1 - ) - # 準備元數據 + # Note: ChromaDB's upsert handles existence check implicitly. + # The .get call here isn't strictly necessary for the upsert operation itself, + # but might be kept if there was other logic depending on prior existence. + # For a clean upsert, it can be removed. Let's assume it's not critical for now. + # results = collection.get(ids=[user_id], limit=1) # Optional: if needed for pre-check logic + metadata = { "id": user_id, "type": "user_profile", @@ -402,14 +396,12 @@ class ChromaDBManager: content_doc = json.dumps(profile_data.get("content", {}), ensure_ascii=False) # 寫入或更新 - # ChromaDB's add/upsert handles both cases. - # If an ID exists, it's an update; otherwise, it's an add. - self.collection.upsert( + collection.upsert( ids=[user_id], documents=[content_doc], metadatas=[metadata] ) - print(f"Upserted user profile: {user_id}") + print(f"Upserted user profile: {user_id} into collection {self.collection_name}") return True @@ -419,6 +411,7 @@ class ChromaDBManager: def upsert_conversation_summary(self, summary_data: Dict[str, Any]) -> bool: """寫入對話總結""" + collection = self._get_db_collection() if not summary_data or not isinstance(summary_data, dict): print("無效的總結數據") return False @@ -450,13 +443,13 @@ class ChromaDBManager: key_points_str = "\n".join([f"- {point}" for point in summary_data["key_points"]]) content_doc += f"\n\n關鍵點:\n{key_points_str}" - # 寫入數據 (ChromaDB's add implies upsert if ID exists, but upsert is more explicit) - self.collection.upsert( + # 寫入數據 + collection.upsert( ids=[summary_id], documents=[content_doc], metadatas=[metadata] ) - print(f"Upserted conversation summary: {summary_id}") + print(f"Upserted conversation summary: {summary_id} into collection {self.collection_name}") return True @@ -466,10 +459,11 @@ class ChromaDBManager: def get_existing_profile(self, username: str) -> Optional[Dict[str, Any]]: """獲取現有的用戶檔案""" + collection = self._get_db_collection() try: profile_id = f"{username}_profile" - results = self.collection.get( - ids=[profile_id], # Query by a list of IDs + results = collection.get( + ids=[profile_id], limit=1 ) diff --git a/reembed_chroma_data.py b/reembed_chroma_data.py new file mode 100644 index 0000000..328adb5 --- /dev/null +++ b/reembed_chroma_data.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +重新嵌入工具 (Reembedding Tool) + +這個腳本用於將現有ChromaDB集合中的數據使用新的嵌入模型重新計算向量並儲存。 +""" + +import os +import sys +import json +import time +import argparse +import shutil +from datetime import datetime +from typing import List, Dict, Any, Optional, Tuple +from tqdm import tqdm # 進度條 + +try: + import chromadb + from chromadb.utils import embedding_functions +except ImportError: + print("錯誤: 請先安裝 chromadb: pip install chromadb") + sys.exit(1) + +try: + from sentence_transformers import SentenceTransformer +except ImportError: + print("錯誤: 請先安裝 sentence-transformers: pip install sentence-transformers") + sys.exit(1) + +# 嘗試導入配置 +try: + import config +except ImportError: + print("警告: 無法導入config.py,將使用預設設定") + # 建立最小配置 + class MinimalConfig: + CHROMA_DATA_DIR = "chroma_data" + BOT_MEMORY_COLLECTION = "wolfhart_memory" + CONVERSATIONS_COLLECTION = "wolfhart_memory" + PROFILES_COLLECTION = "wolfhart_memory" + config = MinimalConfig() + +def parse_args(): + """處理命令行參數""" + parser = argparse.ArgumentParser(description='ChromaDB 數據重新嵌入工具') + + parser.add_argument('--new-model', type=str, + default="sentence-transformers/paraphrase-multilingual-mpnet-base-v2", + help='新的嵌入模型名稱 (預設: sentence-transformers/paraphrase-multilingual-mpnet-base-v2)') + + parser.add_argument('--collections', type=str, nargs='+', + help=f'要處理的集合名稱列表,空白分隔 (預設: 使用配置中的所有集合)') + + parser.add_argument('--backup', action='store_true', + help='在處理前備份資料庫 (推薦)') + + parser.add_argument('--batch-size', type=int, default=100, + help='批處理大小 (預設: 100)') + + parser.add_argument('--temp-collection-suffix', type=str, default="_temp_new", + help='臨時集合的後綴名稱 (預設: _temp_new)') + + parser.add_argument('--dry-run', action='store_true', + help='模擬執行但不實際修改資料') + + parser.add_argument('--confirm-dangerous', action='store_true', + help='確認執行危險操作(例如刪除集合)') + + return parser.parse_args() + +def backup_chroma_directory(chroma_dir: str) -> str: + """備份ChromaDB數據目錄 + + Args: + chroma_dir: ChromaDB數據目錄路徑 + + Returns: + 備份目錄的路徑 + """ + if not os.path.exists(chroma_dir): + print(f"錯誤: ChromaDB目錄 '{chroma_dir}' 不存在") + sys.exit(1) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_dir = f"{chroma_dir}_backup_{timestamp}" + + print(f"備份資料庫從 '{chroma_dir}' 到 '{backup_dir}'...") + shutil.copytree(chroma_dir, backup_dir) + print(f"備份完成: {backup_dir}") + + return backup_dir + +def create_embedding_function(model_name: str): + """創建嵌入函數 + + Args: + model_name: 嵌入模型名稱 + + Returns: + 嵌入函數對象 + """ + if not model_name: + print("使用ChromaDB預設嵌入模型") + return embedding_functions.DefaultEmbeddingFunction() + + print(f"正在加載嵌入模型: {model_name}") + try: + # 直接使用SentenceTransformerEmbeddingFunction + from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction + embedding_function = SentenceTransformerEmbeddingFunction(model_name=model_name) + # 預熱模型 + _ = embedding_function(["."]) + return embedding_function + except Exception as e: + print(f"錯誤: 無法加載模型 '{model_name}': {e}") + print("退回到預設嵌入模型") + return embedding_functions.DefaultEmbeddingFunction() + +def get_collection_names(client, default_collections: List[str]) -> List[str]: + """獲取所有可用的集合名稱 + + Args: + client: ChromaDB客戶端 + default_collections: 預設集合列表 + + Returns: + 可用的集合名稱列表 + """ + try: + all_collections = client.list_collections() + collection_names = [col.name for col in all_collections] + + if collection_names: + return collection_names + else: + print("警告: 沒有找到集合,將使用預設集合") + return default_collections + + except Exception as e: + print(f"獲取集合列表失敗: {e}") + print("將使用預設集合") + return default_collections + +def fetch_collection_data(client, collection_name: str, batch_size: int = 100) -> Dict[str, Any]: + """從集合中提取所有數據 + + Args: + client: ChromaDB客戶端 + collection_name: 集合名稱 + batch_size: 批處理大小 + + Returns: + 集合數據字典,包含ids, documents, metadatas + """ + try: + collection = client.get_collection(name=collection_name) + + # 獲取該集合中的項目總數 + count_result = collection.count() + if count_result == 0: + print(f"集合 '{collection_name}' 是空的") + return {"ids": [], "documents": [], "metadatas": []} + + print(f"從集合 '{collection_name}' 中讀取 {count_result} 項數據...") + + # 分批獲取數據 + all_ids = [] + all_documents = [] + all_metadatas = [] + + offset = 0 + with tqdm(total=count_result, desc=f"正在讀取 {collection_name}") as pbar: + while True: + # 注意: 使用include參數指定只獲取需要的數據 + batch_result = collection.get( + limit=batch_size, + offset=offset, + include=["documents", "metadatas"] + ) + + batch_ids = batch_result.get("ids", []) + if not batch_ids: + break + + all_ids.extend(batch_ids) + all_documents.extend(batch_result.get("documents", [])) + all_metadatas.extend(batch_result.get("metadatas", [])) + + offset += len(batch_ids) + pbar.update(len(batch_ids)) + + if len(batch_ids) < batch_size: + break + + return { + "ids": all_ids, + "documents": all_documents, + "metadatas": all_metadatas + } + + except Exception as e: + print(f"從集合 '{collection_name}' 獲取數據時出錯: {e}") + return {"ids": [], "documents": [], "metadatas": []} + +def create_and_populate_collection( + client, + collection_name: str, + data: Dict[str, Any], + embedding_func, + batch_size: int = 100, + dry_run: bool = False +) -> bool: + """創建新集合並填充數據 + + Args: + client: ChromaDB客戶端 + collection_name: 集合名稱 + data: 要添加的數據 (ids, documents, metadatas) + embedding_func: 嵌入函數 + batch_size: 批處理大小 + dry_run: 是否只模擬執行 + + Returns: + 成功返回True,否則返回False + """ + if dry_run: + print(f"[模擬] 將創建集合 '{collection_name}' 並添加 {len(data['ids'])} 項數據") + return True + + try: + # 檢查集合是否已存在 + if collection_name in [col.name for col in client.list_collections()]: + client.delete_collection(collection_name) + + # 創建新集合 + collection = client.create_collection( + name=collection_name, + embedding_function=embedding_func + ) + + # 如果沒有數據,直接返回 + if not data["ids"]: + print(f"集合 '{collection_name}' 創建完成,但沒有數據添加") + return True + + # 分批添加數據 + total_items = len(data["ids"]) + with tqdm(total=total_items, desc=f"正在填充 {collection_name}") as pbar: + for i in range(0, total_items, batch_size): + end_idx = min(i + batch_size, total_items) + + batch_ids = data["ids"][i:end_idx] + batch_docs = data["documents"][i:end_idx] + batch_meta = data["metadatas"][i:end_idx] + + # 處理可能的None值 + processed_docs = [] + for doc in batch_docs: + if doc is None: + processed_docs.append("") # 使用空字符串替代None + else: + processed_docs.append(doc) + + collection.add( + ids=batch_ids, + documents=processed_docs, + metadatas=batch_meta + ) + + pbar.update(end_idx - i) + + print(f"成功將 {total_items} 項數據添加到集合 '{collection_name}'") + return True + + except Exception as e: + print(f"創建或填充集合 '{collection_name}' 時出錯: {e}") + import traceback + traceback.print_exc() + return False + +def swap_collections( + client, + original_collection: str, + temp_collection: str, + confirm_dangerous: bool = False, + dry_run: bool = False, + embedding_func = None # 添加嵌入函數作為參數 +) -> bool: + """替換集合(刪除原始集合,將臨時集合重命名為原始集合名) + + Args: + client: ChromaDB客戶端 + original_collection: 原始集合名稱 + temp_collection: 臨時集合名稱 + confirm_dangerous: 是否確認危險操作 + dry_run: 是否只模擬執行 + embedding_func: 嵌入函數,用於創建新集合 + + Returns: + 成功返回True,否則返回False + """ + if dry_run: + print(f"[模擬] 將替換集合: 刪除 '{original_collection}',重命名 '{temp_collection}' 到 '{original_collection}'") + return True + + try: + # 檢查是否有確認標誌 + if not confirm_dangerous: + response = input(f"警告: 即將刪除集合 '{original_collection}' 並用 '{temp_collection}' 替換它。確認操作? (y/N): ") + if response.lower() != 'y': + print("操作已取消") + return False + + # 檢查兩個集合是否都存在 + all_collections = [col.name for col in client.list_collections()] + if original_collection not in all_collections: + print(f"錯誤: 原始集合 '{original_collection}' 不存在") + return False + + if temp_collection not in all_collections: + print(f"錯誤: 臨時集合 '{temp_collection}' 不存在") + return False + + # 獲取臨時集合的所有數據 + # 在刪除原始集合之前先獲取臨時集合的所有數據 + print(f"獲取臨時集合 '{temp_collection}' 的數據...") + temp_collection_obj = client.get_collection(temp_collection) + temp_data = temp_collection_obj.get(include=["documents", "metadatas"]) + + # 刪除原始集合 + print(f"刪除原始集合 '{original_collection}'...") + client.delete_collection(original_collection) + + # 創建一個同名的新集合(與原始集合同名) + print(f"創建新集合 '{original_collection}'...") + + # 使用傳入的嵌入函數或臨時集合的嵌入函數 + embedding_function = embedding_func or temp_collection_obj._embedding_function + + # 創建新的集合 + original_collection_obj = client.create_collection( + name=original_collection, + embedding_function=embedding_function + ) + + # 將數據添加到新集合 + if temp_data["ids"]: + print(f"將 {len(temp_data['ids'])} 項數據從臨時集合複製到新集合...") + + # 處理可能的None值 + processed_docs = [] + for doc in temp_data["documents"]: + if doc is None: + processed_docs.append("") + else: + processed_docs.append(doc) + + # 使用分批方式添加數據以避免潛在的大數據問題 + batch_size = 100 + for i in range(0, len(temp_data["ids"]), batch_size): + end = min(i + batch_size, len(temp_data["ids"])) + original_collection_obj.add( + ids=temp_data["ids"][i:end], + documents=processed_docs[i:end], + metadatas=temp_data["metadatas"][i:end] if temp_data["metadatas"] else None + ) + + # 刪除臨時集合 + print(f"刪除臨時集合 '{temp_collection}'...") + client.delete_collection(temp_collection) + + print(f"成功用重新嵌入的數據替換集合 '{original_collection}'") + return True + + except Exception as e: + print(f"替換集合時出錯: {e}") + import traceback + traceback.print_exc() + return False + +def process_collection( + client, + collection_name: str, + embedding_func, + temp_suffix: str, + batch_size: int, + confirm_dangerous: bool, + dry_run: bool +) -> bool: + """處理一個集合的完整流程 + + Args: + client: ChromaDB客戶端 + collection_name: 要處理的集合名稱 + embedding_func: 新的嵌入函數 + temp_suffix: 臨時集合的後綴 + batch_size: 批處理大小 + confirm_dangerous: 是否確認危險操作 + dry_run: 是否只模擬執行 + + Returns: + 處理成功返回True,否則返回False + """ + print(f"\n{'=' * 60}") + print(f"處理集合: '{collection_name}'") + print(f"{'=' * 60}") + + # 暫時集合名稱 + temp_collection_name = f"{collection_name}{temp_suffix}" + + # 1. 獲取原始集合的數據 + data = fetch_collection_data(client, collection_name, batch_size) + + if not data["ids"]: + print(f"集合 '{collection_name}' 為空或不存在,跳過") + return True + + # 2. 創建臨時集合並使用新的嵌入模型填充數據 + success = create_and_populate_collection( + client, + temp_collection_name, + data, + embedding_func, + batch_size, + dry_run + ) + + if not success: + print(f"創建臨時集合 '{temp_collection_name}' 失敗,跳過替換") + return False + + # 3. 替換原始集合 + success = swap_collections( + client, + collection_name, + temp_collection_name, + confirm_dangerous, + dry_run, + embedding_func # 添加嵌入函數作為參數 + ) + + return success + +def main(): + """主函數""" + args = parse_args() + + # 獲取ChromaDB目錄 + chroma_dir = getattr(config, "CHROMA_DATA_DIR", "chroma_data") + print(f"使用ChromaDB目錄: {chroma_dir}") + + # 備份數據庫(如果請求) + if args.backup: + backup_chroma_directory(chroma_dir) + + # 創建ChromaDB客戶端 + try: + client = chromadb.PersistentClient(path=chroma_dir) + except Exception as e: + print(f"錯誤: 無法連接到ChromaDB: {e}") + sys.exit(1) + + # 創建嵌入函數 + embedding_func = create_embedding_function(args.new_model) + + # 確定要處理的集合 + if args.collections: + collections_to_process = args.collections + else: + # 使用配置中的默認集合或獲取所有可用集合 + default_collections = [ + getattr(config, "BOT_MEMORY_COLLECTION", "wolfhart_memory"), + getattr(config, "CONVERSATIONS_COLLECTION", "conversations"), + getattr(config, "PROFILES_COLLECTION", "user_profiles") + ] + collections_to_process = get_collection_names(client, default_collections) + + # 過濾掉已經是臨時集合的集合名稱 + filtered_collections = [] + for collection in collections_to_process: + if args.temp_collection_suffix in collection: + print(f"警告: 跳過可能的臨時集合 '{collection}'") + continue + filtered_collections.append(collection) + + collections_to_process = filtered_collections + + if not collections_to_process: + print("沒有找到可處理的集合。") + sys.exit(0) + + print(f"將處理以下集合: {', '.join(collections_to_process)}") + if args.dry_run: + print("注意: 執行為乾運行模式,不會實際修改數據") + + # 詢問用戶確認 + if not args.confirm_dangerous and not args.dry_run: + confirm = input("這個操作將使用新的嵌入模型重新計算所有數據。繼續? (y/N): ") + if confirm.lower() != 'y': + print("操作已取消") + sys.exit(0) + + # 處理每個集合 + start_time = time.time() + success_count = 0 + + for collection_name in collections_to_process: + if process_collection( + client, + collection_name, + embedding_func, + args.temp_collection_suffix, + args.batch_size, + args.confirm_dangerous, + args.dry_run + ): + success_count += 1 + + # 報告結果 + elapsed_time = time.time() - start_time + print(f"\n{'=' * 60}") + print(f"處理完成: {success_count}/{len(collections_to_process)} 個集合成功") + print(f"總耗時: {elapsed_time:.2f} 秒") + print(f"{'=' * 60}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/chroma_view.py b/tools/chroma_view.py index 0669c31..df2b638 100644 --- a/tools/chroma_view.py +++ b/tools/chroma_view.py @@ -147,6 +147,15 @@ class ChromaDBReader: except Exception as e: self.logger.error(f"無法加載 SentenceTransformer paraphrase-multilingual-MiniLM-L12-v2: {e}。將使用集合內部模型。") self.query_embedding_function = None + # 添加新的模型支持 + elif model_name == "paraphrase-multilingual-mpnet-base-v2": + try: + # 注意: sentence-transformers 庫需要安裝 + self.query_embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2") + self.logger.info(f"查詢將使用外部嵌入模型: {model_name}") + except Exception as e: + self.logger.error(f"無法加載 SentenceTransformer paraphrase-multilingual-mpnet-base-v2: {e}。將使用集合內部模型。") + self.query_embedding_function = None else: self.logger.warning(f"未知的查詢嵌入模型: {model_name}, 將使用集合內部模型。") self.query_embedding_function = None @@ -450,13 +459,11 @@ class ChromaDBReaderUI: self.embedding_models = { "預設 (ChromaDB)": "default", "all-MiniLM-L6-v2 (ST)": "all-MiniLM-L6-v2", - "paraphrase-multilingual-MiniLM-L12-v2 (ST)": "paraphrase-multilingual-MiniLM-L12-v2" + "paraphrase-multilingual-MiniLM-L12-v2 (ST)": "paraphrase-multilingual-MiniLM-L12-v2", + "paraphrase-multilingual-mpnet-base-v2 (ST)": "paraphrase-multilingual-mpnet-base-v2" # 添加新的模型選項 } - # 初始化 reader 中的嵌入模型 (確保 reader 實例已創建) - # self.reader.set_query_embedding_model(self.embedding_models[self.embedding_model_var.get()]) - # ^^^ 這行需要在 setup_ui 之後,或者在 on_embedding_model_changed 中處理首次設置 - - self.setup_ui() # setup_ui 會創建 reader 實例 + + self.setup_ui() # 默認主題 self.current_theme = "darkly" # ttkbootstrap的深色主題 From 7d9ead1c60f940b33489affc16b04a5753afc319 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 9 May 2025 13:13:58 +0800 Subject: [PATCH 08/13] update memory backup scripts --- batch_memory_record.py | 208 +++++++++++++++++++++++++++++++++++++++++ memory_manager.py | 103 +++++++++++++++----- 2 files changed, 290 insertions(+), 21 deletions(-) create mode 100644 batch_memory_record.py diff --git a/batch_memory_record.py b/batch_memory_record.py new file mode 100644 index 0000000..4bbf07d --- /dev/null +++ b/batch_memory_record.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Wolf Chat 批次記憶備份工具 + +自動掃描chat_logs資料夾,針對所有日誌檔案執行記憶備份 +""" + +import os +import re +import sys +import time +import argparse +import subprocess +import logging +from datetime import datetime +from typing import List, Optional, Tuple + +# 設置日誌 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("batch_backup.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger("BatchMemoryBackup") + +def find_log_files(log_dir: str = "chat_logs") -> List[Tuple[str, str]]: + """ + 掃描指定目錄,找出所有符合YYYY-MM-DD.log格式的日誌文件 + + 返回: [(日期字符串, 文件路徑), ...],按日期排序 + """ + date_pattern = re.compile(r'^(\d{4}-\d{2}-\d{2})\.log$') + log_files = [] + + # 確保目錄存在 + if not os.path.exists(log_dir) or not os.path.isdir(log_dir): + logger.error(f"目錄不存在或不是有效目錄: {log_dir}") + return [] + + # 掃描目錄 + for filename in os.listdir(log_dir): + match = date_pattern.match(filename) + if match: + date_str = match.group(1) + file_path = os.path.join(log_dir, filename) + try: + # 驗證日期格式 + datetime.strptime(date_str, "%Y-%m-%d") + log_files.append((date_str, file_path)) + except ValueError: + logger.warning(f"發現無效的日期格式: {filename}") + + # 按日期排序 + log_files.sort(key=lambda x: x[0]) + return log_files + +def process_log_file(date_str: str, backup_script: str = "memory_backup.py") -> bool: + """ + 為指定日期的日誌文件執行記憶備份 + + Parameters: + date_str: 日期字符串,格式為YYYY-MM-DD + backup_script: 備份腳本路徑 + + Returns: + bool: 操作是否成功 + """ + logger.info(f"開始處理日期 {date_str} 的日誌") + + try: + # 構建命令 + cmd = [sys.executable, backup_script, "--backup", "--date", date_str] + + # 執行命令 + logger.info(f"執行命令: {' '.join(cmd)}") + process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False # 不要在命令失敗時拋出異常 + ) + + # 檢查結果 + if process.returncode == 0: + logger.info(f"日期 {date_str} 的處理完成") + return True + else: + logger.error(f"處理日期 {date_str} 失敗: {process.stderr}") + return False + + except Exception as e: + logger.error(f"處理日期 {date_str} 時發生異常: {str(e)}") + return False + +def batch_process(log_dir: str = "chat_logs", backup_script: str = "memory_backup.py", + date_range: Optional[Tuple[str, str]] = None, + wait_seconds: int = 5) -> Tuple[int, int]: + """ + 批次處理多個日誌文件 + + Parameters: + log_dir: 日誌目錄路徑 + backup_script: 備份腳本路徑 + date_range: (開始日期, 結束日期),用於限制處理範圍,格式為YYYY-MM-DD + wait_seconds: 每個文件處理後的等待時間(秒) + + Returns: + (成功數量, 總數量) + """ + log_files = find_log_files(log_dir) + + if not log_files: + logger.warning(f"在 {log_dir} 中未找到有效的日誌文件") + return (0, 0) + + logger.info(f"找到 {len(log_files)} 個日誌文件") + + # 如果指定了日期範圍,過濾文件 + if date_range: + start_date, end_date = date_range + filtered_files = [(date_str, path) for date_str, path in log_files + if start_date <= date_str <= end_date] + logger.info(f"根據日期範圍 {start_date} 到 {end_date} 過濾後剩餘 {len(filtered_files)} 個文件") + log_files = filtered_files + + success_count = 0 + total_count = len(log_files) + + for i, (date_str, file_path) in enumerate(log_files): + logger.info(f"處理進度: {i+1}/{total_count} - 日期: {date_str}") + + if process_log_file(date_str, backup_script): + success_count += 1 + + # 若不是最後一個文件,等待一段時間再處理下一個 + if i < total_count - 1: + logger.info(f"等待 {wait_seconds} 秒後處理下一個文件...") + time.sleep(wait_seconds) + + return (success_count, total_count) + +def parse_date_arg(date_arg: str) -> Optional[str]: + """解析日期參數,確保格式為YYYY-MM-DD""" + if not date_arg: + return None + + try: + parsed_date = datetime.strptime(date_arg, "%Y-%m-%d") + return parsed_date.strftime("%Y-%m-%d") + except ValueError: + logger.error(f"無效的日期格式: {date_arg},請使用YYYY-MM-DD格式") + return None + +def main(): + parser = argparse.ArgumentParser(description='Wolf Chat 批次記憶備份工具') + parser.add_argument('--log-dir', default='chat_logs', help='日誌檔案目錄,預設為 chat_logs') + parser.add_argument('--script', default='memory_backup.py', help='記憶備份腳本路徑,預設為 memory_backup.py') + parser.add_argument('--start-date', help='開始日期(含),格式為 YYYY-MM-DD') + parser.add_argument('--end-date', help='結束日期(含),格式為 YYYY-MM-DD') + parser.add_argument('--wait', type=int, default=5, help='每個檔案處理間隔時間(秒),預設為 5 秒') + + args = parser.parse_args() + + # 驗證日期參數 + start_date = parse_date_arg(args.start_date) + end_date = parse_date_arg(args.end_date) + + # 如果只有一個日期參數,將兩個都設為該日期(僅處理該日期) + if start_date and not end_date: + end_date = start_date + elif end_date and not start_date: + start_date = end_date + + date_range = (start_date, end_date) if start_date and end_date else None + + logger.info("開始批次記憶備份流程") + logger.info(f"日誌目錄: {args.log_dir}") + logger.info(f"備份腳本: {args.script}") + if date_range: + logger.info(f"日期範圍: {date_range[0]} 到 {date_range[1]}") + else: + logger.info("處理所有找到的日誌檔案") + logger.info(f"等待間隔: {args.wait} 秒") + + start_time = time.time() + success, total = batch_process( + log_dir=args.log_dir, + backup_script=args.script, + date_range=date_range, + wait_seconds=args.wait + ) + end_time = time.time() + + duration = end_time - start_time + logger.info(f"批次處理完成。成功: {success}/{total},耗時: {duration:.2f} 秒") + + if success < total: + logger.warning("部分日誌檔案處理失敗,請查看日誌瞭解詳情") + return 1 + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/memory_manager.py b/memory_manager.py index 1a52aea..5ea86d4 100644 --- a/memory_manager.py +++ b/memory_manager.py @@ -14,7 +14,8 @@ import asyncio import datetime import schedule from pathlib import Path -from typing import Dict, List, Optional, Any, Union +from typing import Dict, List, Optional, Any, Union, Callable +from functools import wraps # import chromadb # No longer directly needed by ChromaDBManager # from chromadb.utils import embedding_functions # No longer directly needed by ChromaDBManager @@ -23,6 +24,40 @@ from openai import AsyncOpenAI import config import chroma_client # Import the centralized chroma client +# ============================================================================= +# 重試裝飾器 +# ============================================================================= + +def retry_operation(max_attempts: int = 3, delay: float = 1.0): + """重試裝飾器,用於數據庫操作""" + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs) -> Any: + attempts = 0 + last_error = None + + while attempts < max_attempts: + try: + return func(*args, **kwargs) + except Exception as e: + attempts += 1 + last_error = e + print(f"操作失敗,嘗試次數 {attempts}/{max_attempts}: {e}") + + if attempts < max_attempts: + # 指數退避策略 + sleep_time = delay * (2 ** (attempts - 1)) + print(f"等待 {sleep_time:.2f} 秒後重試...") + time.sleep(sleep_time) + + print(f"操作失敗達到最大嘗試次數 ({max_attempts}),最後錯誤: {last_error}") + # 在生產環境中,您可能希望引發最後一個錯誤或返回一個特定的錯誤指示符 + # 根據您的需求,返回 False 可能適合某些情況 + return False # 或者 raise last_error + + return wrapper + return decorator + # ============================================================================= # 日誌解析部分 # ============================================================================= @@ -359,6 +394,7 @@ class ChromaDBManager: raise RuntimeError(f"Failed to get or create collection '{self.collection_name}' via chroma_client. Check chroma_client logs.") return self._db_collection + @retry_operation(max_attempts=3, delay=1.0) def upsert_user_profile(self, profile_data: Dict[str, Any]) -> bool: """寫入或更新用戶檔案""" collection = self._get_db_collection() @@ -390,7 +426,12 @@ class ChromaDBManager: if "metadata" in profile_data and isinstance(profile_data["metadata"], dict): for k, v in profile_data["metadata"].items(): if k not in ["id", "type", "username", "priority"]: # Avoid overwriting key fields - metadata[k] = v + # 處理非基本類型的值 + if isinstance(v, (list, dict, tuple)): + # 轉換為字符串 + metadata[k] = json.dumps(v, ensure_ascii=False) + else: + metadata[k] = v # 序列化內容 content_doc = json.dumps(profile_data.get("content", {}), ensure_ascii=False) @@ -409,6 +450,7 @@ class ChromaDBManager: print(f"寫入用戶檔案時出錯: {e}") return False + @retry_operation(max_attempts=3, delay=1.0) def upsert_conversation_summary(self, summary_data: Dict[str, Any]) -> bool: """寫入對話總結""" collection = self._get_db_collection() @@ -435,7 +477,12 @@ class ChromaDBManager: if "metadata" in summary_data and isinstance(summary_data["metadata"], dict): for k, v in summary_data["metadata"].items(): if k not in ["id", "type", "username", "date", "priority"]: - metadata[k] = v + # 處理非基本類型的值 + if isinstance(v, (list, dict, tuple)): + # 轉換為字符串 + metadata[k] = json.dumps(v, ensure_ascii=False) + else: + metadata[k] = v # 獲取內容 content_doc = summary_data.get("content", "") @@ -545,27 +592,41 @@ class MemoryManager: print(f"共有 {len(user_conversations)} 個用戶有對話") # 為每個用戶生成/更新檔案和對話總結 + failed_users = [] for username, convs in user_conversations.items(): print(f"處理用戶 '{username}' 的 {len(convs)} 條對話") - # 獲取現有檔案 - existing_profile = self.db_manager.get_existing_profile(username) - - # 生成或更新用戶檔案 - profile_data = await self.memory_generator.generate_user_profile( - username, convs, existing_profile - ) - - if profile_data: - self.db_manager.upsert_user_profile(profile_data) - - # 生成對話總結 - summary_data = await self.memory_generator.generate_conversation_summary( - username, convs - ) - - if summary_data: - self.db_manager.upsert_conversation_summary(summary_data) + try: + # 獲取現有檔案 + existing_profile = self.db_manager.get_existing_profile(username) + + # 生成或更新用戶檔案 + profile_data = await self.memory_generator.generate_user_profile( + username, convs, existing_profile + ) + + if profile_data: + profile_success = self.db_manager.upsert_user_profile(profile_data) + if not profile_success: + print(f"警告: 無法保存用戶 '{username}' 的檔案") + + # 生成對話總結 + summary_data = await self.memory_generator.generate_conversation_summary( + username, convs + ) + + if summary_data: + summary_success = self.db_manager.upsert_conversation_summary(summary_data) + if not summary_success: + print(f"警告: 無法保存用戶 '{username}' 的對話總結") + + except Exception as e: + print(f"處理用戶 '{username}' 時出錯: {e}") + failed_users.append(username) + continue # 繼續處理下一個用戶 + + if failed_users: + print(f"以下用戶處理失敗: {', '.join(failed_users)}") print(f"日誌處理完成: {log_path}") # ============================================================================= From 4a03ca4424e9706ee539195106a78b9d299a42a1 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 9 May 2025 20:14:44 +0800 Subject: [PATCH 09/13] Add character limit to user profile entries --- memory_manager.py | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/memory_manager.py b/memory_manager.py index 5ea86d4..4adec92 100644 --- a/memory_manager.py +++ b/memory_manager.py @@ -231,8 +231,34 @@ class MemoryGenerator: profile_json = json.loads(profile_json_str) - # Add or update word count + # After parsing the initial JSON response content_str = json.dumps(profile_json["content"], ensure_ascii=False) + if len(content_str) > 5000: + # Too long - request a more concise version + condensed_prompt = f"Your profile is {len(content_str)} characters. Create a new version under 5000 characters. Keep the same structure but be extremely concise." + + condensed_response = await self.profile_client.chat.completions.create( + model=self.profile_model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + {"role": "assistant", "content": profile_json_str}, + {"role": "user", "content": condensed_prompt} + ], + temperature=0.5 + ) + + # Extract the condensed JSON + condensed_text = condensed_response.choices[0].message.content + # Parse JSON and update profile_json + json_match = re.search(r'```json\s*(.*?)\s*```', condensed_text, re.DOTALL) + if json_match: + profile_json_str = json_match.group(1) + else: + profile_json_str = condensed_text + profile_json = json.loads(profile_json_str) + content_str = json.dumps(profile_json["content"], ensure_ascii=False) # Recalculate content_str + profile_json["metadata"]["word_count"] = len(content_str) profile_json["last_updated"] = datetime.datetime.now().strftime("%Y-%m-%d") @@ -340,13 +366,16 @@ class MemoryGenerator: system_prompt = f""" You are {bot_name}, an AI assistant with deep analytical capabilities. {persona_details} - Your task is to analyze the user's interactions with you, creating detailed user profiles. The profile should: - 1. Be completely based on your character's perspective (as defined above), including your subjective judgments and feelings. - 2. Analyze the user's personality traits and behavioral patterns. - 3. Evaluate the user's relationship with you. - 4. Record important interaction history. - - The output should be valid JSON format, following the provided template. + Your task is to analyze the user's interactions with you, creating user profiles. + +CRITICAL: The ENTIRE profile content must be under 5000 characters total. Be extremely concise. + +The profile should: +1. Be completely based on your character's perspective +2. Focus only on key personality traits and core relationship dynamics +3. Include only the most significant interactions + +The output should be valid JSON format, following the provided template. """ if existing_profile: From b33ea857680e1a8ac4ac4ed46963b254ed2defc2 Mon Sep 17 00:00:00 2001 From: z060142 Date: Sat, 10 May 2025 01:09:51 +0800 Subject: [PATCH 10/13] Added new styles for speech bubbles for detection --- bubble_colors.json | 8 +++ tools/color_picker.py | 147 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tools/color_picker.py diff --git a/bubble_colors.json b/bubble_colors.json index 6fdfcf1..c0cb71f 100644 --- a/bubble_colors.json +++ b/bubble_colors.json @@ -47,6 +47,14 @@ "hsv_upper": [107, 255, 255], "min_area": 2500, "max_area": 300000 + }, + { + "name": "easter", + "is_bot": false, + "hsv_lower": [5, 154, 183], + "hsv_upper": [29, 255, 255], + "min_area": 2500, + "max_area": 300000 } ] } diff --git a/tools/color_picker.py b/tools/color_picker.py new file mode 100644 index 0000000..c88be8d --- /dev/null +++ b/tools/color_picker.py @@ -0,0 +1,147 @@ +import cv2 +import numpy as np +import pyautogui + +def pick_color_fixed(): + # 截取游戏区域 + screenshot = pyautogui.screenshot(region=(150, 330, 600, 880)) + img = np.array(screenshot) + img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + + # 转为HSV + hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + + # 创建窗口和滑块 + cv2.namedWindow('Color Picker') + + # 存储采样点 + sample_points = [] + + # 定义鼠标回调函数 + def mouse_callback(event, x, y, flags, param): + if event == cv2.EVENT_LBUTTONDOWN: + # 获取点击位置的HSV值 + hsv_value = hsv_img[y, x] + sample_points.append(hsv_value) + print(f"添加采样点 #{len(sample_points)}: HSV = {hsv_value}") + + # 在图像上显示采样点 + cv2.circle(img, (x, y), 3, (0, 255, 0), -1) + cv2.imshow('Color Picker', img) + + # 如果有足够多的采样点,计算更精确的范围 + if len(sample_points) >= 1: + calculate_range() + + def calculate_range(): + """安全计算HSV范围,避免溢出""" + if not sample_points: + return + + # 转换为numpy数组 + points_array = np.array(sample_points) + + # 提取各通道的值并安全计算范围 + h_values = points_array[:, 0].astype(np.int32) # 转为int32避免溢出 + s_values = points_array[:, 1].astype(np.int32) + v_values = points_array[:, 2].astype(np.int32) + + # 检查H值是否跨越边界 + h_range = np.max(h_values) - np.min(h_values) + h_crosses_boundary = h_range > 90 and len(h_values) > 2 + + # 计算安全范围值 + if h_crosses_boundary: + print("检测到H值可能跨越红色边界(0/180)!") + # 特殊处理跨越边界的H值 + # 方法1: 简单方式 - 使用宽范围 + h_min = 0 + h_max = 179 + print(f"使用全H范围: [{h_min}, {h_max}]") + else: + # 正常计算H范围 + h_min = max(0, np.min(h_values) - 5) + h_max = min(179, np.max(h_values) + 5) + + # 安全计算S和V范围 + s_min = max(0, np.min(s_values) - 15) + s_max = min(255, np.max(s_values) + 15) + v_min = max(0, np.min(v_values) - 15) + v_max = min(255, np.max(v_values) + 15) + + print("\n推荐的HSV范围:") + print(f"\"hsv_lower\": [{h_min}, {s_min}, {v_min}],") + print(f"\"hsv_upper\": [{h_max}, {s_max}, {v_max}],") + + # 显示掩码预览 + show_mask_preview(h_min, h_max, s_min, s_max, v_min, v_max) + + def show_mask_preview(h_min, h_max, s_min, s_max, v_min, v_max): + """显示掩码预览,标记检测到的区域""" + + # 创建掩码 + if h_min <= h_max: + # 标准范围 + mask = cv2.inRange(hsv_img, + np.array([h_min, s_min, v_min]), + np.array([h_max, s_max, v_max])) + else: + # 处理H值跨越边界情况 + mask1 = cv2.inRange(hsv_img, + np.array([h_min, s_min, v_min]), + np.array([179, s_max, v_max])) + mask2 = cv2.inRange(hsv_img, + np.array([0, s_min, v_min]), + np.array([h_max, s_max, v_max])) + mask = cv2.bitwise_or(mask1, mask2) + + # 形态学操作 - 闭运算连接临近区域 + kernel = np.ones((5, 5), np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + + # 找到连通区域 + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask) + + # 创建结果图像 + result_img = img.copy() + detected_count = 0 + + # 处理每个连通区域 + for i in range(1, num_labels): # 跳过背景(0) + area = stats[i, cv2.CC_STAT_AREA] + # 面积筛选 + if 3000 <= area <= 100000: + detected_count += 1 + x = stats[i, cv2.CC_STAT_LEFT] + y = stats[i, cv2.CC_STAT_TOP] + w = stats[i, cv2.CC_STAT_WIDTH] + h = stats[i, cv2.CC_STAT_HEIGHT] + + # 绘制区域边框 + cv2.rectangle(result_img, (x, y), (x+w, y+h), (0, 255, 0), 2) + # 显示区域ID + cv2.putText(result_img, f"#{i}", (x+5, y+20), + cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + # 显示结果 + cv2.imshow('Mask Preview', result_img) + print(f"检测到 {detected_count} 个合适大小的区域") + + # 设置鼠标回调 + cv2.setMouseCallback('Color Picker', mouse_callback) + + # 显示操作说明 + print("使用说明:") + print("1. 点击气泡上的多个位置进行采样") + print("2. 程序会自动计算合适的HSV范围") + print("3. 绿色方框表示检测到的区域") + print("4. 按ESC键退出") + print("\n【特别提示】如果气泡混合了红色和紫色,可能需要创建两个配置以处理H通道的边界问题") + + # 显示图像 + cv2.imshow('Color Picker', img) + cv2.waitKey(0) + cv2.destroyAllWindows() + +if __name__ == "__main__": + pick_color_fixed() \ No newline at end of file From 59471b62ced8aa7193507b0b4de87675000f6ea4 Mon Sep 17 00:00:00 2001 From: z060142 Date: Mon, 12 May 2025 23:17:07 +0800 Subject: [PATCH 11/13] Improve game window topmost handling and add forced reconnection for remote control stability --- ClaudeCode.md | 14 +++++++- Setup.py | 96 ++++++++++++++++++++++++++++++++++--------------- game_monitor.py | 38 +++++++++++--------- 3 files changed, 102 insertions(+), 46 deletions(-) diff --git a/ClaudeCode.md b/ClaudeCode.md index 8207283..c329354 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -58,7 +58,7 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 7. **遊戲視窗監控模組 (game_monitor.py)** (取代 window-setup-script.py 和舊的 window-monitor-script.py) - 持續監控遊戲視窗 (`config.WINDOW_TITLE`)。 - 確保視窗維持在設定檔 (`config.py`) 中指定的位置 (`GAME_WINDOW_X`, `GAME_WINDOW_Y`) 和大小 (`GAME_WINDOW_WIDTH`, `GAME_WINDOW_HEIGHT`)。 - - 確保視窗維持在最上層 (Always on Top)。 + - **確保視窗保持活躍**:如果遊戲視窗不是目前的前景視窗,則嘗試將其帶到前景並啟用 (Bring to Foreground/Activate),取代了之前的強制置頂 (Always on Top) 邏輯 (修改於 2025-05-12)。 - **定時遊戲重啟** (如果 `config.ENABLE_SCHEDULED_RESTART` 為 True): - 根據 `config.RESTART_INTERVAL_MINUTES` 設定的間隔執行。 - **簡化流程 (2025-04-25)**: @@ -598,6 +598,18 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 - **依賴項**:Windows 上的控制台事件處理仍然依賴 `pywin32` 套件。如果未安裝,程式會打印警告,關閉時的可靠性可能略有降低(但 `stdio_client` 的正常清理機制應在多數情況下仍然有效)。 - **效果**:恢復了與 `mcp` 庫的兼容性,同時通過標準的上下文管理和輔助性的 Windows 事件處理,實現了在主程式退出時關閉 MCP 伺服器子進程的目標。 +## 最近改進(2025-05-12) + +### 遊戲視窗置頂邏輯修改 + +- **目的**:將 `game_monitor.py` 中強制遊戲視窗「永遠在最上層」(Always on Top) 的行為,修改為「臨時置頂並獲得焦點」(Bring to Foreground/Activate),以解決原方法僅覆蓋其他視窗的問題。 +- **`game_monitor.py`**: + - 在 `monitor_game_window` 函數的監控循環中,移除了使用 `win32gui.SetWindowPos` 和 `win32con.HWND_TOPMOST` 來檢查和設定 `WS_EX_TOPMOST` 樣式的程式碼。 + - 替換為檢查當前前景視窗 (`win32gui.GetForegroundWindow()`) 是否為目標遊戲視窗 (`hwnd`)。 + - 如果不是,則調用 `win32gui.BringWindowToTop(hwnd)` 和 `win32gui.SetForegroundWindow(hwnd)` 來嘗試將遊戲視窗帶到前景並啟用。 + - 更新了相關的日誌訊息以反映新的行為。 +- **效果**:監控腳本現在會嘗試將失去焦點的遊戲視窗重新激活並帶到前景,而不是強制其覆蓋所有其他視窗。這更符合一般視窗的行為模式。 + ## 開發建議 ### 優化方向 diff --git a/Setup.py b/Setup.py index bd0918e..580d463 100644 --- a/Setup.py +++ b/Setup.py @@ -2443,6 +2443,7 @@ if HAS_SOCKETIO: self.authenticated = False self.should_exit_flag = threading.Event() # Use an event for thread control self.client_thread = None + self.last_successful_connection_time = None # Track last successful connection/auth self.registered_commands = [ "restart bot", "restart game", "restart all", @@ -2473,46 +2474,80 @@ if HAS_SOCKETIO: last_heartbeat = time.time() # For heartbeat retry_delay = 1.0 # Start with 1 second delay for exponential backoff max_delay = 300.0 # Maximum delay of 5 minutes for exponential backoff - + hourly_refresh_interval = 3600 # 1 hour in seconds + while not self.should_exit_flag.is_set(): + current_time = time.time() # Get current time at the start of the loop iteration + if not self.sio.connected: + # Reset connection time tracker when attempting to connect + self.last_successful_connection_time = None try: logger.info(f"ControlClient: Attempting to connect to {self.server_url}...") self.sio.connect(self.server_url) - logger.info("ControlClient: Successfully connected.") - retry_delay = 1.0 # Reset delay on successful connection - last_heartbeat = time.time() # Reset heartbeat timer on new connection + # Connection successful, wait for authentication to set last_successful_connection_time + logger.info("ControlClient: Successfully established socket connection. Waiting for authentication.") + retry_delay = 1.0 # Reset delay on successful connection attempt + # last_heartbeat = time.time() # Reset heartbeat timer only after authentication? Or here? Let's keep it after auth. except socketio.exceptions.ConnectionError as e: logger.error(f"ControlClient: Connection failed: {e}. Retrying in {retry_delay:.2f}s.") self.should_exit_flag.wait(retry_delay) # Implement exponential backoff with jitter retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s - continue + continue except Exception as e: # Catch other potential errors during connection logger.error(f"ControlClient: Unexpected error during connection attempt: {e}. Retrying in {retry_delay:.2f}s.") self.should_exit_flag.wait(retry_delay) retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s continue - - # If connected, manage heartbeat and check for exit signal + + # If connected (socket established, maybe not authenticated yet) if self.sio.connected: - current_time = time.time() - if current_time - last_heartbeat > 60: # Send heartbeat every 60 seconds + # Check for hourly refresh ONLY if authenticated and timer is set + if self.authenticated and self.last_successful_connection_time and (current_time - self.last_successful_connection_time > hourly_refresh_interval): + logger.info(f"ControlClient: Hourly session refresh triggered (Connected for > {hourly_refresh_interval}s). Disconnecting for refresh...") + try: + self.sio.disconnect() + # Reset flags immediately after intentional disconnect + self.connected = False + self.authenticated = False + self.last_successful_connection_time = None + logger.info("ControlClient: Disconnected for hourly refresh. Will attempt reconnect in next cycle.") + # Continue to the start of the loop to handle reconnection logic + continue + except Exception as e: + logger.error(f"ControlClient: Error during planned hourly disconnect: {e}") + # Reset flags anyway and let the loop retry + self.connected = False + self.authenticated = False + self.last_successful_connection_time = None + + + # Manage heartbeat if authenticated + if self.authenticated and current_time - last_heartbeat > 60: # Send heartbeat every 60 seconds try: self.sio.emit('heartbeat', {'timestamp': current_time}) last_heartbeat = current_time - logger.debug("ControlClient: Sent heartbeat to keep connection alive.") - except Exception as e: + logger.debug("ControlClient: Sent heartbeat.") + except Exception as e: logger.error(f"ControlClient: Error sending heartbeat: {e}. Connection might be lost.") - + # Consider triggering disconnect/reconnect logic here if heartbeat fails repeatedly + + # Wait before next loop iteration, checking for exit signal self.should_exit_flag.wait(1) # Check for exit signal every second - else: - # Fallback if not connected after attempt block (should be rare with current logic) - logger.debug(f"ControlClient: Not connected (unexpected state in loop), waiting {retry_delay:.2f}s before next cycle.") + + else: # Not connected (e.g., after a disconnect, or failed connection attempt) + # This path is hit after disconnects (intentional or unintentional) + # Reset connection time tracker if not already None + if self.last_successful_connection_time is not None: + logger.debug("ControlClient: Resetting connection timer as client is not connected.") + self.last_successful_connection_time = None + + logger.debug(f"ControlClient: Not connected, waiting {retry_delay:.2f}s before next connection attempt.") self.should_exit_flag.wait(retry_delay) - # Optionally re-calculate retry_delay here if this path is hit, to maintain backoff progression + # Exponential backoff for reconnection attempts retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random()) retry_delay = max(1.0, retry_delay) @@ -2522,6 +2557,7 @@ if HAS_SOCKETIO: def _on_connect(self): self.connected = True + # Don't reset timer here, wait for authentication logger.info("ControlClient: Connected to server. Authenticating...") self.sio.emit('authenticate', { 'type': 'client', @@ -2530,26 +2566,30 @@ if HAS_SOCKETIO: }) def _on_disconnect(self): + was_connected = self.connected # Store previous state self.connected = False self.authenticated = False - logger.info("ControlClient: Disconnected from server.") - - # Force reconnection if not intentionally stopping - if not self.should_exit_flag.is_set(): - logger.info("ControlClient: Attempting immediate reconnection from _on_disconnect...") - try: - # This is an immediate attempt; _run_forever handles sustained retries. - if not self.sio.connected: # Check before trying to connect - self.sio.connect(self.server_url) - except Exception as e: - logger.error(f"ControlClient: Immediate reconnection from _on_disconnect failed: {e}") + self.last_successful_connection_time = None # Reset timer on any disconnect + if was_connected: # Only log if it was previously connected + logger.info("ControlClient: Disconnected from server.") + else: + logger.debug("ControlClient: Received disconnect event, but was already marked as disconnected.") + + # Remove the immediate reconnection attempt here, let _run_forever handle it with backoff + # if not self.should_exit_flag.is_set(): + # logger.info("ControlClient: Disconnected. Reconnection will be handled by the main loop.") def _on_authenticated(self, data): if data.get('success'): self.authenticated = True - logger.info("ControlClient: Authentication successful.") + self.last_successful_connection_time = time.time() # Start timer on successful auth + # Reset heartbeat timer upon successful authentication + # Find where last_heartbeat is accessible or make it accessible (e.g., self.last_heartbeat) + # For now, assume last_heartbeat is managed within _run_forever and will naturally reset timing + logger.info("ControlClient: Authentication successful. Hourly refresh timer started.") else: self.authenticated = False + self.last_successful_connection_time = None # Ensure timer is reset if auth fails logger.error(f"ControlClient: Authentication failed: {data.get('error', 'Unknown error')}") self.sio.disconnect() # Disconnect if auth fails diff --git a/game_monitor.py b/game_monitor.py index 4363769..8897517 100644 --- a/game_monitor.py +++ b/game_monitor.py @@ -217,24 +217,28 @@ def monitor_game_window(): # monitor_logger.warning(f"Failed to adjust window. Current: {new_pos} {new_size}, Target: {target_pos} {target_size}") pass # Keep silent on failure for now - # 2. Check and Set Topmost - style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) - is_topmost = style & win32con.WS_EX_TOPMOST + # 2. Check and Bring to Foreground/Activate + current_foreground_hwnd = win32gui.GetForegroundWindow() - if not is_topmost: - # Set topmost, -1 for HWND_TOPMOST, flags = SWP_NOMOVE | SWP_NOSIZE - win32gui.SetWindowPos(hwnd, win32con.HWND_TOPMOST, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) - # Verify - time.sleep(0.1) - new_style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) - if new_style & win32con.WS_EX_TOPMOST: - current_message += "已將遊戲視窗設為最上層。(Set game window to topmost.)" - adjustment_made = True - else: - # Log failure if needed - # monitor_logger.warning("Failed to set window to topmost.") - pass # Keep silent + if current_foreground_hwnd != hwnd: + try: + # Attempt to bring to top and set foreground + # Note: SetForegroundWindow might fail if the calling process doesn't have foreground rights + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + # Short delay to allow window manager to process + time.sleep(0.1) + # Verify if it became the foreground window + if win32gui.GetForegroundWindow() == hwnd: + current_message += "已將遊戲視窗帶到前景並啟用。(Brought game window to foreground and activated.) " + adjustment_made = True + else: + # Optional: Log if setting foreground failed, might happen if another app steals focus quickly + # monitor_logger.warning("嘗試將視窗設為前景後,它並未成為前景視窗。(Attempted to set window foreground, but it did not become the foreground window.)") + pass + except Exception as fg_err: + # This can happen if the window handle is invalid or other win32 errors occur + monitor_logger.warning(f"嘗試將視窗設為前景時出錯: {fg_err} (Error trying to set window foreground: {fg_err})") except gw.PyGetWindowException as e: # Log PyGetWindowException specifically, might indicate window closed during check From a5b6a441646d8525e91951e5aa5ae245f7c3dbbf Mon Sep 17 00:00:00 2001 From: z060142 Date: Mon, 12 May 2025 23:52:32 +0800 Subject: [PATCH 12/13] Replace always-on-top with foreground activation for game window focus --- ClaudeCode.md | 10 +++++++--- game_monitor.py | 45 ++++++++++++++++++++++++++++++--------------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/ClaudeCode.md b/ClaudeCode.md index c329354..fd0400b 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -606,9 +606,13 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 - **`game_monitor.py`**: - 在 `monitor_game_window` 函數的監控循環中,移除了使用 `win32gui.SetWindowPos` 和 `win32con.HWND_TOPMOST` 來檢查和設定 `WS_EX_TOPMOST` 樣式的程式碼。 - 替換為檢查當前前景視窗 (`win32gui.GetForegroundWindow()`) 是否為目標遊戲視窗 (`hwnd`)。 - - 如果不是,則調用 `win32gui.BringWindowToTop(hwnd)` 和 `win32gui.SetForegroundWindow(hwnd)` 來嘗試將遊戲視窗帶到前景並啟用。 - - 更新了相關的日誌訊息以反映新的行為。 -- **效果**:監控腳本現在會嘗試將失去焦點的遊戲視窗重新激活並帶到前景,而不是強制其覆蓋所有其他視窗。這更符合一般視窗的行為模式。 + - 如果不是,則嘗試以下步驟將視窗帶到前景並獲得焦點: + 1. 使用 `win32gui.SetWindowPos` 搭配 `win32con.HWND_TOP` 旗標,將視窗提升到所有非最上層視窗之上。 + 2. 呼叫 `win32gui.SetForegroundWindow(hwnd)` 嘗試將視窗設為前景並獲得焦點。 + 3. 短暫延遲後,檢查視窗是否成功成為前景視窗。 + 4. 如果 `SetForegroundWindow` 未成功,則嘗試使用 `pygetwindow` 庫提供的 `window.activate()` 方法作為備用方案。 + - 更新了相關的日誌訊息以反映新的行為和備用邏輯。 +- **效果**:監控腳本現在會使用更全面的方法嘗試將失去焦點的遊戲視窗重新激活並帶到前景,包括備用方案,以提高在不同 Windows 環境下獲取焦點的成功率。這取代了之前僅強制視覺覆蓋的行為。 ## 開發建議 diff --git a/game_monitor.py b/game_monitor.py index 8897517..a21c220 100644 --- a/game_monitor.py +++ b/game_monitor.py @@ -217,28 +217,43 @@ def monitor_game_window(): # monitor_logger.warning(f"Failed to adjust window. Current: {new_pos} {new_size}, Target: {target_pos} {target_size}") pass # Keep silent on failure for now - # 2. Check and Bring to Foreground/Activate + # 2. Check and Bring to Foreground/Activate (Improved Logic) current_foreground_hwnd = win32gui.GetForegroundWindow() if current_foreground_hwnd != hwnd: try: - # Attempt to bring to top and set foreground - # Note: SetForegroundWindow might fail if the calling process doesn't have foreground rights - win32gui.BringWindowToTop(hwnd) + # Use HWND_TOP instead of HWND_TOPMOST to bring it above others without forcing always-on-top + win32gui.SetWindowPos(hwnd, win32con.HWND_TOP, 0, 0, 0, 0, + win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) + + # Make window the foreground window (with focus) + # Note: This might still fail due to Windows foreground restrictions win32gui.SetForegroundWindow(hwnd) - # Short delay to allow window manager to process - time.sleep(0.1) - # Verify if it became the foreground window - if win32gui.GetForegroundWindow() == hwnd: - current_message += "已將遊戲視窗帶到前景並啟用。(Brought game window to foreground and activated.) " + + # Verify window is active by checking foreground window + time.sleep(0.1) # Brief pause to let operation complete + foreground_hwnd = win32gui.GetForegroundWindow() + + if foreground_hwnd == hwnd: + current_message += "已將遊戲視窗提升到前景並設為焦點。(Brought game window to foreground with focus.) " adjustment_made = True else: - # Optional: Log if setting foreground failed, might happen if another app steals focus quickly - # monitor_logger.warning("嘗試將視窗設為前景後,它並未成為前景視窗。(Attempted to set window foreground, but it did not become the foreground window.)") - pass - except Exception as fg_err: - # This can happen if the window handle is invalid or other win32 errors occur - monitor_logger.warning(f"嘗試將視窗設為前景時出錯: {fg_err} (Error trying to set window foreground: {fg_err})") + # Optional: Add a fallback for versions of Windows with stricter foreground rules + monitor_logger.warning("SetForegroundWindow 未能成功,嘗試備用方法 window.activate()。(SetForegroundWindow failed, trying fallback window.activate())") + try: + window.activate() # Try pygetwindow's activate method as backup + time.sleep(0.1) # Pause after activate + if win32gui.GetForegroundWindow() == hwnd: + current_message += "已透過備用方法將遊戲視窗設為焦點。(Set game window focus via fallback method.) " + adjustment_made = True + else: + monitor_logger.warning("備用方法 window.activate() 也未能成功。(Fallback window.activate() also failed.)") + except Exception as activate_err: + monitor_logger.warning(f"備用方法 window.activate() 出錯: {activate_err}") + + except Exception as focus_err: + # Log errors during the focus attempt + monitor_logger.warning(f"設置視窗焦點時出錯: {focus_err}") except gw.PyGetWindowException as e: # Log PyGetWindowException specifically, might indicate window closed during check From 51a99ee5ad4a2d17ad838ca35ddccc7f3af344c5 Mon Sep 17 00:00:00 2001 From: z060142 Date: Tue, 13 May 2025 03:40:14 +0800 Subject: [PATCH 13/13] Refactor Game Monitor into Game Manager with Setup.py integration and full process control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced legacy `game_monitor.py` with a new modular `game_manager.py`. - Introduced `GameMonitor` class to encapsulate: - Game window detection, focus enforcement, and resize enforcement. - Timed game restarts based on configuration interval. - Callback system to notify Setup.py on restart completion. - Cross-platform game launching (Windows/Unix). - Process termination using `psutil` if available. - `Setup.py` now acts as the control hub: - Instantiates and manages `GameMonitor`. - Provides live configuration updates (e.g., window title, restart timing). - Coordinates bot lifecycle with game restarts. - Maintains standalone execution mode for `game_manager.py` (for testing or CLI use). - Replaces older “always-on-top” logic with foreground window activation. - Dramatically improves control, flexibility, and automation reliability for game-based workflows. --- ClaudeCode.md | 184 ++++++++++++------ Setup.py | 90 ++++++++- game_manager.py | 494 ++++++++++++++++++++++++++++++++++++++++++++++++ game_monitor.py | 303 ----------------------------- main.py | 152 +-------------- 5 files changed, 708 insertions(+), 515 deletions(-) create mode 100644 game_manager.py delete mode 100644 game_monitor.py diff --git a/ClaudeCode.md b/ClaudeCode.md index fd0400b..6ce842d 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -15,72 +15,66 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 ### 核心元件 -1. **主控模塊 (main.py)** - - 協調各模塊的工作 - - 初始化 MCP 連接 - - **容錯處理**:即使 `config.py` 中未配置 MCP 伺服器,或所有伺服器連接失敗,程式現在也會繼續執行,僅打印警告訊息,MCP 功能將不可用。 (Added 2025-04-21) - - **伺服器子進程管理 (修正 2025-05-02)**:使用 `mcp.client.stdio.stdio_client` 啟動和連接 `config.py` 中定義的每個 MCP 伺服器。`stdio_client` 作為一個異步上下文管理器,負責管理其啟動的子進程的生命週期。 - - **Windows 特定處理 (修正 2025-05-02)**:在 Windows 上,如果 `pywin32` 可用,會註冊一個控制台事件處理程序 (`win32api.SetConsoleCtrlHandler`)。此處理程序主要用於輔助觸發正常的關閉流程(最終會調用 `AsyncExitStack.aclose()`),而不是直接終止進程。伺服器子進程的實際終止依賴於 `stdio_client` 上下文管理器在 `AsyncExitStack.aclose()` 期間的清理操作。 - - **記憶體系統初始化 (新增 2025-05-02)**:在啟動時調用 `chroma_client.initialize_memory_system()`,根據 `config.py` 中的 `ENABLE_PRELOAD_PROFILES` 設定決定是否啟用記憶體預載入。 - - 設置並管理主要事件循環 - - **記憶體預載入 (新增 2025-05-02)**:在主事件循環中,如果預載入已啟用,則在每次收到 UI 觸發後、調用 LLM 之前,嘗試從 ChromaDB 預先獲取用戶資料 (`get_entity_profile`)、相關記憶 (`get_related_memories`) 和潛在相關的機器人知識 (`get_bot_knowledge`)。 - - 處理程式生命週期管理和資源清理(通過 `AsyncExitStack` 間接管理 MCP 伺服器子進程的終止) +1. **主控模塊 (main.py)** + - 協調各模塊的工作 + - 初始化 MCP 連接 + - **容錯處理**:即使 `config.py` 中未配置 MCP 伺服器,或所有伺服器連接失敗,程式現在也會繼續執行,僅打印警告訊息,MCP 功能將不可用。 (Added 2025-04-21) + - **伺服器子進程管理 (修正 2025-05-02)**:使用 `mcp.client.stdio.stdio_client` 啟動和連接 `config.py` 中定義的每個 MCP 伺服器。`stdio_client` 作為一個異步上下文管理器,負責管理其啟動的子進程的生命週期。 + - **Windows 特定處理 (修正 2025-05-02)**:在 Windows 上,如果 `pywin32` 可用,會註冊一個控制台事件處理程序 (`win32api.SetConsoleCtrlHandler`)。此處理程序主要用於輔助觸發正常的關閉流程(最終會調用 `AsyncExitStack.aclose()`),而不是直接終止進程。伺服器子進程的實際終止依賴於 `stdio_client` 上下文管理器在 `AsyncExitStack.aclose()` 期間的清理操作。 + - **記憶體系統初始化 (新增 2025-05-02)**:在啟動時調用 `chroma_client.initialize_memory_system()`,根據 `config.py` 中的 `ENABLE_PRELOAD_PROFILES` 設定決定是否啟用記憶體預載入。 + - 設置並管理主要事件循環 + - **記憶體預載入 (新增 2025-05-02)**:在主事件循環中,如果預載入已啟用,則在每次收到 UI 觸發後、調用 LLM 之前,嘗試從 ChromaDB 預先獲取用戶資料 (`get_entity_profile`)、相關記憶 (`get_related_memories`) 和潛在相關的機器人知識 (`get_bot_knowledge`)。 + - 處理程式生命週期管理和資源清理(通過 `AsyncExitStack` 間接管理 MCP 伺服器子進程的終止) -2. **LLM 交互模塊 (llm_interaction.py)** - - 與語言模型 API 通信 - - 管理系統提示與角色設定 - - **條件式提示 (新增 2025-05-02)**:`get_system_prompt` 函數現在接受預載入的用戶資料、相關記憶和機器人知識。根據是否有預載入數據,動態調整系統提示中的記憶體檢索協議說明。 - - 處理語言模型的工具調用功能 - - 格式化 LLM 回應 - - 提供工具結果合成機制 +2. **LLM 交互模塊 (llm_interaction.py)** + - 與語言模型 API 通信 + - 管理系統提示與角色設定 + - **條件式提示 (新增 2025-05-02)**:`get_system_prompt` 函數現在接受預載入的用戶資料、相關記憶和機器人知識。根據是否有預載入數據,動態調整系統提示中的記憶體檢索協議說明。 + - 處理語言模型的工具調用功能 + - 格式化 LLM 回應 + - 提供工具結果合成機制 -3. **UI 互動模塊 (ui_interaction.py)** - - 使用圖像辨識技術監控遊戲聊天視窗 - - 檢測聊天泡泡與關鍵字 - - 複製聊天內容和獲取發送者姓名 - - 將生成的回應輸入到遊戲中 +3. **UI 互動模塊 (ui_interaction.py)** + - 使用圖像辨識技術監控遊戲聊天視窗 + - 檢測聊天泡泡與關鍵字 + - 複製聊天內容和獲取發送者姓名 + - 將生成的回應輸入到遊戲中 -4. **MCP 客戶端模塊 (mcp_client.py)** - - 管理與 MCP 服務器的通信 - - 列出和調用可用工具 - - 處理工具調用的結果和錯誤 +4. **MCP 客戶端模塊 (mcp_client.py)** + - 管理與 MCP 服務器的通信 + - 列出和調用可用工具 + - 處理工具調用的結果和錯誤 -5. **配置模塊 (config.py)** - - 集中管理系統參數和設定 - - 整合環境變數 - - 配置 API 密鑰和服務器設定 +5. **配置模塊 (config.py)** + - 集中管理系統參數和設定 + - 整合環境變數 + - 配置 API 密鑰和服務器設定 -6. **角色定義 (persona.json)** - - 詳細定義機器人的人格特徵 - - 包含外觀、說話風格、個性特點等資訊 - - 提供給 LLM 以確保角色扮演一致性 +6. **角色定義 (persona.json)** + - 詳細定義機器人的人格特徵 + - 包含外觀、說話風格、個性特點等資訊 + - 提供給 LLM 以確保角色扮演一致性 -7. **遊戲視窗監控模組 (game_monitor.py)** (取代 window-setup-script.py 和舊的 window-monitor-script.py) - - 持續監控遊戲視窗 (`config.WINDOW_TITLE`)。 - - 確保視窗維持在設定檔 (`config.py`) 中指定的位置 (`GAME_WINDOW_X`, `GAME_WINDOW_Y`) 和大小 (`GAME_WINDOW_WIDTH`, `GAME_WINDOW_HEIGHT`)。 - - **確保視窗保持活躍**:如果遊戲視窗不是目前的前景視窗,則嘗試將其帶到前景並啟用 (Bring to Foreground/Activate),取代了之前的強制置頂 (Always on Top) 邏輯 (修改於 2025-05-12)。 - - **定時遊戲重啟** (如果 `config.ENABLE_SCHEDULED_RESTART` 為 True): - - 根據 `config.RESTART_INTERVAL_MINUTES` 設定的間隔執行。 - - **簡化流程 (2025-04-25)**: - 1. 通過 `stdout` 向 `main.py` 發送 JSON 訊號 (`{'action': 'pause_ui'}`),請求暫停 UI 監控。 - 2. 等待固定時間(30 秒)。 - 3. 調用 `restart_game_process` 函數,**嘗試**終止 (`terminate`/`kill`) `LastWar.exe` 進程(**無驗證**)。 - 4. 等待固定時間(2 秒)。 - 5. **嘗試**使用 `os.startfile` 啟動 `config.GAME_EXECUTABLE_PATH`(**無驗證**)。 - 6. 等待固定時間(30 秒)。 - 7. 使用 `try...finally` 結構確保**總是**執行下一步。 - 8. 通過 `stdout` 向 `main.py` 發送 JSON 訊號 (`{'action': 'resume_ui'}`),請求恢復 UI 監控。 - - **視窗調整**:遊戲視窗的位置/大小/置頂狀態的調整完全由 `monitor_game_window` 的主循環持續負責,重啟流程不再進行立即調整。 - - **作為獨立進程運行**:由 `main.py` 使用 `subprocess.Popen` 啟動,捕獲其 `stdout` (用於 JSON 訊號) 和 `stderr` (用於日誌)。 - - **進程間通信**: - - `game_monitor.py` -> `main.py`:通過 `stdout` 發送 JSON 格式的 `pause_ui` 和 `resume_ui` 訊號。 - - **日誌處理**:`game_monitor.py` 的日誌被配置為輸出到 `stderr`,以保持 `stdout` 清潔,確保訊號傳遞可靠性。`main.py` 會讀取 `stderr` 並可能顯示這些日誌。 - - **生命週期管理**:由 `main.py` 在啟動時創建,並在 `shutdown` 過程中嘗試終止 (`terminate`)。 +7. **遊戲管理器模組 (game_manager.py)** (取代舊的 `game_monitor.py`) + - **核心類 `GameMonitor`**:封裝所有遊戲視窗監控、自動重啟和進程管理功能。 + - **由 `Setup.py` 管理**: + - 在 `Setup.py` 的 "Start Managed Bot & Game" 流程中被實例化和啟動。 + - 在停止會話時由 `Setup.py` 停止。 + - 設定(如視窗標題、路徑、重啟間隔等)通過 `Setup.py` 傳遞,並可在運行時通過 `update_config` 方法更新。 + - **功能**: + - 持續監控遊戲視窗 (`config.WINDOW_TITLE`)。 + - 確保視窗維持在設定檔中指定的位置和大小。 + - 確保視窗保持活躍(帶到前景並獲得焦點)。 + - **定時遊戲重啟**:根據設定檔中的間隔執行。 + - **回調機制**:重啟完成後,通過回調函數通知 `Setup.py`(例如,`restart_complete`),`Setup.py` 隨後處理機器人重啟。 + - **進程管理**:使用 `psutil`(如果可用)查找和終止遊戲進程。 + - **跨平台啟動**:使用 `os.startfile` (Windows) 或 `subprocess.Popen` (其他平台) 啟動遊戲。 + - **獨立運行模式**:`game_manager.py` 仍然可以作為獨立腳本運行 (類似舊的 `game_monitor.py`),此時它會從 `config.py` 加載設定,並通過 `stdout` 發送 JSON 訊號。 -8. **ChromaDB 客戶端模塊 (chroma_client.py)** (新增 2025-05-02) - - 處理與本地 ChromaDB 向量數據庫的連接和互動。 - - 提供函數以初始化客戶端、獲取/創建集合,以及查詢用戶資料、相關記憶和機器人知識。 - - 使用 `chromadb.PersistentClient` 連接持久化數據庫。 +8. **ChromaDB 客戶端模塊 (chroma_client.py)** (新增 2025-05-02) + - 處理與本地 ChromaDB 向量數據庫的連接和互動。 + - 提供函數以初始化客戶端、獲取/創建集合,以及查詢用戶資料、相關記憶和機器人知識。 + - 使用 `chromadb.PersistentClient` 連接持久化數據庫。 ### 資料流程 @@ -638,6 +632,39 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 - 添加主題識別與記憶功能 - 探索多輪對話中的上下文理解能力 +## 最近改進(2025-05-13) + +### 遊戲監控模組重構 + +- **目的**:將遊戲監控功能從獨立的 `game_monitor.py` 腳本重構為一個更健壯、更易於管理的 `game_manager.py` 模組,並由 `Setup.py` 統一控制其生命週期和配置。 +- **`game_manager.py` (新模組)**: + - 創建了 `GameMonitor` 類,封裝了所有遊戲視窗監控、自動重啟和進程管理邏輯。 + - 提供了 `create_game_monitor` 工廠函數。 + - 支持通過構造函數和 `update_config` 方法進行配置。 + - 使用回調函數 (`callback`) 與調用者(即 `Setup.py`)通信,例如在遊戲重啟完成時。 + - 保留了獨立運行模式,以便在直接執行時仍能工作(主要用於測試或舊版兼容)。 + - 程式碼註解和日誌訊息已更新為英文。 +- **`Setup.py` (修改)**: + - 導入 `game_manager`。 + - 在 `WolfChatSetup` 類的 `__init__` 方法中初始化 `self.game_monitor = None`。 + - 在 `start_managed_session` 方法中: + - 創建 `game_monitor_callback` 函數以處理來自 `GameMonitor` 的動作(特別是 `restart_complete`)。 + - 使用 `game_manager.create_game_monitor` 創建 `GameMonitor` 實例。 + - 啟動 `GameMonitor`。 + - 新增 `_handle_game_restart_complete` 方法,用於在收到 `GameMonitor` 的重啟完成回調後,處理機器人的重啟。 + - 在 `stop_managed_session` 方法中,調用 `self.game_monitor.stop()` 並釋放實例。 + - 修改 `_restart_game_managed` 方法,使其在 `self.game_monitor` 存在且運行時,調用 `self.game_monitor.restart_now()` 來執行遊戲重啟。 + - 在 `save_settings` 方法中,如果 `self.game_monitor` 實例存在,則調用其 `update_config` 方法以更新運行時配置。 +- **`main.py` (修改)**: + - 移除了所有對舊 `game_monitor.py` 的導入、子進程啟動、訊號讀取和生命週期管理相關的程式碼。遊戲監控現在完全由 `Setup.py` 在受管會話模式下處理。 +- **舊檔案刪除**: + - 刪除了原來的 `game_monitor.py` 文件。 +- **效果**: + - 遊戲監控邏輯更加內聚和模塊化。 + - `Setup.py` 現在完全控制遊戲監控的啟動、停止和配置,簡化了 `main.py` 的職責。 + - 通過回調機制實現了更清晰的模塊間通信。 + - 提高了程式碼的可維護性和可擴展性。 + ### 注意事項 1. **圖像模板**:確保所有必要的 UI 元素模板都已截圖並放置在 templates 目錄 @@ -741,3 +768,42 @@ ClaudeCode.md # Current Mode ACT MODE + + + +Now that you have the latest state of the file, try the operation again with fewer, more precise SEARCH blocks. For large files especially, it may be prudent to try to limit yourself to <5 SEARCH/REPLACE blocks at a time, then wait for the user to respond with the result of the operation before following up with another replace_in_file call to make additional edits. +(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback.) + +# VSCode Visible Files +ClaudeCode.md + +# VSCode Open Tabs +config_template.py +test/llm_debug_script.py +llm_interaction.py +wolf_control.py +.gitignore +chroma_client.py +batch_memory_record.py +memory_manager.py +game_monitor.py +game_manager.py +Setup.py +main.py +ClaudeCode.md +reembedding tool.py +config.py +memory_backup.py +tools/chroma_view.py +ui_interaction.py +remote_config.json + +# Current Time +5/13/2025, 3:31:34 AM (Asia/Taipei, UTC+8:00) + +# Context Window Usage +429,724 / 1,048.576K tokens used (41%) + +# Current Mode +ACT MODE + diff --git a/Setup.py b/Setup.py index 580d463..502a5a7 100644 --- a/Setup.py +++ b/Setup.py @@ -29,6 +29,7 @@ import schedule import psutil import random # Added for exponential backoff jitter import urllib3 # Added for SSL warning suppression +import game_manager # Added for new game monitoring module try: import socketio HAS_SOCKETIO = True @@ -604,6 +605,9 @@ class WolfChatSetup(tk.Tk): # Initialize scheduler process tracker self.scheduler_process = None + + # Initialize game monitor instance (will be created in start_managed_session) + self.game_monitor = None # Set initial states based on loaded data @@ -747,7 +751,39 @@ class WolfChatSetup(tk.Tk): # Start Monitoring Thread - self._start_monitoring_thread() + self._start_monitoring_thread() # This is the old general monitoring thread + + # Initialize and start GameMonitor (new specific game monitor) + try: + # Create callback function for game_monitor + def game_monitor_callback(action): + logger.info(f"Received action from game_manager: {action}") + if action == "restart_complete": + # Schedule _handle_game_restart_complete to run in the main thread + self.after(0, self._handle_game_restart_complete) + # Add other actions if needed, e.g., "restart_begin", "restart_error" + + # Create GameMonitor instance if it doesn't exist + if not self.game_monitor: + self.game_monitor = game_manager.create_game_monitor( + config_data=self.config_data, + remote_data=self.remote_data, + logger=logger, # Use the main Setup logger + callback=game_monitor_callback + ) + + # Start the game monitor + if self.game_monitor.start(): # Ensure start() returns a boolean + logger.info("Game monitor (game_manager) started successfully.") + else: + logger.error("Failed to start game_manager's GameMonitor.") + messagebox.showwarning("Warning", "Game window monitoring (game_manager) could not be started.") + # Continue execution, not a fatal error for the whole session + + except Exception as gm_err: + logger.exception(f"Error setting up game_manager's GameMonitor: {gm_err}") + messagebox.showwarning("Warning", "Failed to initialize game_manager's GameMonitor.") + # Continue execution # Start Scheduler Thread self._start_scheduler_thread() @@ -756,6 +792,23 @@ class WolfChatSetup(tk.Tk): # messagebox.showinfo("Session Started", "Managed bot and game session started. Check console for logs.") # Removed popup logger.info("Managed bot and game session started. Check console for logs.") # Log instead of popup + def _handle_game_restart_complete(self): + """Handles the callback from GameMonitor when a game restart is complete.""" + logger.info("Game restart completed (callback from game_manager). Handling bot restart...") + try: + # Ensure we are in the main thread (already handled by self.after) + # Wait a bit for the game to stabilize + time.sleep(10) + + logger.info("Restarting bot after game restart (triggered by game_manager)...") + if self._restart_bot_managed(): + logger.info("Bot restarted successfully after game_manager's game restart.") + else: + logger.error("Failed to restart bot after game_manager's game restart!") + messagebox.showwarning("Warning", "Failed to restart bot after game_manager's game restart.") + except Exception as e: + logger.exception(f"Error in _handle_game_restart_complete: {e}") + def stop_managed_session(self): logger.info("Attempting to stop managed session...") self.keep_monitoring_flag.clear() # Signal threads to stop @@ -778,7 +831,19 @@ class WolfChatSetup(tk.Tk): if self.monitor_thread_instance.is_alive(): logger.warning("Monitor thread did not stop in time.") self.monitor_thread_instance = None - + + # Stop GameMonitor (from game_manager) + if self.game_monitor: + try: + if self.game_monitor.stop(): # Ensure stop() returns a boolean + logger.info("Game monitor (game_manager) stopped successfully.") + else: + logger.warning("Game monitor (game_manager) stop may have failed.") + except Exception as gm_err: + logger.exception(f"Error stopping game_manager's GameMonitor: {gm_err}") + finally: + self.game_monitor = None # Release the instance + self._stop_bot_managed() self._stop_game_managed() @@ -1063,9 +1128,16 @@ class WolfChatSetup(tk.Tk): def _restart_game_managed(self): logger.info("Restarting game (managed)...") - self._stop_game_managed() - time.sleep(2) # Give it time to fully stop - return self._start_game_managed() + # If GameMonitor (from game_manager) exists and is running, use it to restart + if self.game_monitor and self.game_monitor.running: + logger.info("Using game_manager's GameMonitor to restart game.") + return self.game_monitor.restart_now() + else: + # Fallback to the original method if game_monitor is not active + logger.info("game_manager's GameMonitor not active, using default method to restart game.") + self._stop_game_managed() + time.sleep(2) # Give it time to fully stop + return self._start_game_managed() def _restart_bot_managed(self): logger.info("Restarting bot (managed)...") @@ -2414,6 +2486,14 @@ class WolfChatSetup(tk.Tk): save_env_file(self.env_data) generate_config_file(self.config_data, self.env_data) save_remote_config(self.remote_data) # Save remote config + + # If GameMonitor (from game_manager) exists, update its configuration + if self.game_monitor: + try: + self.game_monitor.update_config(self.config_data, self.remote_data) + logger.info("Game monitor (game_manager) configuration updated.") + except Exception as gm_update_err: + logger.error(f"Failed to update game_manager's GameMonitor configuration: {gm_update_err}") if show_success_message: messagebox.showinfo("Success", "Settings saved successfully.\nRestart managed session for changes to take effect.") diff --git a/game_manager.py b/game_manager.py new file mode 100644 index 0000000..fd5dcfe --- /dev/null +++ b/game_manager.py @@ -0,0 +1,494 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Game Manager Module + +Provides game window monitoring, automatic restart, and process management features. +Designed to be imported and controlled by setup.py or other management scripts. +""" + +import os +import sys +import time +import json +import threading +import subprocess +import logging +import pygetwindow as gw + +# Attempt to import platform-specific modules that might be needed +try: + import win32gui + import win32con + HAS_WIN32 = True +except ImportError: + HAS_WIN32 = False + print("Warning: win32gui/win32con modules not installed, some window management features may be unavailable") + +try: + import psutil + HAS_PSUTIL = True +except ImportError: + HAS_PSUTIL = False + print("Warning: psutil module not installed, process management features may be unavailable") + + +class GameMonitor: + """ + Game window monitoring class. + Responsible for monitoring game window position, scheduled restarts, and providing window management functions. + """ + def __init__(self, config_data, remote_data=None, logger=None, callback=None): + # Use the provided logger or create a new one + self.logger = logger or logging.getLogger("GameMonitor") + if not self.logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + handler.setFormatter(formatter) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + + self.config_data = config_data + self.remote_data = remote_data or {} + self.callback = callback # Callback function to notify the caller + + # Read settings from configuration + self.window_title = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("WINDOW_TITLE", "Last War-Survival Game") + self.enable_restart = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("ENABLE_SCHEDULED_RESTART", True) + self.restart_interval = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("RESTART_INTERVAL_MINUTES", 60) + self.game_path = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_EXECUTABLE_PATH", "") + self.window_x = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_X", 50) + self.window_y = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_Y", 30) + self.window_width = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_WIDTH", 600) + self.window_height = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_HEIGHT", 1070) + self.monitor_interval = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("MONITOR_INTERVAL_SECONDS", 5) + + # Read game process name from remote_data, use default if not found + self.game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe") + + # Internal state + self.running = False + self.next_restart_time = None + self.monitor_thread = None + self.stop_event = threading.Event() + + 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") + + def start(self): + """Start game window monitoring""" + if self.running: + self.logger.info("Game window monitoring is already running") + return True # Return True if already running + + self.logger.info("Starting game window monitoring...") + self.stop_event.clear() + + # Set next restart time + if self.enable_restart and self.restart_interval > 0: + self.next_restart_time = time.time() + (self.restart_interval * 60) + self.logger.info(f"Scheduled restart enabled. First restart in {self.restart_interval} minutes") + else: + self.next_restart_time = None + self.logger.info("Scheduled restart is disabled") + + # Start monitoring thread + self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True) + self.monitor_thread.start() + self.running = True + self.logger.info("Game window monitoring started") + return True + + def stop(self): + """Stop game window monitoring""" + if not self.running: + self.logger.info("Game window monitoring is not running") + return True # Return True if already stopped + + self.logger.info("Stopping game window monitoring...") + self.stop_event.set() + + # Wait for monitoring thread to finish + if self.monitor_thread and self.monitor_thread.is_alive(): + self.logger.info("Waiting for monitoring thread to finish...") + self.monitor_thread.join(timeout=5) + if self.monitor_thread.is_alive(): + self.logger.warning("Game window monitoring thread did not stop within the timeout period") + + self.running = False + self.monitor_thread = None + self.logger.info("Game window monitoring stopped") + return True + + def _monitor_loop(self): + """Main monitoring loop""" + self.logger.info("Game window monitoring loop started") + last_adjustment_message = "" # Avoid logging repetitive adjustment messages + + while not self.stop_event.is_set(): + try: + # 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...") + self._perform_restart() + # Reset next restart time + self.next_restart_time = time.time() + (self.restart_interval * 60) + self.logger.info(f"Restart timer reset. Next restart in {self.restart_interval} minutes") + # Continue to next loop iteration + time.sleep(self.monitor_interval) + continue + + # Find game window + window = self._find_game_window() + adjustment_made = False + current_message = "" + + if window: + try: + # Use win32gui functions only on Windows + if HAS_WIN32: + # Get window handle + hwnd = window._hWnd + + # 1. Check and adjust position/size + current_pos = (window.left, window.top) + current_size = (window.width, window.height) + target_pos = (self.window_x, self.window_y) + target_size = (self.window_width, self.window_height) + + 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 + time.sleep(0.1) + 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]}. " + 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 + 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}") + else: + # Use basic functions on non-Windows platforms + current_pos = (window.left, window.top) + current_size = (window.width, window.height) + target_pos = (self.window_x, self.window_y) + target_size = (self.window_width, self.window_height) + + 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]) + current_message += f"Adjusted game window to position {target_pos} size {target_size[0]}x{target_size[1]}. " + adjustment_made = True + + # Try activating the window (may have limited effect on non-Windows) + try: + window.activate() + current_message += "Attempted to activate game window. " + 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}") + + # Log only if adjustments were made and the message changed + if adjustment_made and current_message and current_message != last_adjustment_message: + self.logger.info(f"[GameMonitor] {current_message.strip()}") + last_adjustment_message = current_message + elif not window: + # Reset last message if window disappears + last_adjustment_message = "" + + except Exception as e: + self.logger.error(f"Error in monitoring loop: {e}") + + # Wait for the next check + time.sleep(self.monitor_interval) + + self.logger.info("Game window monitoring loop finished") + + def _find_game_window(self): + """Find the game window with the specified title""" + try: + windows = gw.getWindowsWithTitle(self.window_title) + if windows: + return windows[0] + except Exception as e: + 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") + return None + + try: + for proc in psutil.process_iter(['pid', 'name', 'exe']): + try: + proc_info = proc.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 '{proc_name}' (PID: {proc.pid})") + return proc + 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") + return None + + def _perform_restart(self): + """Execute the game restart process""" + self.logger.info("Starting game restart process") + + try: + # 1. Notify that restart has begun (optional) + if self.callback: + self.callback("restart_begin") + + # 2. Terminate existing game process + self._terminate_game_process() + time.sleep(2) # Short wait to ensure process termination + + # 3. Start new game process + if self._start_game_process(): + self.logger.info("Game restarted successfully") + else: + self.logger.error("Failed to start game") + + # 4. Wait for game to launch + restart_wait_time = 30 # seconds + self.logger.info(f"Waiting for game to start ({restart_wait_time} seconds)...") + time.sleep(restart_wait_time) + + # 5. Notify restart completion + self.logger.info("Game restart process completed, sending notification") + if self.callback: + self.callback("restart_complete") + + return True + except Exception as e: + self.logger.error(f"Error during game restart process: {e}") + # Attempt to notify error + if self.callback: + self.callback("restart_error") + return False + + def _terminate_game_process(self): + """Terminate the game process""" + self.logger.info(f"Attempting to terminate game process '{self.game_process_name}'") + + if not HAS_PSUTIL: + self.logger.warning("psutil is not available, cannot terminate process") + return False + + process = self._find_game_process() + terminated = False + + if process: + try: + self.logger.info(f"Found game process PID: {process.pid}, terminating...") + process.terminate() + + try: + process.wait(timeout=5) + self.logger.info(f"Process {process.pid} terminated successfully (terminate)") + terminated = True + except psutil.TimeoutExpired: + self.logger.warning(f"Process {process.pid} did not terminate within 5s (terminate), attempting force kill") + process.kill() + process.wait(timeout=5) + self.logger.info(f"Process {process.pid} force killed (kill)") + terminated = True + except Exception as e: + self.logger.error(f"Error terminating process: {e}") + else: + self.logger.warning(f"No running process found with name '{self.game_process_name}'") + + return terminated + + def _start_game_process(self): + """Start the game process""" + if not self.game_path: + self.logger.error("Game executable path not set, cannot start") + return False + + self.logger.info(f"Starting game: {self.game_path}") + try: + if sys.platform == "win32": + os.startfile(self.game_path) + self.logger.info("Called os.startfile to launch game") + return True + else: + # Use subprocess.Popen for non-Windows platforms + # Ensure it runs detached if possible, or handle appropriately + subprocess.Popen([self.game_path], start_new_session=True) # Attempt detached start + self.logger.info("Called subprocess.Popen to launch game") + return True + except FileNotFoundError: + self.logger.error(f"Startup error: Game launcher '{self.game_path}' not found") + except OSError as ose: + self.logger.error(f"Startup error (OSError): {ose} - Check path and permissions", exc_info=True) + except Exception as e: + self.logger.error(f"Unexpected error starting game: {e}", exc_info=True) + + return False + + def restart_now(self): + """Perform an immediate restart""" + self.logger.info("Manually triggering game restart") + result = self._perform_restart() + + # Reset the timer if scheduled restart is enabled + if self.enable_restart and self.restart_interval > 0: + self.next_restart_time = time.time() + (self.restart_interval * 60) + self.logger.info(f"Restart timer reset. Next restart in {self.restart_interval} minutes") + + return result + + def update_config(self, config_data=None, remote_data=None): + """Update configuration settings""" + if config_data: + old_config = self.config_data + self.config_data = config_data + + # Update key settings + self.window_title = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("WINDOW_TITLE", self.window_title) + self.enable_restart = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("ENABLE_SCHEDULED_RESTART", self.enable_restart) + self.restart_interval = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("RESTART_INTERVAL_MINUTES", self.restart_interval) + self.game_path = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_EXECUTABLE_PATH", self.game_path) + self.window_x = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_X", self.window_x) + self.window_y = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_Y", self.window_y) + self.window_width = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_WIDTH", self.window_width) + self.window_height = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_WINDOW_HEIGHT", self.window_height) + self.monitor_interval = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("MONITOR_INTERVAL_SECONDS", self.monitor_interval) + + # Reset scheduled restart timer if parameters changed + if self.running and self.enable_restart and self.restart_interval > 0: + old_interval = old_config.get("GAME_WINDOW_CONFIG", {}).get("RESTART_INTERVAL_MINUTES", 60) + if self.restart_interval != old_interval: + self.next_restart_time = time.time() + (self.restart_interval * 60) + self.logger.info(f"Restart interval updated to {self.restart_interval} minutes, next restart reset") + + if remote_data: + self.remote_data = remote_data + old_process_name = self.game_process_name + self.game_process_name = self.remote_data.get("GAME_PROCESS_NAME", old_process_name) + if self.game_process_name != old_process_name: + self.logger.info(f"Game process name updated to '{self.game_process_name}'") + + self.logger.info("GameMonitor configuration updated") + + +# Provide simple external API functions +def create_game_monitor(config_data, remote_data=None, logger=None, callback=None): + """Create a game monitor instance""" + return GameMonitor(config_data, remote_data, logger, callback) + +def stop_all_monitors(): + """Attempt to stop all created monitors (global cleanup)""" + # This function could be implemented if instance references are stored. + # In the current design, each monitor needs to be stopped individually. + pass + + +# Functionality when run standalone (similar to original game_monitor.py) +if __name__ == "__main__": + # Set up basic logging + logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + logger = logging.getLogger("GameManagerStandalone") + + # Load settings from config.py + try: + import config + logger.info("Loaded config.py") + + # Build basic configuration dictionary + config_data = { + "GAME_WINDOW_CONFIG": { + "WINDOW_TITLE": config.WINDOW_TITLE, + "ENABLE_SCHEDULED_RESTART": config.ENABLE_SCHEDULED_RESTART, + "RESTART_INTERVAL_MINUTES": config.RESTART_INTERVAL_MINUTES, + "GAME_EXECUTABLE_PATH": config.GAME_EXECUTABLE_PATH, + "GAME_WINDOW_X": config.GAME_WINDOW_X, + "GAME_WINDOW_Y": config.GAME_WINDOW_Y, + "GAME_WINDOW_WIDTH": config.GAME_WINDOW_WIDTH, + "GAME_WINDOW_HEIGHT": config.GAME_WINDOW_HEIGHT, + "MONITOR_INTERVAL_SECONDS": config.MONITOR_INTERVAL_SECONDS + } + } + + # Define a callback for standalone execution + def standalone_callback(action): + """Send JSON signal via standard output""" + logger.info(f"Sending signal: {action}") + signal_data = {'action': action} + try: + json_signal = json.dumps(signal_data) + print(json_signal, flush=True) + logger.info(f"Signal sent: {action}") + except Exception as e: + logger.error(f"Failed to send signal '{action}': {e}") + + # Create and start the monitor + monitor = GameMonitor(config_data, logger=logger, callback=standalone_callback) + monitor.start() + + # Keep the program running + try: + logger.info("Game monitoring started. Press Ctrl+C to stop.") + while True: + time.sleep(1) + except KeyboardInterrupt: + logger.info("Ctrl+C received, stopping...") + finally: + monitor.stop() + logger.info("Game monitoring stopped") + + except ImportError: + logger.error("Could not load config.py. Ensure it exists and contains necessary settings.") + sys.exit(1) + except Exception as e: + logger.error(f"Error starting game monitoring: {e}", exc_info=True) + sys.exit(1) diff --git a/game_monitor.py b/game_monitor.py deleted file mode 100644 index a21c220..0000000 --- a/game_monitor.py +++ /dev/null @@ -1,303 +0,0 @@ -#!/usr/bin/env python -""" -Game Window Monitor Module - -Continuously monitors the game window specified in the config, -ensuring it stays at the configured position, size, and remains topmost. -""" - -import time -import datetime # Added -import subprocess # Added -import psutil # Added -import sys # Added -import json # Added -import os # Added for basename -import pygetwindow as gw -import win32gui -import win32con -import config -import logging -# import multiprocessing # Keep for Pipe/Queue if needed later, though using stdio now -# NOTE: config.py should handle dotenv loading. This script only imports values. - -# --- Setup Logging --- -monitor_logger = logging.getLogger('GameMonitor') -monitor_logger.setLevel(logging.INFO) # Set level for the logger -log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') -# Create handler for stderr -stderr_handler = logging.StreamHandler(sys.stderr) # Explicitly use stderr -stderr_handler.setFormatter(log_formatter) -# Add handler to the logger -if not monitor_logger.hasHandlers(): # Avoid adding multiple handlers if run multiple times - monitor_logger.addHandler(stderr_handler) -monitor_logger.propagate = False # Prevent propagation to root logger if basicConfig was called elsewhere - -# --- Helper Functions --- - -def restart_game_process(): - """Finds and terminates the existing game process, then restarts it.""" - monitor_logger.info("嘗試重啟遊戲進程。(Attempting to restart game process.)") - game_path = config.GAME_EXECUTABLE_PATH - if not game_path or not os.path.exists(os.path.dirname(game_path)): # Basic check - monitor_logger.error(f"遊戲執行檔路徑 '{game_path}' 無效或目錄不存在,無法重啟。(Game executable path '{game_path}' is invalid or directory does not exist, cannot restart.)") - return - - target_process_name = "LastWar.exe" # Correct process name - launcher_path = config.GAME_EXECUTABLE_PATH # Keep launcher path for restarting - monitor_logger.info(f"尋找名稱為 '{target_process_name}' 的遊戲進程。(Looking for game process named '{target_process_name}')") - - terminated = False - process_found = False - for proc in psutil.process_iter(['pid', 'name', 'exe']): - try: - proc_info = proc.info - proc_name = proc_info.get('name') - - if proc_name == target_process_name: - process_found = True - monitor_logger.info(f"找到遊戲進程 PID: {proc_info['pid']},名稱: {proc_name}。正在終止...(Found game process PID: {proc_info['pid']}, Name: {proc_name}. Terminating...)") - proc.terminate() - try: - proc.wait(timeout=5) - monitor_logger.info(f"進程 {proc_info['pid']} 已成功終止 (terminate)。(Process {proc_info['pid']} terminated successfully (terminate).)") - terminated = True - except psutil.TimeoutExpired: - monitor_logger.warning(f"進程 {proc_info['pid']} 未能在 5 秒內終止 (terminate),嘗試強制結束 (kill)。(Process {proc_info['pid']} did not terminate in 5s (terminate), attempting kill.)") - proc.kill() - proc.wait(timeout=5) # Wait for kill with timeout - monitor_logger.info(f"進程 {proc_info['pid']} 已強制結束 (kill)。(Process {proc_info['pid']} killed.)") - terminated = True - except Exception as wait_kill_err: - monitor_logger.error(f"等待進程 {proc_info['pid']} 強制結束時出錯: {wait_kill_err}", exc_info=False) - - # Removed Termination Verification - Rely on main loop for eventual state correction - monitor_logger.info(f"已處理匹配的進程 PID: {proc_info['pid']},停止搜索。(Processed matching process PID: {proc_info['pid']}, stopping search.)") - break # Exit the loop once a process is handled - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - pass # Process might have already exited, access denied, or is a zombie - except Exception as e: - pid_str = proc.pid if hasattr(proc, 'pid') else 'N/A' - monitor_logger.error(f"檢查或終止進程 PID:{pid_str} 時出錯: {e}", exc_info=False) - - if process_found and not terminated: - monitor_logger.error("找到遊戲進程但未能成功終止它。(Found game process but failed to terminate it successfully.)") - elif not process_found: - monitor_logger.warning(f"未找到名稱為 '{target_process_name}' 的正在運行的進程。(No running process named '{target_process_name}' was found.)") - - # Wait a moment before restarting, use the launcher path from config - time.sleep(2) - if not launcher_path or not os.path.exists(os.path.dirname(launcher_path)): - monitor_logger.error(f"遊戲啟動器路徑 '{launcher_path}' 無效或目錄不存在,無法啟動。(Game launcher path '{launcher_path}' is invalid or directory does not exist, cannot launch.)") - return - - monitor_logger.info(f"正在使用啟動器啟動遊戲: {launcher_path} (Launching game using launcher: {launcher_path})") - try: - if sys.platform == "win32": - os.startfile(launcher_path) - monitor_logger.info("已調用 os.startfile 啟動遊戲。(os.startfile called to launch game.)") - else: - subprocess.Popen([launcher_path]) - monitor_logger.info("已調用 subprocess.Popen 啟動遊戲。(subprocess.Popen called to launch game.)") - except FileNotFoundError: - monitor_logger.error(f"啟動錯誤:找不到遊戲啟動器 '{launcher_path}'。(Launch Error: Game launcher not found at '{launcher_path}'.)") - except OSError as ose: - monitor_logger.error(f"啟動錯誤 (OSError): {ose} - 檢查路徑和權限。(Launch Error (OSError): {ose} - Check path and permissions.)", exc_info=True) - except Exception as e: - monitor_logger.error(f"啟動遊戲時發生未預期錯誤: {e}", exc_info=True) - # Don't return False here, let the process continue to send resume signal - # Removed Startup Verification - Rely on main loop for eventual state correction - # Always return True (or nothing) to indicate the attempt was made - return # Or return True, doesn't matter much now - -def perform_scheduled_restart(): - """Handles the sequence of pausing UI, restarting game, resuming UI.""" - monitor_logger.info("開始執行定時重啟流程。(Starting scheduled restart sequence.)") - - # Removed pause_ui signal - UI will handle its own pause/resume based on restart_complete - - try: - # 1. Attempt to restart the game (no verification) - monitor_logger.info("嘗試執行遊戲重啟。(Attempting game restart process.)") - restart_game_process() # Fire and forget restart attempt - monitor_logger.info("遊戲重啟嘗試已執行。(Game restart attempt executed.)") - - # 2. Wait fixed time after restart attempt - monitor_logger.info("等待 30 秒讓遊戲啟動(無驗證)。(Waiting 30 seconds for game to launch (no verification)...)") - time.sleep(30) # Fixed wait - - except Exception as restart_err: - monitor_logger.error(f"執行 restart_game_process 時發生未預期錯誤: {restart_err}", exc_info=True) - # Continue to finally block even on error - - finally: - # 3. Signal main process that restart attempt is complete via stdout - monitor_logger.info("發送重啟完成訊號。(Sending restart complete signal.)") - restart_complete_signal_data = {'action': 'restart_complete'} - try: - json_signal = json.dumps(restart_complete_signal_data) - print(json_signal, flush=True) - monitor_logger.info("已發送重啟完成訊號。(Sent restart complete signal.)") - except Exception as e: - monitor_logger.error(f"發送重啟完成訊號 '{json_signal}' 失敗: {e}", exc_info=True) # Log signal data on error - - monitor_logger.info("定時重啟流程(包括 finally 塊)執行完畢。(Scheduled restart sequence (including finally block) finished.)") -# Configure logger (basic example, adjust as needed) -# (Logging setup moved earlier) - -def find_game_window(title=config.WINDOW_TITLE): - """Attempts to find the game window by its title.""" - try: - windows = gw.getWindowsWithTitle(title) - if windows: - return windows[0] - except Exception as e: - # Log errors if a logger was configured - # monitor_logger.error(f"Error finding window '{title}': {e}") - pass # Keep silent if window not found during normal check - return None - -def monitor_game_window(): - """The main monitoring loop. Now runs directly, not in a thread.""" - monitor_logger.info("遊戲視窗監控腳本已啟動。(Game window monitoring script started.)") - last_adjustment_message = "" # Track last message to avoid spam - next_restart_time = None - - # Initialize scheduled restart timer if enabled - if config.ENABLE_SCHEDULED_RESTART and config.RESTART_INTERVAL_MINUTES > 0: - interval_seconds = config.RESTART_INTERVAL_MINUTES * 60 - next_restart_time = time.time() + interval_seconds - monitor_logger.info(f"已啟用定時重啟,首次重啟將在 {config.RESTART_INTERVAL_MINUTES} 分鐘後執行。(Scheduled restart enabled. First restart in {config.RESTART_INTERVAL_MINUTES} minutes.)") - else: - monitor_logger.info("未啟用定時重啟功能。(Scheduled restart is disabled.)") - - - while True: # Run indefinitely until terminated externally - # --- Scheduled Restart Check --- - if next_restart_time and time.time() >= next_restart_time: - monitor_logger.info("到達預定重啟時間。(Scheduled restart time reached.)") - perform_scheduled_restart() - # Reset timer for the next interval - interval_seconds = config.RESTART_INTERVAL_MINUTES * 60 - next_restart_time = time.time() + interval_seconds - monitor_logger.info(f"重啟計時器已重置,下次重啟將在 {config.RESTART_INTERVAL_MINUTES} 分鐘後執行。(Restart timer reset. Next restart in {config.RESTART_INTERVAL_MINUTES} minutes.)") - # Continue to next loop iteration after restart sequence - time.sleep(config.MONITOR_INTERVAL_SECONDS) # Add a small delay before next check - continue - - # --- Regular Window Monitoring --- - window = find_game_window() - adjustment_made = False - current_message = "" - - if window: - try: - hwnd = window._hWnd # Get the window handle for win32 functions - - # 1. Check and Adjust Position/Size - current_pos = (window.left, window.top) - current_size = (window.width, window.height) - target_pos = (config.GAME_WINDOW_X, config.GAME_WINDOW_Y) - target_size = (config.GAME_WINDOW_WIDTH, config.GAME_WINDOW_HEIGHT) - - 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/resize was successful before logging - time.sleep(0.1) # Give window time to adjust - window.activate() # Bring window to foreground before checking again - time.sleep(0.1) - 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"已將遊戲視窗調整至位置 ({target_pos[0]},{target_pos[1]}) 大小 {target_size[0]}x{target_size[1]}。(Adjusted game window to position {target_pos} size {target_size}.) " - adjustment_made = True - else: - # Log failure if needed - # monitor_logger.warning(f"Failed to adjust window. Current: {new_pos} {new_size}, Target: {target_pos} {target_size}") - pass # Keep silent on failure for now - - # 2. Check and Bring to Foreground/Activate (Improved Logic) - current_foreground_hwnd = win32gui.GetForegroundWindow() - - if current_foreground_hwnd != hwnd: - try: - # Use HWND_TOP instead of HWND_TOPMOST to bring it above others without forcing always-on-top - win32gui.SetWindowPos(hwnd, win32con.HWND_TOP, 0, 0, 0, 0, - win32con.SWP_NOMOVE | win32con.SWP_NOSIZE) - - # Make window the foreground window (with focus) - # Note: This might still fail due to Windows foreground restrictions - win32gui.SetForegroundWindow(hwnd) - - # Verify window is active by checking foreground window - time.sleep(0.1) # Brief pause to let operation complete - foreground_hwnd = win32gui.GetForegroundWindow() - - if foreground_hwnd == hwnd: - current_message += "已將遊戲視窗提升到前景並設為焦點。(Brought game window to foreground with focus.) " - adjustment_made = True - else: - # Optional: Add a fallback for versions of Windows with stricter foreground rules - monitor_logger.warning("SetForegroundWindow 未能成功,嘗試備用方法 window.activate()。(SetForegroundWindow failed, trying fallback window.activate())") - try: - window.activate() # Try pygetwindow's activate method as backup - time.sleep(0.1) # Pause after activate - if win32gui.GetForegroundWindow() == hwnd: - current_message += "已透過備用方法將遊戲視窗設為焦點。(Set game window focus via fallback method.) " - adjustment_made = True - else: - monitor_logger.warning("備用方法 window.activate() 也未能成功。(Fallback window.activate() also failed.)") - except Exception as activate_err: - monitor_logger.warning(f"備用方法 window.activate() 出錯: {activate_err}") - - except Exception as focus_err: - # Log errors during the focus attempt - monitor_logger.warning(f"設置視窗焦點時出錯: {focus_err}") - - except gw.PyGetWindowException as e: - # Log PyGetWindowException specifically, might indicate window closed during check - monitor_logger.warning(f"監控循環中無法訪問視窗屬性 (可能已關閉): {e} (Could not access window properties in monitor loop (may be closed): {e})") - except Exception as e: - # Log other exceptions during monitoring - monitor_logger.error(f"監控遊戲視窗時發生未預期錯誤: {e} (Unexpected error during game window monitoring: {e})", exc_info=True) - - # Log adjustment message only if an adjustment was made and it's different from the last one - # This should NOT print JSON signals - if adjustment_made and current_message and current_message != last_adjustment_message: - # Log the adjustment message instead of printing to stdout - monitor_logger.info(f"[GameMonitor] {current_message.strip()}") - last_adjustment_message = current_message - elif not window: - # Reset last message if window disappears - last_adjustment_message = "" - - # Wait before the next check - time.sleep(config.MONITOR_INTERVAL_SECONDS) - - # This part is theoretically unreachable in the new design as the loop is infinite - # and termination is handled externally by the parent process (main.py). - # monitor_logger.info("遊戲視窗監控腳本已停止。(Game window monitoring script stopped.)") - - -# Example usage (if run directly) -if __name__ == '__main__': - monitor_logger.info("直接運行 game_monitor.py。(Running game_monitor.py directly.)") - monitor_logger.info(f"將監控標題為 '{config.WINDOW_TITLE}' 的視窗。(Will monitor window with title '{config.WINDOW_TITLE}')") - monitor_logger.info(f"目標位置: ({config.GAME_WINDOW_X}, {config.GAME_WINDOW_Y}), 目標大小: {config.GAME_WINDOW_WIDTH}x{config.GAME_WINDOW_HEIGHT}") - monitor_logger.info(f"檢查間隔: {config.MONITOR_INTERVAL_SECONDS} 秒。(Check interval: {config.MONITOR_INTERVAL_SECONDS} seconds.)") - if config.ENABLE_SCHEDULED_RESTART: - monitor_logger.info(f"定時重啟已啟用,間隔: {config.RESTART_INTERVAL_MINUTES} 分鐘。(Scheduled restart enabled, interval: {config.RESTART_INTERVAL_MINUTES} minutes.)") - else: - monitor_logger.info("定時重啟已禁用。(Scheduled restart disabled.)") - monitor_logger.info("腳本將持續運行,請從啟動它的終端使用 Ctrl+C 或由父進程終止。(Script will run continuously. Stop with Ctrl+C from the launching terminal or termination by parent process.)") - - try: - monitor_game_window() # Start the main loop directly - except KeyboardInterrupt: - monitor_logger.info("收到 Ctrl+C,正在退出...(Received Ctrl+C, exiting...)") - except Exception as e: - monitor_logger.critical(f"監控過程中發生致命錯誤: {e}", exc_info=True) - sys.exit(1) # Exit with error code - finally: - monitor_logger.info("Game Monitor 腳本執行完畢。(Game Monitor script finished.)") diff --git a/main.py b/main.py index f229d07..81081be 100644 --- a/main.py +++ b/main.py @@ -30,7 +30,6 @@ import llm_interaction # Import UI module import ui_interaction import chroma_client -# import game_monitor # No longer importing, will run as subprocess import subprocess # Import subprocess module import signal import platform @@ -65,9 +64,6 @@ trigger_queue: ThreadSafeQueue = ThreadSafeQueue() # UI Thread -> Main Loop command_queue: ThreadSafeQueue = ThreadSafeQueue() # Main Loop -> UI Thread # --- End Change --- ui_monitor_task: asyncio.Task | None = None # To track the UI monitor task -game_monitor_process: subprocess.Popen | None = None # To store the game monitor subprocess -monitor_reader_task: asyncio.Future | None = None # Store the future from run_in_executor -stop_reader_event = threading.Event() # Event to signal the reader thread to stop # --- Keyboard Shortcut State --- script_paused = False @@ -149,70 +145,6 @@ def keyboard_listener(): # --- End Keyboard Shortcut Handlers --- -# --- Game Monitor Signal Reader (Threaded Blocking Version) --- -def read_monitor_output(process: subprocess.Popen, queue: ThreadSafeQueue, loop: asyncio.AbstractEventLoop, stop_event: threading.Event): - """Runs in a separate thread, reads stdout blocking, parses JSON, and puts commands in the queue.""" - print("Game monitor output reader thread started.") - try: - while not stop_event.is_set(): - if not process.stdout: - print("[Monitor Reader Thread] Subprocess stdout is None. Exiting thread.") - break - - try: - # Blocking read - this is fine in a separate thread - line = process.stdout.readline() - except ValueError: - # Can happen if the pipe is closed during readline - print("[Monitor Reader Thread] ValueError on readline (pipe likely closed). Exiting thread.") - break - - if not line: - # EOF reached (process terminated) - print("[Monitor Reader Thread] EOF reached on stdout. Exiting thread.") - break - - line = line.strip() - if line: - # Log raw line immediately - print(f"[Monitor Reader Thread] Received raw line: '{line}'") - try: - data = json.loads(line) - action = data.get('action') - print(f"[Monitor Reader Thread] Parsed action: '{action}'") # Log parsed action - if action == 'pause_ui': - command = {'action': 'pause'} - print(f"[Monitor Reader Thread] Preparing to queue command: {command}") # Log before queueing - loop.call_soon_threadsafe(queue.put_nowait, command) - print("[Monitor Reader Thread] Pause command queued.") # Log after queueing - elif action == 'resume_ui': - # Removed direct resume_ui handling - ui_interaction will handle pause/resume based on restart_complete - print("[Monitor Reader Thread] Received old 'resume_ui' signal, ignoring.") - elif action == 'restart_complete': - command = {'action': 'handle_restart_complete'} - print(f"[Monitor Reader Thread] Received 'restart_complete' signal, preparing to queue command: {command}") - try: - loop.call_soon_threadsafe(queue.put_nowait, command) - print("[Monitor Reader Thread] 'handle_restart_complete' command queued.") - except Exception as q_err: - print(f"[Monitor Reader Thread] Error putting 'handle_restart_complete' command in queue: {q_err}") - else: - print(f"[Monitor Reader Thread] Received unknown action from monitor: {action}") - except json.JSONDecodeError: - print(f"[Monitor Reader Thread] ERROR: Could not decode JSON from monitor: '{line}'") - # Log the raw line that failed to parse - # print(f"[Monitor Reader Thread] Raw line that failed JSON decode: '{line}'") # Already logged raw line earlier - except Exception as e: - print(f"[Monitor Reader Thread] Error processing monitor output: {e}") - # No sleep needed here as readline() is blocking - except Exception as e: - # Catch broader errors in the thread loop itself - print(f"[Monitor Reader Thread] Thread loop error: {e}") - finally: - print("Game monitor output reader thread stopped.") -# --- End Game Monitor Signal Reader --- - - # --- Chat Logging Function --- def log_chat_interaction(user_name: str, user_message: str, bot_name: str, bot_message: str, bot_thoughts: str | None = None): """Logs the chat interaction, including optional bot thoughts, to a date-stamped file if enabled.""" @@ -318,7 +250,7 @@ if platform.system() == "Windows" and win32api and win32con: # --- Cleanup Function --- async def shutdown(): """Gracefully closes connections and stops monitoring tasks/processes.""" - global wolfhart_persona_details, ui_monitor_task, shutdown_requested, game_monitor_process, monitor_reader_task # Add monitor_reader_task + global wolfhart_persona_details, ui_monitor_task, shutdown_requested # Ensure shutdown is requested if called externally (e.g., Ctrl+C) if not shutdown_requested: print("Shutdown initiated externally (e.g., Ctrl+C).") @@ -338,42 +270,7 @@ async def shutdown(): except Exception as e: print(f"Error while waiting for UI monitoring task cancellation: {e}") - # 1b. Signal and Wait for Monitor Reader Thread - if monitor_reader_task: # Check if the future exists - if not stop_reader_event.is_set(): - print("Signaling monitor output reader thread to stop...") - stop_reader_event.set() - - # Wait for the thread to finish (the future returned by run_in_executor) - # This might block briefly, but it's necessary to ensure clean thread shutdown - # We don't await it directly in the async shutdown, but check if it's done - # A better approach might be needed if the thread blocks indefinitely - print("Waiting for monitor output reader thread to finish (up to 2s)...") - try: - # Wait for the future to complete with a timeout - await asyncio.wait_for(monitor_reader_task, timeout=2.0) - print("Monitor output reader thread finished.") - except asyncio.TimeoutError: - print("Warning: Monitor output reader thread did not finish within timeout.") - except asyncio.CancelledError: - print("Monitor output reader future was cancelled.") # Should not happen if we don't cancel it - except Exception as e: - print(f"Error waiting for monitor reader thread future: {e}") - - # 2. Terminate Game Monitor Subprocess (after signaling reader thread) - if game_monitor_process: - print("Terminating game monitor subprocess...") - try: - game_monitor_process.terminate() - # Optionally wait for a short period or check return code - # game_monitor_process.wait(timeout=1) - print("Game monitor subprocess terminated.") - except Exception as e: - print(f"Error terminating game monitor subprocess: {e}") - finally: - game_monitor_process = None # Clear the reference - - # 3. Close MCP connections via AsyncExitStack + # 2. Close MCP connections via AsyncExitStack # This will trigger the __aexit__ method of stdio_client contexts, # which we assume handles terminating the server subprocesses it started. print(f"Closing MCP Server connections (via AsyncExitStack)...") @@ -555,7 +452,7 @@ def initialize_memory_system(): # --- Main Async Function --- async def run_main_with_exit_stack(): """Initializes connections, loads persona, starts UI monitor and main processing loop.""" - global initialization_successful, main_task, loop, wolfhart_persona_details, trigger_queue, ui_monitor_task, shutdown_requested, script_paused, command_queue, game_monitor_process, monitor_reader_task # Add monitor_reader_task to globals + global initialization_successful, main_task, loop, wolfhart_persona_details, trigger_queue, ui_monitor_task, shutdown_requested, script_paused, command_queue try: # 1. Load Persona Synchronously (before async loop starts) load_persona_from_file() # Corrected function @@ -594,48 +491,7 @@ async def run_main_with_exit_stack(): ui_monitor_task = monitor_task # Store task reference for shutdown # Note: UI task cancellation is handled in shutdown() - # 5b. Start Game Window Monitoring as a Subprocess - # global game_monitor_process, monitor_reader_task # Already declared global at function start - print("\n--- Starting Game Window monitoring as a subprocess ---") - try: - # Use sys.executable to ensure the same Python interpreter is used - # Capture stdout to read signals - game_monitor_process = subprocess.Popen( - [sys.executable, 'game_monitor.py'], - stdout=subprocess.PIPE, # Capture stdout - stderr=subprocess.PIPE, # Capture stderr for logging/debugging - text=True, # Decode stdout/stderr as text (UTF-8 by default) - bufsize=1, # Line buffered - # Ensure process creation flags are suitable for Windows if needed - # creationflags=subprocess.CREATE_NO_WINDOW # Example: Hide console window - ) - print(f"Game monitor subprocess started (PID: {game_monitor_process.pid}).") - - # Start the thread to read monitor output if process started successfully - if game_monitor_process.stdout: - # Run the blocking reader function in a separate thread using the default executor - monitor_reader_task = loop.run_in_executor( - None, # Use default ThreadPoolExecutor - read_monitor_output, # The function to run - game_monitor_process, # Arguments for the function... - command_queue, - loop, - stop_reader_event # Pass the stop event - ) - print("Monitor output reader thread submitted to executor.") - else: - print("Error: Could not access game monitor subprocess stdout.") - monitor_reader_task = None - - # Optionally, start a task to read stderr as well for debugging - # stderr_reader_task = loop.create_task(read_stderr(game_monitor_process), name="monitor_stderr_reader") - - except FileNotFoundError: - print("Error: 'game_monitor.py' not found. Cannot start game monitor subprocess.") - game_monitor_process = None - except Exception as e: - print(f"Error starting game monitor subprocess: {e}") - game_monitor_process = None + # 5b. Game Window Monitoring is now handled by Setup.py # 6. Start the main processing loop (non-blocking check on queue)