diff --git a/app.py b/app.py index 49c974b..24ff7a5 100644 --- a/app.py +++ b/app.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( from . import APP_NAME, DISPLAY_NAME, __version__ from .config import PROVIDER_LABELS, AppConfig from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr +from .presentation.shell.bootstrap import build_context from .state import AppContext from .ui.widgets import tidy_popup from .theme import current_palette, set_active_theme, stylesheet @@ -1293,7 +1294,10 @@ def run(argv: List[str] | None = None) -> int: app = QApplication.instance() or QApplication(argv) app.setApplicationName(APP_NAME) app.setWindowIcon(app_icon()) - ctx = AppContext(AppConfig.load()) + # Composition Root: presentation/shell/bootstrap.py quyết định app chạy + # bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ + # điều hành, không còn config.py::AppConfig. + ctx = build_context() set_language(ctx.config.language) # Built-in default skills (if any are bundled) are always-on and loaded # straight from the package; tidy away any copy seeded by older versions so they diff --git a/infrastructure/config/json_config_repository.py b/infrastructure/config/json_config_repository.py index ec66fb3..cbb2094 100644 --- a/infrastructure/config/json_config_repository.py +++ b/infrastructure/config/json_config_repository.py @@ -170,6 +170,164 @@ class JsonConfigRepository: disabled.append(name) self.data["tools_disabled"] = disabled + + # ---- phần bù để thay được AppConfig ---------------------------------- + # 21 thành viên dưới đây chép nguyên ngữ nghĩa từ ``config.py::AppConfig``. + # Không phải thiết kế mới: chừng nào 29 file còn gọi qua ``ctx.config`` thì + # repository phải trả lời được đúng những câu hỏi cũ, nếu không thì không + # tráo được. Dọn lại là việc của các R sau, không phải của R02. + + #: Các chế độ định tuyến. Delta thêm "fallback" ở R03-T03. Định nghĩa ở đây + #: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với + #: ``config.py`` để hai bên lệch nhau là đỏ ngay. + ROUTING_MODES = ("off", "auto", "manual", "fallback") + + @classmethod + def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None): + """Dựng repository từ đường dẫn mặc định — thay ``AppConfig.load()``.""" + if path is None: + from ... import config as legacy + path = legacy.CONFIG_PATH + return cls(Path(path), secrets=secrets) + + @property + def path(self) -> Path: + return self._file.path + + # ---- TLS ------------------------------------------------------------- + + @property + def ca_bundle(self) -> str: + """Đường dẫn file PEM riêng, hoặc '' để kiểm chứng chỉ như bình thường. + + Dùng làm tham số ``verify=`` của ``requests`` cho mọi lượt gọi HTTPS.""" + return (self.data.get("tls_ca_bundle") or "").strip() + + @ca_bundle.setter + def ca_bundle(self, value: str) -> None: + self.data["tls_ca_bundle"] = (value or "").strip() + + # ---- MS365 ----------------------------------------------------------- + + @property + def ms365(self) -> Dict[str, Any]: + return self.data.setdefault("ms365", copy.deepcopy(self._defaults["ms365"])) + + def ms365_try_unlock(self, code: str) -> bool: + """Mở khoá nhóm MS365 trong Cài đặt cho phiên này. + + Đây là khoá phía giao diện (chặn bấm nhầm vào một mục nhạy cảm), KHÔNG + phải xác thực Microsoft. Không bao giờ được lưu ở trạng thái đã mở.""" + if (code or "") and code == self.ms365.get("unlock_code", ""): + self.data["ms365"]["unlocked"] = True + return True + return False + + def ms365_lock(self) -> None: + self.data.setdefault("ms365", {})["unlocked"] = False + + # ---- nhóm cấu hình đọc thẳng ------------------------------------------ + + @property + def code(self) -> Dict[str, Any]: + return self.data["code"] + + @property + def teams(self) -> Dict[str, Any]: + return self.data["teams"] + + @property + def history(self) -> Dict[str, Any]: + return self.data["history"] + + @property + def codebase_memory(self) -> Dict[str, Any]: + return self.data["codebase_memory"] + + @property + def cowork(self) -> Dict[str, Any]: + return self.data["cowork"] + + @property + def mcp_servers(self) -> list: + return self.data.setdefault("mcp_servers", []) + + @property + def structure(self) -> Dict[str, Any]: + return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400}) + + @property + def monitoring_visibility(self) -> Dict[str, bool]: + return self.data.setdefault( + "monitoring_visibility", + copy.deepcopy(self._defaults["monitoring_visibility"])) + + @property + def ext_connectors(self) -> Dict[str, list]: + """Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other.""" + d = self.data.setdefault( + "ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []}) + for cat in ("cad", "cae", "ms365", "other"): + d.setdefault(cat, []) + return d + + # ---- công tắc tổng cho connector ------------------------------------- + + @property + def connect_external(self) -> bool: + """Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định + BẬT để cấu hình đang chạy không đổi hành vi.""" + return bool(self.data.setdefault("tools", {}).get("connect_external", True)) + + def set_connect_external(self, enabled: bool) -> None: + self.data.setdefault("tools", {})["connect_external"] = bool(enabled) + self.save() + + # ---- những thứ đã gieo sẵn ------------------------------------------- + + @property + def seeded_library_skills(self) -> list: + """Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị + lặng lẽ gieo lại.""" + return list(self.data.setdefault("seeded_library_skills", [])) + + @seeded_library_skills.setter + def seeded_library_skills(self, slugs) -> None: + self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or [])) + + @property + def seeded_builtin_flows(self) -> list: + """Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người + dùng đã xoá như seeded_library_skills).""" + return list(self.data.setdefault("seeded_builtin_flows", [])) + + @seeded_builtin_flows.setter + def seeded_builtin_flows(self, ids) -> None: + self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or [])) + + # ---- định tuyến theo từng bề mặt chat -------------------------------- + + def routing_mode_for(self, surface: str) -> str: + """Chế độ có hiệu lực cho một bề mặt chat. + + Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung. + Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi + có người sửa tay file cấu hình.""" + routing = self.routing + override = (routing.get("surface_modes", {}) or {}).get(surface, "") + mode = override or routing.get("switch_mode", "off") + return mode if mode in self.ROUTING_MODES else "off" + + def set_routing_mode_for(self, surface: str, mode: str) -> None: + mode = mode if mode in self.ROUTING_MODES else "off" + self.routing.setdefault("surface_modes", {})[surface] = mode + self.save() + + # ---- tiện ích -------------------------------------------------------- + + def model_label(self) -> str: + return str(self.provider_conf().get("model", "?")) + # ---- ghi ------------------------------------------------------------- def save(self) -> None: """Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365.""" diff --git a/presentation/shell/bootstrap.py b/presentation/shell/bootstrap.py new file mode 100644 index 0000000..797390a --- /dev/null +++ b/presentation/shell/bootstrap.py @@ -0,0 +1,42 @@ +"""Composition Root — R08-T10. + +Một chỗ duy nhất quyết định app chạy bằng những mảnh nào. Trước đây quyết định +đó nằm rải trong ``app.py::run``, lẫn với việc dựng cửa sổ; tách ra để đổi một +mảnh (ví dụ thay kho bí mật) không phải đụng vào mã giao diện. + +Đây cũng là chỗ hoàn tất R02: từ đây app chạy bằng :class:`JsonConfigRepository` +chứ không còn ``config.py::AppConfig``. Hai thứ đổi thật sự: + +* ghi cấu hình qua ``AtomicJsonFile`` — mất điện giữa lúc lưu không làm hỏng file +* API key nằm trong kho bí mật của hệ điều hành, không nằm trong ``config.json`` + +Máy không có kho bí mật (Linux headless, CI, hoặc keyring hỏng) vẫn chạy bình +thường: repository nhận ``secrets=None`` và đọc khoá thẳng từ file như cũ. Thà +để khoá trong file còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao. +""" +from __future__ import annotations + +from pathlib import Path + +from ...infrastructure.config.json_config_repository import JsonConfigRepository +from ...infrastructure.secrets.keyring_adapter import KeyringAdapter +from ...state import AppContext + + +def build_secret_store(): + """Kho bí mật của hệ điều hành, hoặc None nếu máy này không có. + + ``KeyringAdapter`` không bao giờ ném lỗi — nó tự báo ``available``. Trả về + None thay vì một adapter chết để chỗ gọi khỏi phải đoán. + """ + store = KeyringAdapter() + return store if store.available else None + + +def build_config(path: Path | None = None) -> JsonConfigRepository: + return JsonConfigRepository.load(path, secrets=build_secret_store()) + + +def build_context(path: Path | None = None) -> AppContext: + """Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker.""" + return AppContext(build_config(path))