DazedTL/gui/config_tab.py
2026-07-07 14:18:43 -05:00

925 lines
39 KiB
Python

"""
Configuration Tab - Handles environment variables, global settings, and engine configurations
"""
import os
from pathlib import Path
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QLineEdit,
QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QGroupBox,
QLabel, QFileDialog, QMessageBox, QScrollArea, QTextEdit,
QCheckBox, QApplication, QTabWidget, QFrame, QStackedWidget, QToolButton,
QMenu
)
from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QThread
from PyQt5.QtGui import QIcon
from gui.platform_glyph import platform_glyph
from dotenv import load_dotenv, set_key, dotenv_values
from gui.rpgmaker_tab import RPGMakerTab
from gui.wolf_tab import WolfTab
from gui.csv_tab import CSVTab
from gui.srpg_tab import SRPGTab
class ModelFetchThread(QThread):
"""Background thread that fetches model lists from OpenAI, Anthropic, or Gemini."""
models_fetched = pyqtSignal(list)
fetch_error = pyqtSignal(str)
# Fallback list shown when no API key is set or a fetch fails
DEFAULTS = [
"gpt-4.1-mini", "gpt-4.1", "gpt-4o", "gpt-4o-mini",
"o3", "o4-mini",
"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5",
"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro",
"deepseek-chat",
"mistral-medium-3.5", # best quality/cost Mistral for translation (don't use -latest, it points at the older 3.1)
]
def __init__(self, api_key, api_url, parent=None):
super().__init__(parent)
self.api_key = api_key
self.api_url = api_url.strip()
def run(self):
models = []
errors = []
_url = self.api_url.lower()
# Only attempt each provider's fetcher when the configured URL matches.
# Avoids sending a DeepSeek (or other) key to Anthropic and getting a
# spurious 401 authentication error.
fetchers = [self._fetch_openai]
if not _url or "anthropic" in _url:
fetchers.append(self._fetch_anthropic)
if not _url or "googleapis" in _url or "gemini" in _url:
fetchers.append(self._fetch_gemini)
for fetcher in fetchers:
try:
models.extend(fetcher())
except Exception as exc:
errors.append(str(exc))
if models:
self.models_fetched.emit(sorted(set(models)))
else:
self.fetch_error.emit("\n".join(errors))
def _fetch_openai(self):
import openai
kwargs = {"api_key": self.api_key}
if self.api_url:
kwargs["base_url"] = self.api_url
client = openai.OpenAI(**kwargs)
all_models = [m.id for m in client.models.list()]
# When using a custom URL (non-OpenAI provider like DeepSeek), return all
# model IDs unfiltered. For the default OpenAI endpoint, keep only the
# GPT / o-series models to avoid a cluttered list.
if self.api_url:
return sorted(all_models)
prefixes = ("gpt-", "o1", "o2", "o3", "o4", "chatgpt")
return sorted(m for m in all_models if any(m.lower().startswith(p) for p in prefixes))
def _fetch_anthropic(self):
import anthropic
client = anthropic.Anthropic(api_key=self.api_key)
return sorted(m.id for m in client.models.list(limit=100))
def _fetch_gemini(self):
import openai
base = self.api_url or "https://generativelanguage.googleapis.com/v1beta/openai/"
client = openai.OpenAI(api_key=self.api_key, base_url=base)
return sorted(
m.id for m in client.models.list()
if "gemini" in m.id.lower()
)
def create_section_header(title):
"""Create a clean section header without boxes."""
label = QLabel(platform_glyph(title))
label.setStyleSheet("""
QLabel {
font-size: 13px;
font-weight: bold;
color: #007acc;
padding: 8px 0px 5px 0px;
background-color: transparent;
}
""")
return label
def create_horizontal_line():
"""Create a horizontal separator line."""
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setStyleSheet("QFrame { color: #555555; margin: 5px 0px; }")
return line
class ConfigTab(QWidget):
"""Configuration tab for managing environment variables, global settings, and engine configs."""
config_changed = pyqtSignal()
def __init__(self):
super().__init__()
self.env_file_path = Path(".env")
# Initialize UI first so widgets/tabs exist for resetting or loading
self.init_ui()
# Timer for autosave indicator (created after init_ui so autosave_label exists)
self._autosave_timer = QTimer(self)
self._autosave_timer.setSingleShot(True)
self._autosave_timer.timeout.connect(self._clear_autosave_indicator)
# If a .env file doesn't exist, show defaults in the UI
# (prevents showing stale values from the process/OS environment)
if not self.env_file_path.exists():
self.reset_to_defaults()
else:
self.load_from_env()
self._update_model_placeholder()
# Connect auto-save after initial load
self.connect_auto_save()
# Fetch latest models in the background once the UI is shown
QTimer.singleShot(0, lambda: self.fetch_models(silent=True))
def _is_nvidia_api_url(self, api_url: str) -> bool:
"""Return True when the configured URL points to Nvidia's OpenAI-compatible API."""
return "integrate.api.nvidia.com" in (api_url or "").strip().lower()
def _update_model_placeholder(self):
"""Show a manual model-entry hint only when Nvidia API is selected."""
line_edit = self.model_combo.lineEdit()
if not line_edit:
return
if self._is_nvidia_api_url(self.api_url_edit.text()):
line_edit.setPlaceholderText("Enter Nvidia model name (e.g., deepseek-ai/deepseek-v4-pro)")
else:
line_edit.setPlaceholderText("")
def init_ui(self):
"""Initialize the user interface with horizontal icon navigation at top."""
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Create top navigation bar
nav_bar = QWidget()
nav_bar.setFixedHeight(50)
nav_bar.setStyleSheet("""
QWidget {
background-color: #2d2d30;
}
""")
nav_layout = QHBoxLayout()
nav_layout.setContentsMargins(10, 0, 10, 0)
nav_layout.setSpacing(5)
# Create navigation buttons
self.nav_buttons = []
# General Settings button
btn_general = self.create_nav_button("🔧", "General Settings")
btn_general.clicked.connect(lambda: self.switch_page(0))
nav_layout.addWidget(btn_general)
self.nav_buttons.append(btn_general)
# RPG Maker MV/MZ button
btn_mvmz = self.create_nav_button("🎮", "RPG Maker MV/MZ", engine_key="mvmz")
btn_mvmz.clicked.connect(lambda: self.switch_page(1))
nav_layout.addWidget(btn_mvmz)
self.nav_buttons.append(btn_mvmz)
# Wolf RPG button
btn_wolf = self.create_nav_button("🐺", "Wolf RPG", engine_key="wolf")
btn_wolf.clicked.connect(lambda: self.switch_page(2))
nav_layout.addWidget(btn_wolf)
self.nav_buttons.append(btn_wolf)
# CSV button
btn_csv = self.create_nav_button("📄", "CSV")
btn_csv.clicked.connect(lambda: self.switch_page(3))
nav_layout.addWidget(btn_csv)
self.nav_buttons.append(btn_csv)
# SRPG Studio button
btn_srpg = self.create_nav_button("⚔️", "SRPG Studio", engine_key="srpg")
btn_srpg.clicked.connect(lambda: self.switch_page(4))
nav_layout.addWidget(btn_srpg)
self.nav_buttons.append(btn_srpg)
nav_layout.addStretch()
nav_bar.setLayout(nav_layout)
# Create stacked widget for content pages
self.content_stack = QStackedWidget()
# Page 1: General Settings
general_tab = self.create_general_settings_tab()
self.content_stack.addWidget(general_tab)
# Page 2: RPG Maker MV/MZ Engine
self.mvmz_tab = RPGMakerTab("MVMZ")
self.content_stack.addWidget(self.mvmz_tab)
# Page 3: Wolf RPG Engine
self.wolf_tab = WolfTab()
self.content_stack.addWidget(self.wolf_tab)
# Page 4: CSV Settings
self.csv_tab = CSVTab()
self.content_stack.addWidget(self.csv_tab)
# Page 5: SRPG Studio Engine
self.srpg_tab = SRPGTab()
self.content_stack.addWidget(self.srpg_tab)
# Add navigation bar and content to main layout
main_layout.addWidget(nav_bar)
main_layout.addWidget(self.content_stack)
self.setLayout(main_layout)
# Select first page by default
self.switch_page(0)
def create_nav_button(self, icon_text, tooltip, engine_key=None):
"""Create a navigation button for the top bar."""
from gui.platform_glyph import configure_nav_toolbutton
btn = QToolButton()
btn.setToolTip(tooltip)
btn.setFixedSize(50, 50)
btn.setCheckable(True)
configure_nav_toolbutton(
btn, icon_text, horizontal=True, engine_key=engine_key,
)
return btn
def switch_page(self, index):
"""Switch to the specified page and update button states."""
self.content_stack.setCurrentIndex(index)
self._refresh_engine_config_page(index)
# Update button checked states
for i, btn in enumerate(self.nav_buttons):
btn.setChecked(i == index)
def _refresh_engine_config_page(self, index):
"""Refresh engine config pages from their module files when shown."""
engine_pages = {
1: getattr(self, "mvmz_tab", None),
}
page = engine_pages.get(index)
if page is not None and hasattr(page, "refresh_from_module"):
page.refresh_from_module()
def create_general_settings_tab(self):
"""Create combined general settings tab with API, Translation, Performance, and UI settings."""
widget = QWidget()
content = QWidget()
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(15, 15, 15, 15)
# Create a two-column layout for better space utilization
columns_layout = QHBoxLayout()
columns_layout.setSpacing(30)
# LEFT COLUMN
left_column = QVBoxLayout()
left_column.setSpacing(8)
# API Configuration Section
left_column.addWidget(create_section_header("🔑 API Configuration"))
api_form = QFormLayout()
api_form.setSpacing(6)
api_form.setContentsMargins(0, 0, 0, 12)
api_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
api_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
api_url_label = QLabel("API URL:")
api_url_label.setFixedWidth(150)
api_url_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
api_url_widget = QWidget()
api_url_layout = QHBoxLayout(api_url_widget)
api_url_layout.setContentsMargins(0, 0, 0, 0)
api_url_layout.setSpacing(4)
self.api_url_edit = QLineEdit()
self.api_url_edit.setPlaceholderText("Leave blank for OpenAI API")
api_url_preset_btn = QToolButton()
api_url_preset_btn.setText("Presets ▾")
api_url_preset_btn.setFixedWidth(75)
api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
api_url_preset_btn.setStyleSheet("""
QToolButton { padding: 3px 6px; }
QToolButton::menu-indicator { image: none; }
""")
api_url_menu = QMenu(api_url_preset_btn)
_url_presets = [
("OpenAI", "https://api.openai.com/v1"),
("Claude (Anthropic)", "https://api.anthropic.com/v1"),
("Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/"),
("DeepSeek", "https://api.deepseek.com/v1/"),
("Mistral", "https://api.mistral.ai/v1/"),
("Nvidia", "https://integrate.api.nvidia.com/v1/"),
]
for _name, _url in _url_presets:
_action = api_url_menu.addAction(_name)
_action.triggered.connect(lambda checked, u=_url: self.api_url_edit.setText(u))
api_url_preset_btn.setMenu(api_url_menu)
self.api_url_edit.textChanged.connect(self._update_model_placeholder)
api_url_layout.addWidget(self.api_url_edit)
api_url_layout.addWidget(api_url_preset_btn)
api_url_widget.setFixedWidth(364)
api_form.addRow(api_url_label, api_url_widget)
api_key_label = QLabel("API Key:")
api_key_label.setFixedWidth(150)
api_key_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_key_edit.setPlaceholderText("Enter your API key")
self.api_key_edit.setFixedWidth(350) # Large
api_form.addRow(api_key_label, self.api_key_edit)
model_label = QLabel("Model:")
model_label.setFixedWidth(150)
model_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
model_widget = QWidget()
model_layout = QHBoxLayout(model_widget)
model_layout.setContentsMargins(0, 0, 0, 0)
model_layout.setSpacing(4)
self.model_combo = QComboBox()
self.model_combo.setEditable(True)
self.model_combo.addItems(ModelFetchThread.DEFAULTS)
self.model_combo.setFixedWidth(270)
self.model_refresh_btn = QToolButton()
self.model_refresh_btn.setText("")
self.model_refresh_btn.setToolTip("Fetch latest models from the configured API")
self.model_refresh_btn.setFixedWidth(28)
self.model_refresh_btn.clicked.connect(lambda: self.fetch_models(silent=False))
model_layout.addWidget(self.model_combo)
model_layout.addWidget(self.model_refresh_btn)
model_widget.setFixedWidth(306)
api_form.addRow(model_label, model_widget)
left_column.addLayout(api_form)
left_column.addWidget(create_horizontal_line())
# Translation Settings Section
left_column.addWidget(create_section_header("🌐 Translation Settings"))
trans_form = QFormLayout()
trans_form.setSpacing(6)
trans_form.setContentsMargins(0, 0, 0, 12)
trans_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
trans_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
lang_label = QLabel("Target Language:")
lang_label.setFixedWidth(150)
lang_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.language_combo = QComboBox()
self.language_combo.addItems([
"English", "Spanish", "French", "German", "Italian",
"Portuguese", "Russian", "Chinese", "Korean", "Japanese"
])
self.language_combo.setFixedWidth(200) # Medium
trans_form.addRow(lang_label, self.language_combo)
timeout_label = QLabel("Timeout:")
timeout_label.setFixedWidth(150)
timeout_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.timeout_spin = QSpinBox()
self.timeout_spin.setButtonSymbols(QSpinBox.NoButtons)
self.timeout_spin.setRange(30, 300)
self.timeout_spin.setValue(90)
self.timeout_spin.setSuffix(" sec")
self.timeout_spin.setFixedWidth(120) # Small
trans_form.addRow(timeout_label, self.timeout_spin)
left_column.addLayout(trans_form)
left_column.addWidget(create_horizontal_line())
# Performance Settings Section
left_column.addWidget(create_section_header("⚡ Performance Settings"))
perf_form = QFormLayout()
perf_form.setSpacing(6)
perf_form.setContentsMargins(0, 0, 0, 12)
perf_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
perf_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
file_threads_label = QLabel("File Threads:")
file_threads_label.setFixedWidth(150)
file_threads_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.file_threads_spin = QSpinBox()
self.file_threads_spin.setButtonSymbols(QSpinBox.NoButtons)
self.file_threads_spin.setRange(1, 10)
self.file_threads_spin.setValue(1)
self.file_threads_spin.setFixedWidth(120) # Small
perf_form.addRow(file_threads_label, self.file_threads_spin)
threads_label = QLabel("Threads per File:")
threads_label.setFixedWidth(150)
threads_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.threads_spin = QSpinBox()
self.threads_spin.setButtonSymbols(QSpinBox.NoButtons)
self.threads_spin.setRange(1, 20)
self.threads_spin.setValue(1)
self.threads_spin.setFixedWidth(120) # Small
perf_form.addRow(threads_label, self.threads_spin)
batch_label = QLabel("Batch Size:")
batch_label.setFixedWidth(150)
batch_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.batch_size_spin = QSpinBox()
self.batch_size_spin.setButtonSymbols(QSpinBox.NoButtons)
self.batch_size_spin.setRange(1, 100)
self.batch_size_spin.setValue(30)
self.batch_size_spin.setFixedWidth(120) # Small
perf_form.addRow(batch_label, self.batch_size_spin)
freq_label = QLabel("Frequency Penalty:")
freq_label.setFixedWidth(150)
freq_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.frequency_penalty_spin = QDoubleSpinBox()
self.frequency_penalty_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.frequency_penalty_spin.setRange(0.0, 2.0)
self.frequency_penalty_spin.setSingleStep(0.05)
self.frequency_penalty_spin.setValue(0.05)
self.frequency_penalty_spin.setFixedWidth(120) # Small
perf_form.addRow(freq_label, self.frequency_penalty_spin)
left_column.addLayout(perf_form)
left_column.addStretch()
# RIGHT COLUMN
right_column = QVBoxLayout()
right_column.setSpacing(8)
# Text Formatting Section
right_column.addWidget(create_section_header("📝 Text Formatting"))
format_form = QFormLayout()
format_form.setSpacing(6)
format_form.setContentsMargins(0, 0, 0, 12)
format_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
format_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
dialogue_label = QLabel("Dialogue Width:")
dialogue_label.setFixedWidth(150)
dialogue_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.width_spin = QSpinBox()
self.width_spin.setButtonSymbols(QSpinBox.NoButtons)
self.width_spin.setRange(20, 200)
self.width_spin.setValue(60)
self.width_spin.setSuffix(" chars")
self.width_spin.setFixedWidth(120) # Small
format_form.addRow(dialogue_label, self.width_spin)
list_label = QLabel("List Width:")
list_label.setFixedWidth(150)
list_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.list_width_spin = QSpinBox()
self.list_width_spin.setButtonSymbols(QSpinBox.NoButtons)
self.list_width_spin.setRange(20, 200)
self.list_width_spin.setValue(100)
self.list_width_spin.setSuffix(" chars")
self.list_width_spin.setFixedWidth(120) # Small
format_form.addRow(list_label, self.list_width_spin)
note_label = QLabel("Note Width:")
note_label.setFixedWidth(150)
note_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.note_width_spin = QSpinBox()
self.note_width_spin.setButtonSymbols(QSpinBox.NoButtons)
self.note_width_spin.setRange(20, 200)
self.note_width_spin.setValue(75)
self.note_width_spin.setSuffix(" chars")
self.note_width_spin.setFixedWidth(120) # Small
format_form.addRow(note_label, self.note_width_spin)
right_column.addLayout(format_form)
right_column.addWidget(create_horizontal_line())
# Custom API Pricing Section
right_column.addWidget(create_section_header("💰 Custom API Pricing"))
pricing_note = QLabel("Only used if model isn't in built-in pricing list")
pricing_note.setStyleSheet("color: #888888; font-style: italic; font-size: 9px;")
pricing_note.setWordWrap(True)
right_column.addWidget(pricing_note)
price_form = QFormLayout()
price_form.setSpacing(6)
price_form.setContentsMargins(0, 3, 0, 12)
price_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
price_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
input_label = QLabel("Input Cost:")
input_label.setFixedWidth(150)
input_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.input_cost_spin = QDoubleSpinBox()
self.input_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.input_cost_spin.setRange(0.0, 100.0)
self.input_cost_spin.setDecimals(4)
self.input_cost_spin.setSingleStep(0.1)
self.input_cost_spin.setValue(2.0)
self.input_cost_spin.setSuffix(" per 1M tokens")
self.input_cost_spin.setFixedWidth(200) # Medium
price_form.addRow(input_label, self.input_cost_spin)
output_label = QLabel("Output Cost:")
output_label.setFixedWidth(150)
output_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.output_cost_spin = QDoubleSpinBox()
self.output_cost_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.output_cost_spin.setRange(0.0, 100.0)
self.output_cost_spin.setDecimals(4)
self.output_cost_spin.setSingleStep(0.1)
self.output_cost_spin.setValue(8.0)
self.output_cost_spin.setSuffix(" per 1M tokens")
self.output_cost_spin.setFixedWidth(200) # Medium
price_form.addRow(output_label, self.output_cost_spin)
right_column.addLayout(price_form)
right_column.addWidget(create_horizontal_line())
# UI Settings Section
right_column.addWidget(create_section_header("🖥️ UI Settings"))
ui_form = QFormLayout()
ui_form.setSpacing(6)
ui_form.setContentsMargins(0, 0, 0, 12)
ui_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint)
ui_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
font_scale_label = QLabel("Font Scale:")
font_scale_label.setFixedWidth(150)
font_scale_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.font_scale_spin = QDoubleSpinBox()
self.font_scale_spin.setButtonSymbols(QDoubleSpinBox.NoButtons)
self.font_scale_spin.setRange(0.5, 3.0)
self.font_scale_spin.setSingleStep(0.1)
self.font_scale_spin.setDecimals(1)
self.font_scale_spin.setValue(1.0)
self.font_scale_spin.setSuffix("x")
self.font_scale_spin.setFixedWidth(120)
self.font_scale_spin.setToolTip(
"Scale the application font size.\n"
"1.0 = default, 1.5 = 50% larger, 2.0 = double size.\n"
"Takes effect immediately on save."
)
ui_form.addRow(font_scale_label, self.font_scale_spin)
right_column.addLayout(ui_form)
right_column.addStretch()
# Add columns to layout
columns_layout.addLayout(left_column, 1)
columns_layout.addLayout(right_column, 1)
layout.addLayout(columns_layout)
# Add buttons at the bottom of General Settings tab
layout.addSpacing(15)
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
reset_button = QPushButton(platform_glyph("🔄 Reset to Defaults"))
reset_button.clicked.connect(self.reset_to_defaults_with_save)
reset_button.setMinimumHeight(32)
save_button = QPushButton("💾 Save Changes")
save_button.clicked.connect(lambda: self.save_to_env(show_message=True))
save_button.setMinimumHeight(32)
self.autosave_label = QLabel("")
self.autosave_label.setStyleSheet("color: #4ec9b0; font-weight: bold;")
button_layout.addWidget(reset_button)
button_layout.addWidget(save_button)
button_layout.addSpacing(10)
button_layout.addWidget(self.autosave_label)
button_layout.addStretch()
layout.addLayout(button_layout)
content.setLayout(layout)
widget.setLayout(QVBoxLayout())
widget.layout().setContentsMargins(0, 0, 0, 0)
widget.layout().addWidget(content)
return widget
# ------------------------------------------------------------------
# Model fetching
# ------------------------------------------------------------------
def fetch_models(self, silent=False):
"""Kick off background fetch of models from the configured API."""
api_key = self.api_key_edit.text().strip()
api_url = self.api_url_edit.text().strip()
if not api_key:
if not silent:
QMessageBox.warning(
self, "No API Key",
"Please enter an API key before fetching models."
)
return
self.model_refresh_btn.setEnabled(False)
self.model_refresh_btn.setText("")
self._model_fetch_thread = ModelFetchThread(api_key, api_url, parent=self)
self._model_fetch_thread.models_fetched.connect(self._on_models_fetched)
self._model_fetch_thread.fetch_error.connect(self._on_models_fetch_error)
self._model_fetch_thread.finished.connect(lambda: None) # keep GC away
self._model_fetch_thread.start()
def _on_models_fetched(self, models):
"""Populate the model dropdown with freshly fetched models."""
current = self.model_combo.currentText()
self.model_combo.blockSignals(True)
self.model_combo.clear()
self.model_combo.addItems(models)
self.model_combo.setCurrentText(current) # restore whatever was typed
self.model_combo.blockSignals(False)
self.model_refresh_btn.setEnabled(True)
self.model_refresh_btn.setText("")
def _on_models_fetch_error(self, error):
"""Restore button and show error."""
self.model_refresh_btn.setEnabled(True)
self.model_refresh_btn.setText("")
QMessageBox.warning(
self, "Fetch Error",
f"Could not fetch models from the API:\n{error}"
)
def mousePressEvent(self, event):
"""Clear focus from any text/spin box when clicking on empty space,
which triggers editingFinished and therefore auto-save."""
focused = QApplication.focusWidget()
if focused and isinstance(focused, (QLineEdit, QSpinBox, QDoubleSpinBox)):
focused.clearFocus()
super().mousePressEvent(event)
def showEvent(self, event):
"""Reload values from disk every time this tab becomes visible."""
super().showEvent(event)
if self.env_file_path.exists():
self.disconnect_auto_save()
self.load_from_env()
self.connect_auto_save()
self._refresh_engine_config_page(self.content_stack.currentIndex())
def load_from_env(self):
"""Load configuration from .env file, reading directly from disk."""
# Use dotenv_values() to read the file directly rather than relying on
# os.environ, which may be stale if values were changed after startup.
env = dotenv_values(self.env_file_path) if self.env_file_path.exists() else {}
def _get(key, default=""):
return env.get(key, os.getenv(key, default))
# API settings
self.api_url_edit.setText(_get("api", "").strip())
self.api_key_edit.setText(_get("key", "").strip())
self.model_combo.setCurrentText(_get("model", "gpt-4.1"))
# Translation settings
self.language_combo.setCurrentText(_get("language", "English"))
self.timeout_spin.setValue(int(_get("timeout", "90")))
# Performance settings
self.file_threads_spin.setValue(int(_get("fileThreads", "1")))
self.threads_spin.setValue(int(_get("threads", "1")))
self.batch_size_spin.setValue(int(_get("batchsize", "30")))
self.frequency_penalty_spin.setValue(float(_get("frequency_penalty", "0.05")))
# Formatting settings
self.width_spin.setValue(int(_get("width", "60")))
self.list_width_spin.setValue(int(_get("listWidth", "100")))
self.note_width_spin.setValue(int(_get("noteWidth", "75")))
# Custom API pricing
self.input_cost_spin.setValue(float(_get("input_cost", "2.0")))
self.output_cost_spin.setValue(float(_get("output_cost", "8.0")))
# UI settings
self.font_scale_spin.setValue(float(_get("font_scale", "1.0")))
def connect_auto_save(self):
"""Connect all widgets to auto-save on change."""
# Text fields - use editingFinished to avoid saving on every keystroke
self.api_url_edit.editingFinished.connect(self.auto_save)
self.api_key_edit.editingFinished.connect(self.auto_save)
# Combo boxes
self.model_combo.currentTextChanged.connect(self.auto_save)
self.language_combo.currentTextChanged.connect(self.auto_save)
# Spin boxes — use editingFinished so saves trigger on Enter or focus-out,
# not on every intermediate keystroke while typing.
self.timeout_spin.editingFinished.connect(self.auto_save)
self.file_threads_spin.editingFinished.connect(self.auto_save)
self.threads_spin.editingFinished.connect(self.auto_save)
self.batch_size_spin.editingFinished.connect(self.auto_save)
self.frequency_penalty_spin.editingFinished.connect(self.auto_save)
self.width_spin.editingFinished.connect(self.auto_save)
self.list_width_spin.editingFinished.connect(self.auto_save)
self.note_width_spin.editingFinished.connect(self.auto_save)
self.input_cost_spin.editingFinished.connect(self.auto_save)
self.output_cost_spin.editingFinished.connect(self.auto_save)
self.font_scale_spin.editingFinished.connect(self.auto_save)
def disconnect_auto_save(self):
"""Disconnect all widgets from auto-save."""
try:
self.api_url_edit.editingFinished.disconnect(self.auto_save)
self.api_key_edit.editingFinished.disconnect(self.auto_save)
self.model_combo.currentTextChanged.disconnect(self.auto_save)
self.language_combo.currentTextChanged.disconnect(self.auto_save)
self.timeout_spin.editingFinished.disconnect(self.auto_save)
self.file_threads_spin.editingFinished.disconnect(self.auto_save)
self.threads_spin.editingFinished.disconnect(self.auto_save)
self.batch_size_spin.editingFinished.disconnect(self.auto_save)
self.frequency_penalty_spin.editingFinished.disconnect(self.auto_save)
self.width_spin.editingFinished.disconnect(self.auto_save)
self.list_width_spin.editingFinished.disconnect(self.auto_save)
self.note_width_spin.editingFinished.disconnect(self.auto_save)
self.input_cost_spin.editingFinished.disconnect(self.auto_save)
self.output_cost_spin.editingFinished.disconnect(self.auto_save)
self.font_scale_spin.editingFinished.disconnect(self.auto_save)
except (TypeError, RuntimeError):
pass
def auto_save(self):
"""Auto-save configuration without showing message."""
self.save_to_env(show_message=False)
if hasattr(self, 'autosave_label'):
self.autosave_label.setText("✓ Saved")
self._autosave_timer.start(2000)
def _clear_autosave_indicator(self):
if hasattr(self, 'autosave_label'):
self.autosave_label.setText("")
def save_to_env(self, show_message=True):
"""Save configuration to .env file."""
try:
# Ensure .env file exists
if not self.env_file_path.exists():
self.env_file_path.touch()
# Build config dict for both file and os.environ updates
config = {
"api": self.api_url_edit.text().strip(),
"key": self.api_key_edit.text().strip(),
"model": self.model_combo.currentText(),
"language": self.language_combo.currentText(),
"timeout": str(self.timeout_spin.value()),
"fileThreads": str(self.file_threads_spin.value()),
"threads": str(self.threads_spin.value()),
"batchsize": str(self.batch_size_spin.value()),
"frequency_penalty": str(self.frequency_penalty_spin.value()),
"width": str(self.width_spin.value()),
"listWidth": str(self.list_width_spin.value()),
"noteWidth": str(self.note_width_spin.value()),
"input_cost": str(self.input_cost_spin.value()),
"output_cost": str(self.output_cost_spin.value()),
"font_scale": str(self.font_scale_spin.value()),
}
# Save to .env file and update os.environ so subprocesses inherit new values
for key, value in config.items():
set_key(self.env_file_path, key, value)
os.environ[key] = value
if show_message:
QMessageBox.information(self, "Success", "Configuration saved successfully!")
self.config_changed.emit()
except Exception as e:
if show_message:
QMessageBox.critical(self, "Error", f"Failed to save configuration:\n{str(e)}")
def load_from_file_dialog(self):
"""Load configuration from a file via dialog."""
file_path, _ = QFileDialog.getOpenFileName(
self, "Load Configuration", "", "Environment Files (*.env);;All Files (*)"
)
if file_path:
self.load_from_file(file_path)
def load_from_file(self, file_path):
"""Load configuration from a specific file."""
try:
load_dotenv(file_path, override=True)
self.load_from_env()
QMessageBox.information(self, "Success", f"Configuration loaded from {Path(file_path).name}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load configuration:\n{str(e)}")
def reset_to_defaults(self):
"""Reset all settings to default values."""
self.disconnect_auto_save()
# API settings
self.api_url_edit.clear()
self.api_key_edit.clear()
self.model_combo.setCurrentText("gpt-4.1")
# Translation settings
self.language_combo.setCurrentText("English")
self.timeout_spin.setValue(90)
# Performance settings
self.file_threads_spin.setValue(1)
self.threads_spin.setValue(1)
self.batch_size_spin.setValue(30)
self.frequency_penalty_spin.setValue(0.05)
# Formatting settings
self.width_spin.setValue(60)
self.list_width_spin.setValue(100)
self.note_width_spin.setValue(75)
# UI settings
self.font_scale_spin.setValue(1.0)
self.connect_auto_save()
def reset_to_defaults_with_save(self):
"""Reset to defaults, save, and show confirmation."""
self.reset_to_defaults()
self.save_to_env(show_message=False)
# Custom API settings
self.input_cost_spin.setValue(2.0)
self.output_cost_spin.setValue(8.0)
# Reset engine tabs
self.mvmz_tab.reset_to_defaults()
self.wolf_tab.reset_to_defaults()
self.csv_tab.reset_to_defaults()
def get_config(self):
"""Get current configuration as dictionary."""
return {
"api": self.api_url_edit.text(),
"key": self.api_key_edit.text(),
"model": self.model_combo.currentText(),
"language": self.language_combo.currentText(),
"timeout": self.timeout_spin.value(),
"fileThreads": self.file_threads_spin.value(),
"threads": self.threads_spin.value(),
"batchsize": self.batch_size_spin.value(),
"frequency_penalty": self.frequency_penalty_spin.value(),
"width": self.width_spin.value(),
"listWidth": self.list_width_spin.value(),
"noteWidth": self.note_width_spin.value(),
"input_cost": self.input_cost_spin.value(),
"output_cost": self.output_cost_spin.value(),
"font_scale": self.font_scale_spin.value(),
}
def validate(self):
"""Validate the current configuration."""
errors = []
# Check required fields
if not self.api_key_edit.text().strip():
errors.append("API Key is required")
if not self.model_combo.currentText().strip():
errors.append("Model is required")
# Check numeric ranges
if self.timeout_spin.value() < 30:
errors.append("Timeout should be at least 30 seconds")
if self.threads_spin.value() > 10 and "gpt-4" in self.model_combo.currentText().lower():
errors.append("Too many threads for GPT-4 - recommended: 1-2")
if errors:
QMessageBox.warning(self, "Validation Errors", "\n".join(errors))
return False
return True