Files
cowork-local/ui/settings_dialog.py
T
NamPDTandClaude Opus 5 291a611737 chore: sync local working copy as of 2026-08-15
The Gitea repo was initialised from an earlier snapshot, so main and the
machine this runs on had drifted apart in 153 files before any UI work
started. This commit brings the branch up to the local tree as it stood
on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows
shows up as its own reviewable diff instead of being mixed in with the
pre-existing divergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:38:18 +09:00

648 lines
28 KiB
Python

"""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)
# Group boxes are styled app-wide (see theme._TEMPLATE); this dialog
# used to re-declare them and drifted out of sync with the rest.
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", "quandh14")
# 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 == 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()