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:
co-authored by
Claude Opus 5
parent
9d6a7be31b
commit
2b90492994
@@ -0,0 +1,68 @@
|
||||
# Tin nhắn gửi Team Hoa — 25/08/2026
|
||||
|
||||
*Nam (Team Gamma) soạn. Hai việc, không cần trả lời, chỉ cần đọc trước khi
|
||||
bắt đầu R07-T03 và R06.*
|
||||
|
||||
---
|
||||
|
||||
Chào team Hoa,
|
||||
|
||||
Có hai thứ trong nhánh `gamma/refactor` ảnh hưởng trực tiếp tới phần các bạn
|
||||
sắp làm. Gửi trước để khỏi mất thời gian truy lỗi.
|
||||
|
||||
## 1. `platform/` đã đổi tên thành `adapters/` — plan.md ghi tên cũ
|
||||
|
||||
Plan chỉ đích danh `platform/qt/qt_scheduler_clock.py` (R07-T03, dòng 407 và
|
||||
lịch 25/08 ở dòng 229). **Đừng tạo thư mục `platform/`.**
|
||||
|
||||
Lý do: `platform` là tên một module trong thư viện chuẩn của Python. Tạo thư
|
||||
mục `platform/` ở gốc repo là nó che mất module chuẩn khi chạy từ chính thư
|
||||
mục gốc — mà đó là cách toàn bộ script trong `tools/` và `scripts/` đang chạy.
|
||||
Triệu chứng không hề chỉ về đúng chỗ:
|
||||
|
||||
AttributeError: module 'platform' has no attribute 'system'
|
||||
|
||||
Ném ra từ `import keyring`, không liên quan gì tới file bạn vừa tạo.
|
||||
|
||||
Tôi đã mắc đúng lỗi này hôm 21/08. Lúc thử thì đứng ở thư mục cha nên không
|
||||
tái hiện được, tưởng an toàn. Đổi tên thành `adapters/` và thêm
|
||||
`tests/test_no_stdlib_shadow.py` để lần sau đỏ ngay.
|
||||
|
||||
**Việc cần làm**: tạo `adapters/qt/qt_scheduler_clock.py` thay vì
|
||||
`platform/qt/...`. Thư mục `adapters/qt/` đã có sẵn `__init__.py` trên nhánh
|
||||
`gamma/refactor`, kéo về là dùng được.
|
||||
|
||||
## 2. `AtomicJsonFile` vừa vá một lỗi Windows — lấy bản mới trước khi dựng lên
|
||||
|
||||
Plan giao các bạn hai repository ngồi trên `AtomicJsonFile`:
|
||||
|
||||
* `infrastructure/persistence/json/task_repository_impl.py` (R07-T01, dòng 403)
|
||||
* `infrastructure/persistence/json/workspace_repository_impl.py` (R06, dòng 387)
|
||||
|
||||
Và tiêu chí nghiệm thu **A** (dòng 244) bắt mọi thao tác ghi tệp phải đi qua nó.
|
||||
|
||||
Hôm nay tôi bắt được lỗi thật trong đó:
|
||||
|
||||
PermissionError: [WinError 5] Access is denied
|
||||
.dem.json.xxxxxxx.tmp -> dem.json
|
||||
|
||||
`os.replace` trên Windows bị từ chối khi Defender hoặc Search Indexer đang giữ
|
||||
handle lên file vừa tạo — vài chục mili-giây rồi nhả. Đo được: hỏng 1 trong 7
|
||||
lượt chạy 20 lần ghi, tức **khoảng 1 trên 140 lần lưu**. Người dùng thỉnh
|
||||
thoảng bấm Lưu là văng lỗi và không tài nào tái hiện để báo.
|
||||
|
||||
Đã thêm vòng thử lại (commit `9d6a7be`). Nếu các bạn dựng repository trên bản
|
||||
trước đó thì lưu task và lưu workspace cũng hỏng với tần suất y hệt — nhân lên
|
||||
ba nơi ghi file.
|
||||
|
||||
**Việc cần làm**: `git pull` nhánh `gamma/refactor` (hoặc chờ nó vào `main`)
|
||||
trước khi bắt đầu R06/R07-T01.
|
||||
|
||||
## Tiện thể
|
||||
|
||||
`domain/security/tool_policy.py` là bản đề xuất DTO `ToolPolicyGateway` tôi
|
||||
viết hộ cho R05 của các bạn — ba trạng thái ALLOW/DENY/ASK, kèm fake và test
|
||||
contract. Không có gì của Gamma phụ thuộc vào nó, nên các bạn cứ sửa hoặc bỏ
|
||||
thoải mái, không phải giữ ý.
|
||||
|
||||
Nam
|
||||
@@ -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)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Dựng Qt ở chế độ offscreen cho test giao diện.
|
||||
|
||||
Offscreen là bắt buộc, không phải cho nhanh: máy dev là máy làm việc thật của
|
||||
người dùng. Test bật cửa sổ lên là nó nhảy ra trước mặt, che thứ đang mở.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Đặc tả hành vi SettingsDialog TRƯỚC khi tách — R08-T07.
|
||||
|
||||
Không phải test tính năng mới. Đây là lưới an toàn: chốt lại dialog hiện
|
||||
đang làm gì, để khi bóc 727 dòng thành các widget con còn biết mình có làm
|
||||
lệch đi chỗ nào không. Bài nào ở đây đỏ sau khi tách nghĩa là tách sai.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _Config:
|
||||
"""Đủ dùng cho SettingsDialog, không hơn — xem danh sách ctx.* nó chạm."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {
|
||||
"active_provider": "openai_compat",
|
||||
"language": "vi",
|
||||
"theme": "dark",
|
||||
"providers": {
|
||||
"openai_compat": {"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "khoa-cu", "model": "gpt-4o"},
|
||||
"ollama": {"base_url": "http://localhost:11434",
|
||||
"api_key": "ollama", "model": "qwen2.5-coder"},
|
||||
},
|
||||
"tray": {"minimize_on_close": True, "notify_on_done": False},
|
||||
"agent_security": {
|
||||
"enabled": True, "cowork_confirm_commands": False,
|
||||
"block_network": True, "command_ai_check": False,
|
||||
"resource_limit_cpu_percent": 55,
|
||||
"resource_limit_memory_mb": 1024,
|
||||
"resource_limit_disk_mb": 2048,
|
||||
},
|
||||
"attachments": {"max_tokens": 32000, "max_files": 7},
|
||||
"structure": {"max_nodes": 300, "max_edges": 600},
|
||||
"routing": {"switch_mode": "auto", "policy": "cost",
|
||||
"min_score_gain": 0.05, "confirm_timeout_sec": 90,
|
||||
"reassess_interval_hours": 12,
|
||||
"per_provider_concurrency": 3,
|
||||
"judge_model": "gpt-4o-mini"},
|
||||
}
|
||||
self._data = self.data
|
||||
self._agent_security = self.data["agent_security"]
|
||||
|
||||
language = property(lambda self: self.data["language"])
|
||||
theme = property(lambda self: self.data["theme"])
|
||||
active_provider = property(lambda self: self.data["active_provider"])
|
||||
agent_security = property(lambda self: self.data["agent_security"])
|
||||
routing = property(lambda self: self.data["routing"])
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self):
|
||||
self.config = _Config()
|
||||
self.routing = None
|
||||
self.saves = 0
|
||||
|
||||
def save(self):
|
||||
self.saves += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(qapp):
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
ctx = _Ctx()
|
||||
dlg = SettingsDialog(ctx)
|
||||
yield dlg, ctx
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
# ---- dialog gồm những mục nào -------------------------------------------
|
||||
|
||||
def test_co_dung_nam_muc(dialog):
|
||||
"""Năm mục thật trên màn hình. Plan R08-T07 ghi bốn widget và có một cái
|
||||
tên `connector`, nhưng UI connector đã dời khỏi Settings từ trước (xem
|
||||
ghi chú ở settings_dialog.py:180) — nên con số thật là năm, không bốn."""
|
||||
dlg, _ = dialog
|
||||
labels = [dlg.section_list.item(i).text()
|
||||
for i in range(dlg.section_list.count())]
|
||||
assert len(labels) == 5, labels
|
||||
assert dlg.section_stack.count() == 5
|
||||
|
||||
|
||||
def test_moi_muc_deu_bam_duoc(dialog):
|
||||
dlg, _ = dialog
|
||||
for i in range(dlg.section_list.count()):
|
||||
dlg.section_list.setCurrentRow(i)
|
||||
assert dlg.section_stack.currentIndex() == i
|
||||
|
||||
|
||||
# ---- nạp giá trị từ config ----------------------------------------------
|
||||
|
||||
def test_nap_dung_gia_tri_dang_co(dialog):
|
||||
dlg, ctx = dialog
|
||||
assert dlg.language_combo.currentData() == "vi"
|
||||
assert dlg.theme_combo.currentData() == "dark"
|
||||
assert dlg.provider_combo.currentData() == "openai_compat"
|
||||
assert dlg.prov_base.text() == "https://api.openai.com/v1"
|
||||
assert dlg.tray_chk.isChecked() is True
|
||||
assert dlg.notify_chk.isChecked() is False
|
||||
assert dlg.routing_mode.currentData() == "auto"
|
||||
assert dlg.routing_policy.currentData() == "cost"
|
||||
assert dlg.routing_timeout.value() == 90
|
||||
assert dlg.attach_files.value() == 7
|
||||
assert dlg.struct_nodes.value() == 300
|
||||
assert dlg.sandbox_cpu.value() == 55
|
||||
|
||||
|
||||
def test_khoa_api_khong_hien_ro(dialog):
|
||||
"""QLineEdit.Password — khoá không được đọc được bằng mắt qua vai."""
|
||||
from PySide6.QtWidgets import QLineEdit
|
||||
dlg, _ = dialog
|
||||
assert dlg.prov_key.echoMode() == QLineEdit.Password
|
||||
|
||||
|
||||
# ---- lưu ghi ra đúng chỗ -------------------------------------------------
|
||||
|
||||
def test_luu_ghi_dung_moi_o(dialog):
|
||||
dlg, ctx = dialog
|
||||
dlg.language_combo.setCurrentIndex(
|
||||
dlg.language_combo.findData("en") if dlg.language_combo.findData("en") >= 0 else 0)
|
||||
dlg.tray_chk.setChecked(False)
|
||||
dlg.routing_timeout.setValue(120)
|
||||
dlg.attach_files.setValue(3)
|
||||
dlg.sandbox_cpu.setValue(80)
|
||||
|
||||
dlg._save()
|
||||
d = ctx.config.data
|
||||
|
||||
assert d["tray"]["minimize_on_close"] is False
|
||||
assert d["routing"]["confirm_timeout_sec"] == 120
|
||||
assert d["attachments"]["max_files"] == 3
|
||||
assert d["agent_security"]["resource_limit_cpu_percent"] == 80
|
||||
assert ctx.saves == 1
|
||||
|
||||
|
||||
def test_luu_doi_min_gain_tu_phan_tram_sang_phan_le(dialog):
|
||||
"""Ô nhập là %, config lưu số thập phân. Đây là chỗ dễ tách sai nhất."""
|
||||
dlg, ctx = dialog
|
||||
dlg.routing_min_gain.setValue(25)
|
||||
dlg._save()
|
||||
assert ctx.config.data["routing"]["min_score_gain"] == 0.25
|
||||
|
||||
|
||||
def test_luu_xoa_cache_de_app_doc_lai_ngay(dialog):
|
||||
dlg, ctx = dialog
|
||||
dlg._save()
|
||||
assert ctx.config._data is None
|
||||
assert ctx.config._agent_security is None
|
||||
+56
-377
@@ -3,27 +3,29 @@
|
||||
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, QSizePolicy, QSpinBox, QTreeWidget,
|
||||
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 ..i18n import tr
|
||||
from ..state import AppContext
|
||||
from .icons import icon, IconLabel
|
||||
from .widgets import SegmentedControl, ToggleSwitch
|
||||
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__()
|
||||
@@ -50,63 +52,17 @@ class SettingsDialog(QDialog):
|
||||
self._content = QWidget()
|
||||
root = QVBoxLayout(self._content)
|
||||
|
||||
# --- language + tray ---
|
||||
top = QFormLayout()
|
||||
self.language_combo = SegmentedControl()
|
||||
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)
|
||||
|
||||
# Theme belongs with the other per-account settings. It is also on the
|
||||
# rail's account row (one click for the common flip); this is the same
|
||||
# value, named and explained, for people who come looking in Settings.
|
||||
self.theme_combo = SegmentedControl()
|
||||
for key in ("system", "dark", "light"):
|
||||
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
|
||||
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
|
||||
top.addRow(tr("settings.theme"), self.theme_combo)
|
||||
|
||||
self.tray_chk = ToggleSwitch(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 = ToggleSwitch(tr("settings.tray_notify"))
|
||||
self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
|
||||
top.addRow("", self.notify_chk)
|
||||
# Zero-height anchor so the index can scroll to this section, which is a
|
||||
# bare form rather than a group box.
|
||||
self._anchor_general = QWidget()
|
||||
self._anchor_general.setFixedHeight(0)
|
||||
root.addWidget(self._anchor_general)
|
||||
root.addLayout(top)
|
||||
# --- 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 ---
|
||||
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)
|
||||
# Đã 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 ---
|
||||
@@ -182,136 +138,17 @@ class SettingsDialog(QDialog):
|
||||
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)
|
||||
|
||||
# Đã 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 ------------------------------------------
|
||||
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)
|
||||
# Đã 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)
|
||||
|
||||
note = QLabel(tr("settings.tip"))
|
||||
note.setObjectName("hint")
|
||||
note.setWordWrap(True) # otherwise this one line sets the dialog's width
|
||||
root.addWidget(note)
|
||||
|
||||
# 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
|
||||
@@ -319,15 +156,6 @@ class SettingsDialog(QDialog):
|
||||
# glance instead of by scrolling to find out.
|
||||
from .widgets import section_panels
|
||||
|
||||
self._general_box = QWidget()
|
||||
gv = QVBoxLayout(self._general_box)
|
||||
gv.setContentsMargins(0, 0, 0, 0)
|
||||
root.removeWidget(self._anchor_general)
|
||||
root.removeItem(top)
|
||||
gv.addLayout(top)
|
||||
gv.addWidget(note) # the tip belongs with the general settings
|
||||
gv.addStretch(1)
|
||||
root.removeWidget(note)
|
||||
|
||||
pages = []
|
||||
for label, widget in ((tr("settings.group.general"), self._general_box),
|
||||
@@ -374,18 +202,38 @@ class SettingsDialog(QDialog):
|
||||
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 _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:
|
||||
@@ -395,96 +243,10 @@ class SettingsDialog(QDialog):
|
||||
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)
|
||||
# A combo sizes itself to its longest entry by default; model ids are
|
||||
# long, so the row grew past the dialog and forced a sideways scrollbar
|
||||
# (worse at 125%/150% display scaling). Let it shrink and use a popup
|
||||
# wider than the closed box instead.
|
||||
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
||||
combo.setMinimumContentsLength(8)
|
||||
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
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)
|
||||
# The two buttons keep their natural size; the combo gives way. Without
|
||||
# this the row's minimum was combo + both buttons and nothing could
|
||||
# shrink, so the dialog scrolled sideways instead.
|
||||
for b in (btn, test_btn):
|
||||
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
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:
|
||||
@@ -600,63 +362,7 @@ class SettingsDialog(QDialog):
|
||||
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()
|
||||
@@ -674,19 +380,9 @@ class SettingsDialog(QDialog):
|
||||
|
||||
def _save(self) -> None:
|
||||
data = self.ctx.config.data
|
||||
data["active_provider"] = self.provider_combo.currentData()
|
||||
data["language"] = self.language_combo.currentData()
|
||||
# MainWindow._open_settings re-applies the theme after this returns, so
|
||||
# writing the value here is enough to make it take effect.
|
||||
data["theme"] = self.theme_combo.currentData()
|
||||
self._provider_page.apply_to(data)
|
||||
self._general_box.apply_to(data)
|
||||
|
||||
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.
|
||||
@@ -696,28 +392,11 @@ class SettingsDialog(QDialog):
|
||||
"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()
|
||||
self._param_page.apply_limits_to(data["agent_security"])
|
||||
self._param_page.apply_to(data)
|
||||
|
||||
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._routing_page.apply_to(data)
|
||||
|
||||
self.ctx.save()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user