Compare commits
2
Commits
c890a20f45
...
e4ce9b2f5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4ce9b2f5f | ||
|
|
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