"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group (CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place), and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps).""" from __future__ import annotations from typing import Dict from PySide6.QtCore import Qt from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from ..config import PROVIDER_LABELS from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES from ..core.worker import AgentWorker from ..i18n import LANGUAGES, tr from ..state import AppContext from .icons import icon, IconLabel from .ext_connector_dialog import ExtConnectorEditDialog class SettingsDialog(QDialog): def __init__(self, ctx, parent=None): super().__init__() self.ctx = ctx self.setWindowTitle(tr("settings.title")) self.setMinimumWidth(560) self.setWindowFlags( self.windowFlags() | Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint ) self.setSizeGripEnabled(True) self.setStyleSheet( "QGroupBox { background: transparent;" " border: 1px solid rgba(140,146,152,0.35); }") data = ctx.config.data outer = QVBoxLayout(self) scroll = QScrollArea() scroll.setWidgetResizable(True) self._content = QWidget() root = QVBoxLayout(self._content) # --- language + tray --- top = QFormLayout() self.language_combo = QComboBox() for key, label in LANGUAGES.items(): self.language_combo.addItem(label, key) self._select_combo(self.language_combo, ctx.config.language) top.addRow(tr("settings.language"), self.language_combo) self.tray_chk = QCheckBox(tr("settings.tray_keep")) self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) top.addRow("", self.tray_chk) self.notify_chk = QCheckBox(tr("settings.tray_notify")) self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True))) top.addRow("", self.notify_chk) root.addLayout(top) self._load_workers = [] # --- AI Provider --- self._prov_staging: Dict[str, dict] = { key: dict(conf) for key, conf in data["providers"].items() } self.provider_combo = QComboBox() for key, label in PROVIDER_LABELS.items(): self.provider_combo.addItem(label, key) self._select_combo(self.provider_combo, ctx.config.active_provider) self._prov_current_key = self.provider_combo.currentData() conf = self._prov_staging.get(self._prov_current_key, {}) self.prov_base = QLineEdit(conf.get("base_url", "")) self.prov_key = self._secret(conf.get("api_key", "")) self.prov_model = self._model_combo(conf.get("model", "")) self.prov_status = QLabel("") self.prov_status.setObjectName("hint") self.prov_status.setWordWrap(True) prov_group = self._group(tr("settings.group.provider"), [ (tr("settings.active_provider"), self.provider_combo), (tr("settings.base_url"), self.prov_base), (tr("settings.api_key"), self.prov_key), (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)), ]) prov_group.layout().addRow("", self.prov_status) self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed) root.addWidget(prov_group) # --- Sandbox Security Layer --- sec = ctx.config.agent_security self.sandbox_group = QGroupBox(tr("settings.group.sandbox")) sbl = QVBoxLayout(self.sandbox_group) # --- Password protection for Sandbox Security (at top) --- self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password") sbl.addWidget(self.sandbox_pw_label) pw_row = QHBoxLayout() self.sandbox_pw_edit = QLineEdit("") self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings") self.sandbox_pw_edit.setEchoMode(QLineEdit.Password) pw_row.addWidget(self.sandbox_pw_edit, 1) self.sandbox_unlock_btn = QPushButton("Unlock") self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock) pw_row.addWidget(self.sandbox_unlock_btn) self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00") self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;") pw_row.addWidget(self.sandbox_locked_status) sbl.addLayout(pw_row) self._sandbox_unlocked = False # Start LOCKED — must enter password first self._sandbox_pw = sec.get("sandbox_pw", "") # Separator line between pw section and sandbox settings pw_sep = QLabel("────────────────") sbl.addWidget(pw_sep) self.sandbox_confirm = QCheckBox(tr("settings.sandbox_confirm_commands")) self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) sbl.addWidget(self.sandbox_confirm) self.sandbox_block_network = QCheckBox(tr("settings.sandbox_block_network")) self.sandbox_block_network.setChecked(bool(sec.get("block_network", True))) self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip")) sbl.addWidget(self.sandbox_block_network) # "Allow the agent to fetch URLs" + the live "Test Internet" self-test # moved to Monitoring → Tools → Tool (they govern a tool capability, so # they belong with the other tool toggles — see ToolsAdminTab). # --- Enable/Disable Agent Security --- self.sec_enabled = QCheckBox("Enable Agent Security (command validation)") self.sec_enabled.setChecked(bool(sec.get("enabled", True))) self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") sbl.addWidget(self.sec_enabled) # --- AI Command Check toggle --- self.ai_check = QCheckBox("AI check commands") self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") sbl.addWidget(self.ai_check) # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group # below — see _param_section("settings.group.sandbox_limits"). # Collect all sandbox-editable widgets and lock them until unlocked self._sandbox_widgets = [ self.sandbox_confirm, self.sandbox_block_network, self.ai_check, self.sec_enabled, ] for _w in self._sandbox_widgets: _w.setEnabled(False) root.addWidget(self.sandbox_group) # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools # → Connector now — no connector UI in Settings. (_ms365_workers is kept # for the dead-but-retained MS365 OAuth sign-in handlers below.) self._ms365_workers = [] # --- Parameter --- param_group = QGroupBox(tr("settings.group.parameter")) pgl = QFormLayout(param_group) def _param_section(key: str) -> None: lbl = QLabel(tr(key)) lbl.setStyleSheet("font-weight:600; margin-top:6px;") pgl.addRow(lbl) # Parallel-conversation limit removed — conversations and flows now run # unlimited in parallel (no cap, no Settings row). att = data.get("attachments", {}) _param_section("settings.group.attachments") self.attach_files = QSpinBox() self.attach_files.setRange(1, 50) self.attach_files.setSuffix(tr("settings.max_files_suffix")) self.attach_files.setValue(max(1, int(att.get("max_files", 20)))) self.attach_files.setToolTip(tr("settings.max_files_tooltip")) self.attach_tokens = QSpinBox() self.attach_tokens.setRange(1, 1000) self.attach_tokens.setSingleStep(5) self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix")) self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000)) self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip")) pgl.addRow(tr("settings.max_files"), self.attach_files) pgl.addRow(tr("settings.max_per_file"), self.attach_tokens) st = data.get("structure", {}) _param_section("settings.group.structure") self.struct_nodes = QSpinBox() self.struct_nodes.setRange(0, 100000) self.struct_nodes.setSpecialValueText(tr("settings.unlimited")) self.struct_nodes.setSuffix(tr("settings.nodes_suffix")) self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500)))) self.struct_nodes.setToolTip(tr("settings.nodes_tooltip")) self.struct_edges = QSpinBox() self.struct_edges.setRange(0, 200000) self.struct_edges.setSpecialValueText(tr("settings.unlimited")) self.struct_edges.setSuffix(tr("settings.edges_suffix")) self.struct_edges.setValue(max(0, int(st.get("max_edges", 500)))) self.struct_edges.setToolTip(tr("settings.edges_tooltip")) pgl.addRow(tr("settings.max_nodes"), self.struct_nodes) pgl.addRow(tr("settings.max_edges"), self.struct_edges) # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from # the Sandbox Security group; still stored under agent_security.*. _param_section("settings.group.sandbox_limits") self.sandbox_cpu = QSpinBox() self.sandbox_cpu.setRange(0, 100_000) self.sandbox_cpu.setSuffix(" %") self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited")) self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0)) pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu) self.sandbox_memory = QSpinBox() self.sandbox_memory.setRange(0, 1_000_000) self.sandbox_memory.setSuffix(" MB") self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited")) self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048)) pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory) self.sandbox_disk = QSpinBox() self.sandbox_disk.setRange(0, 1_000_000) self.sandbox_disk.setSuffix(" MB") self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited")) self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048)) pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk) root.addWidget(param_group) # ---- Auto Model Routing ------------------------------------------ routing = self.ctx.config.routing routing_group = QGroupBox(tr("routing.settings_group")) rgl = QFormLayout(routing_group) self.routing_mode = QComboBox() for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"), ("manual", "routing.mode_manual")): self.routing_mode.addItem(tr(key), value) self._select_combo(self.routing_mode, routing.get("switch_mode", "off")) rgl.addRow(tr("routing.settings_mode"), self.routing_mode) self.routing_policy = QComboBox() for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")): self.routing_policy.addItem(tr(key), value) self._select_combo(self.routing_policy, routing.get("policy", "balanced")) rgl.addRow(tr("routing.settings_policy"), self.routing_policy) # Min score gain stored as a fraction (0..1); shown as a percentage. self.routing_min_gain = QSpinBox() self.routing_min_gain.setRange(0, 100) self.routing_min_gain.setSuffix(" %") self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100))) rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain) self.routing_timeout = QSpinBox() self.routing_timeout.setRange(5, 600) self.routing_timeout.setSuffix(" s") self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60)) rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout) self.routing_interval = QSpinBox() self.routing_interval.setRange(0, 720) self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled self.routing_interval.setSuffix(" h") self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0)) rgl.addRow(tr("routing.settings_interval"), self.routing_interval) self.routing_concurrency = QSpinBox() self.routing_concurrency.setRange(1, 16) self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2)) rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency) self.routing_judge = QLineEdit(routing.get("judge_model", "")) rgl.addRow(tr("routing.settings_judge"), self.routing_judge) self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now")) self.routing_reassess_btn.clicked.connect(self._routing_reassess_now) rgl.addRow("", self.routing_reassess_btn) rhint = QLabel(tr("routing.settings_hint")) rhint.setObjectName("hint") rhint.setWordWrap(True) rgl.addRow(rhint) root.addWidget(routing_group) note = QLabel(tr("settings.tip")) note.setObjectName("hint") root.addWidget(note) scroll.setWidget(self._content) outer.addWidget(scroll, 1) buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons.accepted.connect(self._save) buttons.rejected.connect(self.reject) outer.addWidget(buttons) from .widgets import guard_wheel guard_wheel(self) screen = QGuiApplication.primaryScreen() if screen: avail = screen.availableGeometry() self.resize(640, min(740, avail.height() - 80)) self.setMaximumHeight(avail.height()) # ---- helpers ----------------------------------------------------- @staticmethod def _secret(value: str) -> QLineEdit: edit = QLineEdit(value) edit.setEchoMode(QLineEdit.Password) return edit @staticmethod def _select_combo(combo: QComboBox, value: str) -> None: idx = combo.findData(value) if idx >= 0: combo.setCurrentIndex(idx) @staticmethod def _group(title: str, rows) -> QGroupBox: box = QGroupBox(title) form = QFormLayout(box) for label, widget in rows: form.addRow(label, widget) return box def _routing_reassess_now(self) -> None: """Kick off a manual model reassessment in the background.""" try: service = self.ctx.routing() if service.is_reassessing(): return self.routing_reassess_btn.setEnabled(False) self.routing_reassess_btn.setText(tr("routing.reassessing")) def _done(result) -> None: # Re-enable from the (worker) callback; label reflects the count. self.routing_reassess_btn.setEnabled(True) self.routing_reassess_btn.setText( tr("routing.reassess_done", count=len(result or {}))) service.reassess_background(on_done=_done) except Exception: # noqa: BLE001 — a reassess click must never crash Settings self.routing_reassess_btn.setEnabled(True) self.routing_reassess_btn.setText(tr("routing.settings_reassess_now")) @staticmethod def _model_combo(value: str) -> QComboBox: combo = QComboBox() combo.setEditable(True) if value: combo.addItem(value) combo.setCurrentText(value) return combo def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget: row = QWidget() lay = QHBoxLayout(row) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(combo, 1) btn = QPushButton(tr("settings.load")) btn.setIcon(icon("download")) btn.setToolTip(tr("settings.load_tooltip")) btn.clicked.connect( lambda: self._load_models(self.provider_combo.currentData(), combo, status)) lay.addWidget(btn) test_btn = QPushButton(tr("settings.test_connection")) test_btn.setIcon(icon("flask")) test_btn.setToolTip(tr("settings.test_connection_tooltip")) test_btn.clicked.connect( lambda: self._test_connection(self.provider_combo.currentData(), status)) lay.addWidget(test_btn) return row def _stash_provider_fields(self) -> None: staged = self._prov_staging.setdefault(self._prov_current_key, {}) staged.update({ "base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(), "model": self.prov_model.currentText().strip(), }) def _on_provider_edit_changed(self) -> None: self._stash_provider_fields() self._prov_current_key = self.provider_combo.currentData() conf = self._prov_staging.get(self._prov_current_key, {}) self.prov_base.setText(conf.get("base_url", "")) self.prov_key.setText(conf.get("api_key", "")) self.prov_model.clear() if conf.get("model"): self.prov_model.addItem(conf["model"]) self.prov_model.setCurrentText(conf["model"]) else: self.prov_model.setCurrentText("") self.prov_status.setText("") def _current_conf(self, provider: str) -> dict: if provider == self._prov_current_key: return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(), "model": self.prov_model.currentText().strip()} conf = self._prov_staging.get(provider, {}) return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""), "model": conf.get("model", "")} # ---- MS365 zero-config sign-in ("connect like Claude") --------------- def _refresh_ms365_status(self) -> None: from ..core.ms365_auth import current_identity who = current_identity(self.ctx.config) if who: self.ms365_status.setText(tr("settings.ms365_signed_in", who=who)) self.ms365_signin_btn.setEnabled(False) self.ms365_signout_btn.setEnabled(True) else: self.ms365_status.setText(tr("settings.ms365_signed_out")) self.ms365_signin_btn.setEnabled(True) self.ms365_signout_btn.setEnabled(False) self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn")) self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn")) def _ms365_sign_in(self) -> None: from ..core.ms365_auth import current_identity, sign_in self.ms365_signin_btn.setEnabled(False) self.ms365_status.setText(tr("settings.ms365_signing_in")) cfg = self.ctx.config def job(worker): # on_code fires (worker thread) with the MSAL device-flow dict — # marshal it to the UI thread via the worker's event signal. return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg) def on_event(ev: dict) -> None: if "device_flow" in ev: self._show_ms365_device_code(ev["device_flow"]) def done(_result) -> None: self._close_ms365_code_dialog() self.ctx.save() self._refresh_ms365_status() QMessageBox.information( self, tr("settings.ms365_signin_btn"), tr("settings.ms365_signed_in", who=current_identity(cfg))) def failed(err: str) -> None: self._close_ms365_code_dialog() self._refresh_ms365_status() QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err) w = AgentWorker(job) w.event.connect(on_event) w.finished_ok.connect(done) w.failed.connect(failed) self._ms365_workers.append(w) w.start() def _close_ms365_code_dialog(self) -> None: dlg = getattr(self, "_ms365_code_dialog", None) if dlg is not None: dlg.close() self._ms365_code_dialog = None def _show_ms365_device_code(self, flow: dict) -> None: """Auto-open the sign-in page + show the one-time code in a COPYABLE, non-modal dialog (so the worker keeps polling and can auto-close it on success). The code is also copied to the clipboard immediately.""" import webbrowser code = flow.get("user_code", "") url = flow.get("verification_uri", "https://microsoft.com/devicelogin") # Auto-copy the code so the user can just paste it. QGuiApplication.clipboard().setText(code) # Auto-open the browser to the (code-prefilled, if available) sign-in page. try: webbrowser.open(flow.get("verification_uri_complete") or url) except Exception: # noqa: BLE001 — a headless box just shows the link to click pass self._close_ms365_code_dialog() dlg = QDialog(self) dlg.setWindowTitle(tr("settings.ms365_signin_btn")) dlg.setMinimumWidth(420) lay = QVBoxLayout(dlg) info = QLabel(tr("settings.ms365_code_hint", url=url)) info.setWordWrap(True) info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction) info.setOpenExternalLinks(True) lay.addWidget(info) code_row = QHBoxLayout() code_edit = QLineEdit(code) code_edit.setReadOnly(True) f = code_edit.font() f.setPointSize(f.pointSize() + 4) f.setBold(True) code_edit.setFont(f) code_edit.setCursorPosition(0) copy_btn = QPushButton(tr("settings.ms365_copy_code")) copy_btn.setIcon(icon("document")) copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code)) open_btn = QPushButton(tr("settings.ms365_open_link")) open_btn.setIcon(icon("link")) open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url)) code_row.addWidget(code_edit, 1) code_row.addWidget(copy_btn) code_row.addWidget(open_btn) lay.addLayout(code_row) buttons = QDialogButtonBox(QDialogButtonBox.Close) buttons.rejected.connect(dlg.reject) lay.addWidget(buttons) self._ms365_code_dialog = dlg dlg.show() # non-modal — sign-in polling continues; done() closes it def _ms365_sign_out(self) -> None: from ..core.ms365_auth import sign_out_default sign_out_default(self.ctx.config) self._refresh_ms365_status() def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None: conf = self._current_conf(provider) def job(worker): from ..providers import build_provider prov = build_provider(provider, conf) models = prov.list_models() return {"models": models, "error": getattr(prov, "last_error", "")} def done(result): models = result.get("models") or [] current = combo.currentText().strip() combo.clear() if current: combo.addItem(current) for m in models: if m != current: combo.addItem(m) combo.setCurrentText(current) error = result.get("error", "") if models: status.setText(tr("settings.loaded_models", n=len(models), provider=PROVIDER_LABELS.get(provider, provider))) else: status.setText(tr("settings.load_models_error", err=error or tr("settings.load_models_error_unknown"))) w = AgentWorker(job) w.finished_ok.connect(done) w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e))) self._load_workers.append(w) status.setText(tr("settings.loading_models")) w.start() def _test_connection(self, provider: str, status: QLabel) -> None: conf = self._current_conf(provider) def job(worker): from ..providers import build_provider ok, message = build_provider(provider, conf).test_connection() return {"ok": ok, "message": message} def done(result): ok = result.get("ok") status.setText(result.get("message", "")) status.setStyleSheet("color: #090;" if ok else "color: #c00;") def failed(e): status.setText(str(e)) status.setStyleSheet("color: #c00;") w = AgentWorker(job) w.finished_ok.connect(done) w.failed.connect(failed) self._load_workers.append(w) status.setText(tr("settings.testing_connection")) w.start() def _sandbox_unlock(self) -> None: pw = self.sandbox_pw_edit.text() if pw and pw == self._sandbox_pw: self._sandbox_unlocked = True self.sandbox_locked_status.setText("Unlocked") self.sandbox_locked_status.set_icon("unlock", "#090") self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;") # Enable all sandbox widgets for w in self._sandbox_widgets: w.setEnabled(True) QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.") else: QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.") def _save(self) -> None: data = self.ctx.config.data data["active_provider"] = self.provider_combo.currentData() data["language"] = self.language_combo.currentData() self._stash_provider_fields() for key, staged in self._prov_staging.items(): data["providers"].setdefault(key, {}).update({ "base_url": staged.get("base_url", ""), "api_key": staged.get("api_key", ""), "model": staged.get("model", ""), }) # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now # (persisted there directly), so it is intentionally not written here. data.setdefault("agent_security", {}).update({ "enabled": self.sec_enabled.isChecked(), "cowork_confirm_commands": self.sandbox_confirm.isChecked(), "block_network": self.sandbox_block_network.isChecked(), "command_ai_check": self.ai_check.isChecked(), "command_whitelist": [], "resource_limit_cpu_percent": self.sandbox_cpu.value(), "resource_limit_memory_mb": self.sandbox_memory.value(), "resource_limit_disk_mb": self.sandbox_disk.value(), }) att = data.setdefault("attachments", {}) att["max_tokens"] = self.attach_tokens.value() * 1000 att["max_files"] = self.attach_files.value() st = data.setdefault("structure", {}) st["max_nodes"] = self.struct_nodes.value() st["max_edges"] = self.struct_edges.value() tray = data.setdefault("tray", {}) tray["minimize_on_close"] = self.tray_chk.isChecked() tray["notify_on_done"] = self.notify_chk.isChecked() r = data.setdefault("routing", {}) r["switch_mode"] = self.routing_mode.currentData() r["policy"] = self.routing_policy.currentData() r["min_score_gain"] = self.routing_min_gain.value() / 100.0 r["confirm_timeout_sec"] = self.routing_timeout.value() r["reassess_interval_hours"] = self.routing_interval.value() r["per_provider_concurrency"] = self.routing_concurrency.value() r["judge_model"] = self.routing_judge.text().strip() self.ctx.save() # Force-reload config so all parts of the app pick up the new settings immediately self.ctx.config._data = None # invalidate cache self.ctx.config._agent_security = None self.accept()