Files
cowork-local/ui/settings_dialog.py
T
Nam Pham Dinh ThanhandClaude Opus 5 2b90492994 refactor(ui): R08-T07 — bóc settings_dialog.py 727 → 407 dòng thành 4 widget
Bốn mục trong Cài đặt tách thành widget riêng dưới presentation/settings/:

    general_settings_widget.py     ngôn ngữ, giao diện, khay, gợi ý
    provider_settings_widget.py    provider, base URL, key, model + 2 nút nền
    parameter_settings_widget.py   đính kèm, cấu trúc, giới hạn sandbox
    routing_settings_widget.py     Auto Model Routing

Mỗi widget tự dựng control, tự nạp giá trị, tự có apply_to(data). Dialog chỉ
còn lắp ráp và gọi apply_to lúc lưu — _save từ 34 dòng xuống còn phần khung.

Làm lưới an toàn trước khi bóc: tests/ui/test_settings_dialog_dac_ta.py, 7
bài đặc tả hành vi hiện tại (mục nào có mặt, nạp đúng giá trị gì, lưu ghi vào
đúng ô nào, đổi % sang phân lẻ, xoá cache sau lưu). Bóc xong cả 7 vẫn xanh,
và trong lúc bóc chúng đã đỏ đúng hai lần ở chỗ đáng đỏ.

Đây là repo chưa từng có test Qt nào — thêm tests/ui/conftest.py dựng
QApplication offscreen. Offscreen là bắt buộc chứ không phải cho nhanh: máy
dev là máy làm việc thật, test bật cửa sổ lên là nó nhảy ra che màn hình.

Dọn kèm:
* bỏ vòng "dựng vào layout rồi lại gỡ ra" của mục Chung, cùng widget cao 0px
  làm mốc cuộn — không cần nữa khi mục đó tự là một widget
* bỏ _select_combo, _secret, _model_combo, _with_load và 4 hàm provider khác
  đã chuyển vào widget (127 dòng)
* bỏ 5 import chết theo (Dict, QSizePolicy, PROVIDER_LABELS, SegmentedControl,
  LANGUAGES)

Giữ cầu tương thích: self.routing_*, self.prov_*, self.attach_* … thành
property trỏ vào widget con, vì 5 checker trong tools/ đọc thẳng tên cũ. Bỏ
được khi tools/ chuyển sang đọc self._provider_page.

Hai điều KHÔNG làm, ghi lại để khỏi tưởng là quên:
1. Plan ghi 4 widget và có tên `connector`. Thực tế UI connector đã dời khỏi
   Cài đặt từ trước (ghi chú ở settings_dialog.py:180 bản cũ), nên số mục thật
   là 5, không phải 4, và không có mục nào tên connector. Bốn mục bóc ra là 4
   mục có thật; mục Bảo mật sandbox để nguyên trong dialog lần này.
2. Còn ~108 dòng chết của MS365 (_refresh_ms365_status, _ms365_sign_in,
   _show_ms365_device_code, _ms365_sign_out): đọc self.ms365_status,
   self.ms365_signin_btn, self.ms365_signout_btn — ba thuộc tính KHÔNG BAO GIỜ
   được gán, và không hàm nào có người gọi. Gọi vào là AttributeError. Chưa
   xoá vì đó là quyết định của anh Nam, không phải việc kèm theo của T07.

437 test xanh. check_dialogs, check_no_hscroll, check_design_parity đều qua.

Kèm docs/refactor/tin-gui-team-hoa.md — tin báo Hoa về platform/ -> adapters/
và bản vá Windows của AtomicJsonFile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:22:54 +09:00

407 lines
18 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 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 ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import tr
from ..state import AppContext
from .icons import icon, IconLabel
from .widgets import ToggleSwitch
from .ext_connector_dialog import ExtConnectorEditDialog
from ..presentation.settings.general_settings_widget import GeneralSettingsWidget
from ..presentation.settings.provider_settings_widget import ProviderSettingsWidget
from ..presentation.settings.parameter_settings_widget import ParameterSettingsWidget
from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget
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)
# Never sideways: the content must fit the width it is given and scroll
# only downwards. Anything too wide has to shrink (see _model_combo and
# _with_load), not push a second scrollbar onto the user.
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._content = QWidget()
root = QVBoxLayout(self._content)
# --- Chung: ngôn ngữ, giao diện, khay ---
# Đã bóc sang presentation/settings/general_settings_widget.py (R08-T07).
self._general_box = GeneralSettingsWidget(self.ctx)
root.addWidget(self._general_box)
self._load_workers = []
# --- AI Provider ---
# Đã bóc sang presentation/settings/provider_settings_widget.py (R08-T07).
prov_group = ProviderSettingsWidget(self.ctx)
self._provider_page = prov_group
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 = ToggleSwitch(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 = ToggleSwitch(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 = ToggleSwitch("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 = ToggleSwitch("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 ---
# Đã bóc sang presentation/settings/parameter_settings_widget.py (R08-T07).
param_group = ParameterSettingsWidget(self.ctx)
self._param_page = param_group
root.addWidget(param_group)
# ---- Auto Model Routing ------------------------------------------
# Đã bóc sang presentation/settings/routing_settings_widget.py (R08-T07).
routing_group = RoutingSettingsWidget(self.ctx)
self._routing_page = routing_group
root.addWidget(routing_group)
# Left list + right panel: one group on screen at a time, the way the
# audit page's mock-up shows it. The five rows are the five real group
# boxes, so "which group am I in, how many left" is answerable at a
# glance instead of by scrolling to find out.
from .widgets import section_panels
pages = []
for label, widget in ((tr("settings.group.general"), self._general_box),
(tr("settings.group.provider"), prov_group),
(tr("settings.group.sandbox"), self.sandbox_group),
(tr("settings.group.parameter"), param_group),
(tr("routing.settings_group"), routing_group)):
root.removeWidget(widget)
page = QWidget()
pv = QVBoxLayout(page)
pv.setContentsMargins(4, 4, 4, 4)
pv.addWidget(widget)
pv.addStretch(1)
wrap = QScrollArea()
wrap.setWidgetResizable(True)
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
wrap.setWidget(page)
pages.append((label, wrap))
self.section_list, self.section_stack = section_panels(pages)
scroll.setParent(None)
body = QHBoxLayout()
body.setSpacing(10)
body.addWidget(self.section_list)
body.addWidget(self.section_stack, 1)
outer.addLayout(body, 1)
self._content = self._general_box # kept for other callers
# Floor the dialog at the width its WIDEST page needs, at the current
# font. On a 125%/150% display everything is wider, and without this the
# form was simply cut off instead of the window refusing to get smaller.
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
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())
# ---- cầu tương thích sau khi bóc Routing -----------------------------
# Năm checker trong tools/ và bài đặc tả đọc thẳng self.routing_*. Giữ tên
# cũ trỏ vào widget mới để việc bóc không kéo theo sửa chỗ khác — đây là
# đổi chỗ ở, không đổi hành vi. Bỏ được khi tools/ chuyển sang đọc
# self._routing_page.
provider_combo = property(lambda self: self._provider_page.provider_combo)
prov_base = property(lambda self: self._provider_page.prov_base)
prov_key = property(lambda self: self._provider_page.prov_key)
prov_model = property(lambda self: self._provider_page.prov_model)
prov_status = property(lambda self: self._provider_page.prov_status)
language_combo = property(lambda self: self._general_box.language_combo)
theme_combo = property(lambda self: self._general_box.theme_combo)
tray_chk = property(lambda self: self._general_box.tray_chk)
notify_chk = property(lambda self: self._general_box.notify_chk)
attach_files = property(lambda self: self._param_page.attach_files)
attach_tokens = property(lambda self: self._param_page.attach_tokens)
struct_nodes = property(lambda self: self._param_page.struct_nodes)
struct_edges = property(lambda self: self._param_page.struct_edges)
sandbox_cpu = property(lambda self: self._param_page.sandbox_cpu)
sandbox_memory = property(lambda self: self._param_page.sandbox_memory)
sandbox_disk = property(lambda self: self._param_page.sandbox_disk)
routing_mode = property(lambda self: self._routing_page.mode)
routing_policy = property(lambda self: self._routing_page.policy)
routing_min_gain = property(lambda self: self._routing_page.min_gain)
routing_timeout = property(lambda self: self._routing_page.timeout)
routing_interval = property(lambda self: self._routing_page.interval)
routing_concurrency = property(lambda self: self._routing_page.concurrency)
routing_judge = property(lambda self: self._routing_page.judge)
routing_reassess_btn = property(lambda self: self._routing_page.reassess_btn)
# ---- helpers -----------------------------------------------------
@staticmethod
def _group(title: str, rows) -> QGroupBox:
box = QGroupBox(title)
form = QFormLayout(box)
for label, widget in rows:
form.addRow(label, widget)
return box
# ---- 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 _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
self._provider_page.apply_to(data)
self._general_box.apply_to(data)
# 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": [],
})
self._param_page.apply_limits_to(data["agent_security"])
self._param_page.apply_to(data)
self._routing_page.apply_to(data)
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()