CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
318 lines
14 KiB
Python
318 lines
14 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
|
|
|
|
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 .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
|
|
|
|
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", "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
|
|
# 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
|
|
|
|
|
|
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:
|
|
"""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 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:
|
|
"""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() |