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/ClaudeCode.md b/ClaudeCode.md index 8207283..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`)。 - - 確保視窗維持在最上層 (Always on Top)。 - - **定時遊戲重啟** (如果 `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` 連接持久化數據庫。 ### 資料流程 @@ -598,6 +592,22 @@ 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`)。 + - 如果不是,則嘗試以下步驟將視窗帶到前景並獲得焦點: + 1. 使用 `win32gui.SetWindowPos` 搭配 `win32con.HWND_TOP` 旗標,將視窗提升到所有非最上層視窗之上。 + 2. 呼叫 `win32gui.SetForegroundWindow(hwnd)` 嘗試將視窗設為前景並獲得焦點。 + 3. 短暫延遲後,檢查視窗是否成功成為前景視窗。 + 4. 如果 `SetForegroundWindow` 未成功,則嘗試使用 `pygetwindow` 庫提供的 `window.activate()` 方法作為備用方案。 + - 更新了相關的日誌訊息以反映新的行為和備用邏輯。 +- **效果**:監控腳本現在會使用更全面的方法嘗試將失去焦點的遊戲視窗重新激活並帶到前景,包括備用方案,以提高在不同 Windows 環境下獲取焦點的成功率。這取代了之前僅強制視覺覆蓋的行為。 + ## 開發建議 ### 優化方向 @@ -622,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 目錄 @@ -725,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 e4ab7c7..502a5a7 100644 --- a/Setup.py +++ b/Setup.py @@ -19,6 +19,23 @@ 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 +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 +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 +43,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 +55,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 = {} @@ -230,6 +308,43 @@ 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)) + + # 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: + 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 @@ -339,7 +454,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 @@ -415,7 +532,30 @@ 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\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") @@ -434,6 +574,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 +584,708 @@ 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_memory_management_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() + + # 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 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""" + 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() # 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() + + self.update_management_buttons_state(False) # Disable start, enable stop + # 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 + + 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 + + # 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() + + # 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__)) + 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, + 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 + + # 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)...") + # 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)...") + 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 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): """Create the API Settings tab""" tab = ttk.Frame(self.notebook) @@ -853,25 +1685,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) @@ -975,6 +1803,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) @@ -989,6 +1835,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) @@ -1015,9 +1920,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""" @@ -1049,7 +1961,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: @@ -1091,7 +2017,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""" @@ -1102,6 +2099,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""" @@ -1159,14 +2168,38 @@ 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")) + # 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 + 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", "")) + 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 +2373,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 +2451,27 @@ 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 + 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 + 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 +2485,307 @@ 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 + + # 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}") - 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 + + # 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 + 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", + "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}") + 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) + # 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 + 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 (socket established, maybe not authenticated yet) + if self.sio.connected: + # 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.") + 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: # 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) + # 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) + + logger.info("ControlClient: Exited _run_forever loop.") + if self.sio.connected: + self.sio.disconnect() + + 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', + 'clientKey': self.client_key, + 'commands': self.registered_commands + }) + + def _on_disconnect(self): + was_connected = self.connected # Store previous state + self.connected = False + self.authenticated = False + 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 + 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 + + 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 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...") + 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() 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/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/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/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 4363769..0000000 --- a/game_monitor.py +++ /dev/null @@ -1,284 +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 Set Topmost - style = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE) - is_topmost = style & win32con.WS_EX_TOPMOST - - 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 - - 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/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/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) 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..4adec92 --- /dev/null +++ b/memory_manager.py @@ -0,0 +1,783 @@ +#!/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, 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 +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 + +# ============================================================================= +# 日誌解析部分 +# ============================================================================= + +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") + 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, + user_name: str, + conversations: List[Dict[str, str]], + existing_profile: Optional[Dict[str, Any]] = None + ) -> Optional[Dict[str, Any]]: + """Generate or update user profile based on conversations""" + system_prompt = self._get_profile_system_prompt(config.PERSONA_NAME, existing_profile) + + # Prepare user conversation records + conversation_text = self._format_conversations_for_prompt(conversations) + + user_prompt = f""" + Please generate a complete profile for user '{user_name}': + + Conversation history: + {conversation_text} + + 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. 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 personality traits...", + "relationship_with_bot": "Description of relationship with me...", + "bot_perception": "My subjective perception of the user...", + "notable_interactions": ["Important interaction 1", "Important interaction 2"] + }}, + "last_updated": "YYYY-MM-DD", + "metadata": {{ + "priority": 1.0, + "word_count": 0 + }} + }} + ``` + + 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, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.7 + ) + + # 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 parsing directly + profile_json_str = profile_text + + profile_json = json.loads(profile_json_str) + + # 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") + + 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]]: + """Generate conversation summary for user""" + system_prompt = f""" + 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 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} + + 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": {{{{ + "priority": 0.7, + "word_count": 0 + }}}} + }}}} + ``` + + 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, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.5 + ) + + # 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 parsing directly + summary_json_str = summary_text + + summary_json = json.loads(summary_json_str) + + # Add or update word count + 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: + """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. + {persona_details} + 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: + system_prompt += f""" + You already have an existing user profile, please update based on this: + ```json + {json.dumps(existing_profile, ensure_ascii=False, indent=2)} + ``` + + 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: + """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['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['bot_message']}\n\n" + + return conversation_text + +# ============================================================================= +# ChromaDB操作部分 +# ============================================================================= + +class ChromaDBManager: + def __init__(self, collection_name: Optional[str] = None): + self.collection_name = collection_name or config.BOT_MEMORY_COLLECTION + 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 + + @retry_operation(max_attempts=3, delay=1.0) + 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 + + try: + user_id = profile_data.get("id") + if not user_id: + print("檔案缺少ID字段") + return False + + # 準備元數據 + # 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", + "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 + # 處理非基本類型的值 + 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) + + # 寫入或更新 + collection.upsert( + ids=[user_id], + documents=[content_doc], + metadatas=[metadata] + ) + print(f"Upserted user profile: {user_id} into collection {self.collection_name}") + + return True + + except Exception as e: + 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() + 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"]: + # 處理非基本類型的值 + if isinstance(v, (list, dict, tuple)): + # 轉換為字符串 + metadata[k] = json.dumps(v, ensure_ascii=False) + else: + 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}" + + # 寫入數據 + collection.upsert( + ids=[summary_id], + documents=[content_doc], + metadatas=[metadata] + ) + print(f"Upserted conversation summary: {summary_id} into collection {self.collection_name}") + + return True + + except Exception as e: + print(f"寫入對話總結時出錯: {e}") + return False + + def get_existing_profile(self, username: str) -> Optional[Dict[str, Any]]: + """獲取現有的用戶檔案""" + collection = self._get_db_collection() + try: + profile_id = f"{username}_profile" + results = collection.get( + ids=[profile_id], + 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)} 個用戶有對話") + + # 為每個用戶生成/更新檔案和對話總結 + failed_users = [] + for username, convs in user_conversations.items(): + print(f"處理用戶 '{username}' 的 {len(convs)} 條對話") + + 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}") + +# ============================================================================= +# 定時調度器 +# ============================================================================= + +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/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_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() diff --git a/tools/chroma_view.py b/tools/chroma_view.py index 0c627df..df2b638 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,41 @@ 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 + # 添加新的模型支持 + 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 def load_collection(self, collection_name: str) -> bool: """加載指定的集合""" @@ -125,6 +166,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 +177,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,6 +453,16 @@ class ChromaDBReaderUI: # 設置窗口 self.root.title("ChromaDB 備份讀取器") self.root.geometry("1280x800") + + # 初始化嵌入模型相關變量 + 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", + "paraphrase-multilingual-mpnet-base-v2 (ST)": "paraphrase-multilingual-mpnet-base-v2" # 添加新的模型選項 + } + self.setup_ui() # 默認主題 @@ -262,9 +490,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 +504,6 @@ class ChromaDBReaderUI: self.setup_query_frame() self.setup_results_frame() - # 設置狀態欄 - self.setup_status_bar() - # 設置菜單 self.setup_menu() @@ -314,6 +543,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 +635,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 +686,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 +816,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 +921,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 +1023,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 +1034,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 +1229,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 +1282,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 +1310,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 +1380,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 +1456,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 +1828,4 @@ def main(): root.mainloop() if __name__ == "__main__": - main() \ No newline at end of file + main() 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 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.