diff --git a/README.md b/README.md index 2b766a9..9682e66 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ See it in action on YouTube: https://www.youtube.com/watch?v=Izu_8pql7cE - **Wayland Global Hotkey & Command OSD**: Trigger translation, OSD configurations, and sidebars seamlessly using customizable system-wide leader hotkeys. - **Dialogue Mode (Autoplay VNs / Story RPGs)**: Lock onto a screen region, translate automatically, and advance/refresh translations inline with a simple mouse click. - **Live In-Place Translation**: Lock onto a region and let MAGE paint translations directly over the original text — Google Lens style — refreshing as the screen changes, with no bubbles in the way. Two engines: a vision model that reads and translates in one call, or **local OCR** (PP-OCRv5 through ONNX Runtime) that reads on your machine and sends only the text — so a screen full of particle effects over unchanged dialogue costs nothing at all, and a line you have seen before costs nothing twice. Run `scripts/export_ppocr_onnx.py` once to fetch the models. -- **New Interface (optional)**: Instead of a leader key and a menu of letters, place **translation boxes** on screen and click an **orb**. Boxes stay where you put them, each with its own mode — Live, Once, or Ignore, which hides a ticking clock or an animated minimap from everything else. The orb is your translation log, a chat that already knows what is on screen, and a push-to-talk microphone. Turn it on in **Settings → Features**; the classic interface is untouched and stays the default. +- **Boxes and the orb**: Instead of a leader key and a menu of letters, place **translation boxes** on screen and click an **orb**. Boxes stay where you put them, each with its own mode — Live, Once, or Ignore, which hides a ticking clock or an animated minimap from everything else. The orb is your translation log, a chat that already knows what is on screen, and a push-to-talk microphone. It is the interface: the leader key and its menu of letters are gone. - **Session Memory**: MAGE remembers the text it has translated, the dialogue you have read, and the questions you have asked, so you can ask *"what do I do next?"* and get an answer grounded in your actual playthrough rather than generic wiki knowledge. - **NPU Offload (Ryzen AI)**: On XDNA 2 hardware, chat, translation, and speech can run on the NPU via Lemonade's FastFlowLM backend, leaving the GPU to the vision model. Check your machine with `./mage.sh --doctor-npu`. - **Visual Grounding Target Highlighting**: Ask the assistant *"where do I click?"* or *"where is the exit?"* and watch it highlight the exact physical coordinates on your screen. diff --git a/apps/mage-client/src/mage/app.py b/apps/mage-client/src/mage/app.py index a2601b1..2afe2fc 100644 --- a/apps/mage-client/src/mage/app.py +++ b/apps/mage-client/src/mage/app.py @@ -29,20 +29,14 @@ import time from PyQt6.QtWidgets import ( - QApplication, QSystemTrayIcon, QMenu, QDialog, QFormLayout, - QLineEdit, QComboBox, QSpinBox, QPushButton, QLabel, QVBoxLayout, - QHBoxLayout, QWidget, QCheckBox, QMessageBox, QInputDialog, QTabWidget, QSlider, - QPlainTextEdit, - QFileDialog + QApplication, QSystemTrayIcon, QMenu, QWidget, QMessageBox, QFileDialog ) -from PyQt6.QtCore import Qt, QSettings, QRect, QTimer, QStandardPaths, pyqtSignal +from PyQt6.QtCore import Qt, QSettings, QRect, QTimer, QStandardPaths from PyQt6.QtGui import QIcon, QCursor, QColor -from mage.ui.theme import accent_hex, accent_hover_hex from xian.pipeline import VLProcessor, VLConfig from xian.collections import ( - COLLECTIONS, build_pull_body, collection_for_name, get_collection, @@ -63,22 +57,21 @@ from mage.capture.screen import ScreenCapture from mage.capture.audio import play_audio_async, SerialAudioPlayer from xian.dictionary import LocalDictionary -from xian.lemonade_url import normalize_lemonade_api_base_url, should_warn_http_to_non_loopback from mage.ui.notes_sidebar import NotesSidebar from shared_types import constants -from shared_types.enums import SourceLanguage, TargetLanguage, TranslationMode, TranslationStyle from mage.settings_keys import ( - KEY_API_URL, KEY_API_MODEL, KEY_SOURCE_LANG, KEY_TARGET_LANG, - KEY_MODE, KEY_STYLES, KEY_MAX_TOKENS, KEY_LEADER_KEY, KEY_OVERLAY_TOGGLE_KEY, - KEY_GPU_UTIL, KEY_DIALOGUE_DELAY, + KEY_API_MODEL, KEY_SOURCE_LANG, KEY_TARGET_LANG, + KEY_MODE, KEY_MAX_TOKENS, KEY_LEADER_KEY, KEY_OVERLAY_TOGGLE_KEY, + KEY_DIALOGUE_DELAY, KEY_AUTO_CONTINUE, KEY_AUTO_SPEAK, KEY_TARGET_WINDOW_TITLE, KEY_UI_LANG, KEY_FAMILIAR_ENABLED, KEY_FAMILIAR_TTS, KEY_FAMILIAR_TYPE, KEY_FAMILIAR_CUSTOM_RECIPE, KEY_MEMORY_ENABLED, KEY_MEMORY_RETENTION_DAYS, KEY_BACKEND_PREFERENCE, KEY_NPU_POWER_MODE, KEY_LIVE_INTERVAL_MS, - KEY_COLLECTION_TIER, KEY_EXPERIMENTAL_LIVE, KEY_LIVE_ENGINE, - KEY_OCR_DETECTOR, KEY_IGNORE_PHRASES, KEY_TRANSLATION_MODEL, - DEFAULT_LIVE_ENGINE, LIVE_ENGINE_GROUNDING, LIVE_ENGINE_OCR, is_true, + KEY_COLLECTION_TIER, KEY_EXPERIMENTAL_LIVE, KEY_GPU_UTIL, + KEY_IGNORE_PHRASES, KEY_OCR_DETECTOR, + LIVE_ENGINE_OCR, is_true, normalized_api_url_from_settings, parse_styles, ) +from mage.ui.settings_dialog import SettingsDialog from mage.utils.window_binder import WindowBinder from shared_types.state import state, t @@ -89,575 +82,6 @@ MAX_AUTO_CONTINUES = 5 -def _normalized_api_url_from_settings(settings: QSettings) -> str: - return normalize_lemonade_api_base_url(str(settings.value(KEY_API_URL, constants.DEFAULT_API_URL))) - - -def _parse_styles(settings: QSettings) -> list[str]: - """Parse the styles QSettings value into a clean list of strings.""" - raw = settings.value(KEY_STYLES, constants.DEFAULT_STYLES) - if isinstance(raw, str): - return [s.strip() for s in raw.split(",") if s.strip()] - return raw if isinstance(raw, list) else [] - - -class SettingsDialog(QDialog): - """Small modal dialog for configuring the Lemonade backend.""" - - layout_edit_requested = pyqtSignal() - - #: Set on save when the collection tier changed, so the caller installs it. - tier_changed = False - - def __init__(self, settings: QSettings, models: list, parent=None, app=None): - super().__init__(parent) - self.setWindowTitle(t("settings.dialog.title")) - self.setMinimumWidth(450) - self.settings = settings - self.app = app - - main_layout = QVBoxLayout(self) - self.tabs = QTabWidget() - main_layout.addWidget(self.tabs) - - # Tab: General - general_tab = QWidget() - general_layout = QFormLayout(general_tab) - - self.ui_lang_combo = QComboBox() - self.ui_lang_combo.addItems(["en", "zh", "ja", "ko", "ru", "es", "ar", "hi", "vi"]) - self.ui_lang_combo.setCurrentText(settings.value(KEY_UI_LANG, "en")) - general_layout.addRow(t("settings.label.ui_language"), self.ui_lang_combo) - - self.target_window_combo = QComboBox() - self.target_window_combo.setEditable(True) - self.target_window_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) - self.target_window_combo.addItem(t("settings.option.none_overlay"), "") - try: - titles = WindowBinder.get_active_window_titles() - for title in titles: - self.target_window_combo.addItem(title, title) - except Exception as e: - logger.error("Could not fetch active window titles: %s", e) - - current_title = settings.value(KEY_TARGET_WINDOW_TITLE, "") - if current_title: - idx = self.target_window_combo.findData(current_title) - if idx >= 0: - self.target_window_combo.setCurrentIndex(idx) - else: - self.target_window_combo.addItem(current_title, current_title) - self.target_window_combo.setCurrentText(current_title) - else: - self.target_window_combo.setCurrentIndex(0) - general_layout.addRow(t("settings.label.target_window_title"), self.target_window_combo) - - preset_layout = QHBoxLayout() - self.preset_combo = QComboBox() - presets = self.settings.value("layout_presets_list", ["Default"]) - if not isinstance(presets, list): - presets = ["Default"] - self.preset_combo.addItems(presets) - current_preset = self.settings.value("layout_preset", "Default") - idx = self.preset_combo.findText(current_preset) - if idx >= 0: - self.preset_combo.setCurrentIndex(idx) - - self.add_preset_btn = QPushButton("+") - self.add_preset_btn.setFixedWidth(30) - self.add_preset_btn.clicked.connect(self._add_preset) - - self.del_preset_btn = QPushButton("-") - self.del_preset_btn.setFixedWidth(30) - self.del_preset_btn.clicked.connect(self._del_preset) - - self.edit_layout_btn = QPushButton(t("settings.button.edit_layout")) - self.edit_layout_btn.clicked.connect(self._on_edit_layout) - - preset_layout.addWidget(self.preset_combo) - preset_layout.addWidget(self.add_preset_btn) - preset_layout.addWidget(self.del_preset_btn) - preset_layout.addWidget(self.edit_layout_btn) - general_layout.addRow(t("settings.label.layout_preset"), preset_layout) - - self.tabs.addTab(general_tab, "General") - - # Tab: Backend - backend_tab = QWidget() - backend_layout = QFormLayout(backend_tab) - - self.url_edit = QLineEdit() - self.url_edit.setText(_normalized_api_url_from_settings(settings)) - backend_layout.addRow(t("settings.label.server_url"), self.url_edit) - - self.model_combo = QComboBox() - self.model_combo.setEditable(True) - if models: - self.model_combo.addItems(models) - self.model_combo.setCurrentText(settings.value(KEY_API_MODEL, constants.DEFAULT_MODEL)) - backend_layout.addRow(t("settings.label.model"), self.model_combo) - - # Which Xian collection to install. Picking one here re-registers it and - # points the model above at it; the model field stays editable for - # anyone who would rather drive a model of their own choosing. - self.tier_combo = QComboBox() - for tier, collection in COLLECTIONS.items(): - label = f"{t(f'settings.option.collection.{tier.value}')} — {collection.size_gb:.1f} GB" - self.tier_combo.addItem(label, tier.value) - self._initial_tier = settings.value(KEY_COLLECTION_TIER, constants.DEFAULT_COLLECTION_TIER) - tier_idx = self.tier_combo.findData(self._initial_tier) - if tier_idx >= 0: - self.tier_combo.setCurrentIndex(tier_idx) - self.tier_combo.setToolTip(t("settings.tooltip.collection_tier")) - backend_layout.addRow(t("settings.label.collection_tier"), self.tier_combo) - - self.tokens_spin = QSpinBox() - self.tokens_spin.setRange(256, 32768) - self.tokens_spin.setValue(int(settings.value(KEY_MAX_TOKENS, constants.DEFAULT_MAX_TOKENS))) - backend_layout.addRow(t("settings.label.max_tokens"), self.tokens_spin) - - self.gpu_combo = QComboBox() - self.gpu_combo.addItems(["Default", "0.5", "0.75"]) - self.gpu_combo.setCurrentText(settings.value(KEY_GPU_UTIL, constants.DEFAULT_GPU_MEMORY_UTILIZATION)) - backend_layout.addRow(t("settings.label.gpu_memory_utilization"), self.gpu_combo) - - # Accelerator choice. Only text and speech can move to the NPU — the - # vision model always runs on the GPU, so this never affects OCR speed. - self.backend_combo = QComboBox() - for pref in constants.BACKEND_PREFERENCES: - self.backend_combo.addItem(t(f"settings.option.backend.{pref}"), pref) - b_idx = self.backend_combo.findData( - settings.value(KEY_BACKEND_PREFERENCE, constants.DEFAULT_BACKEND_PREFERENCE) - ) - if b_idx >= 0: - self.backend_combo.setCurrentIndex(b_idx) - self.backend_combo.setToolTip(t("settings.tooltip.backend_preference")) - backend_layout.addRow(t("settings.label.backend_preference"), self.backend_combo) - - self.npu_power_combo = QComboBox() - for mode in constants.NPU_POWER_MODES: - self.npu_power_combo.addItem(t(f"settings.option.npu_power.{mode}"), mode) - p_idx = self.npu_power_combo.findData( - settings.value(KEY_NPU_POWER_MODE, constants.DEFAULT_NPU_POWER_MODE) - ) - if p_idx >= 0: - self.npu_power_combo.setCurrentIndex(p_idx) - npu_available = bool(app and app.processor.router.npu_available()) - self.npu_power_combo.setEnabled(npu_available) - if not npu_available: - self.npu_power_combo.setToolTip(t("settings.tooltip.npu_unavailable")) - backend_layout.addRow(t("settings.label.npu_power_mode"), self.npu_power_combo) - - self.tabs.addTab(backend_tab, "Backend") - - # Tab: Translation - trans_tab = QWidget() - trans_layout = QFormLayout(trans_tab) - - self.source_lang_combo = QComboBox() - self.source_lang_combo.addItems([e.value for e in SourceLanguage]) - self.source_lang_combo.setCurrentText(settings.value(KEY_SOURCE_LANG, constants.DEFAULT_SOURCE_LANG)) - trans_layout.addRow(t("settings.label.source_language"), self.source_lang_combo) - - self.lang_combo = QComboBox() - self.lang_combo.addItems([e.value for e in TargetLanguage]) - self.lang_combo.setCurrentText(settings.value(KEY_TARGET_LANG, constants.DEFAULT_TARGET_LANG)) - trans_layout.addRow(t("settings.label.target_language"), self.lang_combo) - - self.mode_combo = QComboBox() - self.mode_combo.addItems([e.value for e in TranslationMode]) - self.mode_combo.setCurrentText(settings.value(KEY_MODE, constants.DEFAULT_MODE)) - trans_layout.addRow(t("settings.label.mode"), self.mode_combo) - - # Text translation is Hy-MT2 or nothing: the prompts are its own - # published instruction formats and the pipeline is shaped around it, - # so the choice here is which size, not which model. - from xian.translate import TRANSLATION_MODEL, TRANSLATION_MODELS - - self.translation_model_combo = QComboBox() - for model_id in TRANSLATION_MODELS: - self.translation_model_combo.addItem(t(f"settings.option.translation_model.{model_id}"), model_id) - tm_idx = self.translation_model_combo.findData( - settings.value(KEY_TRANSLATION_MODEL, TRANSLATION_MODEL) - ) - if tm_idx >= 0: - self.translation_model_combo.setCurrentIndex(tm_idx) - self.translation_model_combo.setToolTip(t("settings.tooltip.translation_model")) - trans_layout.addRow(t("settings.label.translation_model"), self.translation_model_combo) - - style_layout = QVBoxLayout() - self.style_checkboxes = {} - saved_styles = _parse_styles(settings) - for style in TranslationStyle: - cb = QCheckBox(style.value) - if style.value in saved_styles: - cb.setChecked(True) - self.style_checkboxes[style.value] = cb - style_layout.addWidget(cb) - trans_layout.addRow(t("settings.label.styles"), style_layout) - - self.delay_spin = QSpinBox() - self.delay_spin.setRange(100, 10000) - self.delay_spin.setSingleStep(100) - self.delay_spin.setValue(int(settings.value(KEY_DIALOGUE_DELAY, 1000))) - trans_layout.addRow(t("settings.label.dialogue_delay"), self.delay_spin) - - self.tabs.addTab(trans_tab, "Translation") - - # Tab: Features - features_tab = QWidget() - features_layout = QFormLayout(features_tab) - self.features_layout = features_layout - - self.leader_combo = QComboBox() - self.leader_combo.addItem(t("leader.double_shift"), "Double-Tap Shift") - self.leader_combo.addItem(t("leader.double_ctrl"), "Double-Tap Ctrl") - self.leader_combo.addItem(t("leader.double_alt"), "Double-Tap Alt") - self.leader_combo.addItem(t("leader.double_super"), "Double-Tap Super") - leader_val = settings.value(KEY_LEADER_KEY, constants.DEFAULT_LEADER_KEY) - if leader_val == "Shift+Space": leader_val = "Double-Tap Shift" - idx = self.leader_combo.findData(leader_val) - if idx >= 0: - self.leader_combo.setCurrentIndex(idx) - features_layout.addRow(t("settings.label.leader_key"), self.leader_combo) - - self.overlay_toggle_combo = QComboBox() - self.overlay_toggle_combo.addItem("Right Shift", "rshift") - self.overlay_toggle_combo.addItem("Right Ctrl", "rctrl") - self.overlay_toggle_combo.addItem("Right Alt", "ralt") - self.overlay_toggle_combo.addItem("Super", "super") - toggle_val = settings.value(KEY_OVERLAY_TOGGLE_KEY, constants.DEFAULT_OVERLAY_TOGGLE_KEY) - t_idx = self.overlay_toggle_combo.findData(toggle_val) - if t_idx >= 0: - self.overlay_toggle_combo.setCurrentIndex(t_idx) - features_layout.addRow(t("settings.label.overlay_toggle_key"), self.overlay_toggle_combo) - - self.auto_continue_cb = QCheckBox(t("settings.checkbox.auto_continue")) - auto_val = settings.value(KEY_AUTO_CONTINUE, "false") - self.auto_continue_cb.setChecked(is_true(auto_val)) - features_layout.addRow(self.auto_continue_cb) - - self.auto_speak_cb = QCheckBox(t("settings.checkbox.auto_speak")) - speak_val = settings.value(KEY_AUTO_SPEAK, "false") - self.auto_speak_cb.setChecked(is_true(speak_val)) - features_layout.addRow(self.auto_speak_cb) - - experimental_heading = QLabel(t("settings.heading.experimental")) - experimental_heading.setStyleSheet("font-weight: bold;") - features_layout.addRow(experimental_heading) - - self.experimental_live_cb = QCheckBox(t("settings.checkbox.experimental_live")) - self.experimental_live_cb.setToolTip(t("settings.tooltip.experimental_live")) - self.experimental_live_cb.setChecked( - is_true(settings.value(KEY_EXPERIMENTAL_LIVE, "false")) - ) - features_layout.addRow(self.experimental_live_cb) - - self.live_interval_spin = QSpinBox() - self.live_interval_spin.setRange(200, 5000) - self.live_interval_spin.setSingleStep(100) - self.live_interval_spin.setSuffix(" ms") - self.live_interval_spin.setValue(int(settings.value( - KEY_LIVE_INTERVAL_MS, constants.DEFAULT_LIVE_INTERVAL_MS - ))) - self.live_interval_spin.setToolTip(t("settings.tooltip.live_interval")) - features_layout.addRow(t("settings.label.live_interval"), self.live_interval_spin) - - self.live_engine_combo = QComboBox() - self.live_engine_combo.addItem(t("settings.option.live_engine.grounding"), LIVE_ENGINE_GROUNDING) - self.live_engine_combo.addItem(t("settings.option.live_engine.ocr"), LIVE_ENGINE_OCR) - engine_idx = self.live_engine_combo.findData( - settings.value(KEY_LIVE_ENGINE, DEFAULT_LIVE_ENGINE) - ) - if engine_idx >= 0: - self.live_engine_combo.setCurrentIndex(engine_idx) - self.live_engine_combo.setToolTip(t("settings.tooltip.live_engine")) - features_layout.addRow(t("settings.label.live_engine"), self.live_engine_combo) - - self.ocr_detector_combo = QComboBox() - self.ocr_detector_combo.addItem(t("settings.option.ocr_detector.mobile"), "PP-OCRv5_mobile_det") - self.ocr_detector_combo.addItem(t("settings.option.ocr_detector.server"), "PP-OCRv5_server_det") - detector_idx = self.ocr_detector_combo.findData( - settings.value(KEY_OCR_DETECTOR, "PP-OCRv5_mobile_det") - ) - if detector_idx >= 0: - self.ocr_detector_combo.setCurrentIndex(detector_idx) - self.ocr_detector_combo.setToolTip(t("settings.tooltip.ocr_detector")) - features_layout.addRow(t("settings.label.ocr_detector"), self.ocr_detector_combo) - - self.ignore_phrases_edit = QPlainTextEdit() - self.ignore_phrases_edit.setPlainText(settings.value(KEY_IGNORE_PHRASES, "") or "") - self.ignore_phrases_edit.setFixedHeight(70) - self.ignore_phrases_edit.setToolTip(t("settings.tooltip.ignore_phrases")) - features_layout.addRow(t("settings.label.ignore_phrases"), self.ignore_phrases_edit) - - # Only the OCR engine has a detector or a filter list to configure. - def _sync_ocr_rows(_=None): - is_ocr = self.live_engine_combo.currentData() == LIVE_ENGINE_OCR - self.ocr_detector_combo.setEnabled(is_ocr) - self.ignore_phrases_edit.setEnabled(is_ocr) - - self.live_engine_combo.currentIndexChanged.connect(_sync_ocr_rows) - _sync_ocr_rows() - - # The interval only means anything while the live overlay is running. - self.live_interval_spin.setEnabled(self.experimental_live_cb.isChecked()) - self.experimental_live_cb.toggled.connect(self.live_interval_spin.setEnabled) - - - self.memory_enabled_cb = QCheckBox(t("settings.checkbox.memory_enabled")) - self.memory_enabled_cb.setToolTip(t("settings.tooltip.memory_enabled")) - self.memory_enabled_cb.setChecked(is_true(settings.value(KEY_MEMORY_ENABLED, "true"))) - features_layout.addRow(self.memory_enabled_cb) - - memory_row = QHBoxLayout() - self.memory_retention_spin = QSpinBox() - self.memory_retention_spin.setRange(1, 365) - self.memory_retention_spin.setSuffix(" d") - self.memory_retention_spin.setValue(int(settings.value( - KEY_MEMORY_RETENTION_DAYS, constants.DEFAULT_MEMORY_RETENTION_DAYS - ))) - memory_row.addWidget(self.memory_retention_spin, 1) - self.clear_memory_btn = QPushButton(t("settings.button.clear_memory")) - self.clear_memory_btn.clicked.connect(self._on_clear_memory_clicked) - memory_row.addWidget(self.clear_memory_btn) - features_layout.addRow(t("settings.label.memory_retention"), memory_row) - - self.familiar_enabled_cb = QCheckBox(t("settings.checkbox.familiar_enabled")) - fam_val = settings.value(KEY_FAMILIAR_ENABLED, "false") - self.familiar_enabled_cb.setChecked(is_true(fam_val)) - features_layout.addRow(self.familiar_enabled_cb) - - self.familiar_type_combo = QComboBox() - for sp in ("wizard", "witch", "cat", "owl", "lemonfae", "custom"): - self.familiar_type_combo.addItem(t(f"familiar.species.{sp}"), sp) - fam_type_val = settings.value(KEY_FAMILIAR_TYPE, "wizard") - ft_idx = self.familiar_type_combo.findData(fam_type_val) - if ft_idx >= 0: - self.familiar_type_combo.setCurrentIndex(ft_idx) - fam_type_row = QHBoxLayout() - fam_type_row.addWidget(self.familiar_type_combo, 1) - self.conjure_btn = QPushButton(t("familiar.conjure.button")) - self.conjure_btn.clicked.connect(self._on_conjure_clicked) - fam_type_row.addWidget(self.conjure_btn) - self.fam_type_row = fam_type_row - features_layout.addRow(t("settings.label.familiar_type"), fam_type_row) - - self.familiar_tts_cb = QCheckBox(t("settings.checkbox.familiar_tts")) - fam_tts_val = settings.value(KEY_FAMILIAR_TTS, "false") - self.familiar_tts_cb.setChecked(is_true(fam_tts_val)) - features_layout.addRow(self.familiar_tts_cb) - - self.live_voice_raid_cb = QCheckBox(t("settings.checkbox.live_voice_raid")) - lv_raid_val = settings.value("live_voice_raid", "false") - self.live_voice_raid_cb.setChecked(is_true(lv_raid_val)) - features_layout.addRow(self.live_voice_raid_cb) - - self.live_raid_lore_save_cb = QCheckBox(t("settings.checkbox.live_raid_lore_save")) - lr_lore_val = settings.value("live_raid_lore_save", "false") - self.live_raid_lore_save_cb.setChecked(is_true(lr_lore_val)) - features_layout.addRow(self.live_raid_lore_save_cb) - - self.dev_options_cb = QCheckBox(t("settings.checkbox.dev_options")) - dev_options_val = settings.value("developer_options", "false") - self.dev_options_cb.setChecked(is_true(dev_options_val)) - features_layout.addRow(self.dev_options_cb) - self.dev_options_cb.toggled.connect(self._update_dev_visibility) - self._update_dev_visibility(self.dev_options_cb.isChecked()) - - # Overlay Opacity - opacity_row = QHBoxLayout() - self.opacity_slider = QSlider(Qt.Orientation.Horizontal) - self.opacity_slider.setRange(20, 100) - self.opacity_slider.setSingleStep(5) - self.opacity_slider.setPageStep(10) - opacity_val = int(settings.value("overlay_opacity", 85)) - self.opacity_slider.setValue(opacity_val) - self.opacity_value_label = QLabel(f"{opacity_val}%") - self.opacity_value_label.setFixedWidth(36) - self.opacity_slider.valueChanged.connect(lambda v: self.opacity_value_label.setText(f"{v}%")) - opacity_row.addWidget(self.opacity_slider) - opacity_row.addWidget(self.opacity_value_label) - features_layout.addRow(t("settings.label.overlay_opacity"), opacity_row) - - # Text Size - self.text_size_spin = QSpinBox() - self.text_size_spin.setRange(8, 24) - self.text_size_spin.setValue(int(settings.value("overlay_text_size", 13))) - features_layout.addRow(t("settings.label.text_size"), self.text_size_spin) - - self.tabs.addTab(features_tab, "Features") - - # Buttons - btn_row = QHBoxLayout() - save_btn = QPushButton(t("settings.button.save")) - save_btn.clicked.connect(self._save) - cancel_btn = QPushButton(t("settings.button.cancel")) - cancel_btn.clicked.connect(self.reject) - btn_row.addStretch() - btn_row.addWidget(save_btn) - btn_row.addWidget(cancel_btn) - main_layout.addLayout(btn_row) - - self.setStyleSheet(""" - QDialog { background: #1e1e1e; color: #eee; } - QLabel, QCheckBox { color: #ccc; } - QLineEdit, QComboBox, QSpinBox { - background: #2a2a2a; color: #eee; border: 1px solid #555; - border-radius: 4px; padding: 4px; - } - QPushButton { - background: %s; color: white; border: none; - padding: 6px 16px; border-radius: 4px; font-weight: bold; - } - QPushButton:hover { background: %s; } - QTabWidget::pane { border: 1px solid #555; background: #1e1e1e; } - QTabBar::tab { background: #2a2a2a; color: #ccc; padding: 8px 16px; border: 1px solid #555; } - QTabBar::tab:selected { background: %s; color: white; } - """ % (accent_hex(), accent_hover_hex(), accent_hex())) - - def _add_preset(self): - name, ok = QInputDialog.getText( - self, - t("settings.prompt.layout_preset.new"), - t("settings.prompt.layout_preset.new") - ) - if ok: - name = name.strip() - if not name: - QMessageBox.critical(self, "Error", t("settings.error.layout_preset.invalid")) - return - presets = [self.preset_combo.itemText(i) for i in range(self.preset_combo.count())] - if name in presets: - QMessageBox.critical(self, "Error", t("settings.error.layout_preset.exists")) - return - - presets.append(name) - self.settings.setValue("layout_presets_list", presets) - self.preset_combo.addItem(name) - self.preset_combo.setCurrentText(name) - - def _del_preset(self): - current = self.preset_combo.currentText() - if current == "Default": - QMessageBox.critical(self, "Error", "The Default preset cannot be deleted.") - return - - choice = QMessageBox.question( - self, - "Delete Preset", - f"Are you sure you want to delete the layout preset '{current}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - if choice == QMessageBox.StandardButton.Yes: - presets = [self.preset_combo.itemText(i) for i in range(self.preset_combo.count())] - presets.remove(current) - self.settings.setValue("layout_presets_list", presets) - - self.settings.remove(f"layout/{current}") - - idx = self.preset_combo.findText(current) - if idx >= 0: - self.preset_combo.removeItem(idx) - self.preset_combo.setCurrentText("Default") - - def _save(self): - normalized = normalize_lemonade_api_base_url(self.url_edit.text().strip()) - if should_warn_http_to_non_loopback(normalized): - choice = QMessageBox.warning( - self, - t("settings.warn.http_remote.title"), - t("settings.warn.http_remote.body"), - QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Save, - ) - if choice == QMessageBox.StandardButton.Cancel: - return - self.url_edit.setText(normalized) - self.settings.setValue(KEY_API_URL, normalized) - self.settings.setValue(KEY_API_MODEL, self.model_combo.currentText()) - # A changed tier names a different collection, so it also decides the - # model — otherwise the combo above would keep pointing at the old one. - selected_tier = self.tier_combo.currentData() - self.tier_changed = selected_tier != self._initial_tier - self.settings.setValue(KEY_COLLECTION_TIER, selected_tier) - if self.tier_changed: - self.settings.setValue(KEY_API_MODEL, get_collection(selected_tier).name) - self.settings.setValue("layout_preset", self.preset_combo.currentText()) - self.settings.setValue(KEY_SOURCE_LANG, self.source_lang_combo.currentText()) - self.settings.setValue(KEY_TARGET_LANG, self.lang_combo.currentText()) - self.settings.setValue(KEY_MODE, self.mode_combo.currentText()) - ui_lang = self.ui_lang_combo.currentText() - self.settings.setValue(KEY_UI_LANG, ui_lang) - state.load_locale(ui_lang) - selected_styles = [s for s, cb in self.style_checkboxes.items() if cb.isChecked()] - self.settings.setValue(KEY_STYLES, selected_styles) - self.settings.setValue(KEY_MAX_TOKENS, self.tokens_spin.value()) - self.settings.setValue(KEY_LEADER_KEY, self.leader_combo.currentData()) - self.settings.setValue(KEY_OVERLAY_TOGGLE_KEY, self.overlay_toggle_combo.currentData()) - self.settings.setValue(KEY_GPU_UTIL, self.gpu_combo.currentText()) - self.settings.setValue(KEY_BACKEND_PREFERENCE, self.backend_combo.currentData()) - self.settings.setValue(KEY_NPU_POWER_MODE, self.npu_power_combo.currentData()) - self.settings.setValue(KEY_DIALOGUE_DELAY, self.delay_spin.value()) - self.settings.setValue(KEY_AUTO_CONTINUE, "true" if self.auto_continue_cb.isChecked() else "false") - self.settings.setValue(KEY_AUTO_SPEAK, "true" if self.auto_speak_cb.isChecked() else "false") - self.settings.setValue(KEY_LIVE_INTERVAL_MS, self.live_interval_spin.value()) - self.settings.setValue(KEY_EXPERIMENTAL_LIVE, self.experimental_live_cb.isChecked()) - self.settings.setValue(KEY_MEMORY_ENABLED, "true" if self.memory_enabled_cb.isChecked() else "false") - self.settings.setValue(KEY_MEMORY_RETENTION_DAYS, self.memory_retention_spin.value()) - self.settings.setValue(KEY_FAMILIAR_ENABLED, "true" if self.familiar_enabled_cb.isChecked() else "false") - self.settings.setValue(KEY_FAMILIAR_TYPE, self.familiar_type_combo.currentData()) - self.settings.setValue(KEY_FAMILIAR_TTS, "true" if self.familiar_tts_cb.isChecked() else "false") - self.settings.setValue("live_voice_raid", "true" if self.live_voice_raid_cb.isChecked() else "false") - self.settings.setValue("live_raid_lore_save", "true" if self.live_raid_lore_save_cb.isChecked() else "false") - self.settings.setValue("overlay_opacity", self.opacity_slider.value()) - self.settings.setValue("overlay_text_size", self.text_size_spin.value()) - target_val = self.target_window_combo.currentText().strip() - if self.target_window_combo.currentIndex() == 0 or target_val == t("settings.option.none_overlay") or target_val == "None (Standard Overlay Mode)": - target_val = "" - self.settings.setValue(KEY_TARGET_WINDOW_TITLE, target_val) - self.settings.setValue("developer_options", "true" if self.dev_options_cb.isChecked() else "false") - self.settings.setValue(KEY_LIVE_ENGINE, self.live_engine_combo.currentData()) - self.settings.setValue(KEY_OCR_DETECTOR, self.ocr_detector_combo.currentData()) - self.settings.setValue(KEY_IGNORE_PHRASES, self.ignore_phrases_edit.toPlainText()) - self.settings.setValue(KEY_TRANSLATION_MODEL, self.translation_model_combo.currentData()) - self.accept() - - def _on_edit_layout(self): - self.layout_edit_requested.emit() - self.accept() - - def _on_clear_memory_clicked(self): - """Settings 'Clear memory' button: erase the whole play history.""" - confirm = QMessageBox.question( - self, - t("settings.button.clear_memory"), - t("settings.confirm.clear_memory"), - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if confirm != QMessageBox.StandardButton.Yes: - return - if self.app is not None: - self.app.clear_session_memory() - - def _on_conjure_clicked(self): - """Settings 'Conjure…' button: generate via the app, then select custom.""" - if self.app is not None and self.app.conjure_familiar(): - idx = self.familiar_type_combo.findData("custom") - if idx >= 0: - self.familiar_type_combo.setCurrentIndex(idx) - - def _update_dev_visibility(self, checked): - self.live_voice_raid_cb.setVisible(checked) - self.live_raid_lore_save_cb.setVisible(checked) - # Familiar Mode is a developer-only feature while its art is in progress. - self.familiar_enabled_cb.setVisible(checked) - self.familiar_tts_cb.setVisible(checked) - self.features_layout.setRowVisible(self.fam_type_row, checked) - - class XianApp(QWidget): """Tray-resident application controller. @@ -677,7 +101,7 @@ def __init__(self): self.processor = VLProcessor(VLConfig( model_name=self.settings.value(KEY_API_MODEL, constants.DEFAULT_MODEL), - api_url=_normalized_api_url_from_settings(self.settings), + api_url=normalized_api_url_from_settings(self.settings), max_tokens=int(self.settings.value(KEY_MAX_TOKENS, constants.DEFAULT_MAX_TOKENS)), backend_preference=self.settings.value( KEY_BACKEND_PREFERENCE, constants.DEFAULT_BACKEND_PREFERENCE @@ -966,8 +390,9 @@ def _make_live_worker(self, rect: QRect): session_recorder=lambda orig, trans: self.processor.record_event("inpaint", orig, trans), ) - engine = self.settings.value(KEY_LIVE_ENGINE, DEFAULT_LIVE_ENGINE) - if engine == LIVE_ENGINE_OCR: + from mage.live_engine import resolve_live_engine + + if resolve_live_engine(self.settings) == LIVE_ENGINE_OCR: from mage.live_ocr import LiveOcrWorker from xian.filters import TextFilter @@ -1381,7 +806,7 @@ def _run_inference(self, image_data, action: str, anchor_rect: QRect): source_lang = self.settings.value(KEY_SOURCE_LANG, constants.DEFAULT_SOURCE_LANG) target_lang = self.settings.value(KEY_TARGET_LANG, constants.DEFAULT_TARGET_LANG) mode = self.settings.value(KEY_MODE, constants.DEFAULT_MODE) - styles = _parse_styles(self.settings) + styles = parse_styles(self.settings) worker = InferenceWorker( self.processor, @@ -2115,7 +1540,7 @@ def _on_cinematic_trigger(self): def on_done(composite_data, anchor_rect): source_lang = self.settings.value(KEY_SOURCE_LANG, constants.DEFAULT_SOURCE_LANG) target_lang = self.settings.value(KEY_TARGET_LANG, constants.DEFAULT_TARGET_LANG) - styles = _parse_styles(self.settings) + styles = parse_styles(self.settings) worker = CinematicWorker( self.processor, @@ -2283,7 +1708,7 @@ def _on_raid_error(self, msg, worker): # Health check def _run_health_check(self): self._safe_stop_worker("_status_worker") - api_url = _normalized_api_url_from_settings(self.settings) + api_url = normalized_api_url_from_settings(self.settings) self._status_worker = StatusWorker(api_url) self._status_worker.status_changed.connect(self._on_health_result) self._status_worker.start() @@ -2337,7 +1762,7 @@ def _pull_model(self, model_name: str, body: dict | None = None): not already know by name, such as a Xian collection. """ self._safe_stop_worker("_pull_worker") - api_url = _normalized_api_url_from_settings(self.settings) + api_url = normalized_api_url_from_settings(self.settings) gpu_util = self.settings.value(KEY_GPU_UTIL, constants.DEFAULT_GPU_MEMORY_UTILIZATION) self._pull_worker = ModelPullWorker(api_url, model_name, gpu_util, body=body) self._pull_worker.pull_done.connect(self._on_pull_done) @@ -2371,7 +1796,7 @@ def _handle_layout_edit(): if dlg.exec(): # Apply changed settings to processor - self.processor.config.api_url = _normalized_api_url_from_settings(self.settings) + self.processor.config.api_url = normalized_api_url_from_settings(self.settings) self.processor.config.model_name = self.settings.value(KEY_API_MODEL, constants.DEFAULT_MODEL) self.processor.config.max_tokens = int(self.settings.value(KEY_MAX_TOKENS, constants.DEFAULT_MAX_TOKENS)) self.processor.config.backend_preference = self.settings.value( diff --git a/apps/mage-client/src/mage/live_common.py b/apps/mage-client/src/mage/live_common.py index d9dfed8..fd7372d 100644 --- a/apps/mage-client/src/mage/live_common.py +++ b/apps/mage-client/src/mage/live_common.py @@ -59,6 +59,7 @@ import imagehash from PIL import Image from PyQt6.QtCore import QRect, QThread, pyqtSignal +from shared_types.state import t logger = logging.getLogger(__name__) @@ -68,6 +69,7 @@ "DEFAULT_INTERVAL_MS", "LIVE_HASH_SIZE", "MAX_CAPTURE_FAILURES", + "MAX_INFERENCE_FAILURES", "PAINT_MASK_PADDING", "REGION_CHANGE_THRESHOLD", "REGION_COMPARE_SIZE", @@ -130,6 +132,24 @@ #: Consecutive capture failures tolerated before giving up. MAX_CAPTURE_FAILURES = 5 +#: Consecutive *identical* inference failures tolerated before giving up. +#: +#: A missing model, a model that will not load, a backend that is not running: +#: none of these fix themselves between ticks, and a box grinding through +#: capture-read-fail several times a second costs the game frames to learn +#: nothing. Deliberately larger than the capture allowance — a backend +#: restarting mid-session is worth waiting out. +MAX_INFERENCE_FAILURES = 8 + +#: How often the loop says what it has been doing, in seconds. +#: +#: The overlay is silent by design when it has nothing to paint, which makes +#: "reading, and there is nothing there" and "not reading at all" look +#: identical from the outside. This is the line that tells them apart, and it +#: is at INFO because the person who needs it is the person who cannot see +#: their translations. +REPORT_INTERVAL_SECONDS = 30.0 + class LiveRegion: """A translated line plus its background colour, in region coordinates.""" @@ -291,6 +311,17 @@ def __init__( self._last_signature: tuple = () self._warned_untranslated = False + # The last failure reported, and how many times it has repeated. Kept + # so a broken box says so once rather than every tick. + self._last_error: str | None = None + self._failures = 0 + + # What the loop has done since it last said so. + self._tally = dict.fromkeys( + ("ticks", "captured", "changed", "translated", "painted"), 0 + ) + self._reported_at = 0.0 + # Translations from earlier frames, each with a hash of the pixels it # was read from. A box whose pixels are unchanged is still showing the # same text, so its translation stands without another inference — @@ -359,9 +390,19 @@ async def _run_async(self): interval = self.interval_ms / 1000.0 failures = 0 inflight = False + self._reported_at = time.monotonic() + logger.info( + "%s watching %s every %d ms via %s", + type(self).__name__, + self.rect, + self.interval_ms, + "continuous capture" if self._frame_stream is not None else "screenshots", + ) while self._running: started = time.monotonic() + self._tally["ticks"] += 1 + self._report(started) frame = await asyncio.to_thread(self._grab) if frame is None: @@ -372,6 +413,7 @@ async def _run_async(self): await self._sleep_remainder(started, interval) continue failures = 0 + self._tally["captured"] += 1 current = self._masked_hash(frame) @@ -379,6 +421,7 @@ async def _run_async(self): await self._sleep_remainder(started, interval) continue + self._tally["changed"] += 1 self._clean_hash = current # Whatever is painted was read from an older frame. Re-check each @@ -386,7 +429,8 @@ async def _run_async(self): # the call runs, and text that has gone stops being painted now # rather than seconds from now when the call returns. self._carry = self._surviving_regions(frame) - self._publish([], frame) + if self._publish([], frame): + self._tally["painted"] += 1 inflight = True try: @@ -395,19 +439,25 @@ async def _run_async(self): logger.warning("Live lens inference timed out; skipping frame") regions = None except Exception as exc: - logger.warning("Live lens inference failed: %s", exc) - self.error.emit(str(exc)) regions = None + if self._note_failure(str(exc)): + return finally: inflight = False + if regions is not None: + self._last_error = None + self._failures = 0 + self._tally["translated"] += 1 + if regions is None: # The call failed. The carry is still valid for this frame, so # the overlay keeps the lines whose text has not changed # instead of blanking, and they survive into the next tick. self._remember(self._carry, frame) else: - self._publish(regions, frame) + if self._publish(regions, frame): + self._tally["painted"] += 1 self._carry = [] # The boxes we mask changed with this render, so re-hash the # frame we just translated under the *new* mask. Comparing the @@ -604,6 +654,53 @@ def _warn_if_nothing_translated(self, live) -> None: logger.warning("%s", message) self.error.emit(message) + # ── saying what happened ───────────────────────────────────────── + + def _note_failure(self, message: str) -> bool: + """Report an inference failure; return True when the loop should stop. + + Two things a polling loop must not do: report the same failure on + every tick, and keep failing forever. The first buries the message + the user needed under a thousand copies of itself; the second burns a + core reading a screen it can never translate. + """ + if message != self._last_error: + self._last_error = message + self._failures = 0 + logger.warning("%s inference failed: %s", type(self).__name__, message) + self.error.emit(message) + + self._failures += 1 + if self._failures < MAX_INFERENCE_FAILURES: + return False + + logger.error( + "%s stopping after %d identical failures: %s", + type(self).__name__, self._failures, message, + ) + self.error.emit(f"{t('live.error.stopped')} {message}") + return True + + def _report(self, now: float) -> None: + """Log what the loop has been doing, at an interval. + + Written as counts rather than events because the useful question is + which stage the frames stop at: captured but never changed is a gate + problem, changed but never translated is the engine, translated but + never repainted is the overlay. + """ + if now - self._reported_at < REPORT_INTERVAL_SECONDS: + return + self._reported_at = now + logger.info( + "%s: %d ticks, %d captured, %d changed, %d translated, %d repainted", + type(self).__name__, + self._tally["ticks"], self._tally["captured"], self._tally["changed"], + self._tally["translated"], self._tally["painted"], + ) + for key in self._tally: + self._tally[key] = 0 + def _capture_scale(self, frame: Image.Image) -> float: """Capture pixels per logical pixel, measured from the frame itself.""" logical_width = self._served_rect.width() diff --git a/apps/mage-client/src/mage/live_engine.py b/apps/mage-client/src/mage/live_engine.py new file mode 100644 index 0000000..265707b --- /dev/null +++ b/apps/mage-client/src/mage/live_engine.py @@ -0,0 +1,116 @@ +# MAGE — Gaming HUD for real-time screen translation. +# Copyright (C) 2026 Clementine Pendragon +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# Contact: clem@pendragon.systems (Clementine Pendragon, c/o Xian Project Development) + +"""Which live engine actually runs, given the setting and the machine. + +Two engines translate a locked region: a vision model that reads and +translates in one call, and the local PP-OCRv5 reader that sends only text. +The setting says which one the user wants. This says which one they can have. + +The difference matters because PP-OCRv5 has never had a published ONNX +release, so its weights exist only once somebody has run +``scripts/export_ppocr_onnx.py`` — which, on a fresh install, nobody has. A +box built on the reader before that happens fails on its first read, in a +worker thread, on a tick, where all the user sees is a border turning red and +no text ever arriving. The vision model needs nothing exported and is already +loaded for chat and grounding, so that is what an unprepared machine gets, +with one line saying why. + +Resolved here rather than at each call site because there are two — the boxes +and the classic live lens — and an engine chosen one way in one place and +another way in the other is how the boxes came to ignore the setting entirely. +""" + +from __future__ import annotations + +import logging +import os + +from mage.settings_keys import ( + DEFAULT_LIVE_ENGINE, + KEY_LIVE_ENGINE, + KEY_OCR_DETECTOR, + KEY_SOURCE_LANG, + LIVE_ENGINE_GROUNDING, + LIVE_ENGINE_OCR, +) + +logger = logging.getLogger(__name__) + +__all__ = ["ocr_models_ready", "resolve_live_engine"] + +#: Said once per run, not once per box: five boxes starting together would +#: otherwise report the same missing weights five times. +_warned = False + + +def ocr_models_ready(settings) -> bool: + """Whether the local reader has the weights it needs, on disk, intact. + + Checks the two models a read actually loads — the detector, and the + recognizer for the configured source language — rather than the catalog as + a whole, because a machine that exported one script's weights and not + another's is a machine that works for Chinese and not for Korean. + + A configured distribution host counts as ready: fetching is then + ``ensure_model``'s job, and refusing here would fall back to the vision + model for a download that was going to succeed. + """ + if os.environ.get("XIAN_OCR_BASE_URL", "").strip(): + return True + + try: + from xian.ocr.engine import DEFAULT_DETECTOR + from xian.ocr.models import verify_model + from xian.ocr.scripts import SCRIPTS, recognizer_script_for + except ImportError as exc: + # The OCR extra is optional; without it there is no reader at all. + logger.info("local OCR is not installed (%s)", exc) + return False + + detector = (settings.value(KEY_OCR_DETECTOR, "") if settings else "") or DEFAULT_DETECTOR + source = settings.value(KEY_SOURCE_LANG, "") if settings else "" + recognizer = SCRIPTS[recognizer_script_for(source)] + + return verify_model(detector) and verify_model(recognizer) + + +def resolve_live_engine(settings) -> str: + """The engine to build a live worker with. + + Returns one of :data:`~mage.settings_keys.LIVE_ENGINE_OCR` or + :data:`~mage.settings_keys.LIVE_ENGINE_GROUNDING`. The vision model is + never second-guessed — it is always available — so the only substitution + made here is away from a reader that cannot read. + """ + global _warned + + choice = (settings.value(KEY_LIVE_ENGINE, DEFAULT_LIVE_ENGINE) if settings else DEFAULT_LIVE_ENGINE) + if choice != LIVE_ENGINE_OCR: + return LIVE_ENGINE_GROUNDING + if ocr_models_ready(settings): + return LIVE_ENGINE_OCR + + if not _warned: + _warned = True + logger.warning( + "The local OCR weights are not exported, so live translation is using the " + "vision model instead. To use the local reader, run: " + "uv run --package mage-client python scripts/export_ppocr_onnx.py" + ) + return LIVE_ENGINE_GROUNDING diff --git a/apps/mage-client/src/mage/settings_keys.py b/apps/mage-client/src/mage/settings_keys.py index 481489a..2dd13d3 100644 --- a/apps/mage-client/src/mage/settings_keys.py +++ b/apps/mage-client/src/mage/settings_keys.py @@ -16,11 +16,41 @@ # # Contact: clem@pendragon.systems (Clementine Pendragon, c/o Xian Project Development) -"""Canonical QSettings keys for the Mage client. +"""Canonical QSettings keys for the Mage client, and how to read them. -Centralizes key names to avoid typos and drift across files. +Centralizes key names to avoid typos and drift across files. The few readers +that live here too are the ones whose *interpretation* has to be shared — a +boolean QSettings stores as a string on one platform and a bool on another, a +URL that has to be normalised the same way everywhere. """ +from shared_types import constants + + +def normalized_api_url_from_settings(settings) -> str: + """The Lemonade base URL as stored, put in canonical form. + + Beside the keys rather than beside its callers because there are five of + them, in three modules, and a URL normalised on the way in but not on the + way out is a health check that passes against a server the requests never + reach. + """ + from xian.lemonade_url import normalize_lemonade_api_base_url + + return normalize_lemonade_api_base_url(str(settings.value(KEY_API_URL, constants.DEFAULT_API_URL))) + + +def parse_styles(settings) -> list[str]: + """The saved translation styles as a list. + + QSettings hands back a list on one platform and a comma-joined string on + another; every reader has to accept both. + """ + raw = settings.value(KEY_STYLES, constants.DEFAULT_STYLES) + if isinstance(raw, str): + return [style.strip() for style in raw.split(",") if style.strip()] + return raw if isinstance(raw, list) else [] + def is_true(value) -> bool: """Read a QSettings boolean. @@ -70,7 +100,12 @@ def is_true(value) -> bool: KEY_LIVE_ENGINE = "live_engine" LIVE_ENGINE_GROUNDING = "grounding" LIVE_ENGINE_OCR = "ocr" -DEFAULT_LIVE_ENGINE = LIVE_ENGINE_GROUNDING +#: The local reader, which is what the translation boxes were built around and +#: what they have always run whatever this said. It falls back to the vision +#: model on a machine whose weights are not exported — see +#: :mod:`mage.live_engine` — so this default costs an unprepared machine +#: nothing. +DEFAULT_LIVE_ENGINE = LIVE_ENGINE_OCR # Detection model for the OCR engine: PP-OCRv5_mobile_det or _server_det. KEY_OCR_DETECTOR = "ocr_detector" diff --git a/apps/mage-client/src/mage/ui/new/boxes.py b/apps/mage-client/src/mage/ui/new/boxes.py index 9724a11..60ae4cc 100644 --- a/apps/mage-client/src/mage/ui/new/boxes.py +++ b/apps/mage-client/src/mage/ui/new/boxes.py @@ -43,7 +43,7 @@ from enum import Enum from PyQt6.QtCore import QObject, QRect, Qt, QTimer, pyqtSignal -from PyQt6.QtGui import QColor, QPainter, QPen +from PyQt6.QtGui import QColor, QPainter, QPen, QRegion from PyQt6.QtWidgets import QHBoxLayout, QPushButton, QWidget from mage.ui.overlay_base import MageOverlayWindow @@ -58,6 +58,26 @@ #: one engine. MAX_BOXES = 5 +#: Width of the frame you can actually grab, in pixels. +#: +#: A box has to be two contradictory things: a window you can move, resize the +#: attention of, and delete; and a hole you can click *through*, because the +#: game underneath is still a game. The resolution is that only a band around +#: the edge belongs to the box — wide enough to hit without aiming, and no +#: wider, because every pixel of it is a pixel of the game you cannot click. +#: +#: The 2px border this replaced was, on Windows, the entire interactive +#: surface of a translation box: a layered window passes input straight +#: through its fully transparent pixels, so the only thing that could be +#: hovered or dragged was the drawn line itself. Boxes were, in the user's +#: words, unclickable, undraggable and impossible to clear. +FRAME_BAND = 10 + +#: Alpha of the band's fill. Two jobs, and the second is why it cannot be +#: zero: it shows you what you can grab, and on Windows a pixel with no alpha +#: at all is a pixel the mouse never reaches. +BAND_ALPHA = 46 + class BoxMode(Enum): LIVE = "live" @@ -119,9 +139,40 @@ def __init__(self, box_id: str, rect: QRect, mode: BoxMode = BoxMode.LIVE, app=N self._toolbar = _BoxToolbar(self) self._toolbar.hide() + self._toolbar.set_mode(mode) self._toolbar.mode_clicked.connect(self._cycle_mode) self._toolbar.delete_clicked.connect(lambda: self.delete_requested.emit(self)) self.setMouseTracking(True) + self._refresh_mask() + + # ── what belongs to the box, and what belongs to the game ──────── + + def _refresh_mask(self) -> None: + """Keep the middle of the box out of the window entirely. + + Masking rather than painting-nothing, because the two platforms + disagree about what a transparent pixel means: X11 hands the click to + whatever is on top regardless of what it drew, so without this the box + swallows every click aimed at the game inside it. The toolbar is + united back in while it is showing, or the mask would clip the very + controls it exists to reveal. + """ + outer = self.rect() + region = QRegion(outer) + inner = outer.adjusted(FRAME_BAND, FRAME_BAND, -FRAME_BAND, -FRAME_BAND) + if inner.width() > 0 and inner.height() > 0: + region = region.subtracted(QRegion(inner)) + if self._toolbar.isVisible(): + region = region.united(QRegion(self._toolbar.geometry())) + self.setMask(region) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._refresh_mask() + + def showEvent(self, event): + super().showEvent(event) + self._refresh_mask() # ── mode and state ─────────────────────────────────────────────── @@ -161,10 +212,12 @@ def to_dict(self) -> dict: def enterEvent(self, event): self._toolbar.move(4, 4) self._toolbar.show() + self._refresh_mask() super().enterEvent(event) def leaveEvent(self, event): self._toolbar.hide() + self._refresh_mask() super().leaveEvent(event) def mouseReleaseEvent(self, event): @@ -187,9 +240,26 @@ def paintEvent(self, event): painter.setRenderHint(QPainter.RenderHint.Antialiasing) color = _STATE_COLORS.get(self.state, _MODE_COLORS[self.mode]) + + # The grabbable band, drawn as the frame it is. Painted rather than + # left clear because on Windows an unpainted pixel is one the mouse + # passes through, and a frame nobody can hit is a box nobody can move + # or delete. + band = QColor(color) + band.setAlpha(BAND_ALPHA) + outer = self.rect() + inner = outer.adjusted(FRAME_BAND, FRAME_BAND, -FRAME_BAND, -FRAME_BAND) + frame = QRegion(outer) + if inner.width() > 0 and inner.height() > 0: + frame = frame.subtracted(QRegion(inner)) + painter.setPen(Qt.PenStyle.NoPen) + painter.setClipRegion(frame) + painter.fillRect(outer, band) + painter.setClipping(False) + painter.setPen(QPen(color, 2)) painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawRoundedRect(self.rect().adjusted(1, 1, -2, -2), 6, 6) + painter.drawRoundedRect(outer.adjusted(1, 1, -2, -2), 6, 6) if self.mode is BoxMode.IGNORE: # Hatched, so an Ignore box does not read as a translation box @@ -351,6 +421,13 @@ def clear(self) -> None: for box in list(self.boxes): self.remove_box(box) + def box(self, box_id: str) -> "TranslationBox | None": + """The box with this id, for controls that live somewhere else.""" + for box in self.boxes: + if box.box_id == box_id: + return box + return None + def overlays(self) -> list: """The painted overlays, for the keep-on-top tick. @@ -375,6 +452,9 @@ def _connect(self, box: TranslationBox) -> None: def _on_mode_changed(self, box: TranslationBox) -> None: self._sync_box(box) self.save() + # The orb's row names each box's mode, and a mode changed from the + # box's own toolbar has to reach it. + self.boxes_changed.emit() def _on_moved(self, box: TranslationBox) -> None: """A box that moved is bound to the wrong rectangle until it restarts.""" @@ -418,40 +498,79 @@ def _build_worker_for_rect(self, rect: QRect, *, box: "TranslationBox | None" = translation the user just asked for should not wait for a second look at text they can already see. """ - from mage.live_ocr import LiveOcrWorker - from mage.settings_keys import KEY_IGNORE_PHRASES, KEY_LIVE_INTERVAL_MS, KEY_SOURCE_LANG, KEY_TARGET_LANG - from mage.translation import make_translator + from mage.live_engine import resolve_live_engine + from mage.settings_keys import ( + KEY_IGNORE_PHRASES, + KEY_LIVE_INTERVAL_MS, + KEY_SOURCE_LANG, + KEY_TARGET_LANG, + LIVE_ENGINE_OCR, + ) from mage.ui.inpaint_overlay import InpaintOverlay from shared_types import constants - from xian.filters import TextFilter - from xian.text_gate import SettleGate overlay = InpaintOverlay() overlay.bind_to_rect(rect) overlay.show() settings = self.app.settings - worker = LiveOcrWorker( - self.app.processor, - rect, + common = dict( frame_stream=self._stream_for(rect), - engine=self.engine(), - translator=make_translator(settings, self.app.processor), - text_filter=TextFilter.from_settings(settings.value(KEY_IGNORE_PHRASES, "")), - exclude_regions=[r for r in self.exclude_regions() if r != rect], source_lang=settings.value(KEY_SOURCE_LANG, constants.DEFAULT_SOURCE_LANG), target_lang=settings.value(KEY_TARGET_LANG, constants.DEFAULT_TARGET_LANG), interval_ms=int(settings.value(KEY_LIVE_INTERVAL_MS, constants.DEFAULT_LIVE_INTERVAL_MS)), session_recorder=self._record, - gate=SettleGate(settle_seconds=0.0) if settle_now else None, ) + + # Whichever engine the settings ask for, and can actually have. The + # boxes used to build an OCR reader whatever was chosen, so a machine + # that had never run ``scripts/export_ppocr_onnx.py`` — which is every + # machine until someone does — had every box fail on its first read + # while the settings screen said something else entirely. + if resolve_live_engine(settings) == LIVE_ENGINE_OCR: + from mage.live_ocr import LiveOcrWorker + from mage.translation import make_translator + from xian.filters import TextFilter + from xian.text_gate import SettleGate + + worker = LiveOcrWorker( + self.app.processor, + rect, + engine=self.engine(), + translator=make_translator(settings, self.app.processor), + text_filter=TextFilter.from_settings(settings.value(KEY_IGNORE_PHRASES, "")), + exclude_regions=[r for r in self.exclude_regions() if r != rect], + gate=SettleGate(settle_seconds=0.0) if settle_now else None, + **common, + ) + else: + from mage.live_lens import LiveLensWorker + + worker = LiveLensWorker(self.app.processor, rect, **common) + worker.regions_ready.connect( lambda regions, served, scale, ov=overlay, b=box: self._on_regions(ov, b, regions, served, scale) ) - if box is not None: - worker.error.connect(lambda message, b=box: b.set_state(BoxState.FAILED)) + worker.error.connect(lambda message, b=box: self._on_worker_error(b, message)) return worker, overlay + def _on_worker_error(self, box, message: str) -> None: + """Say what went wrong, where the user is already looking. + + A box that could not read has only ever turned its border red, and the + reason went to a log nobody has open — so the commonest first-run + failure of all, weights that were never exported, looked exactly like + a feature that does nothing. The message these raise says what to + run; it needs somewhere to be read. + """ + logger.error("live box %s: %s", getattr(box, "box_id", "one-off"), message) + if box is not None: + box.set_state(BoxState.FAILED) + box.setToolTip(message) + shell = getattr(self.app, "_shell", None) + if shell is not None: + shell.on_error(message) + def _record(self, original: str, translated: str) -> None: """Into session memory, and into the orb's log if there is one.""" self.app.processor.record_event("inpaint", original, translated) diff --git a/apps/mage-client/src/mage/ui/new/orb.py b/apps/mage-client/src/mage/ui/new/orb.py index 3059b5c..ade4151 100644 --- a/apps/mage-client/src/mage/ui/new/orb.py +++ b/apps/mage-client/src/mage/ui/new/orb.py @@ -199,6 +199,9 @@ class OrbPanel(MageOverlayWindow): translate_once_requested = pyqtSignal() settings_requested = pyqtSignal() notes_requested = pyqtSignal() + box_mode_cycled = pyqtSignal(str) + box_removed = pyqtSignal(str) + boxes_cleared = pyqtSignal() #: Entries kept in the view. The session store keeps the real history; #: this is a window onto the recent end of it. @@ -232,6 +235,24 @@ def __init__(self, app=None, parent=None): header.addWidget(button) layout.addLayout(header) + # Every box on screen, reachable without touching it. A box is a + # frame around a piece of the game and mostly a hole, so its own + # controls only appear when the pointer finds that frame — which is + # not a thing to depend on when the alternative is a box that cannot + # be changed or deleted at all. + self._boxes_row = QHBoxLayout() + self._boxes_row.setSpacing(4) + self._boxes_label = QLabel(t("newui.orb.label.boxes")) + self._boxes_label.setStyleSheet("color: #8a8a9e; font-size: 11px;") + self._boxes_row.addWidget(self._boxes_label) + self._boxes_row.addStretch(1) + self._clear_boxes_button = QPushButton(t("newui.orb.button.clear_boxes")) + self._clear_boxes_button.setToolTip(t("newui.orb.tooltip.clear_boxes")) + self._clear_boxes_button.clicked.connect(self.boxes_cleared) + self._boxes_row.addWidget(self._clear_boxes_button) + self._box_widgets: list[QWidget] = [] + layout.addLayout(self._boxes_row) + self._log = QTextBrowser() self._log.setOpenExternalLinks(False) layout.addWidget(self._log, 1) @@ -262,6 +283,40 @@ def __init__(self, app=None, parent=None): "QPushButton:checked { background: rgba(60,140,90,240); }" ) + # ── the boxes ──────────────────────────────────────────────────── + + def set_boxes(self, entries: list[tuple[str, str, str]]) -> None: + """Rebuild the row from ``(box_id, label, mode)`` triples. + + Rebuilt wholesale rather than diffed: there are at most five of them, + and a row that is regenerated cannot drift out of step with the boxes + it describes. + """ + for widget in self._box_widgets: + self._boxes_row.removeWidget(widget) + widget.deleteLater() + self._box_widgets = [] + + self._boxes_label.setText( + t("newui.orb.label.boxes") if entries else t("newui.orb.label.no_boxes") + ) + self._clear_boxes_button.setEnabled(bool(entries)) + + # Inserted before the stretch and the clear button, so they stay right. + position = 1 + for box_id, label, mode in entries: + chip = QPushButton(f"{label} · {mode}") + chip.setToolTip(t("newui.orb.tooltip.cycle_mode")) + chip.clicked.connect(lambda _checked=False, i=box_id: self.box_mode_cycled.emit(i)) + remove = QPushButton("✕") + remove.setFixedWidth(22) + remove.setToolTip(t("newui.orb.tooltip.remove_box")) + remove.clicked.connect(lambda _checked=False, i=box_id: self.box_removed.emit(i)) + for widget in (chip, remove): + self._boxes_row.insertWidget(position, widget) + self._box_widgets.append(widget) + position += 1 + # ── the log ────────────────────────────────────────────────────── def _append(self, body: str) -> None: diff --git a/apps/mage-client/src/mage/ui/settings_dialog.py b/apps/mage-client/src/mage/ui/settings_dialog.py new file mode 100644 index 0000000..c0ac8e4 --- /dev/null +++ b/apps/mage-client/src/mage/ui/settings_dialog.py @@ -0,0 +1,722 @@ +# MAGE — Gaming HUD for real-time screen translation. +# Copyright (C) 2026 Clementine Pendragon +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# Contact: clem@pendragon.systems (Clementine Pendragon, c/o Xian Project Development) + +"""Everything you can configure, arranged by what you came here to do. + +There were four tabs, and one of them — "Features" — had grown to seventeen +controls with nothing in common: two hotkeys, the live translation tuning, the +session memory, the familiar, the overlay's opacity and text size, and the +developer toggle, in one flat column. A tab whose name does not predict its +contents is a tab you have to read end to end every time. + +Five now, each named for a task rather than for a part of the program: + + Translation what the words become — languages, model, style, and the + live reader that produces them + Overlay what you see — opacity, text size, where the windows sit + Server where the models run + Assistant the orb, its voice, and what it remembers + Advanced hotkeys and developer options + +Inside a tab, related rows sit under a heading rather than running together, +because a form of eleven rows reads as eleven unrelated decisions. + +Three settings are gone rather than moved: the leader key and the dialogue +delay drove the old letter-menu interface, and the "experimental live +translation" checkbox gated a live overlay the translation boxes never +consulted. All three had stopped controlling anything, which is worse than +missing: a setting that lies costs the user the time they spend believing it. +""" + +from __future__ import annotations + +import logging + +from PyQt6.QtCore import Qt, QSettings, pyqtSignal +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QFormLayout, + QHBoxLayout, + QInputDialog, + QLabel, + QLineEdit, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSlider, + QSpinBox, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from mage.settings_keys import ( + DEFAULT_LIVE_ENGINE, + KEY_API_MODEL, + KEY_API_URL, + KEY_AUTO_CONTINUE, + KEY_AUTO_SPEAK, + KEY_BACKEND_PREFERENCE, + KEY_COLLECTION_TIER, + KEY_FAMILIAR_ENABLED, + KEY_FAMILIAR_TTS, + KEY_FAMILIAR_TYPE, + KEY_GPU_UTIL, + KEY_IGNORE_PHRASES, + KEY_LIVE_ENGINE, + KEY_LIVE_INTERVAL_MS, + KEY_MAX_TOKENS, + KEY_MEMORY_ENABLED, + KEY_MEMORY_RETENTION_DAYS, + KEY_MODE, + KEY_NPU_POWER_MODE, + KEY_OCR_DETECTOR, + KEY_OVERLAY_TOGGLE_KEY, + KEY_SOURCE_LANG, + KEY_STYLES, + KEY_TARGET_LANG, + KEY_TARGET_WINDOW_TITLE, + KEY_TRANSLATION_MODEL, + KEY_UI_LANG, + LIVE_ENGINE_GROUNDING, + LIVE_ENGINE_OCR, + is_true, + normalized_api_url_from_settings, + parse_styles, +) +from mage.ui.theme import accent_hex, accent_hover_hex +from mage.utils.window_binder import WindowBinder +from shared_types import constants +from shared_types.enums import SourceLanguage, TargetLanguage, TranslationMode, TranslationStyle +from shared_types.state import state, t +from xian.collections import COLLECTIONS, get_collection +from xian.lemonade_url import normalize_lemonade_api_base_url, should_warn_http_to_non_loopback + +logger = logging.getLogger(__name__) + +__all__ = ["SettingsDialog"] + +_STYLE_SHEET = """ + QDialog { background: #1e1e1e; color: #eee; } + QLabel, QCheckBox { color: #ccc; } + QLineEdit, QComboBox, QSpinBox, QPlainTextEdit { + background: #2a2a2a; color: #eee; border: 1px solid #555; + border-radius: 4px; padding: 4px; + } + QPushButton { + background: %s; color: white; border: none; + padding: 6px 16px; border-radius: 4px; font-weight: bold; + } + QPushButton:hover { background: %s; } + QTabWidget::pane { border: 1px solid #555; background: #1e1e1e; } + QTabBar::tab { background: #2a2a2a; color: #ccc; padding: 8px 16px; border: 1px solid #555; } + QTabBar::tab:selected { background: %s; color: white; } +""" + + +def _heading(form: QFormLayout, key: str, *, first: bool = False) -> QLabel: + """A section title inside a tab. + + Spaced above rather than below: the gap is what tells you the heading + belongs to what follows it, and the first one has nothing to be spaced + away from. + """ + label = QLabel(t(key)) + label.setStyleSheet( + "font-weight: bold; color: #9aa0b5; %s" % ("" if first else "margin-top: 10px;") + ) + form.addRow(label) + return label + + +class SettingsDialog(QDialog): + """Everything configurable, in five tabs named for what you came to do.""" + + layout_edit_requested = pyqtSignal() + + #: Set on save when the collection tier changed, so the caller installs it. + tier_changed = False + + def __init__(self, settings: QSettings, models: list, parent=None, app=None): + super().__init__(parent) + self.setWindowTitle(t("settings.dialog.title")) + self.setMinimumWidth(480) + self.settings = settings + self.app = app + self._models = models or [] + + main_layout = QVBoxLayout(self) + self.tabs = QTabWidget() + main_layout.addWidget(self.tabs) + + self.tabs.addTab(self._translation_tab(), t("settings.tab.translation")) + self.tabs.addTab(self._overlay_tab(), t("settings.tab.overlay")) + self.tabs.addTab(self._server_tab(), t("settings.tab.server")) + self.tabs.addTab(self._assistant_tab(), t("settings.tab.assistant")) + self.tabs.addTab(self._advanced_tab(), t("settings.tab.advanced")) + + btn_row = QHBoxLayout() + save_btn = QPushButton(t("settings.button.save")) + save_btn.clicked.connect(self._save) + cancel_btn = QPushButton(t("settings.button.cancel")) + cancel_btn.clicked.connect(self.reject) + btn_row.addStretch() + btn_row.addWidget(save_btn) + btn_row.addWidget(cancel_btn) + main_layout.addLayout(btn_row) + + self.setStyleSheet(_STYLE_SHEET % (accent_hex(), accent_hover_hex(), accent_hex())) + + # Applied last: it hides rows across two tabs, so every row has to + # exist before it runs. + self._update_dev_visibility(self.dev_options_cb.isChecked()) + + # ── Translation ────────────────────────────────────────────────── + + def _translation_tab(self) -> QWidget: + """What the words become. + + The live reader lives here rather than under a "features" heading + because reading the screen and translating what was read are one + operation to everybody except the code. + """ + settings = self.settings + tab = QWidget() + form = QFormLayout(tab) + + _heading(form, "settings.heading.languages", first=True) + + self.source_lang_combo = QComboBox() + self.source_lang_combo.addItems([e.value for e in SourceLanguage]) + self.source_lang_combo.setCurrentText(settings.value(KEY_SOURCE_LANG, constants.DEFAULT_SOURCE_LANG)) + form.addRow(t("settings.label.source_language"), self.source_lang_combo) + + self.lang_combo = QComboBox() + self.lang_combo.addItems([e.value for e in TargetLanguage]) + self.lang_combo.setCurrentText(settings.value(KEY_TARGET_LANG, constants.DEFAULT_TARGET_LANG)) + form.addRow(t("settings.label.target_language"), self.lang_combo) + + self.mode_combo = QComboBox() + self.mode_combo.addItems([e.value for e in TranslationMode]) + self.mode_combo.setCurrentText(settings.value(KEY_MODE, constants.DEFAULT_MODE)) + form.addRow(t("settings.label.mode"), self.mode_combo) + + # Text translation is Hy-MT2 or nothing: the prompts are its own + # published instruction formats and the pipeline is shaped around it, + # so the choice here is which size, not which model. + from xian.translate import TRANSLATION_MODEL, TRANSLATION_MODELS + + self.translation_model_combo = QComboBox() + for model_id in TRANSLATION_MODELS: + self.translation_model_combo.addItem(t(f"settings.option.translation_model.{model_id}"), model_id) + tm_idx = self.translation_model_combo.findData(settings.value(KEY_TRANSLATION_MODEL, TRANSLATION_MODEL)) + if tm_idx >= 0: + self.translation_model_combo.setCurrentIndex(tm_idx) + self.translation_model_combo.setToolTip(t("settings.tooltip.translation_model")) + form.addRow(t("settings.label.translation_model"), self.translation_model_combo) + + style_layout = QVBoxLayout() + self.style_checkboxes = {} + saved_styles = parse_styles(settings) + for style in TranslationStyle: + cb = QCheckBox(style.value) + if style.value in saved_styles: + cb.setChecked(True) + self.style_checkboxes[style.value] = cb + style_layout.addWidget(cb) + form.addRow(t("settings.label.styles"), style_layout) + + _heading(form, "settings.heading.live_translation") + + self.live_engine_combo = QComboBox() + self.live_engine_combo.addItem(t("settings.option.live_engine.grounding"), LIVE_ENGINE_GROUNDING) + self.live_engine_combo.addItem(t("settings.option.live_engine.ocr"), LIVE_ENGINE_OCR) + engine_idx = self.live_engine_combo.findData(settings.value(KEY_LIVE_ENGINE, DEFAULT_LIVE_ENGINE)) + if engine_idx >= 0: + self.live_engine_combo.setCurrentIndex(engine_idx) + self.live_engine_combo.setToolTip(t("settings.tooltip.live_engine")) + form.addRow(t("settings.label.live_engine"), self.live_engine_combo) + + self.live_interval_spin = QSpinBox() + self.live_interval_spin.setRange(200, 5000) + self.live_interval_spin.setSingleStep(100) + self.live_interval_spin.setSuffix(" ms") + self.live_interval_spin.setValue( + int(settings.value(KEY_LIVE_INTERVAL_MS, constants.DEFAULT_LIVE_INTERVAL_MS)) + ) + self.live_interval_spin.setToolTip(t("settings.tooltip.live_interval")) + form.addRow(t("settings.label.live_interval"), self.live_interval_spin) + + self.ocr_detector_combo = QComboBox() + self.ocr_detector_combo.addItem(t("settings.option.ocr_detector.mobile"), "PP-OCRv5_mobile_det") + self.ocr_detector_combo.addItem(t("settings.option.ocr_detector.server"), "PP-OCRv5_server_det") + detector_idx = self.ocr_detector_combo.findData(settings.value(KEY_OCR_DETECTOR, "PP-OCRv5_mobile_det")) + if detector_idx >= 0: + self.ocr_detector_combo.setCurrentIndex(detector_idx) + self.ocr_detector_combo.setToolTip(t("settings.tooltip.ocr_detector")) + form.addRow(t("settings.label.ocr_detector"), self.ocr_detector_combo) + + self.ignore_phrases_edit = QPlainTextEdit() + self.ignore_phrases_edit.setPlainText(settings.value(KEY_IGNORE_PHRASES, "") or "") + self.ignore_phrases_edit.setFixedHeight(70) + self.ignore_phrases_edit.setToolTip(t("settings.tooltip.ignore_phrases")) + form.addRow(t("settings.label.ignore_phrases"), self.ignore_phrases_edit) + + # Only the local reader has a detector or a filter list to configure. + def _sync_ocr_rows(_=None): + is_ocr = self.live_engine_combo.currentData() == LIVE_ENGINE_OCR + self.ocr_detector_combo.setEnabled(is_ocr) + self.ignore_phrases_edit.setEnabled(is_ocr) + + self.live_engine_combo.currentIndexChanged.connect(_sync_ocr_rows) + _sync_ocr_rows() + + _heading(form, "settings.heading.behaviour") + + self.auto_continue_cb = QCheckBox(t("settings.checkbox.auto_continue")) + self.auto_continue_cb.setChecked(is_true(settings.value(KEY_AUTO_CONTINUE, "false"))) + form.addRow(self.auto_continue_cb) + + return tab + + # ── Overlay ────────────────────────────────────────────────────── + + def _overlay_tab(self) -> QWidget: + """What you see, and where it sits.""" + settings = self.settings + tab = QWidget() + form = QFormLayout(tab) + + _heading(form, "settings.heading.appearance", first=True) + + opacity_row = QHBoxLayout() + self.opacity_slider = QSlider(Qt.Orientation.Horizontal) + self.opacity_slider.setRange(20, 100) + self.opacity_slider.setSingleStep(5) + self.opacity_slider.setPageStep(10) + opacity_val = int(settings.value("overlay_opacity", 85)) + self.opacity_slider.setValue(opacity_val) + self.opacity_value_label = QLabel(f"{opacity_val}%") + self.opacity_value_label.setFixedWidth(36) + self.opacity_slider.valueChanged.connect(lambda v: self.opacity_value_label.setText(f"{v}%")) + opacity_row.addWidget(self.opacity_slider) + opacity_row.addWidget(self.opacity_value_label) + form.addRow(t("settings.label.overlay_opacity"), opacity_row) + + self.text_size_spin = QSpinBox() + self.text_size_spin.setRange(8, 24) + self.text_size_spin.setValue(int(settings.value("overlay_text_size", 13))) + form.addRow(t("settings.label.text_size"), self.text_size_spin) + + self.ui_lang_combo = QComboBox() + self.ui_lang_combo.addItems(["en", "zh", "ja", "ko", "ru", "es", "ar", "hi", "vi"]) + self.ui_lang_combo.setCurrentText(settings.value(KEY_UI_LANG, "en")) + form.addRow(t("settings.label.ui_language"), self.ui_lang_combo) + + _heading(form, "settings.heading.placement") + + preset_layout = QHBoxLayout() + self.preset_combo = QComboBox() + presets = settings.value("layout_presets_list", ["Default"]) + if not isinstance(presets, list): + presets = ["Default"] + self.preset_combo.addItems(presets) + current_preset = settings.value("layout_preset", "Default") + idx = self.preset_combo.findText(current_preset) + if idx >= 0: + self.preset_combo.setCurrentIndex(idx) + + self.add_preset_btn = QPushButton("+") + self.add_preset_btn.setFixedWidth(30) + self.add_preset_btn.clicked.connect(self._add_preset) + + self.del_preset_btn = QPushButton("-") + self.del_preset_btn.setFixedWidth(30) + self.del_preset_btn.clicked.connect(self._del_preset) + + self.edit_layout_btn = QPushButton(t("settings.button.edit_layout")) + self.edit_layout_btn.clicked.connect(self._on_edit_layout) + + preset_layout.addWidget(self.preset_combo) + preset_layout.addWidget(self.add_preset_btn) + preset_layout.addWidget(self.del_preset_btn) + preset_layout.addWidget(self.edit_layout_btn) + form.addRow(t("settings.label.layout_preset"), preset_layout) + + self.target_window_combo = QComboBox() + self.target_window_combo.setEditable(True) + self.target_window_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.target_window_combo.addItem(t("settings.option.none_overlay"), "") + try: + for title in WindowBinder.get_active_window_titles(): + self.target_window_combo.addItem(title, title) + except Exception as e: + logger.error("Could not fetch active window titles: %s", e) + + current_title = settings.value(KEY_TARGET_WINDOW_TITLE, "") + if current_title: + idx = self.target_window_combo.findData(current_title) + if idx >= 0: + self.target_window_combo.setCurrentIndex(idx) + else: + self.target_window_combo.addItem(current_title, current_title) + self.target_window_combo.setCurrentText(current_title) + else: + self.target_window_combo.setCurrentIndex(0) + form.addRow(t("settings.label.target_window_title"), self.target_window_combo) + + return tab + + # ── Server ─────────────────────────────────────────────────────── + + def _server_tab(self) -> QWidget: + """Where the models run, and how much of the machine they get.""" + settings = self.settings + tab = QWidget() + form = QFormLayout(tab) + + _heading(form, "settings.heading.connection", first=True) + + self.url_edit = QLineEdit() + self.url_edit.setText(normalized_api_url_from_settings(settings)) + form.addRow(t("settings.label.server_url"), self.url_edit) + + self.model_combo = QComboBox() + self.model_combo.setEditable(True) + if self._models: + self.model_combo.addItems(self._models) + self.model_combo.setCurrentText(settings.value(KEY_API_MODEL, constants.DEFAULT_MODEL)) + form.addRow(t("settings.label.model"), self.model_combo) + + # Which Xian collection to install. Picking one here re-registers it + # and points the model above at it; the model field stays editable for + # anyone who would rather drive a model of their own choosing. + self.tier_combo = QComboBox() + for tier, collection in COLLECTIONS.items(): + label = f"{t(f'settings.option.collection.{tier.value}')} — {collection.size_gb:.1f} GB" + self.tier_combo.addItem(label, tier.value) + self._initial_tier = settings.value(KEY_COLLECTION_TIER, constants.DEFAULT_COLLECTION_TIER) + tier_idx = self.tier_combo.findData(self._initial_tier) + if tier_idx >= 0: + self.tier_combo.setCurrentIndex(tier_idx) + self.tier_combo.setToolTip(t("settings.tooltip.collection_tier")) + form.addRow(t("settings.label.collection_tier"), self.tier_combo) + + _heading(form, "settings.heading.hardware") + + self.tokens_spin = QSpinBox() + self.tokens_spin.setRange(256, 32768) + self.tokens_spin.setValue(int(settings.value(KEY_MAX_TOKENS, constants.DEFAULT_MAX_TOKENS))) + form.addRow(t("settings.label.max_tokens"), self.tokens_spin) + + self.gpu_combo = QComboBox() + self.gpu_combo.addItems(["Default", "0.5", "0.75"]) + self.gpu_combo.setCurrentText(settings.value(KEY_GPU_UTIL, constants.DEFAULT_GPU_MEMORY_UTILIZATION)) + form.addRow(t("settings.label.gpu_memory_utilization"), self.gpu_combo) + + # Accelerator choice. Only text and speech can move to the NPU — the + # vision model always runs on the GPU, so this never affects how fast + # the screen is read. + self.backend_combo = QComboBox() + for pref in constants.BACKEND_PREFERENCES: + self.backend_combo.addItem(t(f"settings.option.backend.{pref}"), pref) + b_idx = self.backend_combo.findData( + settings.value(KEY_BACKEND_PREFERENCE, constants.DEFAULT_BACKEND_PREFERENCE) + ) + if b_idx >= 0: + self.backend_combo.setCurrentIndex(b_idx) + self.backend_combo.setToolTip(t("settings.tooltip.backend_preference")) + form.addRow(t("settings.label.backend_preference"), self.backend_combo) + + self.npu_power_combo = QComboBox() + for mode in constants.NPU_POWER_MODES: + self.npu_power_combo.addItem(t(f"settings.option.npu_power.{mode}"), mode) + p_idx = self.npu_power_combo.findData(settings.value(KEY_NPU_POWER_MODE, constants.DEFAULT_NPU_POWER_MODE)) + if p_idx >= 0: + self.npu_power_combo.setCurrentIndex(p_idx) + npu_available = bool(self.app and self.app.processor.router.npu_available()) + self.npu_power_combo.setEnabled(npu_available) + if not npu_available: + self.npu_power_combo.setToolTip(t("settings.tooltip.npu_unavailable")) + form.addRow(t("settings.label.npu_power_mode"), self.npu_power_combo) + + return tab + + # ── Assistant ──────────────────────────────────────────────────── + + def _assistant_tab(self) -> QWidget: + """The orb, its voice, and what it remembers.""" + settings = self.settings + tab = QWidget() + form = QFormLayout(tab) + self._assistant_form = form + + _heading(form, "settings.heading.memory", first=True) + + self.memory_enabled_cb = QCheckBox(t("settings.checkbox.memory_enabled")) + self.memory_enabled_cb.setToolTip(t("settings.tooltip.memory_enabled")) + self.memory_enabled_cb.setChecked(is_true(settings.value(KEY_MEMORY_ENABLED, "true"))) + form.addRow(self.memory_enabled_cb) + + memory_row = QHBoxLayout() + self.memory_retention_spin = QSpinBox() + self.memory_retention_spin.setRange(1, 365) + self.memory_retention_spin.setSuffix(" d") + self.memory_retention_spin.setValue( + int(settings.value(KEY_MEMORY_RETENTION_DAYS, constants.DEFAULT_MEMORY_RETENTION_DAYS)) + ) + memory_row.addWidget(self.memory_retention_spin, 1) + self.clear_memory_btn = QPushButton(t("settings.button.clear_memory")) + self.clear_memory_btn.clicked.connect(self._on_clear_memory_clicked) + memory_row.addWidget(self.clear_memory_btn) + form.addRow(t("settings.label.memory_retention"), memory_row) + + _heading(form, "settings.heading.voice") + + self.auto_speak_cb = QCheckBox(t("settings.checkbox.auto_speak")) + self.auto_speak_cb.setChecked(is_true(settings.value(KEY_AUTO_SPEAK, "false"))) + form.addRow(self.auto_speak_cb) + + self.familiar_tts_cb = QCheckBox(t("settings.checkbox.familiar_tts")) + self.familiar_tts_cb.setChecked(is_true(settings.value(KEY_FAMILIAR_TTS, "false"))) + form.addRow(self.familiar_tts_cb) + + self.live_voice_raid_cb = QCheckBox(t("settings.checkbox.live_voice_raid")) + self.live_voice_raid_cb.setChecked(is_true(settings.value("live_voice_raid", "false"))) + form.addRow(self.live_voice_raid_cb) + + self.live_raid_lore_save_cb = QCheckBox(t("settings.checkbox.live_raid_lore_save")) + self.live_raid_lore_save_cb.setChecked(is_true(settings.value("live_raid_lore_save", "false"))) + form.addRow(self.live_raid_lore_save_cb) + + self._familiar_heading = _heading(form, "settings.heading.familiar") + + self.familiar_enabled_cb = QCheckBox(t("settings.checkbox.familiar_enabled")) + self.familiar_enabled_cb.setChecked(is_true(settings.value(KEY_FAMILIAR_ENABLED, "false"))) + form.addRow(self.familiar_enabled_cb) + + self.familiar_type_combo = QComboBox() + for species in ("wizard", "witch", "cat", "owl", "lemonfae", "custom"): + self.familiar_type_combo.addItem(t(f"familiar.species.{species}"), species) + ft_idx = self.familiar_type_combo.findData(settings.value(KEY_FAMILIAR_TYPE, "wizard")) + if ft_idx >= 0: + self.familiar_type_combo.setCurrentIndex(ft_idx) + fam_type_row = QHBoxLayout() + fam_type_row.addWidget(self.familiar_type_combo, 1) + self.conjure_btn = QPushButton(t("familiar.conjure.button")) + self.conjure_btn.clicked.connect(self._on_conjure_clicked) + fam_type_row.addWidget(self.conjure_btn) + self.fam_type_row = fam_type_row + form.addRow(t("settings.label.familiar_type"), fam_type_row) + + return tab + + # ── Advanced ───────────────────────────────────────────────────── + + def _advanced_tab(self) -> QWidget: + """The two things left that are neither a task nor a preference.""" + settings = self.settings + tab = QWidget() + form = QFormLayout(tab) + + _heading(form, "settings.heading.hotkeys", first=True) + + # The one gesture with no clickable substitute: a fullscreen game + # holding the pointer leaves nothing to click. + self.overlay_toggle_combo = QComboBox() + self.overlay_toggle_combo.addItem("Right Shift", "rshift") + self.overlay_toggle_combo.addItem("Right Ctrl", "rctrl") + self.overlay_toggle_combo.addItem("Right Alt", "ralt") + self.overlay_toggle_combo.addItem("Super", "super") + t_idx = self.overlay_toggle_combo.findData( + settings.value(KEY_OVERLAY_TOGGLE_KEY, constants.DEFAULT_OVERLAY_TOGGLE_KEY) + ) + if t_idx >= 0: + self.overlay_toggle_combo.setCurrentIndex(t_idx) + self.overlay_toggle_combo.setToolTip(t("settings.tooltip.overlay_toggle_key")) + form.addRow(t("settings.label.overlay_toggle_key"), self.overlay_toggle_combo) + + _heading(form, "settings.heading.developer") + + self.dev_options_cb = QCheckBox(t("settings.checkbox.dev_options")) + self.dev_options_cb.setChecked(is_true(settings.value("developer_options", "false"))) + self.dev_options_cb.toggled.connect(self._update_dev_visibility) + form.addRow(self.dev_options_cb) + + return tab + + # ── layout presets ─────────────────────────────────────────────── + + def _add_preset(self): + name, ok = QInputDialog.getText( + self, t("settings.prompt.layout_preset.new"), t("settings.prompt.layout_preset.new") + ) + if not ok: + return + name = name.strip() + if not name: + QMessageBox.critical(self, "Error", t("settings.error.layout_preset.invalid")) + return + presets = [self.preset_combo.itemText(i) for i in range(self.preset_combo.count())] + if name in presets: + QMessageBox.critical(self, "Error", t("settings.error.layout_preset.exists")) + return + + presets.append(name) + self.settings.setValue("layout_presets_list", presets) + self.preset_combo.addItem(name) + self.preset_combo.setCurrentText(name) + + def _del_preset(self): + current = self.preset_combo.currentText() + if current == "Default": + QMessageBox.critical(self, "Error", "The Default preset cannot be deleted.") + return + + choice = QMessageBox.question( + self, + "Delete Preset", + f"Are you sure you want to delete the layout preset '{current}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if choice != QMessageBox.StandardButton.Yes: + return + + presets = [self.preset_combo.itemText(i) for i in range(self.preset_combo.count())] + presets.remove(current) + self.settings.setValue("layout_presets_list", presets) + self.settings.remove(f"layout/{current}") + + idx = self.preset_combo.findText(current) + if idx >= 0: + self.preset_combo.removeItem(idx) + self.preset_combo.setCurrentText("Default") + + # ── the buttons ────────────────────────────────────────────────── + + def _save(self): + normalized = normalize_lemonade_api_base_url(self.url_edit.text().strip()) + if should_warn_http_to_non_loopback(normalized): + choice = QMessageBox.warning( + self, + t("settings.warn.http_remote.title"), + t("settings.warn.http_remote.body"), + QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Save, + ) + if choice == QMessageBox.StandardButton.Cancel: + return + self.url_edit.setText(normalized) + + settings = self.settings + settings.setValue(KEY_API_URL, normalized) + settings.setValue(KEY_API_MODEL, self.model_combo.currentText()) + + # A changed tier names a different collection, so it also decides the + # model — otherwise the combo above would keep pointing at the old one. + selected_tier = self.tier_combo.currentData() + self.tier_changed = selected_tier != self._initial_tier + settings.setValue(KEY_COLLECTION_TIER, selected_tier) + if self.tier_changed: + settings.setValue(KEY_API_MODEL, get_collection(selected_tier).name) + + settings.setValue(KEY_MAX_TOKENS, self.tokens_spin.value()) + settings.setValue(KEY_GPU_UTIL, self.gpu_combo.currentText()) + settings.setValue(KEY_BACKEND_PREFERENCE, self.backend_combo.currentData()) + settings.setValue(KEY_NPU_POWER_MODE, self.npu_power_combo.currentData()) + + settings.setValue(KEY_SOURCE_LANG, self.source_lang_combo.currentText()) + settings.setValue(KEY_TARGET_LANG, self.lang_combo.currentText()) + settings.setValue(KEY_MODE, self.mode_combo.currentText()) + settings.setValue(KEY_TRANSLATION_MODEL, self.translation_model_combo.currentData()) + settings.setValue(KEY_STYLES, [s for s, cb in self.style_checkboxes.items() if cb.isChecked()]) + settings.setValue(KEY_LIVE_ENGINE, self.live_engine_combo.currentData()) + settings.setValue(KEY_LIVE_INTERVAL_MS, self.live_interval_spin.value()) + settings.setValue(KEY_OCR_DETECTOR, self.ocr_detector_combo.currentData()) + settings.setValue(KEY_IGNORE_PHRASES, self.ignore_phrases_edit.toPlainText()) + settings.setValue(KEY_AUTO_CONTINUE, "true" if self.auto_continue_cb.isChecked() else "false") + + settings.setValue("overlay_opacity", self.opacity_slider.value()) + settings.setValue("overlay_text_size", self.text_size_spin.value()) + ui_lang = self.ui_lang_combo.currentText() + settings.setValue(KEY_UI_LANG, ui_lang) + state.load_locale(ui_lang) + settings.setValue("layout_preset", self.preset_combo.currentText()) + settings.setValue(KEY_TARGET_WINDOW_TITLE, self._target_window_title()) + + settings.setValue(KEY_MEMORY_ENABLED, "true" if self.memory_enabled_cb.isChecked() else "false") + settings.setValue(KEY_MEMORY_RETENTION_DAYS, self.memory_retention_spin.value()) + settings.setValue(KEY_AUTO_SPEAK, "true" if self.auto_speak_cb.isChecked() else "false") + settings.setValue(KEY_FAMILIAR_TTS, "true" if self.familiar_tts_cb.isChecked() else "false") + settings.setValue("live_voice_raid", "true" if self.live_voice_raid_cb.isChecked() else "false") + settings.setValue("live_raid_lore_save", "true" if self.live_raid_lore_save_cb.isChecked() else "false") + settings.setValue(KEY_FAMILIAR_ENABLED, "true" if self.familiar_enabled_cb.isChecked() else "false") + settings.setValue(KEY_FAMILIAR_TYPE, self.familiar_type_combo.currentData()) + + settings.setValue(KEY_OVERLAY_TOGGLE_KEY, self.overlay_toggle_combo.currentData()) + settings.setValue("developer_options", "true" if self.dev_options_cb.isChecked() else "false") + self.accept() + + def _target_window_title(self) -> str: + """The window to follow, or empty for "stay where you are put".""" + value = self.target_window_combo.currentText().strip() + if ( + self.target_window_combo.currentIndex() == 0 + or value == t("settings.option.none_overlay") + or value == "None (Standard Overlay Mode)" + ): + return "" + return value + + def _on_edit_layout(self): + self.layout_edit_requested.emit() + self.accept() + + def _on_clear_memory_clicked(self): + """'Clear memory': erase the whole play history.""" + confirm = QMessageBox.question( + self, + t("settings.button.clear_memory"), + t("settings.confirm.clear_memory"), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if confirm != QMessageBox.StandardButton.Yes: + return + if self.app is not None: + self.app.clear_session_memory() + + def _on_conjure_clicked(self): + """'Conjure…': generate via the app, then select the custom species.""" + if self.app is not None and self.app.conjure_familiar(): + idx = self.familiar_type_combo.findData("custom") + if idx >= 0: + self.familiar_type_combo.setCurrentIndex(idx) + + def _update_dev_visibility(self, checked): + """Hide the half-finished things unless the user asked to see them.""" + self.live_voice_raid_cb.setVisible(checked) + self.live_raid_lore_save_cb.setVisible(checked) + # The familiar is developer-only while its art is in progress. + self._familiar_heading.setVisible(checked) + self.familiar_enabled_cb.setVisible(checked) + self.familiar_tts_cb.setVisible(checked) + self._assistant_form.setRowVisible(self.fam_type_row, checked) diff --git a/apps/mage-client/src/mage/ui/shell.py b/apps/mage-client/src/mage/ui/shell.py index 7fef7fb..dab0452 100644 --- a/apps/mage-client/src/mage/ui/shell.py +++ b/apps/mage-client/src/mage/ui/shell.py @@ -34,6 +34,8 @@ from PyQt6.QtCore import QObject +from shared_types.state import t + logger = logging.getLogger(__name__) @@ -50,6 +52,9 @@ def exclude_regions(self) -> list: def on_translation(self, original: str, translated: str) -> None: """A line was translated somewhere; show it if this shell has a place.""" + def on_error(self, message: str) -> None: + """Something the user asked for failed; put the reason in front of them.""" + def teardown(self) -> None: """Release anything the shell owns.""" @@ -90,9 +95,14 @@ def __init__(self, app): self.panel.translate_once_requested.connect(self.translate_once) self.panel.settings_requested.connect(app._open_settings) self.panel.notes_requested.connect(app.toggle_notes) + self.panel.box_mode_cycled.connect(self._cycle_box_mode) + self.panel.box_removed.connect(self._remove_box_by_id) + self.panel.boxes_cleared.connect(self.boxes.clear) + self.boxes.boxes_changed.connect(self._refresh_boxes) self.boxes.load() self.boxes.start_all() + self._refresh_boxes() self.orb.show() # ── boxes ──────────────────────────────────────────────────────── @@ -116,6 +126,25 @@ def add_box(self) -> None: self._picker.closed.connect(self._on_picker_closed) self._picker.showFullScreen() + def _refresh_boxes(self) -> None: + """Mirror the boxes into the panel, so none of them is unreachable.""" + from mage.ui.new.boxes import BoxMode # noqa: F401 (mode labels are keyed) + + self.panel.set_boxes([ + (box.box_id, str(index + 1), t(f"newui.box.mode.{box.mode.value}")) + for index, box in enumerate(self.boxes.boxes) + ]) + + def _cycle_box_mode(self, box_id: str) -> None: + box = self.boxes.box(box_id) + if box is not None: + box.set_mode(box.mode.next()) + + def _remove_box_by_id(self, box_id: str) -> None: + box = self.boxes.box(box_id) + if box is not None: + self.boxes.remove_box(box) + def _on_boxes_drawn(self, rects) -> None: for rect in rects: self.boxes.add_box(rect) @@ -154,6 +183,15 @@ def _on_picker_closed(self) -> None: def on_translation(self, original: str, translated: str) -> None: self.panel.add_translation(original, translated) + def on_error(self, message: str) -> None: + """Into the log and onto the orb. + + The panel is the record — it is still there when the user opens it + later — and the orb is what they can see without opening anything. + """ + self.panel.add_message("mage", message) + self.orb.set_failed(message) + # ── chat ───────────────────────────────────────────────────────── def _toggle_panel(self) -> None: diff --git a/apps/mage-client/test_boxes.py b/apps/mage-client/test_boxes.py index 6af2313..87f1e2c 100644 --- a/apps/mage-client/test_boxes.py +++ b/apps/mage-client/test_boxes.py @@ -385,3 +385,50 @@ def test_a_live_box_keeps_its_session_across_a_restart(manager, fake_streams): assert fake_streams.opened[0].stopped is False assert manager._streams != {} + + +# ── reachable without hitting them ─────────────────────────────────── + +def test_the_middle_of_a_box_is_not_part_of_the_window(manager): + """The game inside a box is still a game: clicks aimed at it must not be + swallowed by the frame drawn around it.""" + from mage.ui.new.boxes import FRAME_BAND + + box = manager.add_box(QRect(0, 0, 400, 300), BoxMode.OFF) + box.show() + + mask = box.mask() + + assert not mask.isEmpty(), "the frame itself has to stay hittable" + assert not mask.contains(QRect(200, 150, 1, 1)), "the middle belongs to the game" + assert mask.contains(QRect(1, 1, 1, 1)), "the top-left corner is frame" + assert mask.contains(QRect(0, FRAME_BAND - 1, 1, 1)), "the band is FRAME_BAND deep" + + +def test_the_band_is_wide_enough_to_hit(manager): + """A 2px border was the whole interactive surface of a box on Windows, + where a layered window passes input through its transparent pixels.""" + from mage.ui.new.boxes import FRAME_BAND + + assert FRAME_BAND >= 8 + + +def test_a_box_can_be_found_by_its_id(manager): + """The orb's row addresses boxes by id, because it cannot address them + by pointing at them.""" + box = manager.add_box(QRect(0, 0, 400, 300), BoxMode.OFF) + + assert manager.box(box.box_id) is box + assert manager.box("box_nonexistent") is None + + +def test_changing_a_mode_tells_whoever_is_listing_the_boxes(manager): + """The orb's row names each box's mode; a mode cycled from the box's own + toolbar has to reach it.""" + box = manager.add_box(QRect(0, 0, 400, 300), BoxMode.OFF) + seen = [] + manager.boxes_changed.connect(lambda: seen.append(True)) + + box.set_mode(BoxMode.IGNORE) + + assert seen diff --git a/apps/mage-client/test_gui.py b/apps/mage-client/test_gui.py index a999cfe..167038d 100644 --- a/apps/mage-client/test_gui.py +++ b/apps/mage-client/test_gui.py @@ -259,3 +259,71 @@ def test_hiding_from_capture_survives_a_missing_window_id(monkeypatch): monkeypatch.setattr(window_binder.sys, "platform", "win32") assert window_binder.hide_window_from_capture(None) is False assert window_binder.hide_window_from_capture(0) is False + + +# ── the settings dialog's shape ────────────────────────────────────── + +def test_the_settings_are_five_tabs_named_for_tasks(q_app): + """"Features" had grown to seventeen unrelated controls, which is a tab + you have to read end to end every time you open it.""" + from mage.ui.settings_dialog import SettingsDialog + + settings = QSettings("XianProject", "MageTestTabs") + settings.clear() + dialog = SettingsDialog(settings, models=[]) + try: + names = [dialog.tabs.tabText(i) for i in range(dialog.tabs.count())] + assert names == ["Translation", "Overlay", "Server", "Assistant", "Advanced"] + finally: + dialog.deleteLater() + settings.clear() + + +def test_the_settings_that_stopped_controlling_anything_are_gone(q_app): + """The leader key and the dialogue delay drove the old letter-menu + interface; the experimental-live checkbox gated a live overlay the + translation boxes never consulted.""" + from mage.ui.settings_dialog import SettingsDialog + + settings = QSettings("XianProject", "MageTestDead") + settings.clear() + dialog = SettingsDialog(settings, models=[]) + try: + for widget in ("leader_combo", "experimental_live_cb", "delay_spin"): + assert not hasattr(dialog, widget), f"{widget} controls nothing any more" + finally: + dialog.deleteLater() + settings.clear() + + +def test_saving_still_writes_every_setting_it_shows(q_app): + """The tabs moved; what a save persists must not have.""" + from mage.settings_keys import ( + KEY_IGNORE_PHRASES, + KEY_LIVE_ENGINE, + KEY_LIVE_INTERVAL_MS, + KEY_OVERLAY_TOGGLE_KEY, + KEY_SOURCE_LANG, + KEY_TARGET_LANG, + ) + from mage.ui.settings_dialog import SettingsDialog + + settings = QSettings("XianProject", "MageTestSave") + settings.clear() + dialog = SettingsDialog(settings, models=[]) + try: + dialog.live_interval_spin.setValue(1200) + dialog.ignore_phrases_edit.setPlainText("HP\nMP") + dialog.opacity_slider.setValue(60) + dialog.text_size_spin.setValue(19) + dialog._save() + + assert int(settings.value(KEY_LIVE_INTERVAL_MS)) == 1200 + assert settings.value(KEY_IGNORE_PHRASES) == "HP\nMP" + assert int(settings.value("overlay_opacity")) == 60 + assert int(settings.value("overlay_text_size")) == 19 + for key in (KEY_SOURCE_LANG, KEY_TARGET_LANG, KEY_LIVE_ENGINE, KEY_OVERLAY_TOGGLE_KEY): + assert settings.value(key) is not None, f"{key} was not saved" + finally: + dialog.deleteLater() + settings.clear() diff --git a/apps/mage-client/test_live_lens.py b/apps/mage-client/test_live_lens.py index c96bc50..253c93b 100644 --- a/apps/mage-client/test_live_lens.py +++ b/apps/mage-client/test_live_lens.py @@ -202,6 +202,8 @@ def __init__(self): self._carry = [] self.rect = None self._served_rect = None + self._last_error = None + self._failures = 0 self.errors = [] self.emitted = [] @@ -432,3 +434,42 @@ def test_unchanged_text_keeps_the_wording_it_already_had(): worker._publish([TextRegion((11, 11, 99, 39), "前往", "Head over")], frame) assert [r.translated for r in worker.emitted[-1][0]] == ["Go"] + + +# ── a loop that cannot succeed ─────────────────────────────────────── + +def test_the_same_failure_is_reported_once_not_every_tick(): + """A missing model does not fix itself between ticks, and a thousand + copies of the message bury the one the user needed.""" + from mage.live_common import MAX_INFERENCE_FAILURES + + worker = _Publisher() + + for _ in range(MAX_INFERENCE_FAILURES - 1): + assert worker._note_failure("PP-OCRv5_mobile_det is not in the catalog") is False + + assert len(worker.errors) == 1 + + +def test_a_loop_that_keeps_failing_the_same_way_gives_up(): + """Capture, read, fail, several times a second, forever: the game pays + for it and the user learns nothing new.""" + from mage.live_common import MAX_INFERENCE_FAILURES + + worker = _Publisher() + + verdicts = [worker._note_failure("no such model") for _ in range(MAX_INFERENCE_FAILURES)] + + assert verdicts[-1] is True + assert verdicts[:-1] == [False] * (MAX_INFERENCE_FAILURES - 1) + assert len(worker.errors) == 2, "the first failure, and the one that says it stopped" + + +def test_a_different_failure_is_reported_again(): + """Two different problems are two things the user has to know about.""" + worker = _Publisher() + + worker._note_failure("connection refused") + worker._note_failure("model not found") + + assert len(worker.errors) == 2 diff --git a/apps/mage-client/test_live_ocr.py b/apps/mage-client/test_live_ocr.py index 85afc2c..f2d44d2 100644 --- a/apps/mage-client/test_live_ocr.py +++ b/apps/mage-client/test_live_ocr.py @@ -317,3 +317,83 @@ def test_stacked_boxes_are_suppressed_before_painting(): regions = run(Worker(engine, translator)) assert len(regions) == 1 + + +# ── which engine actually runs ─────────────────────────────────────── + +class _FakeSettings: + def __init__(self, values=None): + self._values = values or {} + + def value(self, key, default=None): + return self._values.get(key, default) + + +def test_the_vision_model_is_never_second_guessed(monkeypatch): + """It needs nothing exported, so there is nothing to check.""" + from mage import live_engine + from mage.settings_keys import KEY_LIVE_ENGINE, LIVE_ENGINE_GROUNDING + + monkeypatch.setattr(live_engine, "ocr_models_ready", lambda s: False) + settings = _FakeSettings({KEY_LIVE_ENGINE: LIVE_ENGINE_GROUNDING}) + + assert live_engine.resolve_live_engine(settings) == LIVE_ENGINE_GROUNDING + + +def test_the_local_reader_is_used_when_its_weights_are_there(monkeypatch): + from mage import live_engine + from mage.settings_keys import KEY_LIVE_ENGINE, LIVE_ENGINE_OCR + + monkeypatch.setattr(live_engine, "ocr_models_ready", lambda s: True) + settings = _FakeSettings({KEY_LIVE_ENGINE: LIVE_ENGINE_OCR}) + + assert live_engine.resolve_live_engine(settings) == LIVE_ENGINE_OCR + + +def test_a_reader_with_no_weights_falls_back_rather_than_failing(monkeypatch): + """PP-OCRv5 has no published ONNX release, so a fresh install has no + weights — and a box that picks the reader anyway fails on its first read, + in a worker thread, showing the user nothing but a red border.""" + from mage import live_engine + from mage.settings_keys import KEY_LIVE_ENGINE, LIVE_ENGINE_GROUNDING, LIVE_ENGINE_OCR + + monkeypatch.setattr(live_engine, "ocr_models_ready", lambda s: False) + monkeypatch.setattr(live_engine, "_warned", False) + settings = _FakeSettings({KEY_LIVE_ENGINE: LIVE_ENGINE_OCR}) + + assert live_engine.resolve_live_engine(settings) == LIVE_ENGINE_GROUNDING + + +def test_the_default_is_the_local_reader(monkeypatch): + """What the boxes have always actually run.""" + from mage import live_engine + from mage.settings_keys import LIVE_ENGINE_OCR + + monkeypatch.setattr(live_engine, "ocr_models_ready", lambda s: True) + + assert live_engine.resolve_live_engine(_FakeSettings()) == LIVE_ENGINE_OCR + + +def test_a_distribution_host_counts_as_ready(monkeypatch): + """Fetching is ensure_model's job; refusing here would fall back for a + download that was going to succeed.""" + from mage import live_engine + + monkeypatch.setenv("XIAN_OCR_BASE_URL", "https://example.invalid/ocr") + + assert live_engine.ocr_models_ready(_FakeSettings()) is True + + +def test_readiness_checks_the_recognizer_for_the_chosen_language(monkeypatch): + """A machine that exported Chinese and not Korean works for one and not + the other, and the check has to know the difference.""" + from mage import live_engine + from mage.settings_keys import KEY_SOURCE_LANG + + monkeypatch.delenv("XIAN_OCR_BASE_URL", raising=False) + asked = [] + monkeypatch.setattr("xian.ocr.models.verify_model", lambda model_id: asked.append(model_id) or True) + + live_engine.ocr_models_ready(_FakeSettings({KEY_SOURCE_LANG: "Korean"})) + + assert any("korean" in model_id for model_id in asked), asked diff --git a/docs/AI_ARCHITECTURE.md b/docs/AI_ARCHITECTURE.md index 4cc217a..fc47ac8 100644 --- a/docs/AI_ARCHITECTURE.md +++ b/docs/AI_ARCHITECTURE.md @@ -183,7 +183,7 @@ The client parses this structure using high-performance regular expressions. A s ### 6. The Second Payload: Local OCR, Then Text-Only Translation -Live mode can run a second lifecycle entirely, chosen in **Settings → Features → Live +Live mode can run a second lifecycle entirely, chosen in **Settings → Translation → Live engine**. The vision model does not see the frame at all: PP-OCRv5 reads it on this machine, and only the recognized text crosses the wire. diff --git a/docs/QA_CHECKLIST.md b/docs/QA_CHECKLIST.md index 886d582..8141f03 100644 --- a/docs/QA_CHECKLIST.md +++ b/docs/QA_CHECKLIST.md @@ -53,7 +53,7 @@ that ships. Run §3's items in each cell, then these: -- [ ] 🟢 Settings → Features → **Live engine → Local OCR**, restart live mode; text is +- [ ] 🟢 Settings → Translation → **Live engine → Local OCR**, restart live mode; text is read and painted with no vision model loaded on the server. - [ ] 🟢 First run with no exported models gives an actionable message naming `scripts/export_ppocr_onnx.py`, not a crash. @@ -72,7 +72,7 @@ Run §3's items in each cell, then these: ## 3c. The new UI -- [ ] 🟢 Settings → Features → **Use the new interface**, restart; the orb appears and the +- [ ] 🟢 the orb appears on launch and the command OSD does not. - [ ] 🟢 Double-tapping the leader key does **nothing**. Double-tapping the overlay-toggle key still hides every overlay — including from inside a fullscreen game. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5ddf70f..3165c17 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -85,7 +85,7 @@ The honest state of Ryzen AI on Linux, as of Lemonade v10: * **What works today.** Lemonade serves NPU models through the FastFlowLM (`flm`) recipe, which covers text LLMs, Whisper ASR, and embeddings. Chat, translation, session summarization, and raid-mode speech can all be routed there via - **Settings → Backend → Inference backend**. Run `./mage.sh --doctor-npu` to check the + **Settings → Server → Inference backend**. Run `./mage.sh --doctor-npu` to check the prerequisites. * **What does not exist.** There is no NPU *vision* backend on Linux, so the VLM — and therefore OCR — stays on the GPU. Routing text work to the NPU frees the GPU; it does @@ -102,7 +102,7 @@ The honest state of Ryzen AI on Linux, as of Lemonade v10: ### 3. Live In-Place Translation — ✅ shipped (both paths) The **Live** lens action continuously translates a locked region and paints each translation over the original text — Google Lens / DeepL style — instead of showing a -bubble beside it. Two engines drive it, chosen in **Settings → Features → Live engine**. +bubble beside it. Two engines drive it, chosen in **Settings → Translation → Live engine**. **Vision model** (default). One call detects, reads and translates together. diff --git a/packages/shared-types/locales/en.json b/packages/shared-types/locales/en.json index 8e9f256..6e7ce5e 100644 --- a/packages/shared-types/locales/en.json +++ b/packages/shared-types/locales/en.json @@ -874,5 +874,105 @@ "newui.orb.tooltip.translate_once": { "value": "Drag over anything on screen to translate it once. The translation fades on its own and no box is left behind.", "context": "Tooltip explaining the one-off translation gesture" + }, + "live.error.stopped": { + "value": "This box stopped translating after repeated failures:", + "context": "Prefix shown once a live translation box has given up; the underlying error message follows it" + }, + "newui.orb.label.boxes": { + "value": "Boxes", + "context": "Label in front of the row of translation boxes in the orb panel" + }, + "newui.orb.label.no_boxes": { + "value": "No boxes yet", + "context": "Shown in place of the boxes label when no translation boxes have been placed" + }, + "newui.orb.button.clear_boxes": { + "value": "Clear all", + "context": "Button that deletes every translation box" + }, + "newui.orb.tooltip.clear_boxes": { + "value": "Remove every translation box from the screen", + "context": "Tooltip for the clear-all-boxes button" + }, + "newui.orb.tooltip.cycle_mode": { + "value": "Click to change this box's mode: Live, Once, Ignore, Off", + "context": "Tooltip on a box chip in the orb panel" + }, + "newui.orb.tooltip.remove_box": { + "value": "Delete this box", + "context": "Tooltip on the remove button beside a box chip" + }, + "settings.tab.translation": { + "value": "Translation", + "context": "Settings tab holding languages, the translation model and the live reader" + }, + "settings.tab.overlay": { + "value": "Overlay", + "context": "Settings tab holding what the overlay looks like and where its windows sit" + }, + "settings.tab.server": { + "value": "Server", + "context": "Settings tab holding the Lemonade server address and hardware options" + }, + "settings.tab.assistant": { + "value": "Assistant", + "context": "Settings tab holding session memory, voice and the familiar" + }, + "settings.tab.advanced": { + "value": "Advanced", + "context": "Settings tab holding hotkeys and developer options" + }, + "settings.heading.languages": { + "value": "Languages", + "context": "Section heading above the source and target language settings" + }, + "settings.heading.live_translation": { + "value": "Live translation", + "context": "Section heading above the settings for continuously translating a region" + }, + "settings.heading.behaviour": { + "value": "Behaviour", + "context": "Section heading above optional translation behaviours" + }, + "settings.heading.appearance": { + "value": "Appearance", + "context": "Section heading above overlay opacity, text size and interface language" + }, + "settings.heading.placement": { + "value": "Placement", + "context": "Section heading above layout presets and the window the overlay follows" + }, + "settings.heading.connection": { + "value": "Connection", + "context": "Section heading above the server address and model settings" + }, + "settings.heading.hardware": { + "value": "Hardware", + "context": "Section heading above GPU, NPU and token-budget settings" + }, + "settings.heading.memory": { + "value": "Memory", + "context": "Section heading above the session memory settings" + }, + "settings.heading.voice": { + "value": "Voice", + "context": "Section heading above speech and audio settings" + }, + "settings.heading.familiar": { + "value": "Familiar", + "context": "Section heading above the desktop familiar settings" + }, + "settings.heading.hotkeys": { + "value": "Hotkeys", + "context": "Section heading above keyboard shortcut settings" + }, + "settings.heading.developer": { + "value": "Developer", + "context": "Section heading above developer-only options" + }, + "settings.tooltip.overlay_toggle_key": { + "value": "Double-tap this key to hide or show every overlay — the way back when a fullscreen game holds the pointer", + "context": "Tooltip explaining the overlay toggle hotkey" } }