CI / test (pull_request) Canceled after 0s
Người dùng báo: chọn tiếng Nhật mà nhóm Sandbox Security, nút Save/Cancel và
nhiều chỗ khác vẫn tiếng Anh. Bộ test i18n cũ vẫn xanh vì nó chỉ bắt lỗi "có
dịch nhưng không ai áp lại" — hai lỗ thật nằm chỗ khác:
* Chuỗi HARDCODE không đi qua ``tr()`` bao giờ (``QPushButton("Unlock")``), nên
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
lần chụp và coi là bình thường.
* Nhãn nút do CHÍNH Qt vẽ. ``QDialogButtonBox``, ``QMessageBox.question`` và
``QInputDialog.get*`` lấy chữ từ bảng dịch riêng của Qt; ứng dụng không cài
``QTranslator`` nào và bản PySide6 đang dùng cũng không đóng gói file
``qtbase_*.qm`` nào để cài — nên chúng luôn rơi về tiếng Anh.
``ui/dialog_buttons.py`` gán nhãn của dự án đè lên nhãn Qt: ``dialog_buttons``
(10 hộp thoại), ``confirm`` (13 hộp Có/Không), ``ask_text``/``ask_multiline``/
``ask_item`` (16 hộp nhập liệu). Cùng với 17 chuỗi hardcode và 5 câu lỗi mà
``core/tasks.py`` trả thẳng ra hộp thoại — nay trả KHOÁ i18n, nơi hiển thị mới
gọi ``tr()`` — là 61 chỗ.
Ba chỗ nữa cùng lớp lỗi, phát hiện khi rà lại:
* ``_add_section`` nhận chuỗi ĐÃ dịch nên bốn tiêu đề mục của Step config đứng
nguyên ở ngôn ngữ lúc dựng panel. Nay nhận khoá + ``bind_dynamic`` để không
mất trạng thái gập/mở khi đổi ngôn ngữ.
* Thẻ tool ở Giám sát ▸ Công cụ hiện thẳng ``spec.description`` — chuỗi gửi cho
MÔ HÌNH trong schema function-calling, phải giữ tiếng Anh. Thêm bộ mô tả hiển
thị riêng cho 9 tool.
* Tên nhóm catalog ở tab Connector ("Other (any generic MCP server)").
Kèm theo, phần giao diện người dùng yêu cầu:
* ``__version__`` 2.26.0 -> 0.0.1, một nguồn cho tiêu đề cửa sổ, tab Giới thiệu
và dòng mới ở góc phải thanh trạng thái (thay dòng ghi công tác giả).
* Tắt size grip: nó vẽ một vệt ngay bên phải dòng phiên bản. Cửa sổ vẫn kéo
giãn được từ các cạnh.
* ``_NAV_SETTINGS_GAP`` 10 -> 4: hàng Cài đặt bớt xa nhóm Dashboard/Giám sát.
Hai test SẼ TREO nếu không sửa kèm: chúng patch ``QInputDialog.getText/getItem``
để tự trả lời, mà code nay gọi ``ask_text``/``ask_item`` — patch không còn chặn
được và hộp thoại thật sẽ mở ra chờ người bấm.
Ba cổng mới trong ``tests/ui/test_i18n_khong_hardcode_chu.py`` canh ở mức cấu
trúc (không ai được dựng lại kiểu cũ); đã kiểm chúng CẮN trên bản trước khi sửa:
10 + 13 + 17 vi phạm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
357 lines
16 KiB
Python
357 lines
16 KiB
Python
"""Hộp thoại Cài đặt — khung lắp ráp.
|
|
|
|
Năm mục, mỗi mục một trang: Chung, AI Provider, Bảo mật sandbox, Tham số,
|
|
Auto Model Routing. Bốn mục đầu... đúng hơn: bốn trong năm mục đã bóc sang
|
|
``presentation/settings/`` (R08-T07); file này còn giữ mục Bảo mật sandbox,
|
|
phần lắp ráp danh sách mục bên trái, và ``_save`` gọi ``apply_to`` của từng
|
|
widget con.
|
|
|
|
Không còn phần Connector nào ở đây: nó đã dời sang Monitoring → Tools →
|
|
Connector từ trước. Ngày 25/08 dọn nốt 108 dòng MS365 chết còn sót lại của
|
|
lần dời đó — năm hàm gọi lẫn nhau, không đường vào, và đọc ba thuộc tính
|
|
chưa từng được gán nên gọi vào là AttributeError.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
|
|
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,
|
|
QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..i18n import tr
|
|
from .dialog_buttons import dialog_buttons
|
|
from .icons import IconLabel
|
|
from .widgets import ToggleSwitch
|
|
|
|
|
|
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
|
|
from ..presentation.settings.about_widget import AboutSettingsWidget
|
|
|
|
|
|
def _sandbox_password_matches(entered: str, stored: str) -> bool:
|
|
"""Whether ``entered`` unlocks the Sandbox Security group.
|
|
|
|
An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships
|
|
``agent_security.sandbox_pw = ""`` and the config handed to this dialog is
|
|
always deep-merged with those defaults, so a plain ``entered == stored``
|
|
accepts an empty field on every install that never set a password. The MS365
|
|
unlock guards the same way — see ``json_config_repository.unlock_ms365``.
|
|
|
|
Both sides are compared as UTF-8 bytes, not as ``str``:
|
|
``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside
|
|
ASCII, and this app defaults to Vietnamese and ships to Japanese customers,
|
|
so an accented password is ordinary input rather than an edge case.
|
|
"""
|
|
if not entered or not stored:
|
|
return False
|
|
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
|
|
|
|
|
|
class SettingsDialog(QDialog):
|
|
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
|
|
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
|
|
"""
|
|
def __init__(self, ctx, parent=None):
|
|
"""Hộp thoại Cài đặt, ghép các nhóm thiết lập.
|
|
|
|
Có nút thu nhỏ (không phải mặc định của hộp thoại Qt) vì màn này hay được để
|
|
mở trong lúc người dùng làm việc ở cửa sổ chính.
|
|
"""
|
|
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", tr("settings.sandbox_pw_label"))
|
|
sbl.addWidget(self.sandbox_pw_label)
|
|
|
|
pw_row = QHBoxLayout()
|
|
self.sandbox_pw_edit = QLineEdit("")
|
|
self.sandbox_pw_edit.setPlaceholderText(tr("settings.sandbox_pw_placeholder"))
|
|
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
|
|
pw_row.addWidget(self.sandbox_pw_edit, 1)
|
|
self.sandbox_unlock_btn = QPushButton(tr("settings.sandbox_unlock_btn"))
|
|
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
|
|
pw_row.addWidget(self.sandbox_unlock_btn)
|
|
self.sandbox_locked_status = IconLabel("lock", tr("settings.sandbox_locked"), 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 = 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(tr("settings.sec_enabled"))
|
|
self.sec_enabled.setChecked(bool(sec.get("enabled", True)))
|
|
self.sec_enabled.setToolTip(tr("settings.sec_enabled_tooltip"))
|
|
sbl.addWidget(self.sec_enabled)
|
|
|
|
# --- AI Command Check toggle ---
|
|
self.ai_check = ToggleSwitch(tr("settings.ai_check"))
|
|
self.ai_check.setChecked(bool(sec.get("command_ai_check", False)))
|
|
self.ai_check.setToolTip(tr("settings.ai_check_tooltip"))
|
|
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
|
|
# for the dead-but-retained MS365 OAuth sign-in handlers below.)
|
|
|
|
# --- 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
|
|
|
|
|
|
# Giới thiệu đứng CUỐI: nó không có thiết lập nào để đổi, nên đặt trước
|
|
# các mục thao tác được sẽ đẩy chúng xuống mà không được gì.
|
|
self._about_page = AboutSettingsWidget(self.ctx)
|
|
root.addWidget(self._about_page)
|
|
|
|
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),
|
|
(tr("settings.group.about"), self._about_page)):
|
|
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 = dialog_buttons(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:
|
|
"""Dựng một nhóm có tiêu đề chứa các hàng nhãn–điều khiển."""
|
|
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 _sandbox_unlock(self) -> None:
|
|
"""Mở khoá nhóm cài đặt sandbox bằng mật khẩu.
|
|
|
|
Đây là khoá phía giao diện để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG
|
|
phải cơ chế bảo mật thật.
|
|
"""
|
|
pw = self.sandbox_pw_edit.text()
|
|
if not self._sandbox_pw:
|
|
# No password configured. Refusing with "wrong password" would be a
|
|
# dead end — the user would keep retrying a password that cannot
|
|
# exist — so name the actual state instead.
|
|
QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"),
|
|
tr("settings.sandbox_pw_unset_body"))
|
|
return
|
|
if _sandbox_password_matches(pw, self._sandbox_pw):
|
|
self._sandbox_unlocked = True
|
|
self.sandbox_locked_status.setText(tr("settings.sandbox_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, tr("settings.group.sandbox"),
|
|
tr("settings.sandbox_unlocked_body"))
|
|
else:
|
|
QMessageBox.warning(self, tr("settings.sandbox_pw_wrong_title"),
|
|
tr("settings.sandbox_pw_wrong_body"))
|
|
|
|
def _save(self) -> None:
|
|
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
|
|
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() |