diff --git a/docs/refactor/tin-gui-team-hoa.md b/docs/refactor/tin-gui-team-hoa.md new file mode 100644 index 0000000..e914eec --- /dev/null +++ b/docs/refactor/tin-gui-team-hoa.md @@ -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 diff --git a/presentation/settings/general_settings_widget.py b/presentation/settings/general_settings_widget.py new file mode 100644 index 0000000..6e0c3b0 --- /dev/null +++ b/presentation/settings/general_settings_widget.py @@ -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) diff --git a/presentation/settings/parameter_settings_widget.py b/presentation/settings/parameter_settings_widget.py new file mode 100644 index 0000000..8077c97 --- /dev/null +++ b/presentation/settings/parameter_settings_widget.py @@ -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 diff --git a/presentation/settings/provider_settings_widget.py b/presentation/settings/provider_settings_widget.py new file mode 100644 index 0000000..0b8ee20 --- /dev/null +++ b/presentation/settings/provider_settings_widget.py @@ -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) diff --git a/presentation/settings/routing_settings_widget.py b/presentation/settings/routing_settings_widget.py new file mode 100644 index 0000000..de93e19 --- /dev/null +++ b/presentation/settings/routing_settings_widget.py @@ -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) diff --git a/tests/ui/__init__.py b/tests/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py new file mode 100644 index 0000000..23c5a4f --- /dev/null +++ b/tests/ui/conftest.py @@ -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 diff --git a/tests/ui/test_settings_dialog_dac_ta.py b/tests/ui/test_settings_dialog_dac_ta.py new file mode 100644 index 0000000..b00d9da --- /dev/null +++ b/tests/ui/test_settings_dialog_dac_ta.py @@ -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 diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 1057a14..ea759f6 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -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()