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>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-25 19:22:54 +09:00
co-authored by Claude Opus 5
parent 9d6a7be31b
commit 2b90492994
9 changed files with 794 additions and 377 deletions
@@ -0,0 +1,73 @@
"""Mục Chung trong Cài đặt — R08-T07.
Ngôn ngữ, giao diện, khay hệ thống, và dòng gợi ý cuối trang.
Bản trước khi tách dựng mục này thành một ``QFormLayout`` rời, gắn vào layout
gốc, rồi ở đoạn lắp ráp lại gỡ ra để nhét vào hộp riêng — kèm một widget cao
0 pixel làm mốc cuộn. Vòng vo đó chỉ tồn tại vì mục này không phải group box
như bốn mục kia. Gói thành widget là hết: nó tự là một trang, không cần gỡ ra
gắn vào, không cần mốc giả.
"""
from __future__ import annotations
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
from ...i18n import LANGUAGES, tr
from ...ui.widgets import SegmentedControl, ToggleSwitch
class GeneralSettingsWidget(QWidget):
def __init__(self, ctx, parent=None):
super().__init__(parent)
data = ctx.config.data
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
form = QFormLayout()
self.language_combo = SegmentedControl()
for key, label in LANGUAGES.items():
self.language_combo.addItem(label, key)
_select(self.language_combo, ctx.config.language)
form.addRow(tr("settings.language"), self.language_combo)
# Giao diện cũng có trên hàng tài khoản ở thanh bên (một cú bấm để lật
# nhanh); đây là cùng một giá trị, nhưng có tên và có giải thích, cho
# người đi tìm nó trong Cài đặt.
self.theme_combo = SegmentedControl()
for key in ("system", "dark", "light"):
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
_select(self.theme_combo, getattr(ctx.config, "theme", "system"))
form.addRow(tr("settings.theme"), self.theme_combo)
tray = data.get("tray", {})
self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
self.tray_chk.setChecked(bool(tray.get("minimize_on_close", True)))
form.addRow("", self.tray_chk)
self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
self.notify_chk.setChecked(bool(tray.get("notify_on_done", True)))
form.addRow("", self.notify_chk)
outer.addLayout(form)
note = QLabel(tr("settings.tip"))
note.setObjectName("hint")
note.setWordWrap(True) # không thì đúng một dòng này quyết định bề ngang dialog
outer.addWidget(note)
outer.addStretch(1)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
data["language"] = self.language_combo.currentData()
# MainWindow._open_settings áp lại giao diện sau khi dialog đóng, nên
# ghi giá trị ở đây là đủ để nó có hiệu lực.
data["theme"] = self.theme_combo.currentData()
tray = data.setdefault("tray", {})
tray["minimize_on_close"] = self.tray_chk.isChecked()
tray["notify_on_done"] = self.notify_chk.isChecked()
def _select(combo, value: str) -> None:
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
@@ -0,0 +1,98 @@
"""Mục Tham số trong Cài đặt — R08-T07.
Bóc từ ``ui/settings_dialog.py``. Ba nhóm con: đính kèm, cấu trúc/GraphRAG,
và giới hạn tài nguyên sandbox.
Lưu ý khi đọc: nhóm thứ ba **hiện** ở đây nhưng **lưu** vào ``agent_security``
chứ không phải một khoá riêng — nó vốn nằm ở mục Bảo mật sandbox rồi được dời
sang đây cho gần các con số khác. Chỗ hiện và chỗ lưu khác nhau, đừng gộp.
"""
from __future__ import annotations
from PySide6.QtWidgets import QFormLayout, QGroupBox, QLabel, QSpinBox
from ...i18n import tr
class ParameterSettingsWidget(QGroupBox):
def __init__(self, ctx, parent=None):
super().__init__(tr("settings.group.parameter"), parent)
data = ctx.config.data
sec = ctx.config.agent_security
form = QFormLayout(self)
def tieu_de(key: str) -> None:
lbl = QLabel(tr(key))
lbl.setStyleSheet("font-weight:600; margin-top:6px;")
form.addRow(lbl)
# --- đính kèm ---
att = data.get("attachments", {})
tieu_de("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"))
# Lưu theo token, hiện theo nghìn token.
self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
form.addRow(tr("settings.max_files"), self.attach_files)
form.addRow(tr("settings.max_per_file"), self.attach_tokens)
# --- cấu trúc / GraphRAG ---
st = data.get("structure", {})
tieu_de("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"))
form.addRow(tr("settings.max_nodes"), self.struct_nodes)
form.addRow(tr("settings.max_edges"), self.struct_edges)
# --- giới hạn sandbox (lưu vào agent_security) ---
tieu_de("settings.group.sandbox_limits")
self.sandbox_cpu = _gioi_han(" %", int(sec.get("resource_limit_cpu_percent", 0) or 0),
hi=100_000)
form.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
self.sandbox_memory = _gioi_han(" MB", int(sec.get("resource_limit_memory_mb", 2048) or 2048))
form.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
self.sandbox_disk = _gioi_han(" MB", int(sec.get("resource_limit_disk_mb", 2048) or 2048))
form.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
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()
def apply_limits_to(self, sec: dict) -> None:
"""Ba con số này thuộc ``agent_security``, không thuộc ``structure``."""
sec["resource_limit_cpu_percent"] = self.sandbox_cpu.value()
sec["resource_limit_memory_mb"] = self.sandbox_memory.value()
sec["resource_limit_disk_mb"] = self.sandbox_disk.value()
def _gioi_han(suffix: str, value: int, hi: int = 1_000_000) -> QSpinBox:
box = QSpinBox()
box.setRange(0, hi)
box.setSuffix(suffix)
box.setSpecialValueText(tr("settings.sandbox_unlimited")) # 0 = không giới hạn
box.setValue(value)
return box
@@ -0,0 +1,212 @@
"""Mục AI Provider trong Cài đặt — R08-T07.
Chọn nhà cung cấp, base URL, API key, model — kèm hai nút Tải model và Test
kết nối chạy ở luồng nền.
Điểm cần biết khi sửa: widget giữ **bản nháp cho từng provider**
(``_staging``). Người dùng đổi sang provider khác rồi quay lại thì thấy đúng
những gì mình vừa gõ, dù chưa bấm Lưu. Nếu đọc thẳng từ config thay vì từ bản
nháp là mất phần đang gõ dở.
"""
from __future__ import annotations
from typing import Dict
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QSizePolicy, QWidget,
)
from ...config import PROVIDER_LABELS
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.icons import icon
class ProviderSettingsWidget(QGroupBox):
def __init__(self, ctx, parent=None):
super().__init__(tr("settings.group.provider"), parent)
self.ctx = ctx
data = ctx.config.data
self._workers = []
self._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)
_select(self.provider_combo, ctx.config.active_provider)
self._current_key = self.provider_combo.currentData()
conf = self._staging.get(self._current_key, {})
self.prov_base = QLineEdit(conf.get("base_url", ""))
self.prov_key = QLineEdit(conf.get("api_key", ""))
self.prov_key.setEchoMode(QLineEdit.Password)
self.prov_model = _model_combo(conf.get("model", ""))
self.prov_status = QLabel("")
self.prov_status.setObjectName("hint")
self.prov_status.setWordWrap(True)
form = QFormLayout(self)
form.addRow(tr("settings.active_provider"), self.provider_combo)
form.addRow(tr("settings.base_url"), self.prov_base)
form.addRow(tr("settings.api_key"), self.prov_key)
form.addRow(tr("settings.model"), self._hang_model())
form.addRow("", self.prov_status)
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
data["active_provider"] = self.provider_combo.currentData()
self._stash()
for key, staged in self._staging.items():
data["providers"].setdefault(key, {}).update({
"base_url": staged.get("base_url", ""),
"api_key": staged.get("api_key", ""),
"model": staged.get("model", ""),
})
# ---- bản nháp từng provider -----------------------------------------
def _stash(self) -> None:
self._staging.setdefault(self._current_key, {}).update({
"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
"model": self.prov_model.currentText().strip(),
})
def _on_provider_changed(self) -> None:
self._stash()
self._current_key = self.provider_combo.currentData()
conf = self._staging.get(self._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 _conf_hien_tai(self, provider: str) -> dict:
if provider == self._current_key:
return {"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
"model": self.prov_model.currentText().strip()}
conf = self._staging.get(provider, {})
return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
"model": conf.get("model", "")}
# ---- hàng model + hai nút -------------------------------------------
def _hang_model(self) -> QWidget:
row = QWidget()
lay = QHBoxLayout(row)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.prov_model, 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()))
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()))
lay.addWidget(test_btn)
# Hai nút giữ kích thước tự nhiên, combo là thứ phải nhường. Không có
# dòng này thì bề rộng tối thiểu của hàng bằng combo cộng cả hai nút,
# không co lại được, và dialog sinh ra thanh cuộn ngang.
for b in (btn, test_btn):
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
return row
# ---- việc chạy nền ---------------------------------------------------
def _load_models(self, provider: str) -> None:
conf = self._conf_hien_tai(provider)
combo, status = self.prov_model, self.prov_status
def job(worker):
from ...providers import build_provider
prov = build_provider(provider, conf)
return {"models": prov.list_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)
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=result.get("error", "")
or tr("settings.load_models_error_unknown")))
self._chay_nen(job, done,
lambda e: status.setText(tr("settings.load_failed", err=e)),
tr("settings.loading_models"))
def _test_connection(self, provider: str) -> None:
conf = self._conf_hien_tai(provider)
status = self.prov_status
def job(worker):
from ...providers import build_provider
ok, message = build_provider(provider, conf).test_connection()
return {"ok": ok, "message": message}
def done(result):
status.setText(result.get("message", ""))
status.setStyleSheet("color: #090;" if result.get("ok") else "color: #c00;")
def failed(e):
status.setText(str(e))
status.setStyleSheet("color: #c00;")
self._chay_nen(job, done, failed, tr("settings.testing_connection"))
def _chay_nen(self, job, done, failed, dang_lam: str) -> None:
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
# Giữ tham chiếu: worker bị thu gom giữa chừng là luồng chết lặng lẽ.
self._workers.append(w)
self.prov_status.setText(dang_lam)
w.start()
def _model_combo(value: str) -> QComboBox:
combo = QComboBox()
combo.setEditable(True)
# Mặc định combo rộng bằng mục dài nhất; id model thì dài, nên hàng này
# tràn ra ngoài dialog và đẻ ra thanh cuộn ngang (tệ hơn ở màn 125%/150%).
# Cho nó co lại, phần bung ra để popup lo.
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
combo.setMinimumContentsLength(8)
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
if value:
combo.addItem(value)
combo.setCurrentText(value)
return combo
def _select(combo: QComboBox, value: str) -> None:
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
@@ -0,0 +1,119 @@
"""Mục Auto Model Routing trong Cài đặt — R08-T07.
Bóc từ ``ui/settings_dialog.py`` (khối dòng 254-309 của bản trước khi tách).
Widget tự dựng control, tự nạp giá trị, tự ghi trả về dict cấu hình. Dialog
chỉ còn việc đặt nó vào chỗ và gọi ``apply_to`` lúc lưu.
"""
from __future__ import annotations
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit, QPushButton, QSpinBox,
)
from ...i18n import tr
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
("manual", "routing.mode_manual"))
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
("latency", "routing.policy_latency"),
("balanced", "routing.policy_balanced"))
class RoutingSettingsWidget(QGroupBox):
def __init__(self, ctx, parent=None):
super().__init__(tr("routing.settings_group"), parent)
self.ctx = ctx
routing = ctx.config.routing
form = QFormLayout(self)
self.mode = QComboBox()
for value, key in MODE_KEYS:
self.mode.addItem(tr(key), value)
_select(self.mode, routing.get("switch_mode", "off"))
form.addRow(tr("routing.settings_mode"), self.mode)
self.policy = QComboBox()
for value, key in POLICY_KEYS:
self.policy.addItem(tr(key), value)
_select(self.policy, routing.get("policy", "balanced"))
form.addRow(tr("routing.settings_policy"), self.policy)
# Lưu dạng phân lẻ (0..1) nhưng hiện dạng phần trăm.
self.min_gain = QSpinBox()
self.min_gain.setRange(0, 100)
self.min_gain.setSuffix(" %")
self.min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
form.addRow(tr("routing.settings_min_gain"), self.min_gain)
self.timeout = QSpinBox()
self.timeout.setRange(5, 600)
self.timeout.setSuffix(" s")
self.timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
form.addRow(tr("routing.settings_timeout"), self.timeout)
self.interval = QSpinBox()
self.interval.setRange(0, 720)
self.interval.setSpecialValueText(tr("routing.mode_off")) # 0 = tắt
self.interval.setSuffix(" h")
self.interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
form.addRow(tr("routing.settings_interval"), self.interval)
self.concurrency = QSpinBox()
self.concurrency.setRange(1, 16)
self.concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
form.addRow(tr("routing.settings_concurrency"), self.concurrency)
self.judge = QLineEdit(routing.get("judge_model", ""))
form.addRow(tr("routing.settings_judge"), self.judge)
self.reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
self.reassess_btn.clicked.connect(self._reassess_now)
form.addRow("", self.reassess_btn)
hint = QLabel(tr("routing.settings_hint"))
hint.setObjectName("hint")
hint.setWordWrap(True)
form.addRow(hint)
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
r = data.setdefault("routing", {})
r["switch_mode"] = self.mode.currentData()
r["policy"] = self.policy.currentData()
r["min_score_gain"] = self.min_gain.value() / 100.0
r["confirm_timeout_sec"] = self.timeout.value()
r["reassess_interval_hours"] = self.interval.value()
r["per_provider_concurrency"] = self.concurrency.value()
r["judge_model"] = self.judge.text().strip()
# ---- đánh giá lại ngay ----------------------------------------------
def _reassess_now(self) -> None:
"""Chạy đánh giá lại model ở nền."""
try:
service = self.ctx.routing()
if service.is_reassessing():
return
self.reassess_btn.setEnabled(False)
self.reassess_btn.setText(tr("routing.reassessing"))
def _done(result) -> None:
self.reassess_btn.setEnabled(True)
self.reassess_btn.setText(
tr("routing.reassess_done", count=len(result or {})))
service.reassess_background(on_done=_done)
except Exception: # noqa: BLE001 — bấm đánh giá lại không được làm sập Cài đặt
self.reassess_btn.setEnabled(True)
self.reassess_btn.setText(tr("routing.settings_reassess_now"))
def _select(combo: QComboBox, value: str) -> None:
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)