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

1450 lines
67 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
# ===============================================================
# Constants
# ===============================================================
VERSION = "1.0.0"
CONFIG_TEMPLATE_PATH = "config_template.py"
ENV_FILE_PATH = ".env"
# 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")
# ===============================================================
# Helper Functions
# ===============================================================
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)
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")
f.write(f" \"{absolute_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")
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("800x600")
self.minsize(750, 550)
# Load existing data
self.env_data = load_env_file()
self.config_data = load_current_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() # 新增記憶設定標籤頁
# Create bottom buttons
self.create_bottom_buttons()
# Initialize running process tracker
self.running_process = None
# Set initial states based on loaded data
self.update_ui_from_data()
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
restart_frame = ttk.LabelFrame(main_frame, text="Auto-Restart Settings")
restart_frame.pack(fill=tk.X, pady=10)
self.restart_var = tk.BooleanVar(value=True)
restart_cb = ttk.Checkbutton(restart_frame, text="Enable scheduled game restart", variable=self.restart_var)
restart_cb.pack(anchor=tk.W, padx=10, pady=5)
interval_frame = ttk.Frame(restart_frame)
interval_frame.pack(fill=tk.X, padx=10, pady=5)
interval_label = ttk.Label(interval_frame, text="Restart interval (minutes):")
interval_label.pack(side=tk.LEFT)
self.interval_var = tk.IntVar(value=60)
interval_entry = ttk.Spinbox(interval_frame, textvariable=self.interval_var, from_=15, to=1440, width=5)
interval_entry.pack(side=tk.LEFT, padx=(5, 0))
# Monitor interval
monitor_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))
# Information box
info_frame = ttk.LabelFrame(main_frame, text="Information")
info_frame.pack(fill=tk.BOTH, expand=True, pady=10)
info_text = (
"• Pre-loading user profiles will speed up responses by fetching data before LLM calls\n"
"• Collection names must match your ChromaDB configuration\n"
"• The bot will automatically use pre-loaded data if available\n"
"• If data isn't found locally, the bot will fall back to using tool calls"
)
info_label = ttk.Label(info_frame, text=info_text, justify=tk.LEFT, wraplength=700)
info_label.pack(padx=10, pady=10, anchor=tk.W)
def create_bottom_buttons(self):
"""Create bottom action buttons"""
btn_frame = ttk.Frame(self)
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
self.stop_btn = ttk.Button(btn_frame, text="Stop Process", command=self.stop_process, state=tk.DISABLED)
self.stop_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
self.running_process = subprocess.Popen([sys.executable, "main.py"])
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 process is currently running.")
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_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"))
self.conversations_collection_var.set(self.config_data.get("CONVERSATIONS_COLLECTION", "conversations"))
self.bot_memory_collection_var.set(self.config_data.get("BOT_MEMORY_COLLECTION", "wolfhart_memory"))
# Update visibility and states
self.update_exa_settings_visibility()
except Exception as e:
print(f"Error updating UI from data: {e}")
import traceback
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):
"""Save all settings to config.py and .env files"""
try:
# Update config data from UI
# 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()
# 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)
messagebox.showinfo("Success", "Settings saved successfully.\nRestart Wolf Chat for changes to take effect.")
# self.destroy() # Removed to keep the window open after saving
except Exception as e:
messagebox.showerror("Error", f"An error occurred while saving settings:\n{str(e)}")
import traceback
traceback.print_exc()
# ===============================================================
# Main Entry Point
# ===============================================================
if __name__ == "__main__":
app = WolfChatSetup()
app.mainloop()