2792 lines
140 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Wolf Chat Setup Utility
A graphical configuration tool for Wolf Chat that manages:
- API settings (OpenAI/compatible providers)
- MCP server configurations (Exa, Chroma, custom servers)
- Environment variables (.env)
- Config file generation (config.py)
"""
import os
import sys
import json
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import configparser
from pathlib import Path
import re
import shutil
import time
import signal
import logging
import subprocess
import threading
import datetime
import schedule
import psutil
import random # Added for exponential backoff jitter
import urllib3 # Added for SSL warning suppression
import game_manager # Added for new game monitoring module
try:
import socketio
HAS_SOCKETIO = True
except ImportError:
HAS_SOCKETIO = False
# import ssl # ssl import might not be needed if socketio handles it or if not using wss directly in client setup
# ===============================================================
# Constants
# ===============================================================
VERSION = "1.0.0"
CONFIG_TEMPLATE_PATH = "config_template.py"
ENV_FILE_PATH = ".env"
REMOTE_CONFIG_PATH = "remote_config.json" # New config file for remote settings
# Use absolute path for chroma_data
DEFAULT_CHROMA_DATA_PATH = os.path.abspath("chroma_data")
DEFAULT_CONFIG_SECTION = """# ====================================================================
# Wolf Chat Configuration
# Generated by setup.py - Edit with care
# ====================================================================
"""
# Get current Windows username for default paths
CURRENT_USERNAME = os.getenv("USERNAME", "user")
# Global variables for game/bot management
game_process_instance = None
bot_process_instance = None # This will replace/co-exist with self.running_process
control_client_instance = None
monitor_thread_instance = None # Renamed to avoid conflict if 'monitor_thread' is used elsewhere
scheduler_thread_instance = None # Renamed
keep_monitoring_flag = threading.Event() # Renamed for clarity
keep_monitoring_flag.set()
# Basic logging setup
# logger = logging.getLogger("WolfChatSetup") # Defined later in class or globally if needed
# logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Setup logger instance. This can be configured further if needed.
logger = logging.getLogger(__name__)
if not logger.handlers: # Avoid adding multiple handlers if script is reloaded
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
# ===============================================================
# Helper Functions
# ===============================================================
def load_remote_config():
"""Load remote control and restart settings from remote_config.json"""
defaults = {
"REMOTE_SERVER_URL": "YOUR_URL_HERE",
"REMOTE_CLIENT_KEY": "YOUR_KEY_HERE", # Placeholder
"DEFAULT_GAME_RESTART_INTERVAL_MINUTES": 120,
"DEFAULT_BOT_RESTART_INTERVAL_MINUTES": 120,
"LINK_RESTART_TIMES": True,
"GAME_PROCESS_NAME": "LastWar.exe", # Default game process name
"BOT_SCRIPT_NAME": "main.py" # Default bot script name
}
if os.path.exists(REMOTE_CONFIG_PATH):
try:
with open(REMOTE_CONFIG_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
# Ensure all keys from defaults are present, adding them if missing
for key, value in defaults.items():
data.setdefault(key, value)
return data
except json.JSONDecodeError:
logger.error(f"Error decoding {REMOTE_CONFIG_PATH}. Using default remote settings.")
return defaults.copy() # Return a copy to avoid modifying defaults
except Exception as e:
logger.error(f"Error loading {REMOTE_CONFIG_PATH}: {e}. Using default remote settings.")
return defaults.copy()
logger.info(f"{REMOTE_CONFIG_PATH} not found. Creating with default values.")
save_remote_config(defaults.copy()) # Create the file if it doesn't exist
return defaults.copy()
def save_remote_config(remote_data):
"""Save remote control and restart settings to remote_config.json"""
try:
with open(REMOTE_CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(remote_data, f, indent=4) # Use indent for readability
logger.info(f"Saved remote settings to {REMOTE_CONFIG_PATH}")
except Exception as e:
logger.error(f"Error saving {REMOTE_CONFIG_PATH}: {e}")
def load_env_file():
"""Load existing .env file if it exists"""
env_data = {}
env_path = Path(ENV_FILE_PATH)
if env_path.exists():
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_data[key.strip()] = value.strip().strip('"\'')
return env_data
def save_env_file(env_data):
"""Save environment variables to .env file"""
with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f:
f.write("# Environment variables for Wolf Chat\n")
f.write("# Generated by setup.py\n\n")
for key, value in env_data.items():
if value: # Only write non-empty values
f.write(f"{key}={value}\n")
print(f"Saved environment variables to {ENV_FILE_PATH}")
def load_current_config():
"""Extract settings from existing config.py if it exists"""
# 新增一個幫助函數來標準化路徑
def normalize_path(path):
"""Convert backslashes to forward slashes in paths"""
if path:
return path.replace("\\", "/")
return path
config_data = {
"OPENAI_API_BASE_URL": "",
"LLM_MODEL": "deepseek/deepseek-chat-v3-0324",
"MCP_SERVERS": {
"exa": {
"enabled": True,
"use_smithery": False,
"server_path": normalize_path(f"C:/Users/{CURRENT_USERNAME}/AppData/Roaming/npm/exa-mcp-server")
}
},
"ENABLE_CHAT_LOGGING": True,
"LOG_DIR": "chat_logs",
"GAME_WINDOW_CONFIG": {
"WINDOW_TITLE": "Last War-Survival Game",
"ENABLE_SCHEDULED_RESTART": True,
"RESTART_INTERVAL_MINUTES": 60,
"GAME_EXECUTABLE_PATH": normalize_path(fr"C:\Users\{CURRENT_USERNAME}\AppData\Local\TheLastWar\Launch.exe"),
"GAME_WINDOW_X": 50,
"GAME_WINDOW_Y": 30,
"GAME_WINDOW_WIDTH": 600,
"GAME_WINDOW_HEIGHT": 1070,
"MONITOR_INTERVAL_SECONDS": 5
}
}
if os.path.exists("config.py"):
try:
with open("config.py", 'r', encoding='utf-8') as f:
config_content = f.read()
# Extract OPENAI_API_BASE_URL
api_url_match = re.search(r'OPENAI_API_BASE_URL\s*=\s*["\'](.+?)["\']', config_content)
if api_url_match:
config_data["OPENAI_API_BASE_URL"] = api_url_match.group(1)
# Extract LLM_MODEL
model_match = re.search(r'LLM_MODEL\s*=\s*["\'](.+?)["\']', config_content)
if model_match:
config_data["LLM_MODEL"] = model_match.group(1)
# Extract logging settings
chat_logging_match = re.search(r'ENABLE_CHAT_LOGGING\s*=\s*(True|False)', config_content)
if chat_logging_match:
config_data["ENABLE_CHAT_LOGGING"] = (chat_logging_match.group(1) == "True")
log_dir_match = re.search(r'LOG_DIR\s*=\s*["\'](.+?)["\']', config_content)
if log_dir_match:
config_data["LOG_DIR"] = log_dir_match.group(1)
# Extract game window settings
window_title_match = re.search(r'WINDOW_TITLE\s*=\s*["\'](.+?)["\']', config_content)
if window_title_match:
config_data["GAME_WINDOW_CONFIG"]["WINDOW_TITLE"] = window_title_match.group(1)
restart_match = re.search(r'ENABLE_SCHEDULED_RESTART\s*=\s*(True|False)', config_content)
if restart_match:
config_data["GAME_WINDOW_CONFIG"]["ENABLE_SCHEDULED_RESTART"] = (restart_match.group(1) == "True")
interval_match = re.search(r'RESTART_INTERVAL_MINUTES\s*=\s*(\d+)', config_content)
if interval_match:
config_data["GAME_WINDOW_CONFIG"]["RESTART_INTERVAL_MINUTES"] = int(interval_match.group(1))
exec_path_match = re.search(r'GAME_EXECUTABLE_PATH\s*=\s*r"(.+?)"', config_content)
if exec_path_match:
config_data["GAME_WINDOW_CONFIG"]["GAME_EXECUTABLE_PATH"] = exec_path_match.group(1)
x_match = re.search(r'GAME_WINDOW_X\s*=\s*(\d+)', config_content)
if x_match:
config_data["GAME_WINDOW_CONFIG"]["GAME_WINDOW_X"] = int(x_match.group(1))
y_match = re.search(r'GAME_WINDOW_Y\s*=\s*(\d+)', config_content)
if y_match:
config_data["GAME_WINDOW_CONFIG"]["GAME_WINDOW_Y"] = int(y_match.group(1))
width_match = re.search(r'GAME_WINDOW_WIDTH\s*=\s*(\d+)', config_content)
if width_match:
config_data["GAME_WINDOW_CONFIG"]["GAME_WINDOW_WIDTH"] = int(width_match.group(1))
height_match = re.search(r'GAME_WINDOW_HEIGHT\s*=\s*(\d+)', config_content)
if height_match:
config_data["GAME_WINDOW_CONFIG"]["GAME_WINDOW_HEIGHT"] = int(height_match.group(1))
monitor_interval_match = re.search(r'MONITOR_INTERVAL_SECONDS\s*=\s*(\d+)', config_content)
if monitor_interval_match:
config_data["GAME_WINDOW_CONFIG"]["MONITOR_INTERVAL_SECONDS"] = int(monitor_interval_match.group(1))
# Extract MCP_SERVERS (more complex parsing)
try:
servers_section = re.search(r'MCP_SERVERS\s*=\s*{(.+?)}(?=\n\n)', config_content, re.DOTALL)
if servers_section:
servers_text = servers_section.group(1)
# Extract and parse each server definition
server_blocks = re.findall(r'"([^"]+)":\s*{(.+?)}(?=,\s*"|,?\s*})', servers_text, re.DOTALL)
for server_name, server_block in server_blocks:
if server_name not in config_data["MCP_SERVERS"]:
config_data["MCP_SERVERS"][server_name] = {"enabled": True}
# Skip disabled servers (commented out)
if server_block.strip().startswith("#"):
config_data["MCP_SERVERS"][server_name]["enabled"] = False
continue
# Extract command
command_match = re.search(r'"command":\s*"([^"]+)"', server_block)
if command_match:
config_data["MCP_SERVERS"][server_name]["command"] = command_match.group(1)
# For Exa server
if server_name == "exa":
# Check if using Smithery
if '"@smithery/cli@latest"' in server_block:
config_data["MCP_SERVERS"]["exa"]["use_smithery"] = True
else:
config_data["MCP_SERVERS"]["exa"]["use_smithery"] = False
# Extract server path for local server
args_match = re.search(r'"args":\s*\[\s*"([^"]+)"', server_block)
if args_match:
config_data["MCP_SERVERS"]["exa"]["server_path"] = args_match.group(1)
# For Chroma server
if server_name == "chroma":
# Extract data directory
data_dir_match = re.search(r'"--data-dir",\s*"([^"]+)"', server_block)
if data_dir_match:
config_data["MCP_SERVERS"]["chroma"]["data_dir"] = data_dir_match.group(1)
# For custom servers, store the raw configuration
if server_name not in ["exa", "chroma"]:
config_data["MCP_SERVERS"][server_name]["raw_config"] = server_block
except Exception as e:
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)
# Extract memory management settings
backup_hour_match = re.search(r'MEMORY_BACKUP_HOUR\s*=\s*(\d+)', config_content)
if backup_hour_match:
config_data["MEMORY_BACKUP_HOUR"] = int(backup_hour_match.group(1))
backup_minute_match = re.search(r'MEMORY_BACKUP_MINUTE\s*=\s*(\d+)', config_content)
if backup_minute_match:
config_data["MEMORY_BACKUP_MINUTE"] = int(backup_minute_match.group(1))
# Extract EMBEDDING_MODEL_NAME
embedding_model_match = re.search(r'EMBEDDING_MODEL_NAME\s*=\s*["\'](.+?)["\']', config_content)
if embedding_model_match:
config_data["EMBEDDING_MODEL_NAME"] = embedding_model_match.group(1)
else:
# Default if not found in config.py, will be set in UI if not overridden by load
config_data["EMBEDDING_MODEL_NAME"] = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
profile_model_match = re.search(r'MEMORY_PROFILE_MODEL\s*=\s*["\']?(.+?)["\']?\s*(?:#|$)', config_content)
# Handle potential LLM_MODEL reference
if profile_model_match:
profile_model_val = profile_model_match.group(1).strip()
if profile_model_val == "LLM_MODEL":
# If it refers to LLM_MODEL, use the already parsed LLM_MODEL value
config_data["MEMORY_PROFILE_MODEL"] = config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324") # Fallback if LLM_MODEL wasn't parsed
else:
config_data["MEMORY_PROFILE_MODEL"] = profile_model_val
else:
# Default to LLM_MODEL if not found
config_data["MEMORY_PROFILE_MODEL"] = config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324")
summary_model_match = re.search(r'MEMORY_SUMMARY_MODEL\s*=\s*["\'](.+?)["\']', config_content)
if summary_model_match:
config_data["MEMORY_SUMMARY_MODEL"] = summary_model_match.group(1)
except Exception as e:
print(f"Error reading config.py: {e}")
import traceback
traceback.print_exc()
return config_data
def generate_config_file(config_data, env_data):
"""Generate config.py file based on user settings"""
# Create backup of existing config if it exists
if os.path.exists("config.py"):
backup_path = "config.py.bak"
shutil.copy2("config.py", backup_path)
print(f"Created backup of existing config at {backup_path}")
def normalize_path(path):
"""Convert backslashes to forward slashes in paths"""
if path:
return path.replace("\\", "/")
return path
# Helper function to ensure absolute path
def ensure_absolute_path(path):
if not os.path.isabs(path):
return os.path.abspath(path)
return path
with open("config.py", 'w', encoding='utf-8') as f:
f.write(DEFAULT_CONFIG_SECTION)
f.write("import os\n")
f.write("import json\n")
f.write("from dotenv import load_dotenv\n\n")
f.write("# --- Load environment variables from .env file ---\n")
f.write("load_dotenv()\n")
f.write("print(\"Loaded environment variables from .env file.\")\n\n")
# Write OpenAI API settings
f.write("# =============================================================================\n")
f.write("# OpenAI API Configuration / OpenAI-Compatible Provider Settings\n")
f.write("# =============================================================================\n")
f.write(f"OPENAI_API_BASE_URL = \"{config_data['OPENAI_API_BASE_URL']}\"\n")
f.write("OPENAI_API_KEY = os.getenv(\"OPENAI_API_KEY\")\n")
f.write(f"LLM_MODEL = \"{config_data['LLM_MODEL']}\"\n\n")
# Write API Keys section
f.write("# =============================================================================\n")
f.write("# External API Keys (loaded from environment variables)\n")
f.write("# =============================================================================\n")
f.write("EXA_API_KEY = os.getenv(\"EXA_API_KEY\")\n\n")
# Write Exa config utility
f.write("# --- Exa Configuration ---\n")
f.write("exa_config_dict = {\"exaApiKey\": EXA_API_KEY if EXA_API_KEY else \"YOUR_EXA_KEY_MISSING\"}\n")
f.write("exa_config_arg_string = json.dumps(exa_config_dict)\n\n")
# Write MCP Server Configuration
f.write("# =============================================================================\n")
f.write("# MCP Server Configuration\n")
f.write("# =============================================================================\n")
f.write("MCP_SERVERS = {\n")
# Add configured servers
for server_name, server_config in config_data["MCP_SERVERS"].items():
if not server_config.get("enabled", True):
f.write(f" # \"{server_name}\": {{ # Disabled\n")
continue
f.write(f" \"{server_name}\": {{\n")
# Handle Exa server special config
if server_name == "exa":
if server_config.get("use_smithery", False):
# Smithery config
f.write(" \"command\": \"cmd\",\n")
f.write(" \"args\": [\n")
f.write(" \"/c\",\n")
f.write(" \"npx\",\n")
f.write(" \"-y\",\n")
f.write(" \"@smithery/cli@latest\",\n")
f.write(" \"run\",\n")
f.write(" \"exa\",\n")
f.write(" \"--config\",\n")
f.write(" exa_config_arg_string\n")
f.write(" ],\n")
else:
# Local server config
server_path = server_config.get("server_path", "exa-mcp-server")
f.write(" \"command\": \"npx\",\n")
f.write(" \"args\": [\n")
f.write(f" \"{server_path}\",\n")
f.write(" \"--tools=web_search,research_paper_search,twitter_search,company_research,crawling,competitor_finder\"\n")
f.write(" ],\n")
f.write(" \"env\": {\n")
f.write(" \"EXA_API_KEY\": EXA_API_KEY\n")
f.write(" }\n")
# Handle Chroma server
elif server_name == "chroma":
# Ensure absolute path for chroma data directory
data_dir = server_config.get("data_dir", DEFAULT_CHROMA_DATA_PATH)
absolute_data_dir = ensure_absolute_path(data_dir)
f.write(" \"command\": \"uvx\",\n")
f.write(" \"args\": [\n")
f.write(" \"chroma-mcp\",\n")
f.write(" \"--client-type\",\n")
f.write(" \"persistent\",\n")
f.write(" \"--data-dir\",\n")
# Escape backslashes in the path for the string literal in config.py
escaped_data_dir = absolute_data_dir.replace('\\', '\\\\')
f.write(f" \"{escaped_data_dir}\"\n")
f.write(" ]\n")
# Handle custom server - just write as raw JSON
elif server_name == "custom" and "raw_config" in server_config:
f.write(server_config["raw_config"])
f.write(" },\n")
f.write("}\n\n")
# Write remaining configuration sections
f.write("# =============================================================================\n")
f.write("# MCP Client Configuration\n")
f.write("# =============================================================================\n")
f.write("MCP_CONFIRM_TOOL_EXECUTION = False # True: Confirm before execution, False: Execute automatically\n\n")
f.write("# =============================================================================\n")
f.write("# Chat Logging Configuration\n")
f.write("# =============================================================================\n")
f.write(f"ENABLE_CHAT_LOGGING = {str(config_data['ENABLE_CHAT_LOGGING'])}\n")
f.write(f"LOG_DIR = \"{config_data['LOG_DIR']}\"\n\n")
f.write("# =============================================================================\n")
f.write("# Persona Configuration\n")
f.write("# =============================================================================\n")
f.write("PERSONA_NAME = \"Wolfhart\"\n\n")
f.write("# =============================================================================\n")
f.write("# Game Window Configuration\n")
f.write("# =============================================================================\n")
game_config = config_data["GAME_WINDOW_CONFIG"]
f.write(f"WINDOW_TITLE = \"{game_config['WINDOW_TITLE']}\"\n")
f.write(f"ENABLE_SCHEDULED_RESTART = {str(game_config['ENABLE_SCHEDULED_RESTART'])}\n")
f.write(f"RESTART_INTERVAL_MINUTES = {game_config['RESTART_INTERVAL_MINUTES']}\n")
f.write(f"GAME_EXECUTABLE_PATH = r\"{game_config['GAME_EXECUTABLE_PATH']}\"\n")
f.write(f"GAME_WINDOW_X = {game_config['GAME_WINDOW_X']}\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_HEIGHT = {game_config['GAME_WINDOW_HEIGHT']}\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\n")
# Write Memory Management Configuration
f.write("# =============================================================================\n")
f.write("# Memory Management Configuration\n")
f.write("# =============================================================================\n")
backup_hour = config_data.get('MEMORY_BACKUP_HOUR', 0)
backup_minute = config_data.get('MEMORY_BACKUP_MINUTE', 0)
profile_model = config_data.get('MEMORY_PROFILE_MODEL', 'LLM_MODEL') # Default to referencing LLM_MODEL
summary_model = config_data.get('MEMORY_SUMMARY_MODEL', 'mistral-7b-instruct')
f.write(f"MEMORY_BACKUP_HOUR = {backup_hour}\n")
f.write(f"MEMORY_BACKUP_MINUTE = {backup_minute}\n")
# Write profile model, potentially referencing LLM_MODEL
if profile_model == config_data.get('LLM_MODEL'):
f.write(f"MEMORY_PROFILE_MODEL = LLM_MODEL # Default to main LLM model\n")
else:
f.write(f"MEMORY_PROFILE_MODEL = \"{profile_model}\"\n")
f.write(f"MEMORY_SUMMARY_MODEL = \"{summary_model}\"\n\n")
# Write Embedding Model Name
embedding_model_name = config_data.get('EMBEDDING_MODEL_NAME', "sentence-transformers/paraphrase-multilingual-mpnet-base-v2")
f.write("# Embedding model for ChromaDB\n")
f.write(f"EMBEDDING_MODEL_NAME = \"{embedding_model_name}\"\n")
print("Generated config.py file successfully")
# ===============================================================
# Main Application
# ===============================================================
class WolfChatSetup(tk.Tk):
def __init__(self):
super().__init__()
self.title(f"Wolf Chat Setup v{VERSION}")
self.geometry("900x600")
self.minsize(900, 600)
# Load existing data
self.env_data = load_env_file()
self.config_data = load_current_config()
self.remote_data = load_remote_config() # Load new remote config
# Create the notebook for tabs
self.notebook = ttk.Notebook(self)
self.notebook.pack(expand=True, fill=tk.BOTH, padx=10, pady=10)
# Create tabs
self.create_api_tab()
self.create_mcp_tab()
self.create_game_tab()
self.create_memory_tab()
self.create_memory_management_tab() # 新增記憶管理標籤頁
self.create_management_tab() # New tab for combined management
# Create bottom buttons
self.create_bottom_buttons()
# Initialize running process tracker (will be managed by new system)
self.running_process = None # This might be replaced by bot_process_instance
# Initialize new process management variables
self.bot_process_instance = None
self.game_process_instance = None
self.control_client_instance = None
self.monitor_thread_instance = None
self.scheduler_thread_instance = None
self.keep_monitoring_flag = threading.Event()
self.keep_monitoring_flag.set()
# Initialize scheduler process tracker
self.scheduler_process = None
# Initialize game monitor instance (will be created in start_managed_session)
self.game_monitor = None
# Set initial states based on loaded data
self.update_ui_from_data()
self.update_scheduler_button_states(True) # Set initial scheduler button state
def create_management_tab(self):
"""Create the Bot and Game Management tab"""
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Management")
main_frame = ttk.Frame(tab, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
header = ttk.Label(main_frame, text="Bot & Game Management", font=("", 12, "bold"))
header.pack(anchor=tk.W, pady=(0, 10))
# --- Remote Control Settings ---
remote_frame = ttk.LabelFrame(main_frame, text="Remote Control Settings")
remote_frame.pack(fill=tk.X, pady=10)
# Remote Server URL
remote_url_frame = ttk.Frame(remote_frame)
remote_url_frame.pack(fill=tk.X, pady=5, padx=10)
remote_url_label = ttk.Label(remote_url_frame, text="Server URL:", width=15)
remote_url_label.pack(side=tk.LEFT)
self.remote_url_var = tk.StringVar(value=self.remote_data.get("REMOTE_SERVER_URL", ""))
remote_url_entry = ttk.Entry(remote_url_frame, textvariable=self.remote_url_var)
remote_url_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Remote Client Key
remote_key_frame = ttk.Frame(remote_frame)
remote_key_frame.pack(fill=tk.X, pady=5, padx=10)
remote_key_label = ttk.Label(remote_key_frame, text="Client Key:", width=15)
remote_key_label.pack(side=tk.LEFT)
self.remote_key_var = tk.StringVar(value=self.remote_data.get("REMOTE_CLIENT_KEY", ""))
remote_key_entry = ttk.Entry(remote_key_frame, textvariable=self.remote_key_var, show="*")
remote_key_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.show_remote_key_var = tk.BooleanVar(value=False)
show_remote_key_cb = ttk.Checkbutton(remote_key_frame, text="Show", variable=self.show_remote_key_var,
command=lambda: self.toggle_field_visibility(remote_key_entry, self.show_remote_key_var))
show_remote_key_cb.pack(side=tk.LEFT, padx=(5,0))
# --- Restart Settings ---
restart_settings_frame = ttk.LabelFrame(main_frame, text="Restart Settings")
restart_settings_frame.pack(fill=tk.X, pady=10)
# Game Restart Interval
game_interval_frame = ttk.Frame(restart_settings_frame)
game_interval_frame.pack(fill=tk.X, pady=5, padx=10)
game_interval_label = ttk.Label(game_interval_frame, text="Game Restart Interval (min):", width=25)
game_interval_label.pack(side=tk.LEFT)
self.game_restart_interval_var = tk.IntVar(value=self.remote_data.get("DEFAULT_GAME_RESTART_INTERVAL_MINUTES", 120))
game_interval_spinbox = ttk.Spinbox(game_interval_frame, from_=0, to=1440, width=7, textvariable=self.game_restart_interval_var)
game_interval_spinbox.pack(side=tk.LEFT)
game_interval_info = ttk.Label(game_interval_frame, text="(0 to disable)")
game_interval_info.pack(side=tk.LEFT, padx=(5,0))
# Bot Restart Interval
bot_interval_frame = ttk.Frame(restart_settings_frame)
bot_interval_frame.pack(fill=tk.X, pady=5, padx=10)
bot_interval_label = ttk.Label(bot_interval_frame, text="Bot Restart Interval (min):", width=25)
bot_interval_label.pack(side=tk.LEFT)
self.bot_restart_interval_var = tk.IntVar(value=self.remote_data.get("DEFAULT_BOT_RESTART_INTERVAL_MINUTES", 120))
bot_interval_spinbox = ttk.Spinbox(bot_interval_frame, from_=0, to=1440, width=7, textvariable=self.bot_restart_interval_var)
bot_interval_spinbox.pack(side=tk.LEFT)
bot_interval_info = ttk.Label(bot_interval_frame, text="(0 to disable)")
bot_interval_info.pack(side=tk.LEFT, padx=(5,0))
# Link Restart Times
link_restarts_frame = ttk.Frame(restart_settings_frame)
link_restarts_frame.pack(fill=tk.X, pady=5, padx=10)
self.link_restarts_var = tk.BooleanVar(value=self.remote_data.get("LINK_RESTART_TIMES", True))
link_restarts_cb = ttk.Checkbutton(link_restarts_frame, text="Link Game and Bot restart times (use Game interval if linked)", variable=self.link_restarts_var)
link_restarts_cb.pack(anchor=tk.W)
# Game Process Name
game_proc_name_frame = ttk.Frame(restart_settings_frame)
game_proc_name_frame.pack(fill=tk.X, pady=5, padx=10)
game_proc_name_label = ttk.Label(game_proc_name_frame, text="Game Process Name:", width=25)
game_proc_name_label.pack(side=tk.LEFT)
self.game_process_name_var = tk.StringVar(value=self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe"))
game_proc_name_entry = ttk.Entry(game_proc_name_frame, textvariable=self.game_process_name_var)
game_proc_name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# --- Control Buttons ---
control_buttons_frame = ttk.Frame(main_frame)
control_buttons_frame.pack(fill=tk.X, pady=20)
self.start_managed_button = ttk.Button(control_buttons_frame, text="Start Managed Bot & Game", command=self.start_managed_session)
self.start_managed_button.pack(side=tk.LEFT, padx=5)
self.stop_managed_button = ttk.Button(control_buttons_frame, text="Stop Managed Session", command=self.stop_managed_session, state=tk.DISABLED)
self.stop_managed_button.pack(side=tk.LEFT, padx=5)
# Status Area (Optional, for displaying logs or status messages)
status_label = ttk.Label(main_frame, text="Status messages will appear in the console.")
status_label.pack(pady=10)
def start_managed_session(self):
logger.info("Attempting to start managed session...")
# This will be the new main function to start bot, game, and monitoring
# Ensure previous session is stopped if any
if self.bot_process_instance or self.game_process_instance or self.monitor_thread_instance:
messagebox.showwarning("Session Active", "A managed session might already be active. Please stop it first or check console.")
# self.stop_managed_session() # Optionally force stop
# time.sleep(1) # Give time to stop
# return
# Save current settings before starting
self.save_settings(show_success_message=False) # Save without showing popup, or make it optional
self.keep_monitoring_flag.set() # Ensure monitoring is enabled
# Start Game
if not self._start_game_managed():
messagebox.showerror("Error", "Failed to start the game.")
self.update_management_buttons_state(True) # Enable start, disable stop
return
time.sleep(5) # Give game some time to initialize
# Start Bot (main.py)
if not self._start_bot_managed():
messagebox.showerror("Error", "Failed to start the bot (main.py).")
self._stop_game_managed() # Stop game if bot fails to start
self.update_management_buttons_state(True)
return
# Start Control Client
if HAS_SOCKETIO:
self._start_control_client()
else:
logger.warning("socketio library not found. Remote control will be disabled.")
messagebox.showwarning("Socket.IO Missing", "The 'python-socketio[client]' library is not installed. Remote control features will be disabled. Please install it via 'pip install \"python-socketio[client]\"' or use the 'Install Dependencies' button.")
# Start Monitoring Thread
self._start_monitoring_thread() # This is the old general monitoring thread
# Initialize and start GameMonitor (new specific game monitor)
try:
# Create callback function for game_monitor
def game_monitor_callback(action):
logger.info(f"Received action from game_manager: {action}")
if action == "restart_complete":
# Schedule _handle_game_restart_complete to run in the main thread
self.after(0, self._handle_game_restart_complete)
# Add other actions if needed, e.g., "restart_begin", "restart_error"
# Create GameMonitor instance if it doesn't exist
if not self.game_monitor:
self.game_monitor = game_manager.create_game_monitor(
config_data=self.config_data,
remote_data=self.remote_data,
logger=logger, # Use the main Setup logger
callback=game_monitor_callback
)
# Start the game monitor
if self.game_monitor.start(): # Ensure start() returns a boolean
logger.info("Game monitor (game_manager) started successfully.")
else:
logger.error("Failed to start game_manager's GameMonitor.")
messagebox.showwarning("Warning", "Game window monitoring (game_manager) could not be started.")
# Continue execution, not a fatal error for the whole session
except Exception as gm_err:
logger.exception(f"Error setting up game_manager's GameMonitor: {gm_err}")
messagebox.showwarning("Warning", "Failed to initialize game_manager's GameMonitor.")
# Continue execution
# Start Scheduler Thread
self._start_scheduler_thread()
self.update_management_buttons_state(False) # Disable start, enable stop
# messagebox.showinfo("Session Started", "Managed bot and game session started. Check console for logs.") # Removed popup
logger.info("Managed bot and game session started. Check console for logs.") # Log instead of popup
def _handle_game_restart_complete(self):
"""Handles the callback from GameMonitor when a game restart is complete."""
logger.info("Game restart completed (callback from game_manager). Handling bot restart...")
try:
# Ensure we are in the main thread (already handled by self.after)
# Wait a bit for the game to stabilize
time.sleep(10)
logger.info("Restarting bot after game restart (triggered by game_manager)...")
if self._restart_bot_managed():
logger.info("Bot restarted successfully after game_manager's game restart.")
else:
logger.error("Failed to restart bot after game_manager's game restart!")
messagebox.showwarning("Warning", "Failed to restart bot after game_manager's game restart.")
except Exception as e:
logger.exception(f"Error in _handle_game_restart_complete: {e}")
def stop_managed_session(self):
logger.info("Attempting to stop managed session...")
self.keep_monitoring_flag.clear() # Signal threads to stop
if self.control_client_instance:
self._stop_control_client()
if self.scheduler_thread_instance and self.scheduler_thread_instance.is_alive():
logger.info("Waiting for scheduler thread to stop...")
self.scheduler_thread_instance.join(timeout=5)
if self.scheduler_thread_instance.is_alive():
logger.warning("Scheduler thread did not stop in time.")
self.scheduler_thread_instance = None
schedule.clear()
if self.monitor_thread_instance and self.monitor_thread_instance.is_alive():
logger.info("Waiting for monitor thread to stop...")
self.monitor_thread_instance.join(timeout=5)
if self.monitor_thread_instance.is_alive():
logger.warning("Monitor thread did not stop in time.")
self.monitor_thread_instance = None
# Stop GameMonitor (from game_manager)
if self.game_monitor:
try:
if self.game_monitor.stop(): # Ensure stop() returns a boolean
logger.info("Game monitor (game_manager) stopped successfully.")
else:
logger.warning("Game monitor (game_manager) stop may have failed.")
except Exception as gm_err:
logger.exception(f"Error stopping game_manager's GameMonitor: {gm_err}")
finally:
self.game_monitor = None # Release the instance
self._stop_bot_managed()
self._stop_game_managed()
# Reset process instances
self.bot_process_instance = None
self.game_process_instance = None
self.update_management_buttons_state(True) # Enable start, disable stop
messagebox.showinfo("Session Stopped", "Managed bot and game session stopped.")
def update_management_buttons_state(self, enable_start):
if hasattr(self, 'start_managed_button'):
self.start_managed_button.config(state=tk.NORMAL if enable_start else tk.DISABLED)
if hasattr(self, 'stop_managed_button'):
self.stop_managed_button.config(state=tk.DISABLED if enable_start else tk.NORMAL)
# Placeholder for game/bot start/stop/check methods to be integrated
# These will be adapted from wolf_control.py and use self.config_data and self.remote_data
def _find_process_by_name(self, process_name):
"""Find a process by name using psutil."""
for proc in psutil.process_iter(['pid', 'name']):
try:
if proc.info['name'].lower() == process_name.lower():
return proc
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
return None
def _is_game_running_managed(self):
game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe")
if self.game_process_instance and self.game_process_instance.poll() is None:
# Check if the process name matches, in case Popen object is stale but a process with same PID exists
try:
p = psutil.Process(self.game_process_instance.pid)
if p.name().lower() == game_process_name.lower():
return True
except psutil.NoSuchProcess:
self.game_process_instance = None # Stale process object
return False # Popen object is stale and process is gone
# Fallback to checking by name if self.game_process_instance is None or points to a dead/wrong process
return self._find_process_by_name(game_process_name) is not None
def _start_game_managed(self):
global game_process_instance
game_exe_path = self.config_data.get("GAME_WINDOW_CONFIG", {}).get("GAME_EXECUTABLE_PATH")
game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe")
if not game_exe_path:
logger.error("Game executable path not configured.")
messagebox.showerror("Config Error", "Game executable path is not set in Game Settings.")
return False
if self._is_game_running_managed():
logger.info(f"Game ({game_process_name}) is already running.")
# Try to get a Popen object if we don't have one
if not self.game_process_instance:
existing_proc = self._find_process_by_name(game_process_name)
if existing_proc:
# We can't directly create a Popen object for an existing process this way easily.
# For now, we'll just acknowledge it's running.
# For full control, it's best if this script starts it.
logger.info(f"Found existing game process PID: {existing_proc.pid}. Monitoring without direct Popen control.")
return True
try:
logger.info(f"Starting game: {game_exe_path}")
# Use shell=False and pass arguments as a list if possible, but for .exe, shell=True is often more reliable on Windows
# For better process control, avoid shell=True if not strictly necessary.
# However, if GAME_EXE_PATH can contain spaces or needs shell interpretation, shell=True might be needed.
# For now, let's assume GAME_EXE_PATH is a direct path to an executable.
self.game_process_instance = subprocess.Popen(game_exe_path, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
game_process_instance = self.game_process_instance # Update global if used by other parts from wolf_control
# Wait a bit for the process to appear in psutil
time.sleep(2)
if self._is_game_running_managed():
logger.info(f"Game ({game_process_name}) started successfully with PID {self.game_process_instance.pid}.")
return True
else:
logger.warning(f"Game ({game_process_name}) did not appear to start correctly after Popen call.")
self.game_process_instance = None # Clear if it failed
game_process_instance = None
return False
except Exception as e:
logger.exception(f"Error starting game: {e}")
self.game_process_instance = None
game_process_instance = None
return False
def _stop_game_managed(self):
global game_process_instance
game_process_name = self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe")
stopped = False
if self.game_process_instance and self.game_process_instance.poll() is None:
logger.info(f"Stopping game process (PID: {self.game_process_instance.pid}) started by this manager...")
try:
self.game_process_instance.terminate()
self.game_process_instance.wait(timeout=5) # Wait for termination
logger.info("Game process terminated.")
stopped = True
except subprocess.TimeoutExpired:
logger.warning("Game process did not terminate in time, killing...")
self.game_process_instance.kill()
self.game_process_instance.wait(timeout=5)
logger.info("Game process killed.")
stopped = True
except Exception as e:
logger.error(f"Error terminating/killing own game process: {e}")
self.game_process_instance = None
game_process_instance = None
# If not stopped or no instance, try to find and kill by name
if not stopped:
proc_to_kill = self._find_process_by_name(game_process_name)
if proc_to_kill:
logger.info(f"Found game process '{game_process_name}' (PID: {proc_to_kill.pid}). Attempting to terminate...")
try:
proc_to_kill.terminate()
proc_to_kill.wait(timeout=5) # psutil's wait
logger.info(f"Game process '{game_process_name}' terminated.")
stopped = True
except psutil.TimeoutExpired:
logger.warning(f"Game process '{game_process_name}' did not terminate, killing...")
proc_to_kill.kill()
proc_to_kill.wait(timeout=5)
logger.info(f"Game process '{game_process_name}' killed.")
stopped = True
except Exception as e:
logger.error(f"Error terminating/killing game process by name '{game_process_name}': {e}")
else:
logger.info(f"Game process '{game_process_name}' not found running.")
stopped = True # Considered stopped if not found
if self.game_process_instance: # Clear Popen object if it exists
self.game_process_instance = None
game_process_instance = None
return stopped
def _is_bot_running_managed(self):
bot_script_name = self.remote_data.get("BOT_SCRIPT_NAME", "main.py")
if self.bot_process_instance and self.bot_process_instance.poll() is None:
# Verify it's the correct script, in case of PID reuse
try:
p = psutil.Process(self.bot_process_instance.pid)
if sys.executable in p.cmdline() and any(bot_script_name in arg for arg in p.cmdline()):
return True
except psutil.NoSuchProcess:
self.bot_process_instance = None # Stale process object
return False
# Fallback: Check for any python process running the bot script
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
cmdline = proc.cmdline()
if cmdline and sys.executable in cmdline[0] and any(bot_script_name in arg for arg in cmdline):
# If we find one, and don't have an instance, we can't control it directly with Popen
# but we know it's running.
if not self.bot_process_instance:
logger.info(f"Found external bot process (PID: {proc.pid}). Monitoring without direct Popen control.")
return True
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, IndexError):
continue # Ignore processes that died or we can't access, or have empty cmdline
return False
def _start_bot_managed(self):
global bot_process_instance # For compatibility if other parts use global
bot_script_name = self.remote_data.get("BOT_SCRIPT_NAME", "main.py")
if not os.path.exists(bot_script_name):
messagebox.showerror("Error", f"Could not find bot script: {bot_script_name}")
return False
if self._is_bot_running_managed():
logger.info(f"Bot ({bot_script_name}) is already running.")
return True # Or handle acquiring Popen object if possible (complex)
try:
logger.info(f"Starting bot: {sys.executable} {bot_script_name}")
# Ensure CWD is script's directory if main.py relies on relative paths
script_dir = os.path.dirname(os.path.abspath(__file__))
current_env = os.environ.copy()
current_env["PYTHONIOENCODING"] = "utf-8"
self.bot_process_instance = subprocess.Popen(
[sys.executable, bot_script_name],
cwd=script_dir, # Run main.py from its directory
stdout=subprocess.PIPE, # Capture output
stderr=subprocess.STDOUT, # Redirect stderr to stdout
text=True,
encoding='utf-8', # Specify UTF-8 encoding
errors='replace', # Handle potential encoding errors
bufsize=1, # Line buffered
env=current_env # Set PYTHONIOENCODING
)
bot_process_instance = self.bot_process_instance # Update global
# Start a thread to log bot's output
threading.Thread(target=self._log_subprocess_output, args=(self.bot_process_instance, "Bot"), daemon=True).start()
logger.info(f"Bot ({bot_script_name}) started successfully with PID {self.bot_process_instance.pid}.")
return True
except Exception as e:
logger.exception(f"Error starting bot: {e}")
self.bot_process_instance = None
bot_process_instance = None
return False
def _log_subprocess_output(self, process, name):
"""Reads and logs output from a subprocess."""
if not process or not process.stdout:
logger.error(f"No process or stdout to log for {name}.")
return
logger.info(f"Started logging output for {name} (PID: {process.pid}).")
try:
for line in iter(process.stdout.readline, ''):
if line:
logger.info(f"[{name}] {line.strip()}")
if process.poll() is not None and not line: # Process ended and no more output
break
process.stdout.close()
except Exception as e:
logger.error(f"Error logging output for {name}: {e}")
finally:
return_code = process.wait()
logger.info(f"{name} process (PID: {process.pid}) exited with code {return_code}.")
def _stop_bot_managed(self):
global bot_process_instance
bot_script_name = self.remote_data.get("BOT_SCRIPT_NAME", "main.py")
stopped = False
if self.bot_process_instance and self.bot_process_instance.poll() is None:
logger.info(f"Stopping bot process (PID: {self.bot_process_instance.pid}) started by this manager...")
try:
self.bot_process_instance.terminate()
self.bot_process_instance.wait(timeout=5)
logger.info("Bot process terminated.")
stopped = True
except subprocess.TimeoutExpired:
logger.warning("Bot process did not terminate in time, killing...")
self.bot_process_instance.kill()
self.bot_process_instance.wait(timeout=5)
logger.info("Bot process killed.")
stopped = True
except Exception as e:
logger.error(f"Error terminating/killing own bot process: {e}")
self.bot_process_instance = None
bot_process_instance = None
# Fallback: find and kill any python process running the bot script
if not stopped:
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
cmdline = proc.cmdline()
if cmdline and sys.executable in cmdline[0] and any(bot_script_name in arg for arg in cmdline):
logger.info(f"Found bot process '{bot_script_name}' (PID: {proc.pid}). Attempting to terminate...")
proc.terminate()
proc.wait(timeout=5)
logger.info(f"Bot process '{bot_script_name}' terminated.")
stopped = True
break # Assume only one instance for now
except psutil.TimeoutExpired:
logger.warning(f"Bot process '{bot_script_name}' (PID: {proc.pid}) did not terminate, killing...")
proc.kill()
proc.wait(timeout=5)
logger.info(f"Bot process '{bot_script_name}' killed.")
stopped = True
break
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, IndexError):
continue
if not stopped: # If no Popen instance and no external process found
logger.info(f"Bot process '{bot_script_name}' not found running.")
stopped = True
if self.bot_process_instance: # Clear Popen object if it exists
self.bot_process_instance = None
bot_process_instance = None
return stopped
def _restart_game_managed(self):
logger.info("Restarting game (managed)...")
# If GameMonitor (from game_manager) exists and is running, use it to restart
if self.game_monitor and self.game_monitor.running:
logger.info("Using game_manager's GameMonitor to restart game.")
return self.game_monitor.restart_now()
else:
# Fallback to the original method if game_monitor is not active
logger.info("game_manager's GameMonitor not active, using default method to restart game.")
self._stop_game_managed()
time.sleep(2) # Give it time to fully stop
return self._start_game_managed()
def _restart_bot_managed(self):
logger.info("Restarting bot (managed)...")
self._stop_bot_managed()
time.sleep(2) # Give it time to fully stop
return self._start_bot_managed()
def _restart_all_managed(self):
logger.info("Performing full restart (bot and game)...")
self._stop_bot_managed()
self._stop_game_managed()
time.sleep(3)
game_started = self._start_game_managed()
if game_started:
time.sleep(10) # Wait for game to initialize
bot_started = self._start_bot_managed()
if not bot_started:
logger.error("Failed to restart bot after restarting game.")
return False
else:
logger.error("Failed to restart game during full restart.")
# Optionally try to start bot anyway or declare full failure
# self._start_bot_managed()
return False
logger.info("Full restart completed.")
# Update last restart time if tracking it
# self.last_restart_time = datetime.datetime.now()
return True
def _start_monitoring_thread(self):
if self.monitor_thread_instance and self.monitor_thread_instance.is_alive():
logger.info("Monitor thread already running.")
return
self.monitor_thread_instance = threading.Thread(target=self._monitoring_loop, daemon=True)
self.monitor_thread_instance.start()
logger.info("Started monitoring thread.")
def _monitoring_loop(self):
logger.info("Monitoring loop started.")
while self.keep_monitoring_flag.is_set():
try:
# Check game
if not self._is_game_running_managed():
if self.game_process_instance is None : # Only restart if we are supposed to manage it or it was started by us and died
logger.warning("Managed game process not found. Attempting to restart game...")
self._start_game_managed() # Or _restart_game_managed()
# Check bot
if not self._is_bot_running_managed():
if self.bot_process_instance is None: # Only restart if we are supposed to manage it or it was started by us and died
logger.warning("Managed bot process not found. Attempting to restart bot...")
self._start_bot_managed() # Or _restart_bot_managed()
# Check for remote commands (if control_client_instance is set up)
if self.control_client_instance and hasattr(self.control_client_instance, 'check_signals'):
self.control_client_instance.check_signals(self) # Pass self (WolfChatSetup instance)
time.sleep(self.config_data.get("GAME_WINDOW_CONFIG", {}).get("MONITOR_INTERVAL_SECONDS", 5))
except Exception as e:
logger.exception(f"Error in monitoring loop: {e}")
time.sleep(10) # Wait longer after an error
logger.info("Monitoring loop stopped.")
def _start_scheduler_thread(self):
if self.scheduler_thread_instance and self.scheduler_thread_instance.is_alive():
logger.info("Scheduler thread already running.")
return
self._setup_scheduled_restarts() # Setup jobs based on current config
self.scheduler_thread_instance = threading.Thread(target=self._run_scheduler, daemon=True)
self.scheduler_thread_instance.start()
logger.info("Started scheduler thread.")
def _run_scheduler(self):
logger.info("Scheduler loop started.")
while self.keep_monitoring_flag.is_set(): # Use same flag as monitor
schedule.run_pending()
time.sleep(1)
logger.info("Scheduler loop stopped.")
def _setup_scheduled_restarts(self):
schedule.clear() # Clear previous jobs
link_restarts = self.remote_data.get("LINK_RESTART_TIMES", True)
game_interval = self.remote_data.get("DEFAULT_GAME_RESTART_INTERVAL_MINUTES", 0)
bot_interval = self.remote_data.get("DEFAULT_BOT_RESTART_INTERVAL_MINUTES", 0)
if link_restarts and game_interval > 0:
logger.info(f"Scheduling linked restart (game & bot) every {game_interval} minutes.")
schedule.every(game_interval).minutes.do(self._restart_all_managed)
else:
if game_interval > 0:
logger.info(f"Scheduling game restart every {game_interval} minutes.")
schedule.every(game_interval).minutes.do(self._restart_game_managed)
if bot_interval > 0:
logger.info(f"Scheduling bot restart every {bot_interval} minutes.")
schedule.every(bot_interval).minutes.do(self._restart_bot_managed)
if not schedule.jobs:
logger.info("No scheduled restarts configured.")
def _start_control_client(self):
if not HAS_SOCKETIO:
logger.warning("Cannot start ControlClient: python-socketio is not installed.")
return
if self.control_client_instance and self.control_client_instance.is_connected(): # is_connected or similar check
logger.info("Control client already connected.")
return
server_url = self.remote_data.get("REMOTE_SERVER_URL")
client_key = self.remote_data.get("REMOTE_CLIENT_KEY")
if not server_url or not client_key:
logger.warning("Remote server URL or client key not configured. Cannot start control client.")
messagebox.showwarning("Remote Config Missing", "Remote Server URL or Client Key is not set in Management tab.")
return
self.control_client_instance = ControlClient(server_url, client_key, wolf_chat_setup_instance=self) # Pass self
# The ControlClient should handle its own connection thread.
# self.control_client_instance.start_thread() or similar method
if self.control_client_instance.run_in_thread(): # Assuming run_in_thread starts the connection attempt
logger.info("Control client thread started.")
else:
logger.error("Failed to start control client thread.")
self.control_client_instance = None
def _stop_control_client(self):
if self.control_client_instance:
logger.info("Stopping control client...")
self.control_client_instance.stop() # This should handle thread shutdown
self.control_client_instance = None
logger.info("Control client stopped.")
def on_closing(self):
"""Handle window close event."""
if messagebox.askokcancel("Quit", "Do you want to quit Wolf Chat Setup? This will stop any managed sessions and running scripts."):
print("Closing Setup...")
self.stop_managed_session() # Stop bot/game managed session if running
self.stop_process() # Stop bot/test script if running independently
self.stop_memory_scheduler() # Stop scheduler if running
self.destroy()
def create_api_tab(self):
"""Create the API Settings tab"""
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="API 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="OpenAI API Settings", font=("", 12, "bold"))
header.pack(anchor=tk.W, pady=(0, 10))
# API Base URL
url_frame = ttk.Frame(main_frame)
url_frame.pack(fill=tk.X, pady=5)
url_label = ttk.Label(url_frame, text="API Base URL:", width=15)
url_label.pack(side=tk.LEFT, padx=(0, 5))
self.api_url_var = tk.StringVar()
self.api_url_entry = ttk.Entry(url_frame, textvariable=self.api_url_var)
self.api_url_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Predefined endpoints
url_presets_frame = ttk.Frame(main_frame)
url_presets_frame.pack(fill=tk.X, pady=2)
preset_label = ttk.Label(url_presets_frame, text="Presets:", width=15)
preset_label.pack(side=tk.LEFT, padx=(0, 5))
openai_btn = ttk.Button(url_presets_frame, text="OpenAI", width=10,
command=lambda: self.api_url_var.set(""))
openai_btn.pack(side=tk.LEFT, padx=2)
openrouter_btn = ttk.Button(url_presets_frame, text="OpenRouter", width=10,
command=lambda: self.api_url_var.set("https://openrouter.ai/api/v1"))
openrouter_btn.pack(side=tk.LEFT, padx=2)
localhost_btn = ttk.Button(url_presets_frame, text="Localhost", width=10,
command=lambda: self.api_url_var.set("http://localhost:1234/v1"))
localhost_btn.pack(side=tk.LEFT, padx=2)
# API Key
key_frame = ttk.Frame(main_frame)
key_frame.pack(fill=tk.X, pady=5)
key_label = ttk.Label(key_frame, text="API Key:", width=15)
key_label.pack(side=tk.LEFT, padx=(0, 5))
self.api_key_var = tk.StringVar()
self.api_key_entry = ttk.Entry(key_frame, textvariable=self.api_key_var, show="*")
self.api_key_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.show_key_var = tk.BooleanVar(value=False)
show_key_cb = ttk.Checkbutton(key_frame, text="Show", variable=self.show_key_var,
command=self.toggle_key_visibility)
show_key_cb.pack(side=tk.LEFT, padx=(5, 0))
# Model Selection
model_frame = ttk.Frame(main_frame)
model_frame.pack(fill=tk.X, pady=5)
model_label = ttk.Label(model_frame, text="LLM Model:", width=15)
model_label.pack(side=tk.LEFT, padx=(0, 5))
self.model_var = tk.StringVar(value="deepseek/deepseek-chat-v3-0324")
self.model_entry = ttk.Entry(model_frame, textvariable=self.model_var)
self.model_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Information box
info_frame = ttk.LabelFrame(main_frame, text="Information")
info_frame.pack(fill=tk.BOTH, expand=True, pady=10)
info_text = (
"• Leave the API Base URL empty to use the official OpenAI API\n"
"• For OpenRouter, you need an OpenRouter API key\n"
"• For local LLMs, configure your API to use OpenAI-compatible format\n"
"• Make sure the selected model is available with your chosen provider"
)
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_mcp_tab(self):
"""Create the MCP Settings tab"""
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="MCP Servers")
# 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="Modular Capability Provider (MCP) Servers", font=("", 12, "bold"))
header.pack(anchor=tk.W, pady=(0, 10))
# Create the main frame for server settings
servers_frame = ttk.Frame(main_frame)
servers_frame.pack(fill=tk.BOTH, expand=True)
# Left side - Servers list
servers_list_frame = ttk.LabelFrame(servers_frame, text="Available Servers")
servers_list_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 5))
# Servers list
self.servers_listbox = tk.Listbox(servers_list_frame, width=20, height=10)
self.servers_listbox.pack(side=tk.LEFT, fill=tk.Y, expand=True, padx=5, pady=5)
self.servers_listbox.bind('<<ListboxSelect>>', self.on_server_select)
servers_scrollbar = ttk.Scrollbar(servers_list_frame, orient=tk.VERTICAL, command=self.servers_listbox.yview)
servers_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.servers_listbox.config(yscrollcommand=servers_scrollbar.set)
# Buttons below listbox
servers_btn_frame = ttk.Frame(servers_list_frame)
servers_btn_frame.pack(fill=tk.X, pady=5, padx=5)
add_btn = ttk.Button(servers_btn_frame, text="Add", command=self.add_custom_server)
add_btn.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True)
self.remove_btn = ttk.Button(servers_btn_frame, text="Remove", command=self.remove_server, state=tk.DISABLED)
self.remove_btn.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True)
# Right side - Server settings
self.server_settings_frame = ttk.LabelFrame(servers_frame, text="Server Settings")
self.server_settings_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
# Empty label for initial state
self.empty_settings_label = ttk.Label(self.server_settings_frame,
text="Select a server from the list to configure it.",
justify=tk.CENTER)
self.empty_settings_label.pack(expand=True, pady=20)
# Frames for each server type (initially hidden)
self.create_exa_settings_frame()
self.create_chroma_settings_frame()
self.create_custom_settings_frame()
# Update the servers list
self.update_servers_list()
def create_exa_settings_frame(self):
"""Create settings frame for Exa server"""
self.exa_frame = ttk.Frame(self.server_settings_frame)
# Enable checkbox
enable_frame = ttk.Frame(self.exa_frame)
enable_frame.pack(fill=tk.X, pady=5)
self.exa_enable_var = tk.BooleanVar(value=True)
enable_cb = ttk.Checkbutton(enable_frame, text="Enable Exa Server", variable=self.exa_enable_var)
enable_cb.pack(anchor=tk.W)
# API Key
key_frame = ttk.Frame(self.exa_frame)
key_frame.pack(fill=tk.X, pady=5)
key_label = ttk.Label(key_frame, text="Exa API Key:", width=15)
key_label.pack(side=tk.LEFT)
self.exa_key_var = tk.StringVar()
self.exa_key_entry = ttk.Entry(key_frame, textvariable=self.exa_key_var, show="*")
self.exa_key_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.show_exa_key_var = tk.BooleanVar(value=False)
show_key_cb = ttk.Checkbutton(key_frame, text="Show", variable=self.show_exa_key_var,
command=lambda: self.toggle_field_visibility(self.exa_key_entry, self.show_exa_key_var))
show_key_cb.pack(side=tk.LEFT, padx=(5, 0))
# Server Type
type_frame = ttk.Frame(self.exa_frame)
type_frame.pack(fill=tk.X, pady=5)
type_label = ttk.Label(type_frame, text="Server Type:", width=15)
type_label.pack(side=tk.LEFT)
self.exa_type_var = tk.StringVar(value="local") # Changed default to local
local_radio = ttk.Radiobutton(type_frame, text="Local Server (recommended)",
variable=self.exa_type_var, value="local",
command=self.update_exa_settings_visibility)
local_radio.pack(anchor=tk.W)
smithery_radio = ttk.Radiobutton(type_frame, text="Smithery",
variable=self.exa_type_var, value="smithery",
command=self.update_exa_settings_visibility)
smithery_radio.pack(anchor=tk.W)
# Local Server Path
self.exa_local_frame = ttk.Frame(self.exa_frame)
local_path_label = ttk.Label(self.exa_local_frame, text="Server Path:", width=15)
local_path_label.pack(side=tk.LEFT)
self.exa_path_var = tk.StringVar(value=f"C:/Users/{CURRENT_USERNAME}/AppData/Roaming/npm/exa-mcp-server")
self.exa_path_entry = ttk.Entry(self.exa_local_frame, textvariable=self.exa_path_var)
self.exa_path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
browse_btn = ttk.Button(self.exa_local_frame, text="Browse", command=self.browse_exa_server)
browse_btn.pack(side=tk.LEFT, padx=(5, 0))
# Smithery Info
self.exa_smithery_frame = ttk.Frame(self.exa_frame)
smithery_info = ttk.Label(self.exa_smithery_frame,
text="Smithery will automatically download and run the latest Exa MCP server.",
wraplength=400)
smithery_info.pack(pady=5)
# Information text
info_frame = ttk.LabelFrame(self.exa_frame, text="Information")
info_frame.pack(fill=tk.X, pady=10)
info_text = (
"• Exa MCP provides web search and research capabilities\n"
"• An Exa API key is required (obtain from https://exa.ai)\n"
"• Local server is the default and preferred option\n"
"• Smithery requires an internet connection each time"
)
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=400)
info_label.pack(padx=10, pady=10, anchor=tk.W)
def create_chroma_settings_frame(self):
"""Create settings frame for Chroma MCP server"""
self.chroma_frame = ttk.Frame(self.server_settings_frame)
# Enable checkbox
enable_frame = ttk.Frame(self.chroma_frame)
enable_frame.pack(fill=tk.X, pady=5)
self.chroma_enable_var = tk.BooleanVar(value=True)
enable_cb = ttk.Checkbutton(enable_frame, text="Enable Chroma MCP Server", variable=self.chroma_enable_var)
enable_cb.pack(anchor=tk.W)
# Data directory
dir_frame = ttk.Frame(self.chroma_frame)
dir_frame.pack(fill=tk.X, pady=5)
dir_label = ttk.Label(dir_frame, text="Data Directory:", width=15)
dir_label.pack(side=tk.LEFT)
self.chroma_dir_var = tk.StringVar(value=DEFAULT_CHROMA_DATA_PATH)
dir_entry = ttk.Entry(dir_frame, textvariable=self.chroma_dir_var)
dir_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
browse_btn = ttk.Button(dir_frame, text="Browse", command=self.browse_chroma_dir)
browse_btn.pack(side=tk.LEFT, padx=(5, 0))
# Information text
info_frame = ttk.LabelFrame(self.chroma_frame, text="Information")
info_frame.pack(fill=tk.X, pady=10, expand=True)
info_text = (
"• Chroma MCP provides vector database storage for memory features\n"
"• The data directory will store vector embeddings and metadata\n"
"• Use a persistent directory to maintain memory between sessions\n"
"• Requires uvx to be installed in your Python environment"
)
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=400)
info_label.pack(padx=10, pady=10, anchor=tk.W)
def create_custom_settings_frame(self):
"""Create settings frame for custom server"""
self.custom_frame = ttk.Frame(self.server_settings_frame)
# Name field
name_frame = ttk.Frame(self.custom_frame)
name_frame.pack(fill=tk.X, pady=5)
name_label = ttk.Label(name_frame, text="Server Name:", width=15)
name_label.pack(side=tk.LEFT)
self.custom_name_var = tk.StringVar()
name_entry = ttk.Entry(name_frame, textvariable=self.custom_name_var)
name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Enable checkbox
enable_frame = ttk.Frame(self.custom_frame)
enable_frame.pack(fill=tk.X, pady=5)
self.custom_enable_var = tk.BooleanVar(value=True)
enable_cb = ttk.Checkbutton(enable_frame, text="Enable Server", variable=self.custom_enable_var)
enable_cb.pack(anchor=tk.W)
# Raw configuration editor
config_label = ttk.Label(self.custom_frame, text="Server Configuration (Python Dictionary Format):")
config_label.pack(anchor=tk.W, pady=(10, 5))
self.custom_config_text = scrolledtext.ScrolledText(self.custom_frame, height=12, width=50)
self.custom_config_text.pack(fill=tk.BOTH, expand=True, pady=5)
# Default configuration template
default_config = (
' "command": "npx",\n'
' "args": [\n'
' "some-mcp-server",\n'
' "--option1",\n'
' "--option2=value"\n'
' ]'
)
self.custom_config_text.insert(tk.END, default_config)
# Information text
info_frame = ttk.LabelFrame(self.custom_frame, text="Information")
info_frame.pack(fill=tk.X, pady=10)
info_text = (
"• Enter the raw Python dictionary for your custom MCP server\n"
"• Format must match the structure in config.py\n"
"• Be careful with syntax - incorrect format may cause errors\n"
"• Server name must be unique"
)
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=400)
info_label.pack(padx=10, pady=10, anchor=tk.W)
def create_game_tab(self):
"""Create the Game Settings tab"""
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="Game 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="Game Window Configuration", font=("", 12, "bold"))
header.pack(anchor=tk.W, pady=(0, 10))
# Game window title
title_frame = ttk.Frame(main_frame)
title_frame.pack(fill=tk.X, pady=5)
title_label = ttk.Label(title_frame, text="Window Title:", width=20)
title_label.pack(side=tk.LEFT, padx=(0, 5))
self.window_title_var = tk.StringVar(value="Last War-Survival Game")
title_entry = ttk.Entry(title_frame, textvariable=self.window_title_var)
title_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Game executable path
path_frame = ttk.Frame(main_frame)
path_frame.pack(fill=tk.X, pady=5)
path_label = ttk.Label(path_frame, text="Game Executable Path:", width=20)
path_label.pack(side=tk.LEFT, padx=(0, 5))
self.game_path_var = tk.StringVar(value=fr"C:\Users\{CURRENT_USERNAME}\AppData\Local\TheLastWar\Launch.exe")
path_entry = ttk.Entry(path_frame, textvariable=self.game_path_var)
path_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
browse_btn = ttk.Button(path_frame, text="Browse", command=self.browse_game_path)
browse_btn.pack(side=tk.LEFT, padx=(5, 0))
# Window position
pos_frame = ttk.Frame(main_frame)
pos_frame.pack(fill=tk.X, pady=5)
pos_label = ttk.Label(pos_frame, text="Window Position:", width=20)
pos_label.pack(side=tk.LEFT, padx=(0, 5))
pos_x_label = ttk.Label(pos_frame, text="X:")
pos_x_label.pack(side=tk.LEFT)
self.pos_x_var = tk.IntVar(value=50)
pos_x_entry = ttk.Spinbox(pos_frame, textvariable=self.pos_x_var, from_=0, to=3000, width=5)
pos_x_entry.pack(side=tk.LEFT, padx=(0, 10))
pos_y_label = ttk.Label(pos_frame, text="Y:")
pos_y_label.pack(side=tk.LEFT)
self.pos_y_var = tk.IntVar(value=30)
pos_y_entry = ttk.Spinbox(pos_frame, textvariable=self.pos_y_var, from_=0, to=3000, width=5)
pos_y_entry.pack(side=tk.LEFT)
# Window size
size_frame = ttk.Frame(main_frame)
size_frame.pack(fill=tk.X, pady=5)
size_label = ttk.Label(size_frame, text="Window Size:", width=20)
size_label.pack(side=tk.LEFT, padx=(0, 5))
width_label = ttk.Label(size_frame, text="Width:")
width_label.pack(side=tk.LEFT)
self.width_var = tk.IntVar(value=600)
width_entry = ttk.Spinbox(size_frame, textvariable=self.width_var, from_=300, to=3000, width=5)
width_entry.pack(side=tk.LEFT, padx=(0, 10))
height_label = ttk.Label(size_frame, text="Height:")
height_label.pack(side=tk.LEFT)
self.height_var = tk.IntVar(value=1070)
height_entry = ttk.Spinbox(size_frame, textvariable=self.height_var, from_=300, to=3000, width=5)
height_entry.pack(side=tk.LEFT)
# Auto-restart settings (Now managed by 'Management' tab)
restart_info_frame = ttk.LabelFrame(main_frame, text="Auto-Restart Settings (Legacy)")
restart_info_frame.pack(fill=tk.X, pady=10)
legacy_restart_label = ttk.Label(restart_info_frame,
text="Scheduled game/bot restarts are now configured in the 'Management' tab.",
justify=tk.LEFT, wraplength=680)
legacy_restart_label.pack(padx=10, pady=10, anchor=tk.W)
# Keep the variables for config.py compatibility if other parts of the app might read them,
# but their UI controls are removed from here.
self.restart_var = tk.BooleanVar(value=self.config_data.get("GAME_WINDOW_CONFIG", {}).get("ENABLE_SCHEDULED_RESTART", True))
self.interval_var = tk.IntVar(value=self.config_data.get("GAME_WINDOW_CONFIG", {}).get("RESTART_INTERVAL_MINUTES", 60))
# Monitor interval (Still relevant for window positioning, not restart scheduling)
monitor_frame = ttk.Frame(main_frame)
monitor_frame.pack(fill=tk.X, pady=5)
monitor_label = ttk.Label(monitor_frame, text="Monitor Interval (sec):", width=20)
monitor_label.pack(side=tk.LEFT, padx=(0, 5))
self.monitor_interval_var = tk.IntVar(value=5)
monitor_entry = ttk.Spinbox(monitor_frame, textvariable=self.monitor_interval_var, from_=1, to=60, width=5)
monitor_entry.pack(side=tk.LEFT)
# Information text
info_frame = ttk.LabelFrame(main_frame, text="Information")
info_frame.pack(fill=tk.BOTH, expand=True, pady=10)
info_text = (
"• These settings control how the game window is monitored and positioned\n"
"• Window Title must match exactly what's shown in the title bar\n"
"• Scheduled restart helps prevent game crashes and memory leaks\n"
"• Monitor interval determines how often the window position is checked\n"
"• Changes will take effect after restarting Wolf Chat"
)
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))
# Embedding Model Settings Frame
embedding_model_settings_frame = ttk.LabelFrame(main_frame, text="Embedding Model Settings")
embedding_model_settings_frame.pack(fill=tk.X, pady=10)
embedding_model_name_frame = ttk.Frame(embedding_model_settings_frame)
embedding_model_name_frame.pack(fill=tk.X, pady=5, padx=10)
embedding_model_name_label = ttk.Label(embedding_model_name_frame, text="Embedding Model Name:", width=25) # Adjusted width
embedding_model_name_label.pack(side=tk.LEFT, padx=(0, 5))
self.embedding_model_name_var = tk.StringVar(value="sentence-transformers/paraphrase-multilingual-mpnet-base-v2")
embedding_model_name_entry = ttk.Entry(embedding_model_name_frame, textvariable=self.embedding_model_name_var)
embedding_model_name_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
embedding_model_info = ttk.Label(embedding_model_settings_frame, text="Default: sentence-transformers/paraphrase-multilingual-mpnet-base-v2", justify=tk.LEFT)
embedding_model_info.pack(anchor=tk.W, padx=10, pady=(0,5))
# Information box
info_frame = ttk.LabelFrame(main_frame, text="Information")
info_frame.pack(fill=tk.BOTH, expand=True, pady=10)
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_memory_management_tab(self):
tab = ttk.Frame(self.notebook)
self.notebook.add(tab, text="記憶管理")
main_frame = ttk.Frame(tab, padding=10)
main_frame.pack(fill=tk.BOTH, expand=True)
# 備份時間設置
backup_frame = ttk.LabelFrame(main_frame, text="備份設定")
backup_frame.pack(fill=tk.X, pady=10)
time_frame = ttk.Frame(backup_frame)
time_frame.pack(fill=tk.X, pady=5, padx=10)
time_label = ttk.Label(time_frame, text="執行時間:", width=20)
time_label.pack(side=tk.LEFT, padx=(0, 5))
self.backup_hour_var = tk.IntVar(value=0)
hour_spinner = ttk.Spinbox(time_frame, from_=0, to=23, width=3, textvariable=self.backup_hour_var)
hour_spinner.pack(side=tk.LEFT)
ttk.Label(time_frame, text=":").pack(side=tk.LEFT)
self.backup_minute_var = tk.IntVar(value=0)
minute_spinner = ttk.Spinbox(time_frame, from_=0, to=59, width=3, textvariable=self.backup_minute_var)
minute_spinner.pack(side=tk.LEFT)
# 模型選擇
models_frame = ttk.LabelFrame(main_frame, text="模型選擇")
models_frame.pack(fill=tk.X, pady=10)
profile_model_frame = ttk.Frame(models_frame)
profile_model_frame.pack(fill=tk.X, pady=5, padx=10)
profile_model_label = ttk.Label(profile_model_frame, text="用戶檔案生成模型:", width=20)
profile_model_label.pack(side=tk.LEFT, padx=(0, 5))
# Initialize with a sensible default, will be overwritten by update_ui_from_data
# Use config_data which is loaded in __init__
profile_model_default = self.config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324")
self.profile_model_var = tk.StringVar(value=profile_model_default)
profile_model_entry = ttk.Entry(profile_model_frame, textvariable=self.profile_model_var)
profile_model_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
summary_model_frame = ttk.Frame(models_frame)
summary_model_frame.pack(fill=tk.X, pady=5, padx=10)
summary_model_label = ttk.Label(summary_model_frame, text="聊天總結生成模型:", width=20)
summary_model_label.pack(side=tk.LEFT, padx=(0, 5))
self.summary_model_var = tk.StringVar(value="mistral-7b-instruct")
summary_model_entry = ttk.Entry(summary_model_frame, textvariable=self.summary_model_var)
summary_model_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
# Information box
info_frame_mm = ttk.LabelFrame(main_frame, text="Information") # Renamed to avoid conflict
info_frame_mm.pack(fill=tk.BOTH, expand=True, pady=10)
info_text_mm = (
"• 設定每日自動執行記憶備份的時間。\n"
"• 選擇用於生成用戶檔案和聊天總結的語言模型。\n"
"• 用戶檔案生成模型預設使用主LLM模型。"
)
info_label_mm = ttk.Label(info_frame_mm, text=info_text_mm, justify=tk.LEFT, wraplength=700)
info_label_mm.pack(padx=10, pady=10, anchor=tk.W)
def create_bottom_buttons(self):
"""Create bottom action buttons"""
btn_frame = ttk.Frame(self)
btn_frame.pack(fill=tk.X, padx=10, pady=10)
# Version label on left
version_label = ttk.Label(btn_frame, text=f"v{VERSION}")
version_label.pack(side=tk.LEFT, padx=5)
# Action buttons on right (order matters for packing)
cancel_btn = ttk.Button(btn_frame, text="Cancel", command=self.quit)
cancel_btn.pack(side=tk.RIGHT, padx=5)
save_btn = ttk.Button(btn_frame, text="Save Settings", command=self.save_settings)
save_btn.pack(side=tk.RIGHT, padx=5)
install_deps_btn = ttk.Button(btn_frame, text="Install Dependencies", command=self.install_dependencies)
install_deps_btn.pack(side=tk.RIGHT, padx=5)
# Run buttons
self.run_test_btn = ttk.Button(btn_frame, text="Run Test", command=self.run_test_script)
self.run_test_btn.pack(side=tk.RIGHT, padx=5)
self.run_bot_btn = ttk.Button(btn_frame, text="Run Chat Bot", command=self.run_chat_bot)
self.run_bot_btn.pack(side=tk.RIGHT, padx=5)
# Stop button (for bot/test)
self.stop_btn = ttk.Button(btn_frame, text="Stop Bot/Test", command=self.stop_process, state=tk.DISABLED)
self.stop_btn.pack(side=tk.RIGHT, padx=5)
# Scheduler buttons
self.stop_scheduler_btn = ttk.Button(btn_frame, text="Stop Scheduler", command=self.stop_memory_scheduler, state=tk.DISABLED)
self.stop_scheduler_btn.pack(side=tk.RIGHT, padx=5)
self.start_scheduler_btn = ttk.Button(btn_frame, text="Start Scheduler", command=self.run_memory_scheduler)
self.start_scheduler_btn.pack(side=tk.RIGHT, padx=5)
def install_dependencies(self):
"""Run the installation script for dependencies"""
try:
import subprocess
import sys
# Check if install.py exists
if not os.path.exists("install.py"):
messagebox.showerror("Error", "Could not find install.py script")
return
# Run the installer script in a new process
subprocess.Popen([sys.executable, "install.py"])
except Exception as e:
messagebox.showerror("Error", f"Failed to launch installer: {str(e)}")
def run_chat_bot(self):
"""Run the main chat bot script"""
try:
import subprocess
import sys
if not os.path.exists("main.py"):
messagebox.showerror("Error", "Could not find main.py script")
return
if self.running_process is not None:
messagebox.showwarning("Already Running", "Another process is already running. Please stop it first.")
return
# Run main.py, capturing output with UTF-8 encoding and setting PYTHONIOENCODING
current_env = os.environ.copy()
current_env["PYTHONIOENCODING"] = "utf-8"
self.running_process = subprocess.Popen(
[sys.executable, "main.py"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding='utf-8',
errors='replace',
bufsize=1,
env=current_env # Set PYTHONIOENCODING
)
# Start a thread to log bot's output for this independent run as well
threading.Thread(target=self._log_subprocess_output, args=(self.running_process, "ChatBot"), daemon=True).start()
print("Attempting to start main.py...")
self.update_run_button_states(False) # Disable run buttons, enable stop
except Exception as e:
messagebox.showerror("Error", f"Failed to launch main.py: {str(e)}")
self.update_run_button_states(True) # Re-enable buttons on failure
def run_test_script(self):
"""Run the LLM debug script"""
try:
import subprocess
import sys
test_script_path = os.path.join("test", "llm_debug_script.py")
if not os.path.exists(test_script_path):
messagebox.showerror("Error", f"Could not find {test_script_path}")
return
if self.running_process is not None:
messagebox.showwarning("Already Running", "Another process is already running. Please stop it first.")
return
self.running_process = subprocess.Popen([sys.executable, test_script_path])
print(f"Attempting to start {test_script_path}...")
self.update_run_button_states(False) # Disable run buttons, enable stop
except Exception as e:
messagebox.showerror("Error", f"Failed to launch {test_script_path}: {str(e)}")
self.update_run_button_states(True) # Re-enable buttons on failure
def stop_process(self):
"""Stop the currently running process"""
if hasattr(self, 'running_process') and self.running_process is not None:
try:
print("Attempting to terminate running process...")
self.running_process.terminate() # Or .kill() for a more forceful stop
self.running_process = None
messagebox.showinfo("Process Stopped", "The running process has been terminated.")
except Exception as e:
messagebox.showerror("Error", f"Failed to terminate process: {str(e)}")
finally:
# Re-enable run buttons and disable stop button
self.update_run_button_states(True)
else:
messagebox.showinfo("No Process", "No Bot/Test process is currently running.")
def run_memory_scheduler(self):
"""Run the memory backup scheduler script"""
try:
scheduler_script = "memory_backup.py"
if not os.path.exists(scheduler_script):
messagebox.showerror("Error", f"Could not find {scheduler_script}")
return
if self.scheduler_process is not None and self.scheduler_process.poll() is None:
messagebox.showwarning("Already Running", "The memory scheduler process is already running.")
return
# Run with --schedule argument
# Use CREATE_NO_WINDOW flag on Windows to hide the console window
creationflags = 0
if sys.platform == "win32":
creationflags = subprocess.CREATE_NO_WINDOW
self.scheduler_process = subprocess.Popen(
[sys.executable, scheduler_script, "--schedule"],
creationflags=creationflags
)
print(f"Attempting to start {scheduler_script} --schedule... PID: {self.scheduler_process.pid}")
self.update_scheduler_button_states(False) # Disable start, enable stop
except Exception as e:
logger.exception(f"Failed to launch {scheduler_script}") # Log exception
messagebox.showerror("Error", f"Failed to launch {scheduler_script}: {str(e)}")
self.update_scheduler_button_states(True) # Re-enable start on failure
def stop_memory_scheduler(self):
"""Stop the currently running memory scheduler process"""
if self.scheduler_process is not None and self.scheduler_process.poll() is None:
try:
print(f"Attempting to terminate memory scheduler process (PID: {self.scheduler_process.pid})...")
# Terminate the process group on non-Windows to ensure child processes are handled if any
if sys.platform != "win32":
os.killpg(os.getpgid(self.scheduler_process.pid), signal.SIGTERM)
else:
# On Windows, terminate the parent process directly
self.scheduler_process.terminate()
# Wait briefly to allow termination
try:
self.scheduler_process.wait(timeout=3)
print("Scheduler process terminated gracefully.")
except subprocess.TimeoutExpired:
print("Scheduler process did not terminate gracefully, killing...")
if sys.platform != "win32":
os.killpg(os.getpgid(self.scheduler_process.pid), signal.SIGKILL)
else:
self.scheduler_process.kill()
self.scheduler_process.wait(timeout=2) # Wait after kill
print("Scheduler process killed.")
self.scheduler_process = None
messagebox.showinfo("Scheduler Stopped", "The memory scheduler process has been terminated.")
except Exception as e:
logger.exception("Failed to terminate scheduler process") # Log exception
messagebox.showerror("Error", f"Failed to terminate scheduler process: {str(e)}")
finally:
self.scheduler_process = None # Ensure it's cleared
self.update_scheduler_button_states(True) # Update buttons
else:
# If process exists but poll() is not None (already terminated) or process is None
if self.scheduler_process is not None:
self.scheduler_process = None # Clear stale process object
# messagebox.showinfo("No Scheduler Process", "The memory scheduler process is not running.") # Reduce popups
print("Scheduler process is not running or already stopped.")
self.update_scheduler_button_states(True) # Ensure buttons are in correct state
def update_run_button_states(self, enable):
"""Enable or disable the run buttons and update stop button state"""
# Assuming run_bot_btn and run_test_btn exist and are class attributes
if hasattr(self, 'run_bot_btn'):
self.run_bot_btn.config(state=tk.NORMAL if enable else tk.DISABLED)
if hasattr(self, 'run_test_btn'):
self.run_test_btn.config(state=tk.NORMAL if enable else tk.DISABLED)
if hasattr(self, 'stop_btn'):
self.stop_btn.config(state=tk.DISABLED if enable else tk.NORMAL)
def update_scheduler_button_states(self, enable_start):
"""Enable or disable the scheduler buttons"""
# Check if process is running
is_running = False
if self.scheduler_process is not None and self.scheduler_process.poll() is None:
is_running = True
if hasattr(self, 'start_scheduler_btn'):
self.start_scheduler_btn.config(state=tk.NORMAL if not is_running else tk.DISABLED)
if hasattr(self, 'stop_scheduler_btn'):
self.stop_scheduler_btn.config(state=tk.DISABLED if not is_running else tk.NORMAL)
def update_ui_from_data(self):
"""Update UI controls from loaded data"""
try:
# API Tab
self.api_url_var.set(self.config_data.get("OPENAI_API_BASE_URL", ""))
self.api_key_var.set(self.env_data.get("OPENAI_API_KEY", ""))
self.model_var.set(self.config_data.get("LLM_MODEL", "deepseek/deepseek-chat-v3-0324"))
# MCP Servers
# Exa settings
if "exa" in self.config_data.get("MCP_SERVERS", {}):
exa_config = self.config_data["MCP_SERVERS"]["exa"]
self.exa_enable_var.set(exa_config.get("enabled", True))
self.exa_key_var.set(self.env_data.get("EXA_API_KEY", ""))
if exa_config.get("use_smithery", False):
self.exa_type_var.set("smithery")
else:
self.exa_type_var.set("local")
if "server_path" in exa_config:
self.exa_path_var.set(exa_config.get("server_path", ""))
# Chroma settings
if "chroma" in self.config_data.get("MCP_SERVERS", {}):
chroma_config = self.config_data["MCP_SERVERS"]["chroma"]
self.chroma_enable_var.set(chroma_config.get("enabled", True))
data_dir = chroma_config.get("data_dir", "")
if data_dir:
# Ensure data directory is absolute path
self.chroma_dir_var.set(os.path.abspath(data_dir))
else:
# Set default as absolute path
self.chroma_dir_var.set(DEFAULT_CHROMA_DATA_PATH)
# Update servers list to include custom servers
self.update_servers_list()
# Game settings
game_config = self.config_data.get("GAME_WINDOW_CONFIG", {})
self.window_title_var.set(game_config.get("WINDOW_TITLE", "Last War-Survival Game"))
game_path = game_config.get("GAME_EXECUTABLE_PATH", "")
if game_path:
self.game_path_var.set(game_path)
self.pos_x_var.set(game_config.get("GAME_WINDOW_X", 50))
self.pos_y_var.set(game_config.get("GAME_WINDOW_Y", 30))
self.width_var.set(game_config.get("GAME_WINDOW_WIDTH", 600))
self.height_var.set(game_config.get("GAME_WINDOW_HEIGHT", 1070))
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")) # Default was user_profiles
self.conversations_collection_var.set(self.config_data.get("CONVERSATIONS_COLLECTION", "conversations"))
self.bot_memory_collection_var.set(self.config_data.get("BOT_MEMORY_COLLECTION", "wolfhart_memory"))
# Embedding Model Name for Memory Settings Tab
if hasattr(self, 'embedding_model_name_var'):
self.embedding_model_name_var.set(self.config_data.get("EMBEDDING_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"))
# Memory Management Tab Settings
if hasattr(self, 'backup_hour_var'): # Check if UI elements for memory management tab exist
self.backup_hour_var.set(self.config_data.get("MEMORY_BACKUP_HOUR", 0))
self.backup_minute_var.set(self.config_data.get("MEMORY_BACKUP_MINUTE", 0))
# Default profile model to LLM_MODEL if MEMORY_PROFILE_MODEL isn't set or matches LLM_MODEL
profile_model_config = self.config_data.get("MEMORY_PROFILE_MODEL", self.config_data.get("LLM_MODEL"))
self.profile_model_var.set(profile_model_config)
self.summary_model_var.set(self.config_data.get("MEMORY_SUMMARY_MODEL", "mistral-7b-instruct"))
# Management Tab Settings
if hasattr(self, 'remote_url_var'): # Check if UI elements for management tab exist
self.remote_url_var.set(self.remote_data.get("REMOTE_SERVER_URL", ""))
self.remote_key_var.set(self.remote_data.get("REMOTE_CLIENT_KEY", ""))
self.game_restart_interval_var.set(self.remote_data.get("DEFAULT_GAME_RESTART_INTERVAL_MINUTES", 120))
self.bot_restart_interval_var.set(self.remote_data.get("DEFAULT_BOT_RESTART_INTERVAL_MINUTES", 120))
self.link_restarts_var.set(self.remote_data.get("LINK_RESTART_TIMES", True))
self.game_process_name_var.set(self.remote_data.get("GAME_PROCESS_NAME", "LastWar.exe"))
# Update visibility and states
self.update_exa_settings_visibility()
self.update_management_buttons_state(True) # Initially, start button is enabled
except Exception as e:
logger.exception("Error updating UI from data") # Log full traceback
print(f"Error updating UI from data: {e}")
import traceback
traceback.print_exc()
# ===============================================================
# UI Event Handlers
# ===============================================================
def toggle_key_visibility(self):
"""Toggle visibility of API key field"""
if self.show_key_var.get():
self.api_key_entry.config(show="")
else:
self.api_key_entry.config(show="*")
def toggle_field_visibility(self, entry_widget, show_var):
"""Toggle visibility of a password field"""
if show_var.get():
entry_widget.config(show="")
else:
entry_widget.config(show="*")
def update_exa_settings_visibility(self):
"""Update visibility of Exa settings based on server type"""
if self.exa_type_var.get() == "smithery":
self.exa_local_frame.pack_forget()
self.exa_smithery_frame.pack(fill=tk.X, pady=5)
else:
self.exa_smithery_frame.pack_forget()
self.exa_local_frame.pack(fill=tk.X, pady=5)
def browse_exa_server(self):
"""Browse for Exa server executable"""
file_path = filedialog.askopenfilename(
title="Select Exa MCP Server",
filetypes=[("All Files", "*.*")],
initialdir=os.path.expanduser("~")
)
if file_path:
# 標準化路徑格式(將反斜線改為正斜線)
normalized_path = file_path.replace("\\", "/")
self.exa_path_var.set(normalized_path)
def browse_chroma_dir(self):
"""Browse for Chroma data directory"""
dir_path = filedialog.askdirectory(
title="Select Chroma Data Directory",
initialdir=os.path.dirname(DEFAULT_CHROMA_DATA_PATH)
)
if dir_path:
# 標準化路徑格式(將反斜線改為正斜線)
normalized_path = os.path.abspath(dir_path).replace("\\", "/")
self.chroma_dir_var.set(normalized_path)
def browse_game_path(self):
"""Browse for game executable"""
file_path = filedialog.askopenfilename(
title="Select Game Executable",
filetypes=[("Executable Files", "*.exe"), ("All Files", "*.*")],
initialdir=os.path.expanduser("~")
)
if file_path:
# 標準化路徑格式(將反斜線改為正斜線)
normalized_path = file_path.replace("\\", "/")
self.game_path_var.set(normalized_path)
def update_servers_list(self):
"""Update the servers listbox with current servers"""
self.servers_listbox.delete(0, tk.END)
# Add built-in servers
self.servers_listbox.insert(tk.END, "exa")
self.servers_listbox.insert(tk.END, "chroma")
# Add custom servers
for server_name in self.config_data.get("MCP_SERVERS", {}):
if server_name not in ("exa", "chroma"):
self.servers_listbox.insert(tk.END, server_name)
def on_server_select(self, event):
"""Handle server selection from the listbox"""
selection = self.servers_listbox.curselection()
if not selection:
return
selected_server = self.servers_listbox.get(selection[0])
# Hide all settings frames
self.empty_settings_label.pack_forget()
self.exa_frame.pack_forget()
self.chroma_frame.pack_forget()
self.custom_frame.pack_forget()
# Show the selected server's settings frame
if selected_server == "exa":
self.exa_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.remove_btn.config(state=tk.DISABLED) # Can't remove built-in servers
elif selected_server == "chroma":
self.chroma_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.remove_btn.config(state=tk.DISABLED) # Can't remove built-in servers
else:
# Custom server
if selected_server in self.config_data.get("MCP_SERVERS", {}):
# Load existing custom server config
server_config = self.config_data["MCP_SERVERS"][selected_server]
self.custom_name_var.set(selected_server)
self.custom_enable_var.set(server_config.get("enabled", True))
if "raw_config" in server_config:
self.custom_config_text.delete("1.0", tk.END)
self.custom_config_text.insert("1.0", server_config["raw_config"])
self.custom_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
self.remove_btn.config(state=tk.NORMAL) # Can remove custom servers
def add_custom_server(self):
"""Add a new custom server"""
# Generate a unique name
new_name = "custom_server"
counter = 1
while new_name in self.config_data.get("MCP_SERVERS", {}):
new_name = f"custom_server_{counter}"
counter += 1
# Add to config data
if "MCP_SERVERS" not in self.config_data:
self.config_data["MCP_SERVERS"] = {}
self.config_data["MCP_SERVERS"][new_name] = {
"enabled": True,
"raw_config": ' "command": "npx",\n "args": [\n "custom-mcp-server"\n ]'
}
# Update UI
self.update_servers_list()
# Select the new server
idx = self.servers_listbox.get(0, tk.END).index(new_name)
self.servers_listbox.selection_clear(0, tk.END)
self.servers_listbox.selection_set(idx)
self.servers_listbox.see(idx)
self.on_server_select(None) # Trigger the selection handler
def remove_server(self):
"""Remove the selected custom server"""
selection = self.servers_listbox.curselection()
if not selection:
return
selected_server = self.servers_listbox.get(selection[0])
# Confirmation
confirm = messagebox.askyesno(
"Confirm Removal",
f"Are you sure you want to remove the server '{selected_server}'?",
icon=messagebox.WARNING
)
if confirm:
# Remove from config data
if selected_server in self.config_data.get("MCP_SERVERS", {}):
del self.config_data["MCP_SERVERS"][selected_server]
# Update UI
self.update_servers_list()
# Clear selection
self.servers_listbox.selection_clear(0, tk.END)
# Hide server settings and show empty label
self.custom_frame.pack_forget()
self.empty_settings_label.pack(expand=True, pady=20)
self.remove_btn.config(state=tk.DISABLED)
def save_settings(self, show_success_message=True): # Added optional param
"""Save all settings to config.py, .env, and remote_config.json files"""
try:
# Update config data from UI (for config.py and .env)
# API settings
self.config_data["OPENAI_API_BASE_URL"] = self.api_url_var.get()
self.config_data["LLM_MODEL"] = self.model_var.get()
# Environment variables
self.env_data["OPENAI_API_KEY"] = self.api_key_var.get()
self.env_data["EXA_API_KEY"] = self.exa_key_var.get()
# MCP Servers
if "MCP_SERVERS" not in self.config_data:
self.config_data["MCP_SERVERS"] = {}
# Exa server
if "exa" not in self.config_data["MCP_SERVERS"]:
self.config_data["MCP_SERVERS"]["exa"] = {}
self.config_data["MCP_SERVERS"]["exa"]["enabled"] = self.exa_enable_var.get()
self.config_data["MCP_SERVERS"]["exa"]["use_smithery"] = (self.exa_type_var.get() == "smithery")
if self.exa_type_var.get() == "local":
self.config_data["MCP_SERVERS"]["exa"]["server_path"] = self.exa_path_var.get()
# Chroma server
if "chroma" not in self.config_data["MCP_SERVERS"]:
self.config_data["MCP_SERVERS"]["chroma"] = {}
self.config_data["MCP_SERVERS"]["chroma"]["enabled"] = self.chroma_enable_var.get()
self.config_data["MCP_SERVERS"]["chroma"]["data_dir"] = self.chroma_dir_var.get()
# Custom server - check if one is currently selected
selection = self.servers_listbox.curselection()
if selection:
selected_server = self.servers_listbox.get(selection[0])
if selected_server not in ("exa", "chroma"):
# Update custom server settings
new_name = self.custom_name_var.get().strip()
if not new_name:
messagebox.showerror("Error", "Custom server name cannot be empty")
return
# Handle name change
if new_name != selected_server:
if new_name in self.config_data["MCP_SERVERS"]:
messagebox.showerror("Error", f"Server name '{new_name}' already exists")
return
# Copy config and delete old entry
self.config_data["MCP_SERVERS"][new_name] = self.config_data["MCP_SERVERS"][selected_server].copy()
del self.config_data["MCP_SERVERS"][selected_server]
# Update other settings
self.config_data["MCP_SERVERS"][new_name]["enabled"] = self.custom_enable_var.get()
self.config_data["MCP_SERVERS"][new_name]["raw_config"] = self.custom_config_text.get("1.0", tk.END).strip()
# Game window settings
self.config_data["GAME_WINDOW_CONFIG"] = {
"WINDOW_TITLE": self.window_title_var.get(),
"ENABLE_SCHEDULED_RESTART": self.restart_var.get(),
"RESTART_INTERVAL_MINUTES": self.interval_var.get(),
"GAME_EXECUTABLE_PATH": self.game_path_var.get(),
"GAME_WINDOW_X": self.pos_x_var.get(),
"GAME_WINDOW_Y": self.pos_y_var.get(),
"GAME_WINDOW_WIDTH": self.width_var.get(),
"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()
# Save Embedding Model Name from Memory Settings Tab
if hasattr(self, 'embedding_model_name_var'):
self.config_data["EMBEDDING_MODEL_NAME"] = self.embedding_model_name_var.get()
# Get Memory Management settings from UI
if hasattr(self, 'backup_hour_var'): # Check if UI elements exist
self.config_data["MEMORY_BACKUP_HOUR"] = self.backup_hour_var.get()
self.config_data["MEMORY_BACKUP_MINUTE"] = self.backup_minute_var.get()
self.config_data["MEMORY_PROFILE_MODEL"] = self.profile_model_var.get()
self.config_data["MEMORY_SUMMARY_MODEL"] = self.summary_model_var.get()
# Update remote_data from UI (for remote_config.json)
if hasattr(self, 'remote_url_var'): # Check if management tab UI elements exist
self.remote_data["REMOTE_SERVER_URL"] = self.remote_url_var.get()
self.remote_data["REMOTE_CLIENT_KEY"] = self.remote_key_var.get()
self.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = self.game_restart_interval_var.get()
self.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = self.bot_restart_interval_var.get()
self.remote_data["LINK_RESTART_TIMES"] = self.link_restarts_var.get()
self.remote_data["GAME_PROCESS_NAME"] = self.game_process_name_var.get()
# Validate critical settings
if "exa" in self.config_data["MCP_SERVERS"] and self.config_data["MCP_SERVERS"]["exa"]["enabled"]:
if not self.exa_key_var.get():
messagebox.showerror("Validation Error", "Exa API Key is required when Exa server is enabled")
return
if self.exa_type_var.get() == "local" and not self.exa_path_var.get():
messagebox.showerror("Validation Error", "Exa Server Path is required for local server type")
return
# Generate config.py and .env files
save_env_file(self.env_data)
generate_config_file(self.config_data, self.env_data)
save_remote_config(self.remote_data) # Save remote config
# If GameMonitor (from game_manager) exists, update its configuration
if self.game_monitor:
try:
self.game_monitor.update_config(self.config_data, self.remote_data)
logger.info("Game monitor (game_manager) configuration updated.")
except Exception as gm_update_err:
logger.error(f"Failed to update game_manager's GameMonitor configuration: {gm_update_err}")
if show_success_message:
messagebox.showinfo("Success", "Settings saved successfully.\nRestart managed session for changes to take effect.")
except Exception as e:
logger.exception("Error saving settings") # Log the full traceback
if show_success_message: # Only show error if it's a direct save action
messagebox.showerror("Error", f"An error occurred while saving settings:\n{str(e)}")
import traceback
traceback.print_exc()
# ===============================================================
# ControlClient Class (adapted from wolf_control.py)
# ===============================================================
if HAS_SOCKETIO:
class ControlClient:
def __init__(self, server_url, client_key, wolf_chat_setup_instance):
self.server_url = server_url
self.client_key = client_key
self.wolf_chat_setup = wolf_chat_setup_instance # Reference to the main app
# Suppress InsecureRequestWarning when using ssl_verify=False, as is the current default
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.sio = socketio.Client(ssl_verify=False, logger=logger, engineio_logger=logger) # Use app's logger
self.connected = False
self.authenticated = False
self.should_exit_flag = threading.Event() # Use an event for thread control
self.client_thread = None
self.last_successful_connection_time = None # Track last successful connection/auth
self.registered_commands = [
"restart bot", "restart game", "restart all",
"set game interval", "set bot interval", "set linked interval"
]
# Event handlers
self.sio.on('connect', self._on_connect)
self.sio.on('disconnect', self._on_disconnect)
self.sio.on('authenticated', self._on_authenticated)
self.sio.on('command', self._on_command)
def is_connected(self):
return self.connected and self.authenticated
def run_in_thread(self):
if self.client_thread and self.client_thread.is_alive():
logger.info("Control client thread already running.")
return True
self.should_exit_flag.clear()
self.client_thread = threading.Thread(target=self._run_forever, daemon=True)
self.client_thread.start()
return True
def _run_forever(self):
logger.info(f"ControlClient: Starting connection attempts to {self.server_url}")
last_heartbeat = time.time() # For heartbeat
retry_delay = 1.0 # Start with 1 second delay for exponential backoff
max_delay = 300.0 # Maximum delay of 5 minutes for exponential backoff
hourly_refresh_interval = 3600 # 1 hour in seconds
while not self.should_exit_flag.is_set():
current_time = time.time() # Get current time at the start of the loop iteration
if not self.sio.connected:
# Reset connection time tracker when attempting to connect
self.last_successful_connection_time = None
try:
logger.info(f"ControlClient: Attempting to connect to {self.server_url}...")
self.sio.connect(self.server_url)
# Connection successful, wait for authentication to set last_successful_connection_time
logger.info("ControlClient: Successfully established socket connection. Waiting for authentication.")
retry_delay = 1.0 # Reset delay on successful connection attempt
# last_heartbeat = time.time() # Reset heartbeat timer only after authentication? Or here? Let's keep it after auth.
except socketio.exceptions.ConnectionError as e:
logger.error(f"ControlClient: Connection failed: {e}. Retrying in {retry_delay:.2f}s.")
self.should_exit_flag.wait(retry_delay)
# Implement exponential backoff with jitter
retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random())
retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s
continue
except Exception as e: # Catch other potential errors during connection
logger.error(f"ControlClient: Unexpected error during connection attempt: {e}. Retrying in {retry_delay:.2f}s.")
self.should_exit_flag.wait(retry_delay)
retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random())
retry_delay = max(1.0, retry_delay) # Ensure it's at least 1s
continue
# If connected (socket established, maybe not authenticated yet)
if self.sio.connected:
# Check for hourly refresh ONLY if authenticated and timer is set
if self.authenticated and self.last_successful_connection_time and (current_time - self.last_successful_connection_time > hourly_refresh_interval):
logger.info(f"ControlClient: Hourly session refresh triggered (Connected for > {hourly_refresh_interval}s). Disconnecting for refresh...")
try:
self.sio.disconnect()
# Reset flags immediately after intentional disconnect
self.connected = False
self.authenticated = False
self.last_successful_connection_time = None
logger.info("ControlClient: Disconnected for hourly refresh. Will attempt reconnect in next cycle.")
# Continue to the start of the loop to handle reconnection logic
continue
except Exception as e:
logger.error(f"ControlClient: Error during planned hourly disconnect: {e}")
# Reset flags anyway and let the loop retry
self.connected = False
self.authenticated = False
self.last_successful_connection_time = None
# Manage heartbeat if authenticated
if self.authenticated and current_time - last_heartbeat > 60: # Send heartbeat every 60 seconds
try:
self.sio.emit('heartbeat', {'timestamp': current_time})
last_heartbeat = current_time
logger.debug("ControlClient: Sent heartbeat.")
except Exception as e:
logger.error(f"ControlClient: Error sending heartbeat: {e}. Connection might be lost.")
# Consider triggering disconnect/reconnect logic here if heartbeat fails repeatedly
# Wait before next loop iteration, checking for exit signal
self.should_exit_flag.wait(1) # Check for exit signal every second
else: # Not connected (e.g., after a disconnect, or failed connection attempt)
# This path is hit after disconnects (intentional or unintentional)
# Reset connection time tracker if not already None
if self.last_successful_connection_time is not None:
logger.debug("ControlClient: Resetting connection timer as client is not connected.")
self.last_successful_connection_time = None
logger.debug(f"ControlClient: Not connected, waiting {retry_delay:.2f}s before next connection attempt.")
self.should_exit_flag.wait(retry_delay)
# Exponential backoff for reconnection attempts
retry_delay = min(retry_delay * 2, max_delay) * (0.8 + 0.4 * random.random())
retry_delay = max(1.0, retry_delay)
logger.info("ControlClient: Exited _run_forever loop.")
if self.sio.connected:
self.sio.disconnect()
def _on_connect(self):
self.connected = True
# Don't reset timer here, wait for authentication
logger.info("ControlClient: Connected to server. Authenticating...")
self.sio.emit('authenticate', {
'type': 'client',
'clientKey': self.client_key,
'commands': self.registered_commands
})
def _on_disconnect(self):
was_connected = self.connected # Store previous state
self.connected = False
self.authenticated = False
self.last_successful_connection_time = None # Reset timer on any disconnect
if was_connected: # Only log if it was previously connected
logger.info("ControlClient: Disconnected from server.")
else:
logger.debug("ControlClient: Received disconnect event, but was already marked as disconnected.")
# Remove the immediate reconnection attempt here, let _run_forever handle it with backoff
# if not self.should_exit_flag.is_set():
# logger.info("ControlClient: Disconnected. Reconnection will be handled by the main loop.")
def _on_authenticated(self, data):
if data.get('success'):
self.authenticated = True
self.last_successful_connection_time = time.time() # Start timer on successful auth
# Reset heartbeat timer upon successful authentication
# Find where last_heartbeat is accessible or make it accessible (e.g., self.last_heartbeat)
# For now, assume last_heartbeat is managed within _run_forever and will naturally reset timing
logger.info("ControlClient: Authentication successful. Hourly refresh timer started.")
else:
self.authenticated = False
self.last_successful_connection_time = None # Ensure timer is reset if auth fails
logger.error(f"ControlClient: Authentication failed: {data.get('error', 'Unknown error')}")
self.sio.disconnect() # Disconnect if auth fails
def _on_command(self, data):
command = data.get('command', '').lower()
args_str = data.get('args', '') # Assuming server might send args as a string
from_user = data.get('from', 'unknown')
logger.info(f"ControlClient: Received command '{command}' with args '{args_str}' from {from_user}")
try:
if command == "restart bot":
self.wolf_chat_setup._restart_bot_managed()
self._send_command_result(command, True, "Bot restart initiated.")
elif command == "restart game":
self.wolf_chat_setup._restart_game_managed()
self._send_command_result(command, True, "Game restart initiated.")
elif command == "restart all":
self.wolf_chat_setup._restart_all_managed()
self._send_command_result(command, True, "Full restart initiated.")
elif command == "set game interval" or command == "set bot interval" or command == "set linked interval":
try:
interval = int(args_str)
if interval < 0: # 0 means disable
self._send_command_result(command, False, "Interval must be non-negative.")
return
if command == "set game interval":
self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = interval
if self.wolf_chat_setup.remote_data["LINK_RESTART_TIMES"]:
self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = interval
elif command == "set bot interval":
self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = interval
if self.wolf_chat_setup.remote_data["LINK_RESTART_TIMES"]:
self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = interval
elif command == "set linked interval":
self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"] = interval
self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"] = interval
self.wolf_chat_setup.remote_data["LINK_RESTART_TIMES"] = True
save_remote_config(self.wolf_chat_setup.remote_data)
self.wolf_chat_setup._setup_scheduled_restarts() # Re-apply schedule
# Update UI if possible (tricky from non-main thread)
# self.wolf_chat_setup.game_restart_interval_var.set(self.wolf_chat_setup.remote_data["DEFAULT_GAME_RESTART_INTERVAL_MINUTES"])
# self.wolf_chat_setup.bot_restart_interval_var.set(self.wolf_chat_setup.remote_data["DEFAULT_BOT_RESTART_INTERVAL_MINUTES"])
logger.info(f"Updated restart interval via remote: {command} to {interval} min. Saved and re-scheduled.")
self._send_command_result(command, True, f"Interval updated to {interval} min and re-scheduled.")
except ValueError:
self._send_command_result(command, False, "Invalid interval value. Must be an integer.")
else:
self._send_command_result(command, False, "Unsupported command.")
except Exception as e:
logger.exception(f"ControlClient: Error executing command '{command}'")
self._send_command_result(command, False, f"Error: {str(e)}")
def _send_command_result(self, command, success, message):
if self.sio.connected:
try:
self.sio.emit('commandResult', {
'command': command,
'success': success,
'message': message,
'timestamp': time.time()
})
except Exception as e:
logger.error(f"ControlClient: Failed to send command result: {e}")
def check_signals(self, app_instance): # app_instance is self.wolf_chat_setup from the caller
"""Periodically check connection status and commands, called by monitoring thread."""
# Note: _run_forever is the primary mechanism for establishing and maintaining connection.
# This function's connection check is a secondary check.
if not self.sio.connected or not self.authenticated:
logger.warning("ControlClient: Connection check in check_signals found client not connected/authenticated.")
# Avoid aggressive reconnection here if _run_forever is already handling it.
# If an explicit reconnect attempt is desired here:
# logger.info("ControlClient: Attempting reconnection from check_signals...")
# try:
# if self.sio.connected: # e.g. connected but not authenticated
# self.sio.disconnect()
# if not self.sio.connected: # Check again before connecting
# self.sio.connect(self.server_url)
# except Exception as e:
# logger.error(f"ControlClient: Reconnection attempt from check_signals failed: {e}")
# Placeholder for any other signal processing logic
# logger.debug("ControlClient: check_signals executed.")
def stop(self):
logger.info("ControlClient: Stopping...")
self.should_exit_flag.set() # Signal the run_forever loop to exit
if self.sio.connected:
self.sio.disconnect() # Attempt to disconnect gracefully
if self.client_thread and self.client_thread.is_alive():
logger.info("ControlClient: Waiting for client thread to join...")
self.client_thread.join(timeout=5) # Wait for the thread to finish
if self.client_thread.is_alive():
logger.warning("ControlClient: Client thread did not join in time.")
self.client_thread = None
logger.info("ControlClient: Stopped.")
else: # HAS_SOCKETIO is False
class ControlClient: # Dummy class if socketio is not available
def __init__(self, *args, **kwargs): logger.warning("Socket.IO not installed, ControlClient is a dummy.")
def run_in_thread(self): return False
def stop(self): pass
def is_connected(self): return False
# ===============================================================
# Main Entry Point
# ===============================================================
if __name__ == "__main__":
# Setup main logger for the application if not already done
if not logging.getLogger().handlers: # Check root logger
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
app = WolfChatSetup()
app.protocol("WM_DELETE_WINDOW", app.on_closing) # Handle window close button
app.mainloop()