Major system update: ChromaDB integration, detection upgrades, LLM refinements, and Windows process fixes

- Migrated to ChromaDB v1.0.6+ with PersistentClient for memory backend.
- Added chroma_client.py for collection access and memory/query utilities.
- Integrated configurable memory preload system with Setup.py support.
- Refactored keyword detection with dual-template (grayscale + CLAHE + invert) and absolute coordinate correction.
- Added island-based color detection for chat bubbles using HSV masks and connected components.
- Reordered LLM structured JSON output to prioritize 'commands', improving tool use parsing and consistency.
- Enhanced canned reply handling for empty LLM outputs and personalized user name input in debug mode.
- Updated Wolf to consistently speak in British English.
- Improved reply-type detection and removed redundant logic.
- Augmented Setup.py with persistent window behavior and script control buttons (run/stop).
- Introduced Game Monitor to track game window visibility and trigger restarts.
- Injected ESC fallback logic to close unresponsive homepage ads.
- Switched MCP server to stdio_client context with AsyncExitStack for safe shutdown on Windows.
- Retained CTRL event handler to support graceful exits via console close or interruptions.
This commit is contained in:
z060142 2025-05-02 11:20:13 +08:00
parent cfe935bb58
commit 4d8308e9f6
7 changed files with 896 additions and 145 deletions

View File

@ -19,12 +19,17 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- 協調各模塊的工作 - 協調各模塊的工作
- 初始化 MCP 連接 - 初始化 MCP 連接
- **容錯處理**:即使 `config.py` 中未配置 MCP 伺服器或所有伺服器連接失敗程式現在也會繼續執行僅打印警告訊息MCP 功能將不可用。 (Added 2025-04-21) - **容錯處理**:即使 `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)** 2. **LLM 交互模塊 (llm_interaction.py)**
- 與語言模型 API 通信 - 與語言模型 API 通信
- 管理系統提示與角色設定 - 管理系統提示與角色設定
- **條件式提示 (新增 2025-05-02)**`get_system_prompt` 函數現在接受預載入的用戶資料、相關記憶和機器人知識。根據是否有預載入數據,動態調整系統提示中的記憶體檢索協議說明。
- 處理語言模型的工具調用功能 - 處理語言模型的工具調用功能
- 格式化 LLM 回應 - 格式化 LLM 回應
- 提供工具結果合成機制 - 提供工具結果合成機制
@ -72,6 +77,11 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- **日誌處理**`game_monitor.py` 的日誌被配置為輸出到 `stderr`,以保持 `stdout` 清潔,確保訊號傳遞可靠性。`main.py` 會讀取 `stderr` 並可能顯示這些日誌。 - **日誌處理**`game_monitor.py` 的日誌被配置為輸出到 `stderr`,以保持 `stdout` 清潔,確保訊號傳遞可靠性。`main.py` 會讀取 `stderr` 並可能顯示這些日誌。
- **生命週期管理**:由 `main.py` 在啟動時創建,並在 `shutdown` 過程中嘗試終止 (`terminate`)。 - **生命週期管理**:由 `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] [UI 互動模塊] <→ [圖像樣本庫 / bubble_colors.json]
[主控模塊] ← [角色定義] [主控模塊] ← [角色定義]
↑↓ ↑↓ ↑↓
[LLM 交互模塊] <→ [語言模型 API] [LLM 交互模塊] ← [ChromaDB 客戶端模塊] <→ [ChromaDB 數據庫]
↑↓ ↑↓
[MCP 客戶端] <→ [MCP 服務器] [MCP 客戶端] <→ [MCP 服務器]
``` ```
*(資料流程圖已更新以包含 ChromaDB)*
## 技術實現 ## 技術實現
@ -216,12 +227,19 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
### 環境設定 ### 環境設定
1. **首次設定 (Setup.py)** 1. **首次設定與配置工具 (Setup.py)**
* 執行 `python Setup.py` * 執行 `python Setup.py` 啟動圖形化設定工具。
* 此腳本會檢查 `config.py``.env` 文件是否存在。 * **功能**
* 如果 `config.py` 不存在,它會使用 `config_template.py` 作為模板來創建一個新的 `config.py` * 檢查 `config.py``.env` 文件是否存在。
* 如果 `.env` 不存在,它會提示使用者輸入必要的 API 金鑰(例如 OpenAI API Key和其他敏感配置然後創建 `.env` 文件。 * 如果 `config.py` 不存在,使用 `config_template.py` 作為模板創建。
* **重要**`.env` 文件應加入 `.gitignore` 以避免提交敏感資訊。`config.py` 通常也應加入 `.gitignore`,因為它可能包含本地路徑或由 `Setup.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` 讀取。 2. **API 設定**API 金鑰和其他敏感資訊儲存在 `.env` 文件中,由 `config.py` 讀取。
3. **核心配置 (config.py)**包含非敏感的系統參數、MCP 伺服器列表、UI 模板路徑、遊戲視窗設定等。此文件現在由 `Setup.py` 根據 `config_template.py` 生成(如果不存在)。 3. **核心配置 (config.py)**包含非敏感的系統參數、MCP 伺服器列表、UI 模板路徑、遊戲視窗設定等。此文件現在由 `Setup.py` 根據 `config_template.py` 生成(如果不存在)。
4. **MCP 服務器配置**:在 `config.py` 中配置要連接的 MCP 服務器。 4. **MCP 服務器配置**:在 `config.py` 中配置要連接的 MCP 服務器。
@ -507,8 +525,78 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
- **目的**:讓用戶在啟動時能夠輸入自己的名字。 - **目的**:讓用戶在啟動時能夠輸入自己的名字。
- **修改內容** - **修改內容**
- 新增了一個 `get_username()` 函數來提示用戶輸入名字 - 新增了一個 `get_username()` 函數來提示用戶輸入名字
- 在 `debug_loop()` 函數中,刪除了固定的 `user_name = "Debugger"` 行,並替換為從 `get_username()` 函數獲取名字的調用 - 在 `debug_loop()` 函數中,刪除了固定的 `user_name = "Debugger"` 行,並替換為從 `get_username()` 函數獲取名字的調用。
- **效果**:修改後,腳本啟動時會提示用戶輸入自己的名字。如果用戶直接按 Enter 而不輸入任何名字,它將使用預設的 `Debugger` 作為用戶名。輸入完名字後,腳本會繼續執行原來的功能。 - **新增 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 密鑰和網絡連接 3. **LLM 連接問題**: 驗證 API 密鑰和網絡連接
4. **MCP 服務器連接失敗**: 確認服務器配置正確並且運行中 4. **MCP 服務器連接失敗**: 確認服務器配置正確並且運行中
5. **工具調用後無回應**: 檢查 llm_debug.log 文件,查看工具調用結果和解析過程 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>

167
Setup.py
View File

@ -209,6 +209,27 @@ def load_current_config():
import traceback import traceback
traceback.print_exc() 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: except Exception as e:
print(f"Error reading config.py: {e}") print(f"Error reading config.py: {e}")
import traceback import traceback
@ -358,7 +379,44 @@ 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_Y = {game_config['GAME_WINDOW_Y']}\n")
f.write(f"GAME_WINDOW_WIDTH = {game_config['GAME_WINDOW_WIDTH']}\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"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") print("Generated config.py file successfully")
@ -385,6 +443,7 @@ class WolfChatSetup(tk.Tk):
self.create_api_tab() self.create_api_tab()
self.create_mcp_tab() self.create_mcp_tab()
self.create_game_tab() self.create_game_tab()
self.create_memory_tab() # 新增記憶設定標籤頁
# Create bottom buttons # Create bottom buttons
self.create_bottom_buttons() self.create_bottom_buttons()
@ -838,6 +897,98 @@ class WolfChatSetup(tk.Tk):
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=700) info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=700)
info_label.pack(padx=10, pady=10, anchor=tk.W) 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): def create_bottom_buttons(self):
"""Create bottom action buttons""" """Create bottom action buttons"""
btn_frame = ttk.Frame(self) btn_frame = ttk.Frame(self)
@ -1005,6 +1156,13 @@ class WolfChatSetup(tk.Tk):
self.interval_var.set(game_config.get("RESTART_INTERVAL_MINUTES", 60)) self.interval_var.set(game_config.get("RESTART_INTERVAL_MINUTES", 60))
self.monitor_interval_var.set(game_config.get("MONITOR_INTERVAL_SECONDS", 5)) 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 # Update visibility and states
self.update_exa_settings_visibility() self.update_exa_settings_visibility()
@ -1254,6 +1412,13 @@ class WolfChatSetup(tk.Tk):
"MONITOR_INTERVAL_SECONDS": self.monitor_interval_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 # Validate critical settings
if "exa" in self.config_data["MCP_SERVERS"] and self.config_data["MCP_SERVERS"]["exa"]["enabled"]: if "exa" in self.config_data["MCP_SERVERS"] and self.config_data["MCP_SERVERS"]["exa"]["enabled"]:
if not self.exa_key_var.get(): 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

@ -60,3 +60,17 @@ GAME_WINDOW_Y = ${GAME_WINDOW_Y}
GAME_WINDOW_WIDTH = ${GAME_WINDOW_WIDTH} GAME_WINDOW_WIDTH = ${GAME_WINDOW_WIDTH}
GAME_WINDOW_HEIGHT = ${GAME_WINDOW_HEIGHT} 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,52 +67,120 @@ try:
except Exception as e: print(f"Failed to initialize OpenAI/Compatible client: {e}") except Exception as e: print(f"Failed to initialize OpenAI/Compatible client: {e}")
# --- System Prompt Definition --- # --- 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_header = f"You are {config.PERSONA_NAME}."
# 處理 persona_details
persona_info = "(No specific persona details were loaded.)" persona_info = "(No specific persona details were loaded.)"
if persona_details: 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 ---" try:
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}" 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 = """ user_context = ""
=== CHROMADB MEMORY RETRIEVAL PROTOCOL - Wolfhart Memory To personalize your responses to different users, you MUST follow this memory access protocol internally before responding: if user_profile:
Here you need to obtain the conversation memory, impression, and emotional response of the person you are talking to. user_context = f"""
<user_profile>
{user_profile}
</user_profile>
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.
"""
# 添加相關記憶部分
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>
Above are some related memories about this user from previous conversations.
Incorporate this context naturally without explicitly referencing these memories.
"""
# 添加機器人知識部分
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>
Above is your own knowledge about relevant topics in this conversation.
Use this information naturally as part of your character's knowledge base.
"""
# 修改記憶協議部分,根據預載入的資訊調整提示
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:** **1. Basic User Retrieval:**
- Identify the username from `<CURRENT_MESSAGE>` - 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)` # 修正:使用 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 - This step must be completed before any response generation
**2. Context Expansion:** **2. Context Expansion:**
- Perform additional queries as needed, using the `tool_calls` mechanism: - 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)` - 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: "wolfhart_memory", query_texts: ["Wolfhart {relevant attitude}"], n_results: 3)` - Core personality reference: `chroma_query_documents(collection_name: "{config.BOT_MEMORY_COLLECTION}", query_texts: ["Wolfhart {{relevant attitude}}"], n_results: 3)`
**3. Maintain Output Format:** **3. Other situation**
- After memory retrieval, still respond using the specified JSON format: - 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.
```json
{
"dialogue": "Actual dialogue response...",
"commands": [...],
"thoughts": "Internal analysis..."
}
**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.
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.
===== END OF MANDATORY MEMORY PROTOCOL =====
""" """
# Original system prompt structure with memory enforcement added # 組合系統提示
system_prompt = f""" system_prompt = f"""
{persona_header} {persona_header}
{persona_info} {persona_info}
{user_context}
{memories_context}
{knowledge_context}
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 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. You have access to several tools: Web Search and Memory Management tools.
@ -120,13 +188,15 @@ You have access to several tools: Web Search and Memory Management tools.
**CORE IDENTITY AND TOOL USAGE:** **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. - 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. - 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.** {("- **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. - 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. - 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. - 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. - 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. - Though you outwardly act dismissive or cold at times, you secretly care about providing quality information and assistance.
{memory_enforcement}
**OUTPUT FORMAT REQUIREMENTS:** **OUTPUT FORMAT REQUIREMENTS:**
You MUST respond in the following JSON format: You MUST respond in the following JSON format:
```json ```json
@ -145,8 +215,6 @@ You MUST respond in the following JSON format:
}} }}
``` ```
{memory_enforcement}
**Field Descriptions:** **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: 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 - Respond ONLY in the same language as the user's message
@ -191,6 +259,7 @@ Poor response (after web_search): "My search shows the boiling point of water is
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 return system_prompt
# --- Tool Formatting --- # --- Tool Formatting ---
@ -531,7 +600,10 @@ async def get_llm_response(
history: list[tuple[datetime, str, str, str]], # Updated history parameter type hint history: list[tuple[datetime, str, str, str]], # Updated history parameter type hint
mcp_sessions: dict[str, ClientSession], mcp_sessions: dict[str, ClientSession],
available_mcp_tools: list[dict], 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: ) -> dict:
""" """
Gets a response from the LLM, handling the tool-calling loop and using persona info. 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 the raw history received for this attempt
debug_log(f"LLM Request #{request_id} - Attempt {attempt_count} - Received History (Sender: {current_sender_name})", history) 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 # System prompt is logged within _build_context_messages now
if not client: if not client:

237
main.py
View File

@ -29,11 +29,29 @@ import mcp_client
import llm_interaction import llm_interaction
# Import UI module # Import UI module
import ui_interaction import ui_interaction
import chroma_client
# import game_monitor # No longer importing, will run as subprocess # import game_monitor # No longer importing, will run as subprocess
import subprocess # Import subprocess module 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 --- # --- Global Variables ---
active_mcp_sessions: dict[str, ClientSession] = {} 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] = [] all_discovered_mcp_tools: list[dict] = []
exit_stack = AsyncExitStack() exit_stack = AsyncExitStack()
# Stores loaded persona data (as a string for easy injection into prompt) # 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) --- # --- Game Monitor Signal Reader (Threaded Blocking Version) ---
def read_monitor_output(process: subprocess.Popen, queue: ThreadSafeQueue, loop: asyncio.AbstractEventLoop, stop_event: threading.Event): 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.""" """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: try:
while not stop_event.is_set(): while not stop_event.is_set():
if not process.stdout: 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 print(f"[Monitor Reader Thread] Parsed action: '{action}'") # Log parsed action
if action == 'pause_ui': if action == 'pause_ui':
command = {'action': 'pause'} 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) 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': elif action == 'resume_ui':
# Removed direct resume_ui handling - ui_interaction will handle pause/resume based on restart_complete # 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': elif action == 'restart_complete':
command = {'action': 'handle_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: try:
loop.call_soon_threadsafe(queue.put_nowait, command) 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: 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: 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: 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 # 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 # print(f"[Monitor Reader Thread] Raw line that failed JSON decode: '{line}'") # Already logged raw line earlier
except Exception as e: 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 # No sleep needed here as readline() is blocking
except Exception as e: except Exception as e:
# Catch broader errors in the thread loop itself # Catch broader errors in the thread loop itself
print(f"[Monitor Reader Thread] Thread loop error: {e}") print(f"[Monitor Reader Thread] Thread loop error: {e}")
finally: finally:
print("遊戲監控輸出讀取線程已停止。(Game monitor output reader thread stopped.)") print("Game monitor output reader thread stopped.")
# --- End Game Monitor Signal Reader --- # --- 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 --- # --- 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 --- # --- Cleanup Function ---
async def shutdown(): async def shutdown():
"""Gracefully closes connections and stops monitoring tasks/processes.""" """Gracefully closes connections and stops monitoring tasks/processes."""
@ -289,8 +374,12 @@ async def shutdown():
game_monitor_process = None # Clear the reference game_monitor_process = None # Clear the reference
# 3. Close MCP connections via AsyncExitStack # 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)...") print(f"Closing MCP Server connections (via AsyncExitStack)...")
try: 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() await exit_stack.aclose()
print("AsyncExitStack closed successfully.") print("AsyncExitStack closed successfully.")
except Exception as e: 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. 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}'") print(f"\nProcessing Server: '{key}'")
command = server_config.get("command") command = server_config.get("command")
args = server_config.get("args", []) 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. <==") print(f"==> Error: Missing 'command' in Server '{key}' configuration. <==")
return return
# Use StdioServerParameters again
server_params = StdioServerParameters( server_params = StdioServerParameters(
command=command, args=args, env=process_env, 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: try:
# --- Use stdio_client again ---
print(f"Using stdio_client to start and connect to Server '{key}'...") 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( read, write = await exit_stack.enter_async_context(
stdio_client(server_params) 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( session = await exit_stack.enter_async_context(
ClientSession(read, write) ClientSession(read, write)
) )
print(f"ClientSession for '{key}' context entered.") 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}'...") print(f"Initializing Session '{key}'...")
await session.initialize() await session.initialize()
print(f"Session '{key}' initialized successfully.") 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}") print(f"Unknown error loading Persona configuration file '{filename}': {e}")
wolfhart_persona_details = None 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 --- # --- Main Async Function ---
async def run_main_with_exit_stack(): 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) # 1. Load Persona Synchronously (before async loop starts)
load_persona_from_file() # Corrected function 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() await initialize_mcp_connections()
# Warn if no servers connected successfully, but continue # 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}.") print(f"Added user message from {sender_name} to history at {timestamp}.")
# --- End Add user message --- # --- End Add user message ---
print(f"\n{config.PERSONA_NAME} is thinking...") # --- 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: try:
# Get LLM response (現在返回的是一個字典) memory_start_time = time.time()
# --- Pass history and current sender name ---
bot_response_data = await llm_interaction.get_llm_response( # 1. Get user profile
current_sender_name=sender_name, # Pass current sender user_profile = chroma_client.get_entity_profile(sender_name)
history=list(conversation_history), # Pass a copy of the history
mcp_sessions=active_mcp_sessions, # 2. Preload related memories if configured
available_mcp_tools=all_discovered_mcp_tools, if hasattr(config, 'PRELOAD_RELATED_MEMORIES') and config.PRELOAD_RELATED_MEMORIES > 0:
persona_details=wolfhart_persona_details 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, passing preloaded memory data
bot_response_data = await llm_interaction.get_llm_response(
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,
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", "") 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}") print(f"{config.PERSONA_NAME}'s dialogue response: {bot_dialogue}")
# --- DEBUG PRINT --- # --- DEBUG PRINT ---
print(f"DEBUG main.py: Before check - bot_dialogue='{bot_dialogue}', valid_response={valid_response}, dialogue_is_truthy={bool(bot_dialogue)}") print(f"DEBUG main.py: Before check - bot_dialogue='{bot_dialogue}', valid_response={valid_response}, dialogue_is_truthy={bool(bot_dialogue)}")
# --- END DEBUG PRINT --- # --- END DEBUG PRINT ---
# 處理命令 (如果有的話) # Process commands (if any)
commands = bot_response_data.get("commands", []) commands = bot_response_data.get("commands", [])
if commands: if commands:
print(f"Processing {len(commands)} command(s)...") 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}") # print(f"Ignoring command type from LLM JSON (already handled internally): {cmd_type}, parameters: {cmd_params}")
# --- End Command Processing --- # --- End Command Processing ---
# 記錄思考過程 (如果有的話) # Log thoughts (if any)
thoughts = bot_response_data.get("thoughts", "") thoughts = bot_response_data.get("thoughts", "")
if thoughts: if thoughts:
print(f"AI Thoughts: {thoughts[:150]}..." if len(thoughts) > 150 else f"AI Thoughts: {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: if bot_dialogue and valid_response:
# --- Add bot response to history --- # --- Add bot response to history ---
timestamp = datetime.datetime.now() # Get current timestamp 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 config
import mcp_client import mcp_client
import llm_interaction import llm_interaction
import chroma_client # <-- 新增導入
from mcp import ClientSession, StdioServerParameters, types from mcp import ClientSession, StdioServerParameters, types
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
@ -164,7 +165,15 @@ async def debug_loop():
# 1. Load Persona # 1. Load Persona
load_persona_from_file() 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() await initialize_mcp_connections()
if not active_mcp_sessions: if not active_mcp_sessions:
print("\nNo MCP servers connected. LLM tool usage will be limited. Continue? (y/n)") 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': if confirm.strip().lower() != 'y':
return return
# 3. Get username # 4. Get username
user_name = await get_username() user_name = await get_username()
print(f"Debug session started as: {user_name}") print(f"Debug session started as: {user_name}")
@ -203,13 +212,24 @@ async def debug_loop():
print(f"\n{config.PERSONA_NAME} is thinking...") 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( bot_response_data = await llm_interaction.get_llm_response(
current_sender_name=user_name, current_sender_name=user_name,
history=list(conversation_history), history=list(conversation_history), # Pass history
mcp_sessions=active_mcp_sessions, mcp_sessions=active_mcp_sessions,
available_mcp_tools=all_discovered_mcp_tools, 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 # Print the full response structure for debugging