Merge pull request #11 from z060142/Refactoring

Refactoring
This commit is contained in:
z060142 2025-05-07 01:11:58 +08:00 committed by GitHub
commit 6cffa4c70c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 959 additions and 191 deletions

View File

@ -19,12 +19,17 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- 協調各模塊的工作
- 初始化 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 回應
- 提供工具結果合成機制
@ -72,6 +77,11 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- **日誌處理**`game_monitor.py` 的日誌被配置為輸出到 `stderr`,以保持 `stdout` 清潔,確保訊號傳遞可靠性。`main.py` 會讀取 `stderr` 並可能顯示這些日誌。
- **生命週期管理**:由 `main.py` 在啟動時創建,並在 `shutdown` 過程中嘗試終止 (`terminate`)。
8. **ChromaDB 客戶端模塊 (chroma_client.py)** (新增 2025-05-02)
- 處理與本地 ChromaDB 向量數據庫的連接和互動。
- 提供函數以初始化客戶端、獲取/創建集合,以及查詢用戶資料、相關記憶和機器人知識。
- 使用 `chromadb.PersistentClient` 連接持久化數據庫。
### 資料流程
```
@ -80,11 +90,12 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
[UI 互動模塊] <→ [圖像樣本庫 / bubble_colors.json]
[主控模塊] ← [角色定義]
↑↓
[LLM 交互模塊] <→ [語言模型 API]
↑↓ ↑↓
[LLM 交互模塊] ← [ChromaDB 客戶端模塊] <→ [ChromaDB 數據庫]
↑↓
[MCP 客戶端] <→ [MCP 服務器]
```
*(資料流程圖已更新以包含 ChromaDB)*
## 技術實現
@ -216,12 +227,19 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
### 環境設定
1. **首次設定 (Setup.py)**
* 執行 `python Setup.py`
* 此腳本會檢查 `config.py``.env` 文件是否存在。
* 如果 `config.py` 不存在,它會使用 `config_template.py` 作為模板來創建一個新的 `config.py`
* 如果 `.env` 不存在,它會提示使用者輸入必要的 API 金鑰(例如 OpenAI API Key和其他敏感配置然後創建 `.env` 文件。
* **重要**`.env` 文件應加入 `.gitignore` 以避免提交敏感資訊。`config.py` 通常也應加入 `.gitignore`,因為它可能包含本地路徑或由 `Setup.py` 生成。
1. **首次設定與配置工具 (Setup.py)**
* 執行 `python Setup.py` 啟動圖形化設定工具。
* **功能**
* 檢查 `config.py``.env` 文件是否存在。
* 如果 `config.py` 不存在,使用 `config_template.py` 作為模板創建。
* 如果 `.env` 不存在,提示用戶輸入 API 金鑰等敏感資訊並創建。
* 提供多個標籤頁用於配置:
* **API Settings**: 設定 OpenAI/兼容 API 的 Base URL、API Key 和模型名稱。
* **MCP Servers**: 啟用/禁用和配置 Exa、Chroma 及自定義 MCP 伺服器。
* **Game Settings**: 配置遊戲視窗標題、執行檔路徑、位置、大小和自動重啟選項。
* **Memory Settings (新增 2025-05-02)**: 配置 ChromaDB 記憶體整合,包括啟用預載入、設定集合名稱(用戶資料、對話、機器人知識)和預載入記憶數量。
* 提供按鈕以保存設定、安裝依賴項、運行主腳本 (`main.py`)、運行測試腳本 (`test/llm_debug_script.py`) 以及停止由其啟動的進程。
* **重要**`.env` 文件應加入 `.gitignore``config.py` 通常也應加入 `.gitignore`
2. **API 設定**API 金鑰和其他敏感資訊儲存在 `.env` 文件中,由 `config.py` 讀取。
3. **核心配置 (config.py)**包含非敏感的系統參數、MCP 伺服器列表、UI 模板路徑、遊戲視窗設定等。此文件現在由 `Setup.py` 根據 `config_template.py` 生成(如果不存在)。
4. **MCP 服務器配置**:在 `config.py` 中配置要連接的 MCP 服務器。
@ -507,8 +525,78 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- **目的**:讓用戶在啟動時能夠輸入自己的名字。
- **修改內容**
- 新增了一個 `get_username()` 函數來提示用戶輸入名字
- 在 `debug_loop()` 函數中,刪除了固定的 `user_name = "Debugger"` 行,並替換為從 `get_username()` 函數獲取名字的調用
- **效果**:修改後,腳本啟動時會提示用戶輸入自己的名字。如果用戶直接按 Enter 而不輸入任何名字,它將使用預設的 `Debugger` 作為用戶名。輸入完名字後,腳本會繼續執行原來的功能。
- 在 `debug_loop()` 函數中,刪除了固定的 `user_name = "Debugger"` 行,並替換為從 `get_username()` 函數獲取名字的調用。
- **新增 ChromaDB 初始化與數據預取**
- 在 `debug_loop()` 開始時導入 `chroma_client` 並調用 `initialize_chroma_client()`
- 在每次用戶輸入後、調用 `llm_interaction.get_llm_response` 之前,新增了調用 `chroma_client.get_entity_profile()``chroma_client.get_related_memories()` 的邏輯。
- 將獲取的用戶資料和相關記憶作為參數傳遞給 `get_llm_response`
- **效果**:修改後,腳本啟動時會提示用戶輸入自己的名字(預設為 'Debugger')。它現在不僅會初始化 ChromaDB 連接,還會在每次互動前預先查詢用戶資料和相關記憶,並將這些資訊注入到發送給 LLM 的系統提示中,以便在測試期間更真實地模擬記憶體功能。
## 最近改進2025-05-02
### ChromaDB 客戶端初始化更新
- **目的**:更新 ChromaDB 客戶端初始化方式以兼容 ChromaDB v1.0.6+ 版本,解決舊版 `chromadb.Client(Settings(...))` 方法被棄用的問題。
- **`chroma_client.py`**
- 修改了 `initialize_chroma_client` 函數。
- 將舊的初始化代碼:
```python
_client = chromadb.Client(Settings(
chroma_db_impl="duckdb+parquet",
persist_directory=config.CHROMA_DATA_DIR
))
```
- 替換為新的推薦方法:
```python
_client = chromadb.PersistentClient(path=config.CHROMA_DATA_DIR)
```
- **效果**:腳本現在使用 ChromaDB v1.0.6+ 推薦的 `PersistentClient` 來連接本地持久化數據庫,避免了啟動時的 `deprecated configuration` 錯誤。
### ChromaDB 記憶體系統整合與優化
- **目的**:引入基於 ChromaDB 的向量記憶體系統,以提高回應速度和上下文連貫性,並提供可配置的記憶體預載入選項。
- **`Setup.py`**
- 新增 "Memory Settings" 標籤頁,允許用戶:
- 啟用/禁用用戶資料預載入 (`ENABLE_PRELOAD_PROFILES`)。
- 設定預載入的相關記憶數量 (`PRELOAD_RELATED_MEMORIES`)。
- 配置 ChromaDB 集合名稱 (`PROFILES_COLLECTION`, `CONVERSATIONS_COLLECTION`, `BOT_MEMORY_COLLECTION`)。
- 更新 `load_current_config`, `update_ui_from_data`, `save_settings``generate_config_file` 函數以處理這些新設定。
- 修正了 `generate_config_file` 中寫入 ChromaDB 設定的邏輯,確保設定能正確保存到 `config.py`
- 修正了 `update_ui_from_data` 中的 `NameError`
- 將 "Profiles Collection" 的預設值更新為 "wolfhart_memory",以匹配實際用法。
- **`config_template.py`**
- 添加了 ChromaDB 相關設定的佔位符。
- **`chroma_client.py`**
- 新增模塊,封裝 ChromaDB 連接和查詢邏輯。
- 實現 `initialize_chroma_client`, `get_collection`, `get_entity_profile`, `get_related_memories`, `get_bot_knowledge` 函數。
- 更新 `initialize_chroma_client` 以使用 `chromadb.PersistentClient`
- 修正 `get_entity_profile` 以使用 `query` 方法(而非 `get`)和正確的集合名稱 (`config.BOT_MEMORY_COLLECTION`) 來查找用戶資料。
- **`main.py`**
- 導入 `chroma_client`
- 添加 `initialize_memory_system` 函數,在啟動時根據配置初始化 ChromaDB。
- 在主循環中,根據 `config.ENABLE_PRELOAD_PROFILES``config.PRELOAD_RELATED_MEMORIES` 設定,在調用 LLM 前預載入用戶資料、相關記憶和機器人知識。
- 將預載入的數據傳遞給 `llm_interaction.get_llm_response`
- **`llm_interaction.py`**
- 更新 `get_llm_response``get_system_prompt` 函數簽名以接收預載入的記憶體數據。
- 修改 `get_system_prompt` 以:
- 在提示中包含預載入的用戶資料、相關記憶和機器人知識(如果可用)。
- 根據是否有預載入數據,動態調整記憶體檢索協議的說明(優化版 vs. 完整版)。
- 修正了在禁用預載入時,基本用戶檢索範例中使用的集合名稱,使其與 `chroma_client.py` 一致 (`config.BOT_MEMORY_COLLECTION`)。
- **效果**:實現了可配置的記憶體預載入功能,預期能提高回應速度和質量。統一了各模塊中關於集合名稱和查詢邏輯的處理。
### MCP 伺服器子進程管理與 Windows 安全終止 (修正)
- **目的**:確保由 `main.py` 啟動的 MCP 伺服器(根據 `config.py` 配置)能夠在主腳本退出時(無論正常或異常)被可靠地終止,特別是在 Windows 環境下。
- **`main.py`**
- **恢復啟動方式**`connect_and_discover` 函數恢復使用 `mcp.client.stdio.stdio_client` 來啟動伺服器並建立連接。這解決了先前手動管理子進程導致的 `AttributeError: 'StreamWriter' object has no attribute 'send'` 問題。
- **依賴 `stdio_client` 進行終止**:不再手動管理伺服器子進程的 `Process` 對象。現在依賴 `stdio_client` 的異步上下文管理器 (`__aexit__` 方法) 在 `AsyncExitStack` 關閉時(於 `shutdown` 函數中調用 `exit_stack.aclose()` 時觸發)來處理其啟動的子進程的終止。
- **保留 Windows 事件處理器**
- 仍然保留了 `windows_ctrl_handler` 函數和使用 `win32api.SetConsoleCtrlHandler` 註冊它的邏輯(如果 `pywin32` 可用)。
- **注意**:此處理程序現在**不直接**終止 MCP 伺服器進程(因為 `mcp_server_processes` 字典不再被填充)。它的主要作用是確保在 Windows 上的各種退出事件(如關閉控制台窗口)能觸發 Python 的正常關閉流程,進而執行 `finally` 塊中的 `shutdown()` 函數,最終調用 `exit_stack.aclose()` 來讓 `stdio_client` 清理其子進程。
- `terminate_all_mcp_servers` 函數雖然保留,但因 `mcp_server_processes` 為空而不會執行實際的終止操作。
- **移除冗餘終止調用**:從 `shutdown` 函數中移除了對 `terminate_all_mcp_servers` 的直接調用,因為終止邏輯現在由 `exit_stack.aclose()` 間接觸發。
- **依賴項**Windows 上的控制台事件處理仍然依賴 `pywin32` 套件。如果未安裝,程式會打印警告,關閉時的可靠性可能略有降低(但 `stdio_client` 的正常清理機制應在多數情況下仍然有效)。
- **效果**:恢復了與 `mcp` 庫的兼容性,同時通過標準的上下文管理和輔助性的 Windows 事件處理,實現了在主程式退出時關閉 MCP 伺服器子進程的目標。
## 開發建議
@ -590,3 +678,50 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
3. **LLM 連接問題**: 驗證 API 密鑰和網絡連接
4. **MCP 服務器連接失敗**: 確認服務器配置正確並且運行中
5. **工具調用後無回應**: 檢查 llm_debug.log 文件,查看工具調用結果和解析過程
## 最近改進2025-05-02
### MCP 伺服器子進程管理與 Windows 安全終止 (修正)
- **目的**:確保由 `main.py` 啟動的 MCP 伺服器(根據 `config.py` 配置)能夠在主腳本退出時(無論正常或異常)被可靠地終止,特別是在 Windows 環境下。
- **`main.py`**
- **恢復啟動方式**`connect_and_discover` 函數恢復使用 `mcp.client.stdio.stdio_client` 來啟動伺服器並建立連接。這解決了先前手動管理子進程導致的 `AttributeError: 'StreamWriter' object has no attribute 'send'` 問題。
- **依賴 `stdio_client` 進行終止**:不再手動管理伺服器子進程的 `Process` 對象。現在依賴 `stdio_client` 的異步上下文管理器 (`__aexit__` 方法) 在 `AsyncExitStack` 關閉時(於 `shutdown` 函數中調用 `exit_stack.aclose()` 時觸發)來處理其啟動的子進程的終止。
- **保留 Windows 事件處理器**
- 仍然保留了 `windows_ctrl_handler` 函數和使用 `win32api.SetConsoleCtrlHandler` 註冊它的邏輯(如果 `pywin32` 可用)。
- **注意**:此處理程序現在**不直接**終止 MCP 伺服器進程(因為 `mcp_server_processes` 字典不再被填充)。它的主要作用是確保在 Windows 上的各種退出事件(如關閉控制台窗口)能觸發 Python 的正常關閉流程,進而執行 `finally` 塊中的 `shutdown()` 函數,最終調用 `exit_stack.aclose()` 來讓 `stdio_client` 清理其子進程。
- `terminate_all_mcp_servers` 函數雖然保留,但因 `mcp_server_processes` 為空而不會執行實際的終止操作。
- **移除冗餘終止調用**:從 `shutdown` 函數中移除了對 `terminate_all_mcp_servers` 的直接調用,因為終止邏輯現在由 `exit_stack.aclose()` 間接觸發。
- **依賴項**Windows 上的控制台事件處理仍然依賴 `pywin32` 套件。如果未安裝,程式會打印警告,關閉時的可靠性可能略有降低(但 `stdio_client` 的正常清理機制應在多數情況下仍然有效)。
- **效果**:恢復了與 `mcp` 庫的兼容性,同時通過標準的上下文管理和輔助性的 Windows 事件處理,實現了在主程式退出時關閉 MCP 伺服器子進程的目標。
</final_file_content>
IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.<environment_details>
# VSCode Visible Files
ui_interaction.py
ui_interaction.py
ClaudeCode.md
# VSCode Open Tabs
config_template.py
test/llm_debug_script.py
chroma_client.py
main.py
ClaudeCode.md
Setup.py
llm_interaction.py
# Recently Modified Files
These files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):
ClaudeCode.md
# Current Time
5/2/2025, 11:11:05 AM (Asia/Taipei, UTC+8:00)
# Context Window Usage
796,173 / 1,000K tokens used (80%)
# Current Mode
ACT MODE
</environment_details>

181
Setup.py
View File

@ -208,7 +208,28 @@ def load_current_config():
print(f"Error parsing MCP_SERVERS section: {e}")
import traceback
traceback.print_exc()
# Extract memory settings
enable_preload_match = re.search(r'ENABLE_PRELOAD_PROFILES\s*=\s*(True|False)', config_content)
if enable_preload_match:
config_data["ENABLE_PRELOAD_PROFILES"] = (enable_preload_match.group(1) == "True")
related_memories_match = re.search(r'PRELOAD_RELATED_MEMORIES\s*=\s*(\d+)', config_content)
if related_memories_match:
config_data["PRELOAD_RELATED_MEMORIES"] = int(related_memories_match.group(1))
profiles_collection_match = re.search(r'PROFILES_COLLECTION\s*=\s*["\'](.+?)["\']', config_content)
if profiles_collection_match:
config_data["PROFILES_COLLECTION"] = profiles_collection_match.group(1)
conversations_collection_match = re.search(r'CONVERSATIONS_COLLECTION\s*=\s*["\'](.+?)["\']', config_content)
if conversations_collection_match:
config_data["CONVERSATIONS_COLLECTION"] = conversations_collection_match.group(1)
bot_memory_collection_match = re.search(r'BOT_MEMORY_COLLECTION\s*=\s*["\'](.+?)["\']', config_content)
if bot_memory_collection_match:
config_data["BOT_MEMORY_COLLECTION"] = bot_memory_collection_match.group(1)
except Exception as e:
print(f"Error reading config.py: {e}")
import traceback
@ -358,8 +379,45 @@ def generate_config_file(config_data, env_data):
f.write(f"GAME_WINDOW_Y = {game_config['GAME_WINDOW_Y']}\n")
f.write(f"GAME_WINDOW_WIDTH = {game_config['GAME_WINDOW_WIDTH']}\n")
f.write(f"GAME_WINDOW_HEIGHT = {game_config['GAME_WINDOW_HEIGHT']}\n")
f.write(f"MONITOR_INTERVAL_SECONDS = {game_config['MONITOR_INTERVAL_SECONDS']}\n")
f.write(f"MONITOR_INTERVAL_SECONDS = {game_config['MONITOR_INTERVAL_SECONDS']}\n\n")
# --- Add explicit print before writing Chroma section ---
print("DEBUG: Writing ChromaDB Memory Configuration section...")
# --- End explicit print ---
# Write ChromaDB Memory Configuration
f.write("# =============================================================================\n")
f.write("# ChromaDB Memory Configuration\n")
f.write("# =============================================================================\n")
# Ensure boolean is written correctly as True/False, not string 'True'/'False'
enable_preload = config_data.get('ENABLE_PRELOAD_PROFILES', True) # Default to True if key missing
f.write(f"ENABLE_PRELOAD_PROFILES = {str(enable_preload)}\n") # Writes True or False literal
preload_memories = config_data.get('PRELOAD_RELATED_MEMORIES', 2) # Default to 2
f.write(f"PRELOAD_RELATED_MEMORIES = {preload_memories}\n\n")
f.write("# Collection Names (used for both local access and MCP tool calls)\n")
profiles_col = config_data.get('PROFILES_COLLECTION', 'user_profiles')
f.write(f"PROFILES_COLLECTION = \"{profiles_col}\"\n")
conversations_col = config_data.get('CONVERSATIONS_COLLECTION', 'conversations')
f.write(f"CONVERSATIONS_COLLECTION = \"{conversations_col}\"\n")
bot_memory_col = config_data.get('BOT_MEMORY_COLLECTION', 'wolfhart_memory')
f.write(f"BOT_MEMORY_COLLECTION = \"{bot_memory_col}\"\n\n")
f.write("# Ensure Chroma path is consistent for both direct access and MCP\n")
# Get the path set in the UI (or default)
# Use .get() chain with defaults for safety
chroma_data_dir_ui = config_data.get("MCP_SERVERS", {}).get("chroma", {}).get("data_dir", DEFAULT_CHROMA_DATA_PATH)
# Normalize path for writing into the config file string (use forward slashes)
normalized_chroma_path = normalize_path(chroma_data_dir_ui)
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")
print("Generated config.py file successfully")
@ -385,7 +443,8 @@ class WolfChatSetup(tk.Tk):
self.create_api_tab()
self.create_mcp_tab()
self.create_game_tab()
self.create_memory_tab() # 新增記憶設定標籤頁
# Create bottom buttons
self.create_bottom_buttons()
@ -837,7 +896,99 @@ 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_tab(self):
"""Create the Memory Settings tab"""
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Memory Settings")
# Main frame with padding
main_frame = ttk.Frame(tab, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
# Header
header = ttk.Label(main_frame, text="ChromaDB Memory Integration", font=("", 12, "bold"))
header.pack(anchor=tk.W, pady=(0, 10))
# Enable Pre-loading
preload_frame = ttk.Frame(main_frame)
preload_frame.pack(fill=tk.X, pady=5)
self.preload_profiles_var = tk.BooleanVar(value=True)
preload_cb = ttk.Checkbutton(preload_frame, text="Enable user profile pre-loading",
variable=self.preload_profiles_var)
preload_cb.pack(anchor=tk.W, pady=2)
# Collection Names Frame
collections_frame = ttk.LabelFrame(main_frame, text="Collection Names")
collections_frame.pack(fill=tk.X, pady=10)
# User Profiles Collection
profiles_col_frame = ttk.Frame(collections_frame)
profiles_col_frame.pack(fill=tk.X, pady=5, padx=10)
profiles_col_label = ttk.Label(profiles_col_frame, text="Profiles Collection:", width=20)
profiles_col_label.pack(side=tk.LEFT, padx=(0, 5))
# 修正:將預設值改為 "wolfhart_memory" 以匹配實際用法
self.profiles_collection_var = tk.StringVar(value="wolfhart_memory")
profiles_col_entry = ttk.Entry(profiles_col_frame, textvariable=self.profiles_collection_var)
profiles_col_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Conversations Collection
conv_col_frame = ttk.Frame(collections_frame)
conv_col_frame.pack(fill=tk.X, pady=5, padx=10)
conv_col_label = ttk.Label(conv_col_frame, text="Conversations Collection:", width=20)
conv_col_label.pack(side=tk.LEFT, padx=(0, 5))
self.conversations_collection_var = tk.StringVar(value="conversations")
conv_col_entry = ttk.Entry(conv_col_frame, textvariable=self.conversations_collection_var)
conv_col_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Bot Memory Collection
bot_col_frame = ttk.Frame(collections_frame)
bot_col_frame.pack(fill=tk.X, pady=5, padx=10)
bot_col_label = ttk.Label(bot_col_frame, text="Bot Memory Collection:", width=20)
bot_col_label.pack(side=tk.LEFT, padx=(0, 5))
self.bot_memory_collection_var = tk.StringVar(value="wolfhart_memory")
bot_col_entry = ttk.Entry(bot_col_frame, textvariable=self.bot_memory_collection_var)
bot_col_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Pre-loading Settings
preload_settings_frame = ttk.LabelFrame(main_frame, text="Pre-loading Settings")
preload_settings_frame.pack(fill=tk.X, pady=10)
# Related memories to preload
related_frame = ttk.Frame(preload_settings_frame)
related_frame.pack(fill=tk.X, pady=5, padx=10)
related_label = ttk.Label(related_frame, text="Related Memories Count:", width=20)
related_label.pack(side=tk.LEFT, padx=(0, 5))
self.related_memories_var = tk.IntVar(value=2)
related_spinner = ttk.Spinbox(related_frame, from_=0, to=10, width=5, textvariable=self.related_memories_var)
related_spinner.pack(side=tk.LEFT)
related_info = ttk.Label(related_frame, text="(0 to disable related memories pre-loading)")
related_info.pack(side=tk.LEFT, padx=(5, 0))
# Information box
info_frame = ttk.LabelFrame(main_frame, text="Information")
info_frame.pack(fill=tk.BOTH, expand=True, pady=10)
info_text = (
"• Pre-loading user profiles will speed up responses by fetching data before LLM calls\n"
"• Collection names must match your ChromaDB configuration\n"
"• The bot will automatically use pre-loaded data if available\n"
"• If data isn't found locally, the bot will fall back to using tool calls"
)
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_bottom_buttons(self):
"""Create bottom action buttons"""
btn_frame = ttk.Frame(self)
@ -1004,10 +1155,17 @@ class WolfChatSetup(tk.Tk):
self.restart_var.set(game_config.get("ENABLE_SCHEDULED_RESTART", True))
self.interval_var.set(game_config.get("RESTART_INTERVAL_MINUTES", 60))
self.monitor_interval_var.set(game_config.get("MONITOR_INTERVAL_SECONDS", 5))
# 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.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"))
# Update visibility and states
self.update_exa_settings_visibility()
except Exception as e:
print(f"Error updating UI from data: {e}")
import traceback
@ -1253,7 +1411,14 @@ class WolfChatSetup(tk.Tk):
"GAME_WINDOW_HEIGHT": self.height_var.get(),
"MONITOR_INTERVAL_SECONDS": self.monitor_interval_var.get()
}
# 保存記憶設定
self.config_data["ENABLE_PRELOAD_PROFILES"] = self.preload_profiles_var.get()
self.config_data["PRELOAD_RELATED_MEMORIES"] = self.related_memories_var.get()
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()
# 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():

158
chroma_client.py Normal file
View File

@ -0,0 +1,158 @@
# chroma_client.py
import chromadb
from chromadb.config import Settings
import os
import json
import config
import time
# Global client variables
_client = None
_collections = {}
def initialize_chroma_client():
"""Initializes and connects to ChromaDB"""
global _client
try:
# Ensure Chroma directory exists
os.makedirs(config.CHROMA_DATA_DIR, exist_ok=True)
# New method (for v1.0.6+)
_client = chromadb.PersistentClient(path=config.CHROMA_DATA_DIR)
print(f"Successfully connected to ChromaDB ({config.CHROMA_DATA_DIR})")
return True
except Exception as e:
print(f"Failed to connect to ChromaDB: {e}")
return False
def get_collection(collection_name):
"""Gets or creates a collection"""
global _client, _collections
if not _client:
if not initialize_chroma_client():
return None
if collection_name not in _collections:
try:
_collections[collection_name] = _client.get_or_create_collection(
name=collection_name
)
print(f"Successfully got or created collection '{collection_name}'")
except Exception as e:
print(f"Failed to get collection '{collection_name}': {e}")
return None
return _collections[collection_name]
def get_entity_profile(entity_name, collection_name=None):
"""
Retrieves entity data (e.g., user profile) from the specified collection
Args:
entity_name: The name of the entity to retrieve (e.g., username)
collection_name: The name of the collection; if None, uses BOT_MEMORY_COLLECTION from config (Correction: Use bot memory collection)
"""
if not collection_name:
# Correction: Default to using BOT_MEMORY_COLLECTION to store user data
collection_name = config.BOT_MEMORY_COLLECTION
profile_collection = get_collection(collection_name)
if not profile_collection:
return None
try:
# Restore: Use query method for similarity search instead of exact ID matching
query_text = f"{entity_name} profile"
start_time = time.time()
results = profile_collection.query(
query_texts=[query_text],
n_results=1 # Only get the most relevant result
)
duration = time.time() - start_time
# Restore: Check the return result of the query method
if results and results.get('documents') and results['documents'][0]:
# query returns a list of lists, so [0][0] is needed
print(f"Successfully retrieved data for '{entity_name}' (Query: '{query_text}') (Time taken: {duration:.3f}s)")
return results['documents'][0][0]
else:
print(f"Could not find data for '{entity_name}' (Query: '{query_text}')")
return None
except Exception as e:
print(f"Error querying entity data (Query: '{query_text}'): {e}")
return None
def get_related_memories(entity_name, topic=None, limit=3, collection_name=None):
"""
Retrieves memories related to an entity
Args:
entity_name: The name of the entity (e.g., username)
topic: Optional topic keyword
limit: Maximum number of memories to return
collection_name: The name of the collection; if None, uses CONVERSATIONS_COLLECTION from config
"""
if not collection_name:
collection_name = config.CONVERSATIONS_COLLECTION
memory_collection = get_collection(collection_name)
if not memory_collection:
return []
query = f"{entity_name}"
if topic:
query += f" {topic}"
try:
start_time = time.time()
results = memory_collection.query(
query_texts=[query],
n_results=limit
)
duration = time.time() - start_time
if results and results['documents'] and results['documents'][0]:
memory_count = len(results['documents'][0])
print(f"Successfully retrieved {memory_count} related memories for '{entity_name}' (Time taken: {duration:.3f}s)")
return results['documents'][0]
print(f"Could not find related memories for '{entity_name}'")
return []
except Exception as e:
print(f"Error querying related memories: {e}")
return []
def get_bot_knowledge(concept, limit=3, collection_name=None):
"""
Retrieves the bot's knowledge about a specific concept
Args:
concept: The concept to query
limit: Maximum number of knowledge entries to return
collection_name: The name of the collection; if None, uses BOT_MEMORY_COLLECTION from config
"""
if not collection_name:
collection_name = config.BOT_MEMORY_COLLECTION
knowledge_collection = get_collection(collection_name)
if not knowledge_collection:
return []
try:
start_time = time.time()
results = knowledge_collection.query(
query_texts=[concept],
n_results=limit
)
duration = time.time() - start_time
if results and results['documents'] and results['documents'][0]:
knowledge_count = len(results['documents'][0])
print(f"Successfully retrieved {knowledge_count} bot knowledge entries about '{concept}' (Time taken: {duration:.3f}s)")
return results['documents'][0]
print(f"Could not find bot knowledge about '{concept}'")
return []
except Exception as e:
print(f"Error querying bot knowledge: {e}")
return []

View File

@ -59,4 +59,18 @@ GAME_WINDOW_X = ${GAME_WINDOW_X}
GAME_WINDOW_Y = ${GAME_WINDOW_Y}
GAME_WINDOW_WIDTH = ${GAME_WINDOW_WIDTH}
GAME_WINDOW_HEIGHT = ${GAME_WINDOW_HEIGHT}
MONITOR_INTERVAL_SECONDS = ${MONITOR_INTERVAL_SECONDS}
MONITOR_INTERVAL_SECONDS = ${MONITOR_INTERVAL_SECONDS}
# =============================================================================
# ChromaDB Memory Configuration
# =============================================================================
ENABLE_PRELOAD_PROFILES = ${ENABLE_PRELOAD_PROFILES}
PRELOAD_RELATED_MEMORIES = ${PRELOAD_RELATED_MEMORIES}
# Collection Names (used for both local access and MCP tool calls)
PROFILES_COLLECTION = "${PROFILES_COLLECTION}"
CONVERSATIONS_COLLECTION = "${CONVERSATIONS_COLLECTION}"
BOT_MEMORY_COLLECTION = "${BOT_MEMORY_COLLECTION}"
# Ensure Chroma path is consistent for both direct access and MCP
CHROMA_DATA_DIR = os.path.abspath("chroma_data")

View File

@ -67,130 +67,199 @@ try:
except Exception as e: print(f"Failed to initialize OpenAI/Compatible client: {e}")
# --- System Prompt Definition ---
def get_system_prompt(persona_details: str | None) -> str:
def get_system_prompt(
persona_details: str | None,
user_profile: str | None = None,
related_memories: list | None = None,
bot_knowledge: list | None = None
) -> str:
"""
Constructs the system prompt requiring structured JSON output format.
構建系統提示包括預加載的用戶資料相關記憶和機器人知識
"""
persona_header = f"You are {config.PERSONA_NAME}."
# 處理 persona_details
persona_info = "(No specific persona details were loaded.)"
if persona_details:
try: persona_info = f"Your key persona information is defined below. Adhere to it strictly:\n--- PERSONA START ---\n{persona_details}\n--- PERSONA END ---"
except Exception as e: print(f"Warning: Could not process persona_details string: {e}"); persona_info = f"Your key persona information (raw):\n{persona_details}"
try:
persona_info = f"Your key persona information is defined below. Adhere to it strictly:\n--- PERSONA START ---\n{persona_details}\n--- PERSONA END ---"
except Exception as e:
print(f"Warning: Could not process persona_details string: {e}")
persona_info = f"Your key persona information (raw):\n{persona_details}"
# Add mandatory memory tool usage enforcement based on Wolfhart Memory Integration protocol
memory_enforcement = """
=== CHROMADB MEMORY RETRIEVAL PROTOCOL - Wolfhart Memory To personalize your responses to different users, you MUST follow this memory access protocol internally before responding:
Here you need to obtain the conversation memory, impression, and emotional response of the person you are talking to.
# 添加用戶資料部分
user_context = ""
if user_profile:
user_context = f"""
<user_profile>
{user_profile}
</user_profile>
**1. Basic User Retrieval:**
- Identify the username from `<CURRENT_MESSAGE>`
- Using the `tool_calls` mechanism, execute: `chroma_query_documents(collection_name: "wolfhart_user_profiles", query_texts: ["{username} profile"], n_results: 3)`
- This step must be completed before any response generation
Above is the profile information for your current conversation partner.
Reference this information to personalize your responses appropriately without explicitly mentioning you have this data.
"""
**2. Context Expansion:**
- Perform additional queries as needed, using the `tool_calls` mechanism:
- Relevant conversations: `chroma_query_documents(collection_name: "wolfhart_conversations", query_texts: ["{username} {query keywords}"], n_results: 5)`
- Core personality reference: `chroma_query_documents(collection_name: "wolfhart_memory", query_texts: ["Wolfhart {relevant attitude}"], n_results: 3)`
# 添加相關記憶部分
memories_context = ""
if related_memories and len(related_memories) > 0:
memories_formatted = "\n".join([f"- {memory}" for memory in related_memories])
memories_context = f"""
<related_memories>
{memories_formatted}
</related_memories>
**3. Maintain Output Format:**
- After memory retrieval, still respond using the specified JSON format:
```json
{
"dialogue": "Actual dialogue response...",
"commands": [...],
"thoughts": "Internal analysis..."
}
Above are some related memories about this user from previous conversations.
Incorporate this context naturally without explicitly referencing these memories.
"""
**4. 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 and systems within Last War that have specific strategic implications and player evaluation metrics.
# 添加機器人知識部分
knowledge_context = ""
if bot_knowledge and len(bot_knowledge) > 0:
knowledge_formatted = "\n".join([f"- {knowledge}" for knowledge in bot_knowledge])
knowledge_context = f"""
<bot_knowledge>
{knowledge_formatted}
</bot_knowledge>
WARNING: Failure to follow this memory retrieval protocol, especially skipping Step 1, will be considered a critical roleplaying failure.
===== END OF MANDATORY MEMORY PROTOCOL =====
"""
Above is your own knowledge about relevant topics in this conversation.
Use this information naturally as part of your character's knowledge base.
"""
# Original system prompt structure with memory enforcement added
# 修改記憶協議部分,根據預載入的資訊調整提示
has_preloaded_data = bool(user_profile or (related_memories and len(related_memories) > 0) or (bot_knowledge and len(bot_knowledge) > 0))
if has_preloaded_data:
memory_enforcement = f"""
=== CHROMADB MEMORY INTEGRATION - OPTIMIZED VERSION
You've been provided with pre-loaded information:
{("- User profile information" if user_profile else "")}
{("- " + str(len(related_memories)) + " related memories about this user" if related_memories and len(related_memories) > 0 else "")}
{("- " + str(len(bot_knowledge)) + " pieces of your knowledge about relevant topics" if bot_knowledge and len(bot_knowledge) > 0 else "")}
You can still use memory tools for additional information when helpful:
1. **Additional User Context:**
- To get more user conversations: `chroma_query_documents(collection_name: "{config.CONVERSATIONS_COLLECTION}", query_texts: ["{{username}} {{specific topic}}"], n_results: 5)`
2. **Your Knowledge Base:**
- To recall more of your knowledge: `chroma_query_documents(collection_name: "{config.BOT_MEMORY_COLLECTION}", query_texts: ["Wolfhart {{specific concept}}"], n_results: 3)`
IMPORTANT: You already have good context. Use tools only when truly needed for specific topics not covered in pre-loaded information.
"""
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:
**1. Basic User Retrieval:**
- Identify the username from `<CURRENT_MESSAGE>`
# 修正:使用 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
**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.
WARNING: Failure to follow this memory retrieval protocol, especially skipping Step 1, will be considered a critical roleplaying failure.
"""
# 組合系統提示
system_prompt = f"""
{persona_header}
{persona_info}
{persona_header}
{persona_info}
You are an AI assistant integrated into this game's chat environment. Your primary goal is to engage naturally in conversations, be particularly attentive when the name "wolf" is mentioned, and provide assistance or information when relevant, all while strictly maintaining your persona.
{user_context}
You have access to several tools: Web Search and Memory Management tools.
{memories_context}
**CORE IDENTITY AND TOOL USAGE:**
- You ARE Wolfhart - an intelligent, calm, and strategic mastermind who serves as a member of server #11 and is responsible for the Capital position. Youspeaks good British aristocratic English.
- Positions bring buffs, so people often confuse them.
- **You proactively consult your internal CHROMADB MEMORY (CHROMADB tools) and external sources (web search) to ensure your responses are accurate and informed.**
- When you use tools to gain information, you ASSIMILATE that knowledge as if it were already part of your intelligence network.
- Your responses should NEVER sound like search results or data dumps.
- Information from tools should be expressed through your unique personality - sharp, precise, with an air of confidence and authority.
- You speak with deliberate pace, respectful but sharp-tongued, and maintain composure even in unusual situations.
- Though you outwardly act dismissive or cold at times, you secretly care about providing quality information and assistance.
{knowledge_context}
**OUTPUT FORMAT REQUIREMENTS:**
You MUST respond in the following JSON format:
```json
{{
"commands": [
You are an AI assistant integrated into this game's chat environment. Your primary goal is to engage naturally in conversations, be particularly attentive when the name "wolf" is mentioned, and provide assistance or information when relevant, all while strictly maintaining your persona.
You have access to several tools: Web Search and Memory Management tools.
**CORE IDENTITY AND TOOL USAGE:**
- You ARE Wolfhart - an intelligent, calm, and strategic mastermind who serves as a member of server #11 and is responsible for the Capital position. Youspeaks good British aristocratic English.
- Positions bring buffs, so people often confuse them.
{("- **You already have the user's profile information and some related memories (shown above). Use this to personalize your responses.**" if has_preloaded_data else "- **You must use memory tools to understand who you're talking to and personalize responses.**")}
- When you use tools to gain information, you ASSIMILATE that knowledge as if it were already part of your intelligence network.
- Your responses should NEVER sound like search results or data dumps.
- Information from tools should be expressed through your unique personality - sharp, precise, with an air of confidence and authority.
- You speak with deliberate pace, respectful but sharp-tongued, and maintain composure even in unusual situations.
- Though you outwardly act dismissive or cold at times, you secretly care about providing quality information and assistance.
{memory_enforcement}
**OUTPUT FORMAT REQUIREMENTS:**
You MUST respond in the following JSON format:
```json
{{
"type": "command_type",
"parameters": {{
"param1": "value1",
"param2": "value2"
}}
"commands": [
{{
"type": "command_type",
"parameters": {{
"param1": "value1",
"param2": "value2"
}}
}}
],
"thoughts": "Your internal analysis and reasoning inner thoughts or emotions (not shown to the user)",
"dialogue": "Your actual response that will be shown in the game chat"
}}
],
"thoughts": "Your internal analysis and reasoning inner thoughts or emotions (not shown to the user)",
"dialogue": "Your actual response that will be shown in the game chat"
}}
```
```
{memory_enforcement}
**Field Descriptions:**
1. `dialogue` (REQUIRED): This is the ONLY text that will be shown to the user in the game chat. Must follow these rules:
- Respond ONLY in the same language as the user's message
- Keep it brief and conversational (1-2 sentences usually)
- ONLY include spoken dialogue words (no actions, expressions, narration, etc.)
- Maintain your character's personality and speech patterns
- AFTER TOOL USAGE: Your dialogue MUST contain a non-empty response that incorporates the tool results naturally
- **Crucially, this field must contain ONLY the NEW response generated for the LATEST user message marked with `<CURRENT_MESSAGE>`. DO NOT include any previous chat history in this field.**
**Field Descriptions:**
1. `dialogue` (REQUIRED): This is the ONLY text that will be shown to the user in the game chat. Must follow these rules:
- Respond ONLY in the same language as the user's message
- Keep it brief and conversational (1-2 sentences usually)
- ONLY include spoken dialogue words (no actions, expressions, narration, etc.)
- Maintain your character's personality and speech patterns
- AFTER TOOL USAGE: Your dialogue MUST contain a non-empty response that incorporates the tool results naturally
- **Crucially, this field must contain ONLY the NEW response generated for the LATEST user message marked with `<CURRENT_MESSAGE>`. DO NOT include any previous chat history in this field.**
2. `commands` (OPTIONAL): An array of specific command objects the *application* should execute *after* delivering your dialogue. Currently, the only supported command here is `remove_position`.
- `remove_position`: Initiate the process to remove a user's assigned position/role.
Parameters: (none)
Usage: Include this ONLY if you decide to grant a user's explicit request for position removal, based on Wolfhart's judgment.
**IMPORTANT**: Do NOT put requests for Web Search or MEMORY RETRIEVAL PROTOCOL (like `web_search`, `chroma_query_documents`, `chroma_get_documents`, etc.) in this `commands` field. Use the dedicated `tool_calls` mechanism for those. You have access to tools for web search and managing your memory (querying, creating, deleting nodes/observations/relations) - invoke them via `tool_calls` when needed according to the Memory Protocol.
2. `commands` (OPTIONAL): An array of specific command objects the *application* should execute *after* delivering your dialogue. Currently, the only supported command here is `remove_position`.
- `remove_position`: Initiate the process to remove a user's assigned position/role.
Parameters: (none)
Usage: Include this ONLY if you decide to grant a user's explicit request for position removal, based on Wolfhart's judgment.
**IMPORTANT**: Do NOT put requests for Web Search or MEMORY RETRIEVAL PROTOCOL (like `web_search`, `chroma_query_documents`, `chroma_get_documents`, etc.) in this `commands` field. Use the dedicated `tool_calls` mechanism for those. You have access to tools for web search and managing your memory (querying, creating, deleting nodes/observations/relations) - invoke them via `tool_calls` when needed according to the Memory Protocol.
3. `thoughts` (OPTIONAL): Your internal analysis that won't be shown to users. Use this for your reasoning process, thoughts, emotions
- Think about whether you need to use memory tools (via `tool_calls`) or chroma_query_documents or chroma_get_documents (via `tool_calls`).
- Analyze the user's message: Is it a request to remove their position? If so, evaluate its politeness and intent from Wolfhart's perspective. Decide whether to issue the `remove_position` command.
- Plan your approach before responding.
3. `thoughts` (OPTIONAL): Your internal analysis that won't be shown to users. Use this for your reasoning process, thoughts, emotions
- Think about whether you need to use memory tools (via `tool_calls`) or chroma_query_documents or chroma_get_documents (via `tool_calls`).
- Analyze the user's message: Is it a request to remove their position? If so, evaluate its politeness and intent from Wolfhart's perspective. Decide whether to issue the `remove_position` command.
- Plan your approach before responding.
**CONTEXT MARKER:**
- The final user message in the input sequence will be wrapped in `<CURRENT_MESSAGE>` tags. This is the specific message you MUST respond to. Your `dialogue` output should be a direct reply to this message ONLY. Preceding messages provide historical context.
**CONTEXT MARKER:**
- The final user message in the input sequence will be wrapped in `<CURRENT_MESSAGE>` tags. This is the specific message you MUST respond to. Your `dialogue` output should be a direct reply to this message ONLY. Preceding messages provide historical context.
**VERY IMPORTANT Instructions:**
**VERY IMPORTANT Instructions:**
1. **Focus your analysis and response generation *exclusively* on the LATEST user message marked with `<CURRENT_MESSAGE>`. Refer to preceding messages only for context.**
2. Determine the appropriate language for your response
3. **Tool Invocation:** If you need to use Web Search or Memory Management tools, you MUST request them using the API's dedicated `tool_calls` feature. DO NOT include tool requests like `search_nodes` or `web_search` within the `commands` array in your JSON output. The `commands` array is ONLY for the specific `remove_position` action if applicable.
4. Formulate your response in the required JSON format
5. Always maintain the {config.PERSONA_NAME} persona
6. CRITICAL: After using tools (via the `tool_calls` mechanism), ALWAYS provide a substantive dialogue response - NEVER return an empty dialogue field
7. **Handling Repetition:** If you receive a request identical or very similar to a recent one (especially action requests like position removal), DO NOT return an empty response. Acknowledge the request again briefly (e.g., "Processing this request," or "As previously stated...") and include any necessary commands or thoughts in the JSON structure. Always provide a `dialogue` value.
1. **Focus your analysis and response generation *exclusively* on the LATEST user message marked with `<CURRENT_MESSAGE>`. Refer to preceding messages only for context.**
2. Determine the appropriate language for your response
3. **Tool Invocation:** If you need to use Web Search or Memory Management tools, you MUST request them using the API's dedicated `tool_calls` feature. DO NOT include tool requests like `search_nodes` or `web_search` within the `commands` array in your JSON output. The `commands` array is ONLY for the specific `remove_position` action if applicable.
4. Formulate your response in the required JSON format
5. Always maintain the {config.PERSONA_NAME} persona
6. CRITICAL: After using tools (via the `tool_calls` mechanism), ALWAYS provide a substantive dialogue response - NEVER return an empty dialogue field
7. **Handling Repetition:** If you receive a request identical or very similar to a recent one (especially action requests like position removal), DO NOT return an empty response. Acknowledge the request again briefly (e.g., "Processing this request," or "As previously stated...") and include any necessary commands or thoughts in the JSON structure. Always provide a `dialogue` value.
**EXAMPLES OF GOOD TOOL USAGE:**
**EXAMPLES OF GOOD TOOL USAGE:**
Poor response (after web_search): "根據我的搜索水的沸點是攝氏100度。"
Poor response (after web_search): "根據我的搜索水的沸點是攝氏100度。"
Good response (after web_search): "水的沸點是的標準條件下是攝氏100度。合情合理。"
Good response (after web_search): "水的沸點是的標準條件下是攝氏100度。合情合理。"
Poor response (after web_search): "My search shows the boiling point of water is 100 degrees Celsius."
Poor response (after web_search): "My search shows the boiling point of water is 100 degrees Celsius."
Good response (after web_search): "The boiling point of water, yes. 100 degrees Celsius under standard conditions. Absolutley."
"""
Good response (after web_search): "The boiling point of water, yes. 100 degrees Celsius under standard conditions. Absolutley."
"""
return system_prompt
# --- Tool Formatting ---
@ -531,7 +600,10 @@ async def get_llm_response(
history: list[tuple[datetime, str, str, str]], # Updated history parameter type hint
mcp_sessions: dict[str, ClientSession],
available_mcp_tools: list[dict],
persona_details: str | None
persona_details: str | None,
user_profile: str | None = None, # 新增參數
related_memories: list | None = None, # 新增參數
bot_knowledge: list | None = None # 新增參數
) -> dict:
"""
Gets a response from the LLM, handling the tool-calling loop and using persona info.
@ -552,7 +624,13 @@ async def get_llm_response(
# Debug log the raw history received for this attempt
debug_log(f"LLM Request #{request_id} - Attempt {attempt_count} - Received History (Sender: {current_sender_name})", history)
system_prompt = get_system_prompt(persona_details)
# Pass new arguments to get_system_prompt
system_prompt = get_system_prompt(
persona_details,
user_profile=user_profile,
related_memories=related_memories,
bot_knowledge=bot_knowledge
)
# System prompt is logged within _build_context_messages now
if not client:

229
main.py
View File

@ -29,11 +29,29 @@ import mcp_client
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
# Conditionally import Windows-specific modules
if platform.system() == "Windows":
try:
import win32api
import win32con
except ImportError:
print("Warning: 'pywin32' not installed. MCP server subprocess termination on exit might not work reliably on Windows.")
win32api = None
win32con = None
else:
win32api = None
win32con = None
# --- Global Variables ---
active_mcp_sessions: dict[str, ClientSession] = {}
# Store Popen objects for managed MCP servers
mcp_server_processes: dict[str, asyncio.subprocess.Process] = {}
all_discovered_mcp_tools: list[dict] = []
exit_stack = AsyncExitStack()
# Stores loaded persona data (as a string for easy injection into prompt)
@ -134,7 +152,7 @@ def keyboard_listener():
# --- 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.)")
print("Game monitor output reader thread started.")
try:
while not stop_event.is_set():
if not process.stdout:
@ -164,34 +182,34 @@ def read_monitor_output(process: subprocess.Popen, queue: ThreadSafeQueue, loop:
print(f"[Monitor Reader Thread] Parsed action: '{action}'") # Log parsed action
if action == 'pause_ui':
command = {'action': 'pause'}
print(f"[Monitor Reader Thread] 準備將命令放入隊列: {command} (Preparing to queue command)") # Log before queueing
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
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] 收到舊的 'resume_ui' 訊號,忽略。(Received old 'resume_ui' signal, ignoring.)")
print("[Monitor Reader Thread] Received old 'resume_ui' signal, ignoring.")
elif action == 'restart_complete':
command = {'action': 'handle_restart_complete'}
print(f"[Monitor Reader Thread] 收到 'restart_complete' 訊號,準備將命令放入隊列: {command} (Received 'restart_complete' signal, preparing to queue command)")
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' 命令已放入隊列。(handle_restart_complete command queued.)")
print("[Monitor Reader Thread] 'handle_restart_complete' command queued.")
except Exception as q_err:
print(f"[Monitor Reader Thread] 'handle_restart_complete' 命令放入隊列時出錯: {q_err} (Error putting 'handle_restart_complete' command in queue: {q_err})")
print(f"[Monitor Reader Thread] Error putting 'handle_restart_complete' command in queue: {q_err}")
else:
print(f"[Monitor Reader Thread] 從監控器收到未知動作: {action} (Received unknown action from monitor: {action})")
print(f"[Monitor Reader Thread] Received unknown action from monitor: {action}")
except json.JSONDecodeError:
print(f"[Monitor Reader Thread] ERROR: 無法解析來自監控器的 JSON: '{line}' (Could not decode JSON from monitor: '{line}')")
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] 處理監控器輸出時出錯: {e} (Error processing monitor output: {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.)")
print("Game monitor output reader thread stopped.")
# --- End Game Monitor Signal Reader ---
@ -230,6 +248,73 @@ def log_chat_interaction(user_name: str, user_message: str, bot_name: str, bot_m
# --- End Chat Logging Function ---
# --- MCP Server Subprocess Termination Logic (Windows) ---
def terminate_all_mcp_servers():
"""Attempts to terminate all managed MCP server subprocesses."""
if not mcp_server_processes:
return
print(f"[INFO] Terminating {len(mcp_server_processes)} managed MCP server subprocess(es)...")
for key, proc in list(mcp_server_processes.items()): # Iterate over a copy of items
if proc.returncode is None: # Check if process is still running
print(f"[INFO] Terminating server '{key}' (PID: {proc.pid})...")
try:
if platform.system() == "Windows" and win32api:
# Send CTRL_BREAK_EVENT on Windows if flag was set
# Note: This requires the process was started with CREATE_NEW_PROCESS_GROUP
proc.send_signal(signal.CTRL_BREAK_EVENT)
print(f"[INFO] Sent CTRL_BREAK_EVENT to server '{key}'.")
# Optionally wait with timeout, then kill if needed
# proc.wait(timeout=5) # This is blocking, avoid in async handler?
else:
# Use standard terminate for non-Windows or if win32api failed
proc.terminate()
print(f"[INFO] Sent SIGTERM to server '{key}'.")
except ProcessLookupError:
print(f"[WARN] Process for server '{key}' (PID: {proc.pid}) not found.")
except Exception as e:
print(f"[ERROR] Error terminating server '{key}' (PID: {proc.pid}): {e}")
# Fallback to kill if terminate fails or isn't applicable
try:
if proc.returncode is None:
proc.kill()
print(f"[WARN] Forcefully killed server '{key}' (PID: {proc.pid}).")
except Exception as kill_e:
print(f"[ERROR] Error killing server '{key}' (PID: {proc.pid}): {kill_e}")
# Remove from dict after attempting termination
if key in mcp_server_processes:
del mcp_server_processes[key]
print("[INFO] Finished attempting MCP server termination.")
def windows_ctrl_handler(ctrl_type):
"""Handles Windows console control events."""
if win32con and ctrl_type in (
win32con.CTRL_C_EVENT,
win32con.CTRL_BREAK_EVENT,
win32con.CTRL_CLOSE_EVENT,
win32con.CTRL_LOGOFF_EVENT,
win32con.CTRL_SHUTDOWN_EVENT
):
print(f"[INFO] Windows Control Event ({ctrl_type}) detected. Initiating MCP server termination.")
# Directly call the termination function.
# Avoid doing complex async operations here if possible.
# The main shutdown sequence will handle async cleanup.
terminate_all_mcp_servers()
# Returning True indicates we handled the event,
# but might prevent default clean exit. Let's return False
# to allow Python's default handler to also run (e.g., for KeyboardInterrupt).
return False # Allow other handlers to process the event
return False # Event not handled
# Register the handler only on Windows and if imports succeeded
if platform.system() == "Windows" and win32api and win32con:
try:
win32api.SetConsoleCtrlHandler(windows_ctrl_handler, True)
print("[INFO] Registered Windows console control handler for MCP server cleanup.")
except Exception as e:
print(f"[ERROR] Failed to register Windows console control handler: {e}")
# --- End MCP Server Termination Logic ---
# --- Cleanup Function ---
async def shutdown():
"""Gracefully closes connections and stops monitoring tasks/processes."""
@ -289,8 +374,12 @@ async def shutdown():
game_monitor_process = None # Clear the reference
# 3. 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)...")
try:
# This will close the ClientSession contexts, which might involve
# closing the stdin/stdout pipes to the (now hopefully terminated) servers.
await exit_stack.aclose()
print("AsyncExitStack closed successfully.")
except Exception as e:
@ -310,7 +399,7 @@ async def connect_and_discover(key: str, server_config: dict):
"""
Connects to a single MCP server, initializes the session, and discovers tools.
"""
global all_discovered_mcp_tools, active_mcp_sessions, exit_stack
global all_discovered_mcp_tools, active_mcp_sessions, exit_stack # Remove mcp_server_processes from global list here
print(f"\nProcessing Server: '{key}'")
command = server_config.get("command")
args = server_config.get("args", [])
@ -322,22 +411,34 @@ async def connect_and_discover(key: str, server_config: dict):
print(f"==> Error: Missing 'command' in Server '{key}' configuration. <==")
return
# Use StdioServerParameters again
server_params = StdioServerParameters(
command=command, args=args, env=process_env,
# Note: We assume stdio_client handles necessary flags internally or
# that its cleanup mechanism is sufficient. Explicit flag passing here
# might require checking the mcp library's StdioServerParameters definition.
)
try:
# --- Use stdio_client again ---
print(f"Using stdio_client to start and connect to Server '{key}'...")
# Pass server_params to stdio_client
# stdio_client manages the subprocess lifecycle within its context
read, write = await exit_stack.enter_async_context(
stdio_client(server_params)
)
print(f"stdio_client for '{key}' active.")
print(f"stdio_client for '{key}' active, provides read/write streams.")
# --- End stdio_client usage ---
# stdio_client provides the correct stream types for ClientSession
session = await exit_stack.enter_async_context(
ClientSession(read, write)
)
print(f"ClientSession for '{key}' context entered.")
# We no longer manually manage the process object here.
# We rely on stdio_client's context manager (__aexit__) to terminate the process.
print(f"Initializing Session '{key}'...")
await session.initialize()
print(f"Session '{key}' initialized successfully.")
@ -424,6 +525,32 @@ def load_persona_from_file(filename="persona.json"):
print(f"Unknown error loading Persona configuration file '{filename}': {e}")
wolfhart_persona_details = None
# --- Memory System Initialization ---
def initialize_memory_system():
"""Initialize memory system"""
if hasattr(config, 'ENABLE_PRELOAD_PROFILES') and config.ENABLE_PRELOAD_PROFILES:
print("\nInitializing ChromaDB memory system...")
if chroma_client.initialize_chroma_client():
# Check if collections are available
collections_to_check = [
config.PROFILES_COLLECTION,
config.CONVERSATIONS_COLLECTION,
config.BOT_MEMORY_COLLECTION
]
success_count = 0
for coll_name in collections_to_check:
if chroma_client.get_collection(coll_name):
success_count += 1
print(f"Memory system initialization complete, successfully connected to {success_count}/{len(collections_to_check)} collections")
return True
else:
print("Memory system initialization failed, falling back to tool calls")
return False
else:
print("Memory system preloading is disabled, will use tool calls to get memory")
return False
# --- End Memory System Initialization ---
# --- Main Async Function ---
async def run_main_with_exit_stack():
@ -433,7 +560,10 @@ async def run_main_with_exit_stack():
# 1. Load Persona Synchronously (before async loop starts)
load_persona_from_file() # Corrected function
# 2. Initialize MCP Connections Asynchronously
# 2. Initialize Memory System (after loading config, before main loop)
memory_system_active = initialize_memory_system()
# 3. Initialize MCP Connections Asynchronously
await initialize_mcp_connections()
# Warn if no servers connected successfully, but continue
@ -586,27 +716,78 @@ async def run_main_with_exit_stack():
print(f"Added user message from {sender_name} to history at {timestamp}.")
# --- End Add user message ---
# --- Memory Preloading ---
user_profile = None
related_memories = []
bot_knowledge = []
memory_retrieval_time = 0
# If memory system is active and preloading is enabled
if memory_system_active and hasattr(config, 'ENABLE_PRELOAD_PROFILES') and config.ENABLE_PRELOAD_PROFILES:
try:
memory_start_time = time.time()
# 1. Get user profile
user_profile = chroma_client.get_entity_profile(sender_name)
# 2. Preload related memories if configured
if hasattr(config, 'PRELOAD_RELATED_MEMORIES') and config.PRELOAD_RELATED_MEMORIES > 0:
related_memories = chroma_client.get_related_memories(
sender_name,
limit=config.PRELOAD_RELATED_MEMORIES
)
# 3. Optionally preload bot knowledge based on message content
key_game_terms = ["capital_position", "capital_administrator_role", "server_hierarchy",
"last_war", "winter_war", "excavations", "blueprints",
"honor_points", "golden_eggs", "diamonds"]
# Check if message contains these keywords
found_terms = [term for term in key_game_terms if term.lower() in bubble_text.lower()]
if found_terms:
# Retrieve knowledge for found terms (limit to 2 terms, 2 results each)
for term in found_terms[:2]:
term_knowledge = chroma_client.get_bot_knowledge(term, limit=2)
bot_knowledge.extend(term_knowledge)
memory_retrieval_time = time.time() - memory_start_time
print(f"Memory retrieval complete: User profile {'successful' if user_profile else 'failed'}, "
f"{len(related_memories)} related memories, "
f"{len(bot_knowledge)} bot knowledge, "
f"total time {memory_retrieval_time:.3f}s")
except Exception as mem_err:
print(f"Error during memory retrieval: {mem_err}")
# Clear all memory data on error to avoid using partial data
user_profile = None
related_memories = []
bot_knowledge = []
# --- End Memory Preloading ---
print(f"\n{config.PERSONA_NAME} is thinking...")
try:
# Get LLM response (現在返回的是一個字典)
# --- Pass history and current sender name ---
# Get LLM response, passing preloaded memory data
bot_response_data = await llm_interaction.get_llm_response(
current_sender_name=sender_name, # Pass current sender
history=list(conversation_history), # Pass a copy of the history
current_sender_name=sender_name,
history=list(conversation_history),
mcp_sessions=active_mcp_sessions,
available_mcp_tools=all_discovered_mcp_tools,
persona_details=wolfhart_persona_details
persona_details=wolfhart_persona_details,
user_profile=user_profile, # Added: Pass user profile
related_memories=related_memories, # Added: Pass related memories
bot_knowledge=bot_knowledge # Added: Pass bot knowledge
)
# 提取對話內容
# Extract dialogue content
bot_dialogue = bot_response_data.get("dialogue", "")
valid_response = bot_response_data.get("valid_response", False) # <-- 獲取 valid_response 標誌
valid_response = bot_response_data.get("valid_response", False) # <-- Get valid_response flag
print(f"{config.PERSONA_NAME}'s dialogue response: {bot_dialogue}")
# --- DEBUG PRINT ---
print(f"DEBUG main.py: Before check - bot_dialogue='{bot_dialogue}', valid_response={valid_response}, dialogue_is_truthy={bool(bot_dialogue)}")
# --- END DEBUG PRINT ---
# 處理命令 (如果有的話)
# Process commands (if any)
commands = bot_response_data.get("commands", [])
if commands:
print(f"Processing {len(commands)} command(s)...")
@ -676,12 +857,12 @@ async def run_main_with_exit_stack():
# print(f"Ignoring command type from LLM JSON (already handled internally): {cmd_type}, parameters: {cmd_params}")
# --- End Command Processing ---
# 記錄思考過程 (如果有的話)
# Log thoughts (if any)
thoughts = bot_response_data.get("thoughts", "")
if thoughts:
print(f"AI Thoughts: {thoughts[:150]}..." if len(thoughts) > 150 else f"AI Thoughts: {thoughts}")
# 只有當有效回應時才發送到遊戲 (via command queue)
# Only send to game when valid response (via command queue)
if bot_dialogue and valid_response:
# --- Add bot response to history ---
timestamp = datetime.datetime.now() # Get current timestamp

View File

@ -15,6 +15,7 @@ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
import mcp_client
import llm_interaction
import chroma_client # <-- 新增導入
from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client
@ -164,7 +165,15 @@ async def debug_loop():
# 1. Load Persona
load_persona_from_file()
# 2. Initialize MCP
# 2. Initialize ChromaDB
print("\n--- Initializing ChromaDB ---")
if not chroma_client.initialize_chroma_client():
print("Warning: ChromaDB initialization failed. Memory functions may not work.")
else:
print("ChromaDB initialized successfully.")
print("-----------------------------")
# 3. Initialize MCP
await initialize_mcp_connections()
if not active_mcp_sessions:
print("\nNo MCP servers connected. LLM tool usage will be limited. Continue? (y/n)")
@ -172,7 +181,7 @@ async def debug_loop():
if confirm.strip().lower() != 'y':
return
# 3. Get username
# 4. Get username
user_name = await get_username()
print(f"Debug session started as: {user_name}")
@ -203,13 +212,24 @@ async def debug_loop():
print(f"\n{config.PERSONA_NAME} is thinking...")
# Call LLM interaction function
# --- Pre-fetch ChromaDB data ---
print(f"Fetching ChromaDB data for '{user_name}'...")
user_profile_data = chroma_client.get_entity_profile(user_name)
related_memories_data = chroma_client.get_related_memories(user_name, topic=user_input, limit=5) # Use user input as topic hint
# bot_knowledge_data = chroma_client.get_bot_knowledge(concept=user_input, limit=3) # Optional: Fetch bot knowledge based on input
print("ChromaDB data fetch complete.")
# --- End Pre-fetch ---
# Call LLM interaction function, passing fetched data
bot_response_data = await llm_interaction.get_llm_response(
current_sender_name=user_name,
history=list(conversation_history),
history=list(conversation_history), # Pass history
mcp_sessions=active_mcp_sessions,
available_mcp_tools=all_discovered_mcp_tools,
persona_details=wolfhart_persona_details
persona_details=wolfhart_persona_details,
user_profile=user_profile_data, # Pass fetched profile
related_memories=related_memories_data, # Pass fetched memories
# bot_knowledge=bot_knowledge_data # Optional: Pass fetched knowledge
)
# Print the full response structure for debugging
@ -280,4 +300,4 @@ if __name__ == "__main__":
print("Running final shutdown...")
loop.run_until_complete(shutdown())
loop.close()
print("LLM Debug Script finished.")
print("LLM Debug Script finished.")

View File

@ -740,6 +740,7 @@ class DetectionModule:
best_match = None
final_result_coords = None
final_template_key = None # 新增:用於儲存最終匹配的範本 key
detection_type = "None" # For stats
if not gray_results and not clahe_results:
@ -755,6 +756,7 @@ class DetectionModule:
if best_gray and not best_clahe and best_gray['confidence'] >= DUAL_METHOD_HIGH_CONFIDENCE_THRESHOLD:
final_result_coords = best_gray['center']
final_template_key = best_gray['template'] # 新增
self.last_detection_method = "Gray" + (" (Inv)" if best_gray['is_inverted'] else "")
self.last_detection_confidence = best_gray['confidence']
detection_type = "Gray Only (High Conf)"
@ -764,6 +766,7 @@ class DetectionModule:
elif best_clahe and not best_gray and best_clahe['confidence'] >= DUAL_METHOD_HIGH_CONFIDENCE_THRESHOLD:
final_result_coords = best_clahe['center']
final_template_key = best_clahe['template'] # 新增
self.last_detection_method = "CLAHE" + (" (Inv)" if best_clahe['is_inverted'] else "")
self.last_detection_confidence = best_clahe['confidence']
detection_type = "CLAHE Only (High Conf)"
@ -803,6 +806,7 @@ class DetectionModule:
if best_overlap_match:
final_result_coords = best_overlap_match['center']
final_template_key = best_overlap_match['template'] # 新增
self.last_detection_method = "Dual Overlap" + (" (Inv)" if best_overlap_match['is_inverted'] else "")
self.last_detection_confidence = best_overlap_match['confidence']
detection_type = "Dual Overlap"
@ -818,6 +822,7 @@ class DetectionModule:
# Use a slightly lower threshold for fallback
if best_overall['confidence'] >= DUAL_METHOD_FALLBACK_CONFIDENCE_THRESHOLD:
final_result_coords = best_overall['center']
final_template_key = best_overall['template'] # 新增
method_name = "Gray Fallback" if best_overall in gray_results else "CLAHE Fallback"
method_name += " (Inv)" if best_overall['is_inverted'] else ""
self.last_detection_method = method_name
@ -872,38 +877,37 @@ class DetectionModule:
print(f"Error during visual debugging image generation: {debug_e}")
# --- End Visual Debugging ---
return final_result_coords # Return absolute coordinates
# Return absolute coordinates and the matched key
return (final_result_coords, final_template_key)
else:
if self.DEBUG_LEVEL > 0: # Log failure only if debug level > 0
print(f"[Dual Method] No sufficiently confident match found. Time: {elapsed_time:.3f}s")
self.last_detection_method = None
self.last_detection_confidence = 0.0
return None
return None # Return None for both coords and key on failure
def find_keyword_in_region(self, region: Tuple[int, int, int, int]) -> Optional[Tuple[int, int]]:
def find_keyword_in_region(self, region: Tuple[int, int, int, int]) -> Optional[Tuple[Tuple[int, int], str]]:
"""
Wrapper method to find keywords in a region.
Uses either the new dual method or the legacy method based on the 'use_dual_method' flag.
Returns absolute center coordinates or None.
Returns a tuple (absolute_center_coordinates, matched_template_key) or None.
"""
if region is None or len(region) != 4 or region[2] <= 0 or region[3] <= 0:
print(f"Error: Invalid region provided to find_keyword_in_region: {region}")
return None
if self.use_dual_method:
result = self.find_keyword_dual_method(region)
# Debug Fallback Check
if result is None and self.DEBUG_LEVEL >= 3:
print("[DEBUG] Dual method failed. Trying legacy method for comparison...")
legacy_result = self._find_keyword_legacy(region)
if legacy_result:
print(f"[DEBUG] Legacy method succeeded where dual method failed. Legacy Coords: {legacy_result}")
else:
print("[DEBUG] Legacy method also failed.")
return result # Return the result from the dual method
# Directly return the result from the dual method (now returns tuple or None)
return self.find_keyword_dual_method(region)
else:
# Use legacy method if dual method is disabled
return self._find_keyword_legacy(region)
# Legacy method needs adaptation if we want it to return a key.
# For now, it returns only coords or None. We'll return None for the key part.
legacy_coords = self._find_keyword_legacy(region)
if legacy_coords:
# We don't know the key from the legacy method easily. Return a placeholder or None.
return (legacy_coords, None) # Or maybe a specific string like 'legacy_match'
else:
return None
def print_detection_stats(self):
"""Prints the collected keyword detection performance statistics."""
@ -1855,33 +1859,45 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu
# 3. Detect Keyword in Bubble
# print(f"[DEBUG] UI Loop: Detecting keyword in region {bubble_region}...") # DEBUG REMOVED
keyword_coords = detector.find_keyword_in_region(bubble_region)
result = detector.find_keyword_in_region(bubble_region) # Now returns (coords, key) or None
if keyword_coords:
print(f"\n!!! Keyword detected in bubble {target_bbox} !!!")
if result: # 檢查是否真的找到了關鍵字
keyword_coords, detected_template_key = result # 解包得到座標和 key
# 在這裡可以更新或加入日誌,包含 detected_template_key
print(f"\n!!! Keyword '{detected_template_key}' detected in bubble {target_bbox} at {keyword_coords} !!!")
# --- Determine if it's a reply keyword for offset ---
is_reply_keyword = False
reply_keyword_keys = ['keyword_wolf_reply', 'keyword_wolf_reply_type2', 'keyword_wolf_reply_type3', 'keyword_wolf_reply_type4']
for key in reply_keyword_keys:
reply_locs = detector._find_template(key, region=bubble_region, grayscale=False, confidence=detector.confidence)
if reply_locs:
for loc in reply_locs:
if abs(keyword_coords[0] - loc[0]) <= 2 and abs(keyword_coords[1] - loc[1]) <= 2:
print(f"Confirmed detected keyword at {keyword_coords} matches reply keyword template '{key}' at {loc}.")
is_reply_keyword = True
break
if is_reply_keyword:
break
# --- 接下來是移除冗餘邏輯並使用新 key ---
# ------------ START: 刪除或註解掉以下區塊 ------------
# is_reply_keyword = False
# reply_keyword_keys = ['keyword_wolf_reply', 'keyword_wolf_reply_type2', 'keyword_wolf_reply_type3', 'keyword_wolf_reply_type4']
# for key in reply_keyword_keys:
# reply_locs = detector._find_template(key, region=bubble_region, grayscale=False, confidence=detector.confidence)
# if reply_locs:
# for loc in reply_locs:
# if abs(keyword_coords[0] - loc[0]) <= 2 and abs(keyword_coords[1] - loc[1]) <= 2:
# print(f"Confirmed detected keyword at {keyword_coords} matches reply keyword template '{key}' at {loc}.")
# is_reply_keyword = True
# break
# if is_reply_keyword:
# break
# ------------- END: 刪除或註解掉以上區塊 -------------
# 直接根據返回的 key 判斷是否為 reply
# Note: Dual method currently only returns 'keyword_wolf_reply' as a reply type key
is_reply_keyword = (detected_template_key == 'keyword_wolf_reply')
# Calculate click coordinates with potential offset
click_coords = keyword_coords
if is_reply_keyword:
click_coords = (keyword_coords[0], keyword_coords[1] + 25)
print(f"Applying +25 Y-offset for reply keyword. Click target: {click_coords}")
click_coords = (keyword_coords[0], keyword_coords[1] + 25) # 假設 reply 需要 +25 Y 偏移
# 更新日誌,包含 key
print(f"Applying +25 Y-offset for reply keyword '{detected_template_key}'. Click target: {click_coords}")
else:
print(f"Detected keyword is not a reply type. Click target: {click_coords}")
# 更新日誌,包含 key
print(f"Detected keyword '{detected_template_key}' is not a reply type. Click target: {click_coords}")
# --- 將剩餘的邏輯放在 if result: 區塊內 ---
# --- Variables needed later ---
bubble_snapshot = None
search_area = SCREENSHOT_REGION
@ -1939,23 +1955,24 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu
# Find the keyword *again* within the *new* bubble region to get current coords
# print("[DEBUG] UI Loop: Finding keyword again in re-located region...") # DEBUG REMOVED
current_keyword_coords = detector.find_keyword_in_region(copy_bubble_region)
if not current_keyword_coords:
current_result = detector.find_keyword_in_region(copy_bubble_region) # Returns (coords, key) or None
if not current_result:
print("Warning: Keyword not found in the re-located bubble region. Skipping this bubble.")
continue # Skip to the next bubble
# Determine if it's a reply keyword based on the *new* location/region
is_reply_keyword_current = False
# (Re-check is_reply_keyword logic here based on current_keyword_coords and copy_bubble_region)
# This check might be complex, for simplicity, we can reuse the 'is_reply_keyword'
# determined earlier based on the initial detection, assuming the keyword type doesn't change.
# Let's reuse the previously determined 'is_reply_keyword' for offset calculation.
current_keyword_coords, current_detected_key = current_result
print(f"Keyword '{current_detected_key}' re-located at {current_keyword_coords}")
# Determine if it's a reply keyword based on the *new* location/key
# Use the key found in the *re-located* region for the most accurate offset decision
is_reply_keyword_current = (current_detected_key == 'keyword_wolf_reply')
click_coords_current = current_keyword_coords
if is_reply_keyword: # Use the flag determined from initial detection
if is_reply_keyword_current:
click_coords_current = (current_keyword_coords[0], current_keyword_coords[1] + 25)
print(f"Applying +25 Y-offset for reply keyword (current location). Click target: {click_coords_current}")
print(f"Applying +25 Y-offset for reply keyword '{current_detected_key}' (current location). Click target: {click_coords_current}")
else:
print(f"Detected keyword is not a reply type (current location). Click target: {click_coords_current}")
print(f"Detected keyword '{current_detected_key}' is not a reply type (current location). Click target: {click_coords_current}")
# Interact: Get Bubble Text using current coordinates
# print("[DEBUG] UI Loop: Copying text...") # DEBUG REMOVED