From df04f44db4426b85854ea8cd5a51fe6cda7d9996 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Wed, 22 Jul 2026 14:57:33 -0500 Subject: [PATCH] feat(config): align settings UI and bind optional API endpoints to keys - Rebuild General Settings on a shared label/field grid with Fusion controls - Store optional per-key endpoints and apply them on key select - Keep legacy string vault entries working and cover endpoint sync in tests --- gui/config_tab.py | 2514 ++++++++++++++++++++-------------------- gui/main.py | 36 +- tests/test_api_keys.py | 75 +- util/api_keys.py | 108 +- 4 files changed, 1443 insertions(+), 1290 deletions(-) diff --git a/gui/config_tab.py b/gui/config_tab.py index e527fad..efd7402 100644 --- a/gui/config_tab.py +++ b/gui/config_tab.py @@ -1,1253 +1,1261 @@ -""" -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, QDialog, QDialogButtonBox, QSizePolicy, -) -from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QThread -from PyQt5.QtGui import QIcon -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 -from util import api_keys as api_key_vault - - -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-6", "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", # free-tier recommendation; avoid -latest (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.""" - from gui.qt_icons import make_section_header - - return make_section_header( - title, - "QLabel {" - "font-size: 13px;" - "font-weight: bold;" - "color: #007acc;" - "padding: 8px 0px 5px 0px;" - "background-color: transparent;" - "}", - ) - -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 ApiKeyEditDialog(QDialog): - """Dialog to create or overwrite a named API key (secret entered once).""" - - def __init__(self, parent=None, initial_name: str = ""): - super().__init__(parent) - self.setWindowTitle("API Key") - self.setModal(True) - self.setMinimumWidth(420) - - layout = QVBoxLayout(self) - layout.setSpacing(10) - - form = QFormLayout() - form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - - self.name_edit = QLineEdit(initial_name) - self.name_edit.setPlaceholderText("e.g. OpenAI, DeepSeek, Work") - form.addRow("Name:", self.name_edit) - - self.secret_edit = QLineEdit() - self.secret_edit.setEchoMode(QLineEdit.Password) - self.secret_edit.setPlaceholderText("Paste API key secret") - form.addRow("Secret:", self.secret_edit) - - layout.addLayout(form) - - hint = QLabel( - "The name appears in the dropdown. The secret is stored locally " - "and is never shown again in the main Config page." - ) - hint.setWordWrap(True) - hint.setStyleSheet("color: #888888; font-size: 11px;") - layout.addWidget(hint) - - buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) - buttons.accepted.connect(self._accept_if_valid) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - self._name = "" - self._secret = "" - self.secret_edit.setFocus() - - def _accept_if_valid(self): - name = self.name_edit.text().strip() - secret = self.secret_edit.text().strip() - if not name: - QMessageBox.warning(self, "API Key", "Enter a name for this key.") - self.name_edit.setFocus() - return - if not secret: - QMessageBox.warning(self, "API Key", "Enter the API key secret.") - self.secret_edit.setFocus() - return - self._name = name - self._secret = secret - self.accept() - - def result_values(self) -> tuple[str, str]: - return self._name, self._secret - - -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_field_width = 364 - _api_btn_style = """ - QToolButton { - background-color: #3e3e42; - color: #cccccc; - border: 1px solid #555555; - border-radius: 3px; - padding: 3px 6px; - } - QToolButton:hover { background-color: #505050; } - QToolButton::menu-indicator { image: none; } - """ - - 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") - self.api_url_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - 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(_api_btn_style) - - 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, 1) - api_url_layout.addWidget(api_url_preset_btn) - api_url_widget.setFixedWidth(_api_field_width) - - 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) - - api_key_widget = QWidget() - api_key_layout = QHBoxLayout(api_key_widget) - api_key_layout.setContentsMargins(0, 0, 0, 0) - api_key_layout.setSpacing(4) - - self.api_key_combo = QComboBox() - self.api_key_combo.setEditable(False) - self.api_key_combo.setToolTip( - "Select a saved API key by name. Secrets stay hidden after you add them." - ) - self.api_key_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - self.api_key_new_btn = QToolButton() - self.api_key_new_btn.setText("New") - self.api_key_new_btn.setToolTip("Add or update a named API key") - self.api_key_new_btn.setFixedWidth(48) - self.api_key_new_btn.clicked.connect(self._on_api_key_new) - - self.api_key_delete_btn = QToolButton() - self.api_key_delete_btn.setText("Delete") - self.api_key_delete_btn.setToolTip("Remove the selected API key") - self.api_key_delete_btn.setFixedWidth(56) - self.api_key_delete_btn.clicked.connect(self._on_api_key_delete) - - for btn in (self.api_key_new_btn, self.api_key_delete_btn): - btn.setStyleSheet(_api_btn_style) - - api_key_layout.addWidget(self.api_key_combo, 1) - api_key_layout.addWidget(self.api_key_new_btn) - api_key_layout.addWidget(self.api_key_delete_btn) - api_key_widget.setFixedWidth(_api_field_width) - api_form.addRow(api_key_label, api_key_widget) - - 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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - 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.setStyleSheet(_api_btn_style) - self.model_refresh_btn.clicked.connect(lambda: self.fetch_models(silent=False)) - - model_layout.addWidget(self.model_combo, 1) - model_layout.addWidget(self.model_refresh_btn) - model_widget.setFixedWidth(_api_field_width) - - 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) - - quotes_label = QLabel("Convert Quotes:") - quotes_label.setFixedWidth(150) - quotes_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.convert_quotes_cb = QCheckBox('Convert 「」 / 『』 to ""') - self.convert_quotes_cb.setChecked(True) - self.convert_quotes_cb.setToolTip( - "When enabled, Japanese corner brackets 「」 and 『』 are replaced " - 'with ASCII double quotes "" in translated output (and on RPG Maker ' - "source text before translation).\n\n" - "Leaving this on is recommended - the AI often fails to keep 「」 " - "consistent across lines." - ) - format_form.addRow(quotes_label, self.convert_quotes_cb) - - 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) - - # Game Update defaults (written into gameupdate/patch-config.txt on copy) - right_column.addWidget(create_section_header("📦 Game Update Defaults")) - gu_form = QFormLayout() - gu_form.setSpacing(6) - gu_form.setContentsMargins(0, 0, 0, 12) - gu_form.setFieldGrowthPolicy(QFormLayout.FieldsStayAtSizeHint) - gu_form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) - - def _gu_label(text: str) -> QLabel: - lbl = QLabel(text) - lbl.setFixedWidth(150) - lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - return lbl - - self.gu_forge_combo = QComboBox() - self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab") - self.gu_forge_combo.addItem("GitHub", "github") - self.gu_forge_combo.addItem("Forgejo / Gitea", "forgejo") - self.gu_forge_combo.setFixedWidth(220) - self.gu_forge_combo.setToolTip( - "Where your patch repos live. Used when Step 1 writes patch-config.txt." - ) - self.gu_forge_combo.currentIndexChanged.connect(self._on_gu_forge_changed) - gu_form.addRow(_gu_label("Forge:"), self.gu_forge_combo) - - self.gu_host_edit = QLineEdit() - self.gu_host_edit.setFixedWidth(220) - self.gu_host_edit.setPlaceholderText("gitgud.io") - self.gu_host_edit.setToolTip( - "Hostname only (no https://). Leave blank to use the forge default." - ) - gu_form.addRow(_gu_label("Host:"), self.gu_host_edit) - - self.gu_username_edit = QLineEdit() - self.gu_username_edit.setFixedWidth(220) - self.gu_username_edit.setPlaceholderText("your-org-or-user") - self.gu_username_edit.setToolTip( - "Owner / org / group for all patch repos. Set once; rarely changes." - ) - gu_form.addRow(_gu_label("Org / username:"), self.gu_username_edit) - - self.gu_branch_edit = QLineEdit() - self.gu_branch_edit.setFixedWidth(220) - self.gu_branch_edit.setPlaceholderText("main") - self.gu_branch_edit.setToolTip("Default branch to track (usually main or master).") - gu_form.addRow(_gu_label("Branch:"), self.gu_branch_edit) - - gu_hint = QLabel( - "Copied into each game's gameupdate/patch-config.txt when you run " - "Copy gameupdate/ (repo= is still per-game)." - ) - gu_hint.setWordWrap(True) - gu_hint.setStyleSheet("color:#7a7a7a;font-size:11px;") - right_column.addLayout(gu_form) - right_column.addWidget(gu_hint) - 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) - - from gui import qt_icons - - reset_button = QPushButton() - qt_icons.apply_button_icon(reset_button, "🔄 Reset to Defaults", color="#cccccc") - reset_button.clicked.connect(self.reset_to_defaults_with_save) - reset_button.setMinimumHeight(32) - - save_button = QPushButton() - qt_icons.apply_button_icon(save_button, "💾 Save Changes", color="#cccccc") - 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 _active_api_key_secret(self) -> str: - """Return the secret for the currently selected named key.""" - name = self.api_key_combo.currentText().strip() - if not name: - return api_key_vault.get_active_secret() - secret = api_key_vault.get_secret(name) - return secret if secret is not None else "" - - def _refresh_api_key_combo(self, preferred: str | None = None): - """Reload named keys into the dropdown without firing autosave.""" - api_key_vault.ensure_vault(env_path=self.env_file_path) - names = api_key_vault.list_names() - active = preferred or api_key_vault.get_active_name() - self.api_key_combo.blockSignals(True) - self.api_key_combo.clear() - if names: - self.api_key_combo.addItems(names) - if active and active in names: - self.api_key_combo.setCurrentText(active) - else: - self.api_key_combo.setCurrentIndex(0) - else: - self.api_key_combo.setPlaceholderText("No keys saved - click New") - self.api_key_combo.blockSignals(False) - self.api_key_delete_btn.setEnabled(bool(names)) - - def _on_api_key_selected(self, _name: str = ""): - """Persist dropdown selection as the active vault key and mirror to .env.""" - name = self.api_key_combo.currentText().strip() - if not name: - return - try: - api_key_vault.set_active(name) - api_key_vault.sync_active_to_env(env_path=self.env_file_path) - except KeyError: - return - self.auto_save() - - def _on_api_key_new(self): - """Open dialog to create or overwrite a named API key.""" - current = self.api_key_combo.currentText().strip() - dialog = ApiKeyEditDialog(self, initial_name=current) - if dialog.exec_() != QDialog.Accepted: - return - name, secret = dialog.result_values() - try: - api_key_vault.upsert_key(name, secret, make_active=True) - api_key_vault.sync_active_to_env(env_path=self.env_file_path) - except ValueError as exc: - QMessageBox.warning(self, "API Key", str(exc)) - return - self._refresh_api_key_combo(preferred=name) - self.auto_save() - - def _on_api_key_delete(self): - """Delete the selected named key after confirmation.""" - name = self.api_key_combo.currentText().strip() - if not name: - return - reply = QMessageBox.question( - self, - "Delete API Key", - f'Remove saved key "{name}"?\nThe secret will be deleted from this machine.', - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return - api_key_vault.delete_key(name) - api_key_vault.sync_active_to_env(env_path=self.env_file_path) - self._refresh_api_key_combo() - self.auto_save() - - def fetch_models(self, silent=False): - """Kick off background fetch of models from the configured API.""" - api_key = self._active_api_key_secret() - api_url = self.api_url_edit.text().strip() - - if not api_key: - if not silent: - QMessageBox.warning( - self, "No API Key", - "Please add or select 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._refresh_api_key_combo() - 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"))) - self.convert_quotes_cb.setChecked( - _get("convertQuotes", "true").strip().lower() in ("true", "1", "yes") - ) - - # 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"))) - - # Game Update defaults - from util.gameupdate_config import ( - DEFAULT_BRANCH, - DEFAULT_FORGE, - default_host_for_forge, - normalize_forge, - ) - - forge = normalize_forge(_get("gameUpdateForge", DEFAULT_FORGE)) - forge_idx = self.gu_forge_combo.findData(forge) - self.gu_forge_combo.setCurrentIndex(forge_idx if forge_idx >= 0 else 0) - host = _get("gameUpdateHost", "") or default_host_for_forge(forge) - self.gu_host_edit.setText(host) - self.gu_username_edit.setText(_get("gameUpdateUsername", "")) - self.gu_branch_edit.setText(_get("gameUpdateBranch", DEFAULT_BRANCH) or DEFAULT_BRANCH) - - def _on_gu_forge_changed(self, _index: int = 0): - """Fill host with the forge default when the user switches forge.""" - from util.gameupdate_config import default_host_for_forge - - forge = self.gu_forge_combo.currentData() or "gitlab" - current = self.gu_host_edit.text().strip() - defaults = { - "gitgud.io", - "gitlab.com", - "github.com", - "codeberg.org", - "", - } - if current.lower() in defaults: - self.gu_host_edit.setText(default_host_for_forge(str(forge))) - - 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_combo.currentTextChanged.connect(self._on_api_key_selected) - - - # 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.convert_quotes_cb.stateChanged.connect(self._on_convert_quotes_changed) - 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) - self.gu_forge_combo.currentIndexChanged.connect(self.auto_save) - self.gu_host_edit.editingFinished.connect(self.auto_save) - self.gu_username_edit.editingFinished.connect(self.auto_save) - self.gu_branch_edit.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_combo.currentTextChanged.disconnect(self._on_api_key_selected) - - 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.convert_quotes_cb.stateChanged.disconnect(self._on_convert_quotes_changed) - 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) - self.gu_forge_combo.currentIndexChanged.disconnect(self.auto_save) - self.gu_host_edit.editingFinished.disconnect(self.auto_save) - self.gu_username_edit.editingFinished.disconnect(self.auto_save) - self.gu_branch_edit.editingFinished.disconnect(self.auto_save) - except (TypeError, RuntimeError): - pass - - def _on_convert_quotes_changed(self, state): - """Warn when quote conversion is disabled, then auto-save.""" - if state == Qt.Unchecked: - QMessageBox.warning( - self, - "Convert Quotes Disabled", - "The AI often struggles to keep 「」 and 『』 consistent " - "across translated lines.\n\n" - "Leaving conversion enabled is recommended unless you " - "specifically need the Japanese brackets in the output.", - ) - self.auto_save() - - 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._active_api_key_secret(), - "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()), - "convertQuotes": "true" if self.convert_quotes_cb.isChecked() else "false", - "input_cost": str(self.input_cost_spin.value()), - "output_cost": str(self.output_cost_spin.value()), - "font_scale": str(self.font_scale_spin.value()), - "gameUpdateForge": str(self.gu_forge_combo.currentData() or "gitlab"), - "gameUpdateHost": self.gu_host_edit.text().strip(), - "gameUpdateUsername": self.gu_username_edit.text().strip(), - "gameUpdateBranch": self.gu_branch_edit.text().strip() or "main", - } - - # 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._refresh_api_key_combo() - 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) - self.convert_quotes_cb.setChecked(True) - - # UI settings - self.font_scale_spin.setValue(1.0) - - # Game Update defaults - from util.gameupdate_config import DEFAULT_BRANCH, DEFAULT_FORGE, DEFAULT_HOST - - forge_idx = self.gu_forge_combo.findData(DEFAULT_FORGE) - self.gu_forge_combo.setCurrentIndex(forge_idx if forge_idx >= 0 else 0) - self.gu_host_edit.setText(DEFAULT_HOST) - self.gu_username_edit.clear() - self.gu_branch_edit.setText(DEFAULT_BRANCH) - - 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._active_api_key_secret(), - "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(), - "convertQuotes": self.convert_quotes_cb.isChecked(), - "input_cost": self.input_cost_spin.value(), - "output_cost": self.output_cost_spin.value(), - "font_scale": self.font_scale_spin.value(), - "gameUpdateForge": str(self.gu_forge_combo.currentData() or "gitlab"), - "gameUpdateHost": self.gu_host_edit.text().strip(), - "gameUpdateUsername": self.gu_username_edit.text().strip(), - "gameUpdateBranch": self.gu_branch_edit.text().strip() or "main", - } - - def validate(self): - """Validate the current configuration.""" - errors = [] - - # Check required fields - if not self._active_api_key_secret(): - 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 +""" +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, QDialog, QDialogButtonBox, QSizePolicy, +) +from PyQt5.QtCore import Qt, pyqtSignal, QTimer, QThread +from PyQt5.QtGui import QIcon +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 +from util import api_keys as api_key_vault + + +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-6", "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", # free-tier recommendation; avoid -latest (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.""" + from gui.qt_icons import make_section_header + + return make_section_header( + title, + "QLabel {" + "font-size: 13px;" + "font-weight: bold;" + "color: #007acc;" + "padding: 8px 0px 5px 0px;" + "background-color: transparent;" + "}", + ) + +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 ApiKeyEditDialog(QDialog): + """Dialog to create or overwrite a named API key (secret entered once).""" + + def __init__( + self, + parent=None, + initial_name: str = "", + initial_endpoint: str = "", + *, + allow_blank_secret: bool = False, + ): + super().__init__(parent) + self.setWindowTitle("API Key") + self.setModal(True) + self.setMinimumWidth(460) + self._allow_blank_secret = allow_blank_secret + + layout = QVBoxLayout(self) + layout.setSpacing(10) + + form = QFormLayout() + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.name_edit = QLineEdit(initial_name) + self.name_edit.setPlaceholderText("e.g. OpenAI, DeepSeek, Work") + form.addRow("Name:", self.name_edit) + + self.secret_edit = QLineEdit() + self.secret_edit.setEchoMode(QLineEdit.Password) + if allow_blank_secret: + self.secret_edit.setPlaceholderText("Leave blank to keep the existing secret") + else: + self.secret_edit.setPlaceholderText("Paste API key secret") + form.addRow("Secret:", self.secret_edit) + + endpoint_wrap = QWidget() + endpoint_row = QHBoxLayout(endpoint_wrap) + endpoint_row.setContentsMargins(0, 0, 0, 0) + endpoint_row.setSpacing(6) + + self.endpoint_edit = QLineEdit(initial_endpoint) + self.endpoint_edit.setPlaceholderText("Optional - leave blank to keep the global API URL") + endpoint_row.addWidget(self.endpoint_edit, 1) + + endpoint_preset_btn = QToolButton() + endpoint_preset_btn.setText("Presets ▾") + endpoint_preset_btn.setPopupMode(QToolButton.InstantPopup) + endpoint_menu = QMenu(endpoint_preset_btn) + for label, url in ( + ("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/"), + ("Clear", ""), + ): + action = endpoint_menu.addAction(label) + action.triggered.connect( + lambda checked=False, u=url: self.endpoint_edit.setText(u) + ) + endpoint_preset_btn.setMenu(endpoint_menu) + endpoint_row.addWidget(endpoint_preset_btn) + form.addRow("Endpoint:", endpoint_wrap) + + layout.addLayout(form) + + hint = QLabel( + "The name appears in the dropdown. The secret is stored locally " + "and is never shown again on the main Config page. " + "If you set an endpoint, selecting this key always applies that API URL." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color: #888888; font-size: 11px;") + layout.addWidget(hint) + + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._accept_if_valid) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self._name = "" + self._secret = "" + self._endpoint = "" + self.secret_edit.setFocus() + + def _accept_if_valid(self): + name = self.name_edit.text().strip() + secret = self.secret_edit.text().strip() + endpoint = self.endpoint_edit.text().strip() + if not name: + QMessageBox.warning(self, "API Key", "Enter a name for this key.") + self.name_edit.setFocus() + return + if not secret and not self._allow_blank_secret: + QMessageBox.warning(self, "API Key", "Enter the API key secret.") + self.secret_edit.setFocus() + return + self._name = name + self._secret = secret + self._endpoint = endpoint + self.accept() + + def result_values(self) -> tuple[str, str, str]: + return self._name, self._secret, self._endpoint + + +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) + + # Shared geometry: one label column + one field edge across both columns. + label_w = 160 + field_w = 360 + row_gap = 12 + btn_style = ( + "QToolButton {" + "background-color: #3e3e42; color: #cccccc;" + "border: 1px solid #555555; border-radius: 3px; padding: 4px 8px;" + "}" + "QToolButton:hover { background-color: #505050; }" + "QToolButton::menu-indicator { image: none; }" + ) + + def form_label(text: str) -> QLabel: + lbl = QLabel(text) + lbl.setFixedWidth(label_w) + lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + lbl.setStyleSheet("padding: 0px; background: transparent;") + return lbl + + def size_field(w: QWidget) -> QWidget: + w.setFixedWidth(field_w) + w.setMinimumWidth(field_w) + w.setMaximumWidth(field_w) + w.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + if isinstance(w, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)): + w.setMinimumHeight(32) + return w + + def row_box(*widgets: QWidget, stretch_index: int = 0) -> QWidget: + """Fixed-width control cluster; stretch one child, never overflow buttons.""" + box = QWidget() + row = QHBoxLayout(box) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(8) + for i, child in enumerate(widgets): + if i == stretch_index: + child.setMinimumWidth(0) + child.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + row.addWidget(child, 1) + else: + child.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + row.addWidget(child, 0) + box.setFixedWidth(field_w) + box.setMinimumHeight(32) + box.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred) + return box + + def make_section() -> QVBoxLayout: + section = QVBoxLayout() + section.setContentsMargins(0, 0, 0, 12) + section.setSpacing(row_gap) + return section + + def add_row(section: QVBoxLayout, label: str, field: QWidget) -> None: + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(20) + row.addWidget(form_label(label), 0, Qt.AlignVCenter) + row.addWidget(field, 0, Qt.AlignVCenter) + row.addStretch(1) + section.addLayout(row) + + columns_layout = QHBoxLayout() + columns_layout.setSpacing(40) + + # ── LEFT COLUMN ────────────────────────────────────────────── + left_column = QVBoxLayout() + left_column.setSpacing(8) + + left_column.addWidget(create_section_header("🔑 API Configuration")) + api_section = make_section() + + 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(84) + api_url_preset_btn.setPopupMode(QToolButton.InstantPopup) + api_url_preset_btn.setStyleSheet(btn_style) + + api_url_menu = QMenu(api_url_preset_btn) + for name, url in ( + ("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/"), + ): + 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) + + add_row( + api_section, + "API URL:", + row_box(self.api_url_edit, api_url_preset_btn), + ) + + self.api_key_combo = QComboBox() + self.api_key_combo.setEditable(False) + self.api_key_combo.setToolTip( + "Select a saved API key by name. Optional per-key endpoints are " + "applied to API URL automatically. Secrets stay hidden after you add them." + ) + + self.api_key_new_btn = QToolButton() + self.api_key_new_btn.setText("New") + self.api_key_new_btn.setToolTip("Add or update a named API key") + self.api_key_new_btn.setFixedWidth(52) + self.api_key_new_btn.setStyleSheet(btn_style) + self.api_key_new_btn.clicked.connect(self._on_api_key_new) + + self.api_key_delete_btn = QToolButton() + self.api_key_delete_btn.setText("Delete") + self.api_key_delete_btn.setToolTip("Remove the selected API key") + self.api_key_delete_btn.setFixedWidth(64) + self.api_key_delete_btn.setStyleSheet(btn_style) + self.api_key_delete_btn.clicked.connect(self._on_api_key_delete) + + add_row( + api_section, + "API Key:", + row_box(self.api_key_combo, self.api_key_new_btn, self.api_key_delete_btn), + ) + + self.model_combo = QComboBox() + self.model_combo.setEditable(True) + self.model_combo.addItems(ModelFetchThread.DEFAULTS) + + 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(32) + self.model_refresh_btn.setStyleSheet(btn_style) + self.model_refresh_btn.clicked.connect(lambda: self.fetch_models(silent=False)) + + add_row( + api_section, + "Model:", + row_box(self.model_combo, self.model_refresh_btn), + ) + + left_column.addLayout(api_section) + left_column.addWidget(create_horizontal_line()) + + left_column.addWidget(create_section_header("🌐 Translation Settings")) + trans_section = make_section() + + self.language_combo = QComboBox() + self.language_combo.addItems([ + "English", "Spanish", "French", "German", "Italian", + "Portuguese", "Russian", "Chinese", "Korean", "Japanese", + ]) + add_row(trans_section, "Target Language:", size_field(self.language_combo)) + + 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") + add_row(trans_section, "Timeout:", size_field(self.timeout_spin)) + + left_column.addLayout(trans_section) + left_column.addWidget(create_horizontal_line()) + + left_column.addWidget(create_section_header("⚡ Performance Settings")) + perf_section = make_section() + + 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) + add_row(perf_section, "File Threads:", size_field(self.file_threads_spin)) + + self.threads_spin = QSpinBox() + self.threads_spin.setButtonSymbols(QSpinBox.NoButtons) + self.threads_spin.setRange(1, 20) + self.threads_spin.setValue(1) + add_row(perf_section, "Threads per File:", size_field(self.threads_spin)) + + 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) + add_row(perf_section, "Batch Size:", size_field(self.batch_size_spin)) + + 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) + add_row( + perf_section, + "Frequency Penalty:", + size_field(self.frequency_penalty_spin), + ) + + left_column.addLayout(perf_section) + left_column.addStretch() + + # ── RIGHT COLUMN ───────────────────────────────────────────── + right_column = QVBoxLayout() + right_column.setSpacing(8) + + right_column.addWidget(create_section_header("📝 Text Formatting")) + format_section = make_section() + + 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") + add_row(format_section, "Dialogue Width:", size_field(self.width_spin)) + + 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") + add_row(format_section, "List Width:", size_field(self.list_width_spin)) + + 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") + add_row(format_section, "Note Width:", size_field(self.note_width_spin)) + + self.convert_quotes_cb = QCheckBox('Convert 「」 / 『』 to ""') + self.convert_quotes_cb.setChecked(True) + self.convert_quotes_cb.setToolTip( + "When enabled, Japanese corner brackets 「」 and 『』 are replaced " + 'with ASCII double quotes "" in translated output (and on RPG Maker ' + "source text before translation).\n\n" + "Leaving this on is recommended - the AI often fails to keep 「」 " + "consistent across lines." + ) + quotes_wrap = QWidget() + quotes_wrap.setFixedWidth(field_w) + quotes_row = QHBoxLayout(quotes_wrap) + quotes_row.setContentsMargins(0, 0, 0, 0) + quotes_row.setSpacing(0) + quotes_row.addWidget(self.convert_quotes_cb) + quotes_row.addStretch() + add_row(format_section, "Convert Quotes:", quotes_wrap) + + right_column.addLayout(format_section) + right_column.addWidget(create_horizontal_line()) + + 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( + f"color: #888888; font-style: italic; font-size: 11px; " + f"padding-left: {label_w + 20}px;" + ) + pricing_note.setWordWrap(True) + right_column.addWidget(pricing_note) + + price_section = make_section() + + 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(" / 1M tokens") + add_row(price_section, "Input Cost:", size_field(self.input_cost_spin)) + + 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(" / 1M tokens") + add_row(price_section, "Output Cost:", size_field(self.output_cost_spin)) + + right_column.addLayout(price_section) + right_column.addWidget(create_horizontal_line()) + + right_column.addWidget(create_section_header("🖥️ UI Settings")) + ui_section = make_section() + + 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.setToolTip( + "Scale the application font size.\n" + "1.0 = default, 1.5 = 50% larger, 2.0 = double size.\n" + "Takes effect immediately on save." + ) + add_row(ui_section, "Font Scale:", size_field(self.font_scale_spin)) + + right_column.addLayout(ui_section) + right_column.addWidget(create_horizontal_line()) + + right_column.addWidget(create_section_header("📦 Game Update Defaults")) + gu_section = make_section() + + self.gu_forge_combo = QComboBox() + self.gu_forge_combo.addItem("GitLab / gitgud", "gitlab") + self.gu_forge_combo.addItem("GitHub", "github") + self.gu_forge_combo.addItem("Forgejo / Gitea", "forgejo") + self.gu_forge_combo.setToolTip( + "Where your patch repos live. Used when Step 1 writes patch-config.txt." + ) + self.gu_forge_combo.currentIndexChanged.connect(self._on_gu_forge_changed) + add_row(gu_section, "Forge:", size_field(self.gu_forge_combo)) + + self.gu_host_edit = QLineEdit() + self.gu_host_edit.setPlaceholderText("gitgud.io") + self.gu_host_edit.setToolTip( + "Hostname only (no https://). Leave blank to use the forge default." + ) + add_row(gu_section, "Host:", size_field(self.gu_host_edit)) + + self.gu_username_edit = QLineEdit() + self.gu_username_edit.setPlaceholderText("your-org-or-user") + self.gu_username_edit.setToolTip( + "Owner / org / group for all patch repos. Set once; rarely changes." + ) + add_row(gu_section, "Username:", size_field(self.gu_username_edit)) + + self.gu_branch_edit = QLineEdit() + self.gu_branch_edit.setPlaceholderText("main") + self.gu_branch_edit.setToolTip("Default branch to track (usually main or master).") + add_row(gu_section, "Branch:", size_field(self.gu_branch_edit)) + + gu_hint = QLabel( + "Copied into each game's gameupdate/patch-config.txt when you run " + "Copy gameupdate/ (repo= is still per-game)." + ) + gu_hint.setWordWrap(True) + gu_hint.setStyleSheet( + f"color:#7a7a7a;font-size:11px;padding-left: {label_w + 20}px;" + ) + right_column.addLayout(gu_section) + right_column.addWidget(gu_hint) + right_column.addStretch() + + columns_layout.addLayout(left_column, 1) + columns_layout.addLayout(right_column, 1) + layout.addLayout(columns_layout) + + layout.addSpacing(15) + button_layout = QHBoxLayout() + button_layout.setSpacing(10) + + from gui import qt_icons + + reset_button = QPushButton() + qt_icons.apply_button_icon(reset_button, "🔄 Reset to Defaults", color="#cccccc") + reset_button.clicked.connect(self.reset_to_defaults_with_save) + reset_button.setMinimumHeight(32) + + save_button = QPushButton() + qt_icons.apply_button_icon(save_button, "💾 Save Changes", color="#cccccc") + 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 _active_api_key_secret(self) -> str: + """Return the secret for the currently selected named key.""" + name = self.api_key_combo.currentText().strip() + if not name: + return api_key_vault.get_active_secret() + secret = api_key_vault.get_secret(name) + return secret if secret is not None else "" + + def _apply_key_endpoint(self, name: str | None = None): + """If the named key has an endpoint, apply it to the API URL field.""" + key_name = (name or self.api_key_combo.currentText()).strip() + if not key_name: + return + endpoint = api_key_vault.get_endpoint(key_name) + if not endpoint: + return + self.api_url_edit.blockSignals(True) + self.api_url_edit.setText(endpoint) + self.api_url_edit.blockSignals(False) + self._update_model_placeholder() + + def _refresh_api_key_combo(self, preferred: str | None = None): + """Reload named keys into the dropdown without firing autosave.""" + api_key_vault.ensure_vault(env_path=self.env_file_path) + names = api_key_vault.list_names() + active = preferred or api_key_vault.get_active_name() + self.api_key_combo.blockSignals(True) + self.api_key_combo.clear() + if names: + self.api_key_combo.addItems(names) + if active and active in names: + self.api_key_combo.setCurrentText(active) + else: + self.api_key_combo.setCurrentIndex(0) + else: + self.api_key_combo.setPlaceholderText("No keys saved - click New") + self.api_key_combo.blockSignals(False) + self.api_key_delete_btn.setEnabled(bool(names)) + self._apply_key_endpoint() + + def _on_api_key_selected(self, _name: str = ""): + """Persist dropdown selection as the active vault key and mirror to .env.""" + name = self.api_key_combo.currentText().strip() + if not name: + return + try: + api_key_vault.set_active(name) + self._apply_key_endpoint(name) + api_key_vault.sync_active_to_env(env_path=self.env_file_path) + except KeyError: + return + self.auto_save() + + def _on_api_key_new(self): + """Open dialog to create or overwrite a named API key.""" + current = self.api_key_combo.currentText().strip() + existing_endpoint = api_key_vault.get_endpoint(current) or "" if current else "" + dialog = ApiKeyEditDialog( + self, + initial_name=current, + initial_endpoint=existing_endpoint or self.api_url_edit.text().strip(), + allow_blank_secret=bool(current and api_key_vault.get_secret(current)), + ) + if dialog.exec_() != QDialog.Accepted: + return + name, secret, endpoint = dialog.result_values() + try: + api_key_vault.upsert_key( + name, + secret, + endpoint=endpoint, + make_active=True, + keep_secret_if_blank=True, + ) + self._apply_key_endpoint(name) + api_key_vault.sync_active_to_env(env_path=self.env_file_path) + except ValueError as exc: + QMessageBox.warning(self, "API Key", str(exc)) + return + self._refresh_api_key_combo(preferred=name) + self.auto_save() + + def _on_api_key_delete(self): + """Delete the selected named key after confirmation.""" + name = self.api_key_combo.currentText().strip() + if not name: + return + reply = QMessageBox.question( + self, + "Delete API Key", + f'Remove saved key "{name}"?\nThe secret will be deleted from this machine.', + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + api_key_vault.delete_key(name) + api_key_vault.sync_active_to_env(env_path=self.env_file_path) + self._refresh_api_key_combo() + self.auto_save() + + def fetch_models(self, silent=False): + """Kick off background fetch of models from the configured API.""" + api_key = self._active_api_key_secret() + api_url = self.api_url_edit.text().strip() + + if not api_key: + if not silent: + QMessageBox.warning( + self, "No API Key", + "Please add or select 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._refresh_api_key_combo() + 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"))) + self.convert_quotes_cb.setChecked( + _get("convertQuotes", "true").strip().lower() in ("true", "1", "yes") + ) + + # 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"))) + + # Game Update defaults + from util.gameupdate_config import ( + DEFAULT_BRANCH, + DEFAULT_FORGE, + default_host_for_forge, + normalize_forge, + ) + + forge = normalize_forge(_get("gameUpdateForge", DEFAULT_FORGE)) + forge_idx = self.gu_forge_combo.findData(forge) + self.gu_forge_combo.setCurrentIndex(forge_idx if forge_idx >= 0 else 0) + host = _get("gameUpdateHost", "") or default_host_for_forge(forge) + self.gu_host_edit.setText(host) + self.gu_username_edit.setText(_get("gameUpdateUsername", "")) + self.gu_branch_edit.setText(_get("gameUpdateBranch", DEFAULT_BRANCH) or DEFAULT_BRANCH) + + def _on_gu_forge_changed(self, _index: int = 0): + """Fill host with the forge default when the user switches forge.""" + from util.gameupdate_config import default_host_for_forge + + forge = self.gu_forge_combo.currentData() or "gitlab" + current = self.gu_host_edit.text().strip() + defaults = { + "gitgud.io", + "gitlab.com", + "github.com", + "codeberg.org", + "", + } + if current.lower() in defaults: + self.gu_host_edit.setText(default_host_for_forge(str(forge))) + + 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_combo.currentTextChanged.connect(self._on_api_key_selected) + + + # 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.convert_quotes_cb.stateChanged.connect(self._on_convert_quotes_changed) + 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) + self.gu_forge_combo.currentIndexChanged.connect(self.auto_save) + self.gu_host_edit.editingFinished.connect(self.auto_save) + self.gu_username_edit.editingFinished.connect(self.auto_save) + self.gu_branch_edit.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_combo.currentTextChanged.disconnect(self._on_api_key_selected) + + 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.convert_quotes_cb.stateChanged.disconnect(self._on_convert_quotes_changed) + 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) + self.gu_forge_combo.currentIndexChanged.disconnect(self.auto_save) + self.gu_host_edit.editingFinished.disconnect(self.auto_save) + self.gu_username_edit.editingFinished.disconnect(self.auto_save) + self.gu_branch_edit.editingFinished.disconnect(self.auto_save) + except (TypeError, RuntimeError): + pass + + def _on_convert_quotes_changed(self, state): + """Warn when quote conversion is disabled, then auto-save.""" + if state == Qt.Unchecked: + QMessageBox.warning( + self, + "Convert Quotes Disabled", + "The AI often struggles to keep 「」 and 『』 consistent " + "across translated lines.\n\n" + "Leaving conversion enabled is recommended unless you " + "specifically need the Japanese brackets in the output.", + ) + self.auto_save() + + 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._active_api_key_secret(), + "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()), + "convertQuotes": "true" if self.convert_quotes_cb.isChecked() else "false", + "input_cost": str(self.input_cost_spin.value()), + "output_cost": str(self.output_cost_spin.value()), + "font_scale": str(self.font_scale_spin.value()), + "gameUpdateForge": str(self.gu_forge_combo.currentData() or "gitlab"), + "gameUpdateHost": self.gu_host_edit.text().strip(), + "gameUpdateUsername": self.gu_username_edit.text().strip(), + "gameUpdateBranch": self.gu_branch_edit.text().strip() or "main", + } + + # 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._refresh_api_key_combo() + 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) + self.convert_quotes_cb.setChecked(True) + + # UI settings + self.font_scale_spin.setValue(1.0) + + # Game Update defaults + from util.gameupdate_config import DEFAULT_BRANCH, DEFAULT_FORGE, DEFAULT_HOST + + forge_idx = self.gu_forge_combo.findData(DEFAULT_FORGE) + self.gu_forge_combo.setCurrentIndex(forge_idx if forge_idx >= 0 else 0) + self.gu_host_edit.setText(DEFAULT_HOST) + self.gu_username_edit.clear() + self.gu_branch_edit.setText(DEFAULT_BRANCH) + + 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._active_api_key_secret(), + "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(), + "convertQuotes": self.convert_quotes_cb.isChecked(), + "input_cost": self.input_cost_spin.value(), + "output_cost": self.output_cost_spin.value(), + "font_scale": self.font_scale_spin.value(), + "gameUpdateForge": str(self.gu_forge_combo.currentData() or "gitlab"), + "gameUpdateHost": self.gu_host_edit.text().strip(), + "gameUpdateUsername": self.gu_username_edit.text().strip(), + "gameUpdateBranch": self.gu_branch_edit.text().strip() or "main", + } + + def validate(self): + """Validate the current configuration.""" + errors = [] + + # Check required fields + if not self._active_api_key_secret(): + 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 diff --git a/gui/main.py b/gui/main.py index a0be7a5..e610c5e 100644 --- a/gui/main.py +++ b/gui/main.py @@ -804,6 +804,15 @@ class DazedMTLGUI(QMainWindow): font.setPointSize(max(6, int(9 * scale_factor))) app.setFont(font) + # Keep line edits / combos tall enough for the scaled font. + from PyQt5.QtGui import QFontMetrics + from PyQt5.QtWidgets import QComboBox, QDoubleSpinBox, QLineEdit, QSpinBox + + control_h = max(30, QFontMetrics(font).height() + 14) + for widget in app.allWidgets(): + if isinstance(widget, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)): + widget.setMinimumHeight(control_h) + # Scale inline font-size values in every widget's individual stylesheet. # We store the *original* stylesheet on each widget (using a Qt property) # so that re-scaling always starts from the unmodified values. @@ -1309,6 +1318,7 @@ def main(): migrate_app_settings() app = QApplication(sys.argv) + app.setStyle("Fusion") # Windows taskbar groups by executable (python.exe) unless we set an explicit # AppUserModelID; combine with QApplication window icon so the pinned icon @@ -1437,7 +1447,8 @@ def main(): background-color: #404040; color: #ffffff; border: 1px solid #555555; - padding: 5px 8px; + padding: 1px 10px; + min-height: 30px; border-radius: 2px; selection-background-color: #007acc; } @@ -1448,7 +1459,8 @@ def main(): background-color: #404040; color: #ffffff; border: 1px solid #555555; - padding: 5px 8px; + padding: 1px 10px; + min-height: 30px; border-radius: 2px; } QSpinBox:focus, QDoubleSpinBox:focus { @@ -1468,30 +1480,24 @@ def main(): background-color: #404040; color: #ffffff; border: 1px solid #555555; - padding: 5px; - padding-right: 30px; + padding: 1px 8px; + min-height: 30px; border-radius: 2px; } QComboBox:focus { border: 1px solid #007acc; } QComboBox::drop-down { - subcontrol-origin: padding; - subcontrol-position: center right; - width: 25px; + subcontrol-origin: border; + subcontrol-position: top right; + width: 20px; + border: none; border-left: 1px solid #555555; - background-color: #555555; + background-color: #4a4a4a; } QComboBox::drop-down:hover { background-color: #007acc; } - QComboBox::down-arrow { - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 6px solid #ffffff; - } QComboBox QAbstractItemView { background-color: #404040; color: #ffffff; diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index 44cbc19..78dd248 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -47,13 +47,18 @@ class ApiKeyVaultTests(unittest.TestCase): self.assertEqual(api_keys.get_active_secret(self.vault_path), "") def test_migrate_from_env_when_vault_empty(self): - self.env_path.write_text('key="sk-from-env"\n', encoding="utf-8") + self.env_path.write_text( + 'key="sk-from-env"\napi="https://api.example.com/v1"\n', + encoding="utf-8", + ) vault = api_keys.migrate_from_env_if_empty( vault_path=self.vault_path, env_path=self.env_path, ) self.assertEqual(vault["active"], api_keys.DEFAULT_KEY_NAME) - self.assertEqual(vault["keys"][api_keys.DEFAULT_KEY_NAME], "sk-from-env") + entry = vault["keys"][api_keys.DEFAULT_KEY_NAME] + self.assertEqual(entry["secret"], "sk-from-env") + self.assertEqual(entry["endpoint"], "https://api.example.com/v1") # Second call must not overwrite existing vault entries. self.env_path.write_text('key="sk-other"\n', encoding="utf-8") @@ -61,7 +66,9 @@ class ApiKeyVaultTests(unittest.TestCase): vault_path=self.vault_path, env_path=self.env_path, ) - self.assertEqual(vault2["keys"][api_keys.DEFAULT_KEY_NAME], "sk-from-env") + self.assertEqual( + vault2["keys"][api_keys.DEFAULT_KEY_NAME]["secret"], "sk-from-env" + ) def test_migrate_skips_placeholder_env_key(self): vault = api_keys.migrate_from_env_if_empty( @@ -71,7 +78,12 @@ class ApiKeyVaultTests(unittest.TestCase): self.assertEqual(vault["keys"], {}) def test_sync_active_to_env(self): - api_keys.upsert_key("Work", "sk-work", path=self.vault_path) + api_keys.upsert_key( + "Work", + "sk-work", + endpoint="https://api.work.test/v1", + path=self.vault_path, + ) with patch.dict(os.environ, {}, clear=False): secret = api_keys.sync_active_to_env( vault_path=self.vault_path, @@ -79,14 +91,67 @@ class ApiKeyVaultTests(unittest.TestCase): ) self.assertEqual(secret, "sk-work") self.assertEqual(os.environ.get("key"), "sk-work") + self.assertEqual(os.environ.get("api"), "https://api.work.test/v1") text = self.env_path.read_text(encoding="utf-8") self.assertIn("sk-work", text) + self.assertIn("https://api.work.test/v1", text) + + def test_sync_without_endpoint_leaves_api_alone(self): + self.env_path.write_text('api="https://keep.me/v1"\nkey="old"\n', encoding="utf-8") + api_keys.upsert_key("Local", "sk-local", path=self.vault_path) + with patch.dict(os.environ, {"api": "https://keep.me/v1"}, clear=False): + api_keys.sync_active_to_env( + vault_path=self.vault_path, + env_path=self.env_path, + ) + text = self.env_path.read_text(encoding="utf-8") + self.assertIn("https://keep.me/v1", text) + self.assertEqual(os.environ.get("api"), "https://keep.me/v1") + + def test_endpoint_roundtrip_and_legacy_string_entries(self): + self.vault_path.write_text( + json.dumps({"active": "Legacy", "keys": {"Legacy": "sk-legacy"}}), + encoding="utf-8", + ) + self.assertEqual(api_keys.get_secret("Legacy", self.vault_path), "sk-legacy") + self.assertEqual(api_keys.get_endpoint("Legacy", self.vault_path), "") + + api_keys.upsert_key( + "Claude", + "sk-claude", + endpoint="https://api.anthropic.com/v1", + path=self.vault_path, + ) + self.assertEqual( + api_keys.get_endpoint("Claude", self.vault_path), + "https://api.anthropic.com/v1", + ) + data = json.loads(self.vault_path.read_text(encoding="utf-8")) + self.assertEqual(data["keys"]["Claude"]["endpoint"], "https://api.anthropic.com/v1") + # Legacy string entry is normalized on next save. + api_keys.set_active("Legacy", path=self.vault_path) + data2 = json.loads(self.vault_path.read_text(encoding="utf-8")) + self.assertEqual(data2["keys"]["Legacy"]["secret"], "sk-legacy") + + def test_keep_secret_if_blank_updates_endpoint(self): + api_keys.upsert_key("A", "sk-a", path=self.vault_path) + api_keys.upsert_key( + "A", + "", + endpoint="https://api.a.test/v1", + path=self.vault_path, + keep_secret_if_blank=True, + ) + self.assertEqual(api_keys.get_secret("A", self.vault_path), "sk-a") + self.assertEqual( + api_keys.get_endpoint("A", self.vault_path), "https://api.a.test/v1" + ) def test_save_writes_json_and_restrictive_mode(self): api_keys.upsert_key("X", "sk-x", path=self.vault_path) data = json.loads(self.vault_path.read_text(encoding="utf-8")) self.assertEqual(data["active"], "X") - self.assertEqual(data["keys"]["X"], "sk-x") + self.assertEqual(data["keys"]["X"]["secret"], "sk-x") mode = self.vault_path.stat().st_mode & 0o777 # Best-effort: owner read/write only when chmod is honored. self.assertEqual(mode & 0o077, 0) diff --git a/util/api_keys.py b/util/api_keys.py index da17df3..610e94d 100644 --- a/util/api_keys.py +++ b/util/api_keys.py @@ -2,6 +2,9 @@ Secrets live in ``data/api_keys.json`` (user-local). The active secret is also mirrored to ``.env`` ``key=`` so translation and subprocesses keep working. + +Each named entry may optionally include an ``endpoint`` (API base URL). When the +active key has one, it is mirrored to ``.env`` ``api=`` as well. """ from __future__ import annotations @@ -22,6 +25,20 @@ def _empty_vault() -> dict[str, Any]: return {"active": "", "keys": {}} +def _normalize_entry(raw: Any) -> dict[str, str]: + """Accept legacy plain-string secrets or ``{secret, endpoint}`` objects.""" + if isinstance(raw, str): + return {"secret": raw, "endpoint": ""} + if isinstance(raw, dict): + secret = raw.get("secret", raw.get("key", "")) + endpoint = raw.get("endpoint", raw.get("api", "")) + return { + "secret": "" if secret is None else str(secret), + "endpoint": ("" if endpoint is None else str(endpoint)).strip(), + } + return {"secret": "", "endpoint": ""} + + def load_vault(path: Path | None = None) -> dict[str, Any]: """Load vault JSON. Returns ``{"active": "", "keys": {}}`` when missing/invalid.""" vault_path = path or API_KEYS_PATH @@ -36,13 +53,14 @@ def load_vault(path: Path | None = None) -> dict[str, Any]: keys = data.get("keys") if not isinstance(keys, dict): keys = {} - cleaned: dict[str, str] = {} - for name, secret in keys.items(): + cleaned: dict[str, dict[str, str]] = {} + for name, raw in keys.items(): if not isinstance(name, str) or not name.strip(): continue - if secret is None: + entry = _normalize_entry(raw) + if not entry["secret"]: continue - cleaned[name.strip()] = str(secret) + cleaned[name.strip()] = entry active = data.get("active") or "" if not isinstance(active, str): active = "" @@ -58,9 +76,18 @@ def save_vault(vault: dict[str, Any], path: Path | None = None) -> None: """Write vault JSON with restrictive permissions when the OS allows.""" vault_path = path or API_KEYS_PATH vault_path.parent.mkdir(parents=True, exist_ok=True) + keys_out: dict[str, dict[str, str]] = {} + for name, raw in dict(vault.get("keys") or {}).items(): + entry = _normalize_entry(raw) + if not entry["secret"]: + continue + keys_out[str(name)] = { + "secret": entry["secret"], + "endpoint": entry["endpoint"], + } payload = { "active": str(vault.get("active") or ""), - "keys": dict(vault.get("keys") or {}), + "keys": keys_out, } tmp = vault_path.with_suffix(vault_path.suffix + ".tmp") tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") @@ -85,38 +112,73 @@ def get_active_name(path: Path | None = None) -> str: return str(load_vault(path).get("active") or "") +def get_entry(name: str, path: Path | None = None) -> dict[str, str] | None: + keys = load_vault(path).get("keys") or {} + entry = keys.get(name) + if entry is None: + return None + return _normalize_entry(entry) + + def get_active_secret(path: Path | None = None) -> str: vault = load_vault(path) active = vault.get("active") or "" keys = vault.get("keys") or {} if active and active in keys: - return str(keys[active]) + return _normalize_entry(keys[active])["secret"] return "" def get_secret(name: str, path: Path | None = None) -> str | None: - keys = load_vault(path).get("keys") or {} - if name not in keys: + entry = get_entry(name, path) + if entry is None: return None - return str(keys[name]) + return entry["secret"] + + +def get_endpoint(name: str, path: Path | None = None) -> str | None: + """Return the optional endpoint for *name*, or None if the key is unknown.""" + entry = get_entry(name, path) + if entry is None: + return None + return entry["endpoint"] + + +def get_active_endpoint(path: Path | None = None) -> str: + vault = load_vault(path) + active = vault.get("active") or "" + if not active: + return "" + return get_endpoint(active, path) or "" def upsert_key( name: str, secret: str, *, + endpoint: str = "", make_active: bool = True, path: Path | None = None, + keep_secret_if_blank: bool = False, ) -> dict[str, Any]: - """Create or replace a named key. Optionally make it active.""" + """Create or replace a named key. Optionally make it active. + + When *keep_secret_if_blank* is true and *secret* is empty, an existing + entry's secret is preserved (useful for editing endpoint only). + """ name = (name or "").strip() if not name: raise ValueError("Key name is required") secret = (secret or "").strip() - if not secret: - raise ValueError("API key secret is required") + endpoint = (endpoint or "").strip() vault = load_vault(path) - vault["keys"][name] = secret + existing = vault["keys"].get(name) + if not secret: + if keep_secret_if_blank and existing: + secret = _normalize_entry(existing)["secret"] + else: + raise ValueError("API key secret is required") + vault["keys"][name] = {"secret": secret, "endpoint": endpoint} if make_active or not vault.get("active"): vault["active"] = name save_vault(vault, path) @@ -152,16 +214,21 @@ def sync_active_to_env( ) -> str: """Write the active vault secret to ``.env`` ``key=`` and ``os.environ``. + When the active key has an endpoint, also write ``api=``. Returns the secret that was written (may be empty). """ from dotenv import set_key secret = get_active_secret(vault_path) + endpoint = get_active_endpoint(vault_path) target = env_path or ENV_PATH if not target.exists(): target.touch() set_key(target, "key", secret) os.environ["key"] = secret + if endpoint: + set_key(target, "api", endpoint) + os.environ["api"] = endpoint return secret @@ -170,24 +237,30 @@ def migrate_from_env_if_empty( vault_path: Path | None = None, env_path: Path | None = None, env_key: str | None = None, + env_api: str | None = None, ) -> dict[str, Any]: - """If the vault has no keys, import ``key`` from ``.env`` as ``Default``. + """If the vault has no keys, import ``key`` / ``api`` from ``.env`` as ``Default``. - ``env_key`` overrides reading from disk (useful in tests). + ``env_key`` / ``env_api`` override reading from disk (useful in tests). """ vault = load_vault(vault_path) if vault["keys"]: return vault secret = env_key - if secret is None: + endpoint = env_api + if secret is None or endpoint is None: from dotenv import dotenv_values target = env_path or ENV_PATH values = dotenv_values(target) if target.is_file() else {} - secret = (values.get("key") or os.getenv("key") or "").strip() + if secret is None: + secret = (values.get("key") or os.getenv("key") or "").strip() + if endpoint is None: + endpoint = (values.get("api") or os.getenv("api") or "").strip() else: secret = str(secret).strip() + endpoint = str(endpoint or "").strip() if not secret or secret.startswith("<"): return vault @@ -195,6 +268,7 @@ def migrate_from_env_if_empty( return upsert_key( DEFAULT_KEY_NAME, secret, + endpoint=endpoint or "", make_active=True, path=vault_path, )