- Reorganize settings into an even, scroll-free card grid - Use uniform controls, API buttons, and opaque dropdown popups - Add regression coverage for layout, persistence, and defaults - Restore Git archive SHA substitution metadata
1450 lines
60 KiB
Python
1450 lines
60 KiB
Python
"""
|
|
Configuration Tab - Handles environment variables, global settings, and engine configurations
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from PyQt5.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, QLineEdit,
|
|
QSpinBox, QDoubleSpinBox, QComboBox, QPushButton, QGroupBox,
|
|
QLabel, QFileDialog, QMessageBox, QTextEdit,
|
|
QCheckBox, QApplication, QTabWidget, QFrame, QStackedWidget, QToolButton,
|
|
QMenu, QDialog, QDialogButtonBox, QListView, QSizePolicy,
|
|
)
|
|
from PyQt5.QtCore import Qt, QPoint, pyqtSignal, QTimer, QThread
|
|
from PyQt5.QtGui import QColor, QIcon, QPainter, QPalette, QPolygon
|
|
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
|
|
# The SDK requires a non-empty value even when a local server ignores
|
|
# authentication entirely.
|
|
kwargs = {"api_key": self.api_key or "not-needed"}
|
|
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 ConfigComboBox(QComboBox):
|
|
"""Combo box with a platform-independent visible dropdown indicator."""
|
|
|
|
_POPUP_COLOR = QColor("#353539")
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
view = QListView()
|
|
view.setObjectName("configComboPopup")
|
|
view.setAutoFillBackground(True)
|
|
view.setAttribute(Qt.WA_StyledBackground, True)
|
|
view.viewport().setAutoFillBackground(True)
|
|
view.viewport().setAttribute(Qt.WA_StyledBackground, True)
|
|
view.setStyleSheet("""
|
|
QListView#configComboPopup {
|
|
background-color: #353539;
|
|
color: #f2f2f2;
|
|
border: 1px solid #56565b;
|
|
outline: none;
|
|
padding: 2px;
|
|
}
|
|
QListView#configComboPopup::item {
|
|
background-color: #353539;
|
|
color: #f2f2f2;
|
|
min-height: 26px;
|
|
padding: 3px 8px;
|
|
}
|
|
QListView#configComboPopup::item:selected {
|
|
background-color: #007acc;
|
|
color: #ffffff;
|
|
}
|
|
QListView#configComboPopup::item:hover:!selected {
|
|
background-color: #45454a;
|
|
}
|
|
""")
|
|
self.setView(view)
|
|
self._apply_popup_palette()
|
|
|
|
def _apply_popup_palette(self):
|
|
for widget in (self.view(), self.view().viewport()):
|
|
widget.setAutoFillBackground(True)
|
|
widget.setAttribute(Qt.WA_StyledBackground, True)
|
|
widget.setAttribute(Qt.WA_OpaquePaintEvent, True)
|
|
palette = widget.palette()
|
|
palette.setColor(QPalette.Base, self._POPUP_COLOR)
|
|
palette.setColor(QPalette.Window, self._POPUP_COLOR)
|
|
palette.setColor(QPalette.AlternateBase, self._POPUP_COLOR)
|
|
palette.setColor(QPalette.Text, QColor("#f2f2f2"))
|
|
palette.setColor(QPalette.Highlight, QColor("#007acc"))
|
|
palette.setColor(QPalette.HighlightedText, QColor("#ffffff"))
|
|
widget.setPalette(palette)
|
|
|
|
def showPopup(self):
|
|
self._apply_popup_palette()
|
|
super().showPopup()
|
|
self._apply_popup_palette()
|
|
popup = self.view().window()
|
|
popup.setWindowOpacity(1.0)
|
|
popup.setAttribute(Qt.WA_TranslucentBackground, False)
|
|
popup.setAutoFillBackground(True)
|
|
palette = popup.palette()
|
|
palette.setColor(QPalette.Window, self._POPUP_COLOR)
|
|
palette.setColor(QPalette.Base, self._POPUP_COLOR)
|
|
popup.setPalette(palette)
|
|
|
|
def paintEvent(self, event):
|
|
super().paintEvent(event)
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.Antialiasing)
|
|
painter.setPen(Qt.NoPen)
|
|
painter.setBrush(QColor("#d8d8d8" if self.isEnabled() else "#858585"))
|
|
center_x = self.width() - 13
|
|
center_y = self.height() // 2 + 1
|
|
painter.drawPolygon(QPolygon([
|
|
QPoint(center_x - 4, center_y - 2),
|
|
QPoint(center_x + 4, center_y - 2),
|
|
QPoint(center_x, center_y + 3),
|
|
]))
|
|
|
|
|
|
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,
|
|
initial_keyless: 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)
|
|
|
|
self.keyless_cb = QCheckBox("No API key required (local model)")
|
|
self.keyless_cb.setChecked(initial_keyless)
|
|
self.keyless_cb.setToolTip(
|
|
"Use this for a local OpenAI-compatible server that does not require authentication."
|
|
)
|
|
self.keyless_cb.toggled.connect(self._on_keyless_toggled)
|
|
form.addRow("", self.keyless_cb)
|
|
|
|
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._keyless = False
|
|
self._on_keyless_toggled(initial_keyless)
|
|
self.secret_edit.setFocus()
|
|
|
|
def _on_keyless_toggled(self, checked: bool):
|
|
self.secret_edit.setEnabled(not checked)
|
|
if checked:
|
|
self.secret_edit.clear()
|
|
self.secret_edit.setPlaceholderText("Not used for this local endpoint")
|
|
elif self._allow_blank_secret:
|
|
self.secret_edit.setPlaceholderText("Leave blank to keep the existing secret")
|
|
else:
|
|
self.secret_edit.setPlaceholderText("Paste API key secret")
|
|
|
|
def _accept_if_valid(self):
|
|
name = self.name_edit.text().strip()
|
|
secret = self.secret_edit.text().strip()
|
|
endpoint = self.endpoint_edit.text().strip()
|
|
keyless = self.keyless_cb.isChecked()
|
|
if not name:
|
|
QMessageBox.warning(self, "API Key", "Enter a name for this key.")
|
|
self.name_edit.setFocus()
|
|
return
|
|
if not secret and not keyless and not self._allow_blank_secret:
|
|
QMessageBox.warning(self, "API Key", "Enter the API key secret.")
|
|
self.secret_edit.setFocus()
|
|
return
|
|
if keyless and not endpoint:
|
|
QMessageBox.warning(
|
|
self, "API Key", "Enter the local model server's API endpoint."
|
|
)
|
|
self.endpoint_edit.setFocus()
|
|
return
|
|
self._name = name
|
|
self._secret = secret
|
|
self._endpoint = endpoint
|
|
self._keyless = keyless
|
|
self.accept()
|
|
|
|
def result_values(self) -> tuple[str, str, str, bool]:
|
|
return self._name, self._secret, self._endpoint, self._keyless
|
|
|
|
|
|
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 a compact general-settings dashboard without nested scrolling."""
|
|
widget = QWidget()
|
|
widget.setObjectName("generalSettingsPage")
|
|
widget.setStyleSheet("""
|
|
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {
|
|
background-color: #3e3e42;
|
|
color: #f2f2f2;
|
|
border: 1px solid #56565b;
|
|
border-radius: 4px;
|
|
padding: 1px 9px;
|
|
min-height: 30px;
|
|
selection-background-color: #007acc;
|
|
}
|
|
QLineEdit:hover, QComboBox:hover,
|
|
QSpinBox:hover, QDoubleSpinBox:hover {
|
|
border-color: #68686e;
|
|
}
|
|
QLineEdit:focus, QComboBox:focus,
|
|
QSpinBox:focus, QDoubleSpinBox:focus {
|
|
border-color: #007acc;
|
|
background-color: #424247;
|
|
}
|
|
QComboBox::drop-down {
|
|
subcontrol-origin: border;
|
|
subcontrol-position: top right;
|
|
width: 26px;
|
|
border: none;
|
|
border-left: 1px solid #56565b;
|
|
border-top-right-radius: 4px;
|
|
border-bottom-right-radius: 4px;
|
|
background-color: #4a4a50;
|
|
}
|
|
QComboBox::drop-down:hover {
|
|
background-color: #007acc;
|
|
}
|
|
QComboBox QLineEdit {
|
|
background-color: #3e3e42;
|
|
border: none;
|
|
border-radius: 0;
|
|
padding: 0 4px;
|
|
}
|
|
QComboBox QAbstractItemView, QMenu {
|
|
background-color: #353539;
|
|
color: #f2f2f2;
|
|
border: 1px solid #56565b;
|
|
selection-background-color: #007acc;
|
|
selection-color: #ffffff;
|
|
outline: none;
|
|
}
|
|
QComboBox QAbstractItemView::item, QMenu::item {
|
|
min-height: 26px;
|
|
padding: 3px 9px;
|
|
}
|
|
QMenu::item:selected {
|
|
background-color: #007acc;
|
|
}
|
|
QLineEdit:disabled, QComboBox:disabled,
|
|
QSpinBox:disabled, QDoubleSpinBox:disabled {
|
|
background-color: #343438;
|
|
color: #858585;
|
|
border-color: #47474b;
|
|
}
|
|
""")
|
|
page_layout = QVBoxLayout(widget)
|
|
page_layout.setContentsMargins(0, 0, 0, 0)
|
|
page_layout.setSpacing(0)
|
|
self._general_form_labels = []
|
|
self._general_cards_by_title = {}
|
|
self._general_label_width = None
|
|
|
|
btn_style = (
|
|
"QToolButton {"
|
|
"background-color: #3e3e42; color: #cccccc;"
|
|
"border: 1px solid #555555; border-radius: 4px; padding: 4px 8px;"
|
|
"}"
|
|
"QToolButton:hover { background-color: #505050; }"
|
|
"QToolButton::menu-indicator { image: none; }"
|
|
)
|
|
|
|
def size_field(w: QWidget, minimum_width: int = 120) -> QWidget:
|
|
"""Give controls room to breathe while allowing them to resize."""
|
|
w.setMinimumWidth(minimum_width)
|
|
w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
if isinstance(w, (QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox)):
|
|
w.setMinimumHeight(32)
|
|
return w
|
|
|
|
def row_box(field: QWidget, *actions: QWidget) -> QWidget:
|
|
"""API row with equal field width and no visible filler widget."""
|
|
box = QWidget()
|
|
box.setStyleSheet("background: transparent;")
|
|
row = QHBoxLayout(box)
|
|
row.setContentsMargins(0, 0, 0, 0)
|
|
row.setSpacing(8)
|
|
|
|
field.setMinimumWidth(0)
|
|
field.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
row.addWidget(field, 1)
|
|
|
|
used_action_width = 0
|
|
for action in actions:
|
|
action.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
|
row.addWidget(action)
|
|
used_action_width += action.width()
|
|
used_action_width += max(0, len(actions) - 1) * row.spacing()
|
|
row.addSpacing(max(0, 200 - used_action_width))
|
|
|
|
box.setMinimumHeight(32)
|
|
box.setMinimumWidth(120)
|
|
box.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
return box
|
|
|
|
def create_card(title: str) -> tuple[QFrame, QFormLayout]:
|
|
card = QFrame()
|
|
card.setObjectName("settingsCard")
|
|
self._general_cards_by_title[title] = card
|
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
|
card.setStyleSheet(
|
|
"QFrame#settingsCard {"
|
|
"background-color: #303033;"
|
|
"border: 1px solid #444448;"
|
|
"border-radius: 7px;"
|
|
"}"
|
|
"QFrame#settingsCard:hover { border-color: #505055; }"
|
|
)
|
|
|
|
card_layout = QVBoxLayout(card)
|
|
card_layout.setContentsMargins(14, 10, 14, 12)
|
|
card_layout.setSpacing(6)
|
|
header = create_section_header(title)
|
|
if not isinstance(header, QLabel):
|
|
header.setStyleSheet("background: transparent;")
|
|
card_layout.addWidget(header)
|
|
|
|
form = QFormLayout()
|
|
form.setContentsMargins(0, 0, 0, 0)
|
|
form.setHorizontalSpacing(12)
|
|
form.setVerticalSpacing(7)
|
|
form.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
|
form.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
|
|
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
|
form.setRowWrapPolicy(QFormLayout.WrapLongRows)
|
|
card_layout.addLayout(form)
|
|
card_layout.addStretch()
|
|
return card, form
|
|
|
|
def add_row(form: QFormLayout, label: str, field: QWidget) -> None:
|
|
label_widget = QLabel(label)
|
|
label_widget.setFixedWidth(180)
|
|
label_widget.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
|
label_widget.setStyleSheet("background: transparent;")
|
|
self._general_form_labels.append(label_widget)
|
|
form.addRow(label_widget, field)
|
|
|
|
# ── API ──────────────────────────────────────────────────────
|
|
api_card, api_section = create_card("🔑 API Configuration")
|
|
|
|
self.api_url_edit = QLineEdit()
|
|
self.api_url_edit.setPlaceholderText("Leave blank for OpenAI API")
|
|
|
|
self.api_url_preset_btn = QToolButton()
|
|
self.api_url_preset_btn.setText("Presets ▾")
|
|
self.api_url_preset_btn.setFixedWidth(96)
|
|
self.api_url_preset_btn.setPopupMode(QToolButton.InstantPopup)
|
|
self.api_url_preset_btn.setStyleSheet(btn_style)
|
|
|
|
api_url_menu = QMenu(self.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))
|
|
self.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, self.api_url_preset_btn),
|
|
)
|
|
|
|
self.api_key_combo = ConfigComboBox()
|
|
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(96)
|
|
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(96)
|
|
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 = ConfigComboBox()
|
|
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(96)
|
|
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),
|
|
)
|
|
|
|
# ── TRANSLATION + FORMATTING ─────────────────────────────────
|
|
translation_card, trans_section = create_card("🌐 Translation & Text")
|
|
|
|
self.language_combo = ConfigComboBox()
|
|
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))
|
|
|
|
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(trans_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(trans_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(trans_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."
|
|
)
|
|
add_row(trans_section, "Convert Quotes:", self.convert_quotes_cb)
|
|
|
|
# ── PERFORMANCE + PRICING ────────────────────────────────────
|
|
performance_card, perf_section = create_card("⚡ Performance & Pricing")
|
|
|
|
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),
|
|
)
|
|
|
|
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(perf_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(perf_section, "Output Cost:", size_field(self.output_cost_spin))
|
|
|
|
# ── INTERFACE ────────────────────────────────────────────────
|
|
interface_card, ui_section = create_card("🖥️ Interface")
|
|
|
|
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))
|
|
|
|
# ── GAME UPDATE ──────────────────────────────────────────────
|
|
updates_card, gu_section = create_card("📦 Game Update Defaults")
|
|
|
|
self.gu_forge_combo = ConfigComboBox()
|
|
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))
|
|
|
|
content = QWidget()
|
|
content.setObjectName("generalSettingsContent")
|
|
content_layout = QVBoxLayout(content)
|
|
content_layout.setContentsMargins(12, 10, 12, 10)
|
|
content_layout.setSpacing(0)
|
|
|
|
dashboard = QGridLayout()
|
|
dashboard.setContentsMargins(0, 4, 0, 0)
|
|
dashboard.setHorizontalSpacing(12)
|
|
dashboard.setVerticalSpacing(12)
|
|
dashboard.setColumnStretch(0, 1)
|
|
dashboard.setColumnStretch(1, 1)
|
|
dashboard.setColumnStretch(2, 1)
|
|
|
|
# The wider API card keeps its button clusters readable. The three
|
|
# denser sections share the second row so the whole page fits without
|
|
# its own scroll bar.
|
|
dashboard.addWidget(api_card, 0, 0, 1, 2)
|
|
dashboard.addWidget(interface_card, 0, 2, 1, 1)
|
|
dashboard.addWidget(translation_card, 1, 0, 1, 1)
|
|
dashboard.addWidget(performance_card, 1, 1, 1, 1)
|
|
dashboard.addWidget(updates_card, 1, 2, 1, 1)
|
|
content_layout.addLayout(dashboard)
|
|
content_layout.addStretch()
|
|
page_layout.addWidget(content, 1)
|
|
|
|
# Persistent action bar keeps save/reset in the same predictable place.
|
|
action_bar = QFrame()
|
|
action_bar.setObjectName("settingsActionBar")
|
|
action_bar.setStyleSheet(
|
|
"QFrame#settingsActionBar {"
|
|
"background-color: #252526; border-top: 1px solid #414141;"
|
|
"}"
|
|
)
|
|
button_layout = QHBoxLayout(action_bar)
|
|
button_layout.setContentsMargins(20, 9, 20, 9)
|
|
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)
|
|
reset_button.setStyleSheet(
|
|
"QPushButton { background: #3e3e42; color: #dddddd; "
|
|
"border: 1px solid #555555; }"
|
|
"QPushButton:hover { background: #505050; }"
|
|
)
|
|
|
|
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(self.autosave_label)
|
|
button_layout.addStretch()
|
|
button_layout.addWidget(reset_button)
|
|
button_layout.addWidget(save_button)
|
|
page_layout.addWidget(action_bar)
|
|
|
|
QTimer.singleShot(0, self._update_general_label_width)
|
|
return widget
|
|
|
|
def _update_general_label_width(self):
|
|
"""Keep every form aligned without crowding the minimum-width layout."""
|
|
if not hasattr(self, "_general_form_labels"):
|
|
return
|
|
label_width = 150 if self.width() < 1150 else 180
|
|
if label_width == self._general_label_width:
|
|
return
|
|
for label in self._general_form_labels:
|
|
label.setFixedWidth(label_width)
|
|
self._general_label_width = label_width
|
|
|
|
def resizeEvent(self, event):
|
|
super().resizeEvent(event)
|
|
if hasattr(self, "_general_form_labels"):
|
|
QTimer.singleShot(0, self._update_general_label_width)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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 _active_api_key_is_optional(self) -> bool:
|
|
"""Return whether the selected vault entry is explicitly keyless."""
|
|
name = self.api_key_combo.currentText().strip()
|
|
return api_key_vault.is_keyless(name) if name else False
|
|
|
|
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)),
|
|
initial_keyless=bool(current and api_key_vault.is_keyless(current)),
|
|
)
|
|
if dialog.exec_() != QDialog.Accepted:
|
|
return
|
|
name, secret, endpoint, keyless = dialog.result_values()
|
|
try:
|
|
api_key_vault.upsert_key(
|
|
name,
|
|
secret,
|
|
endpoint=endpoint,
|
|
make_active=True,
|
|
keep_secret_if_blank=True,
|
|
keyless=keyless,
|
|
)
|
|
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 and not self._active_api_key_is_optional():
|
|
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(),
|
|
"API_KEY_OPTIONAL": (
|
|
"true" if self._active_api_key_is_optional() else "false"
|
|
),
|
|
"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)
|
|
|
|
# Custom API pricing
|
|
self.input_cost_spin.setValue(2.0)
|
|
self.output_cost_spin.setValue(8.0)
|
|
|
|
# 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)
|
|
|
|
# Reset engine tabs
|
|
self.mvmz_tab.reset_to_defaults()
|
|
self.wolf_tab.reset_to_defaults()
|
|
self.csv_tab.reset_to_defaults()
|
|
self.srpg_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() and not self._active_api_key_is_optional():
|
|
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
|