1205 lines
54 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()
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")
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()
# Create bottom buttons
self.create_bottom_buttons()
# 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_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)
# Install dependencies button
install_deps_btn = ttk.Button(btn_frame, text="Install Dependencies", command=self.install_dependencies)
install_deps_btn.pack(side=tk.RIGHT, padx=5)
# Action buttons on right
save_btn = ttk.Button(btn_frame, text="Save Settings", command=self.save_settings)
save_btn.pack(side=tk.RIGHT, padx=5)
cancel_btn = ttk.Button(btn_frame, text="Cancel", command=self.quit)
cancel_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 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))
# 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()
}
# 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()
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()