2026-09-09 16:46:15 +00:00
committed by gitea-admin
co-authored by duylh19
parent 13e2c22067
commit 1b8429e33a
147 changed files with 20993 additions and 461 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Lớp phủ "đang xử lý" ở cấp cửa sổ, dành cho tác vụ chặn GUI thread.
Vì sao là file riêng chứ không nhét vào ``main_window.py``: file đó chỉ còn 9
dòng vật lý dưới trần 400 của Gate S, và một lớp phủ cấp cửa sổ là một trách
nhiệm riêng (guardrail G6).
Cùng lý do ``repaint()`` với panel bận của GraphRAG — xem
``presentation/graph/structure_graph_view.py:139-151``.
"""
from __future__ import annotations
from time import perf_counter
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
# Dưới mức này người dùng chưa kịp nhận ra mình đang đợi, nên một lớp phủ toàn
# cửa sổ chỉ kịp nháy lên rồi tắt — tự nó là một khuyết tật giao diện, không
# phải một lời trấn an.
_NOTICEABLE_MS = 400.0
class BusyOverlay(QWidget):
"""A window-wide "please wait" cover for work that blocks the GUI thread.
Deliberately NOT registered with :func:`i18n.on_language_changed`: the text
is supplied by the caller right before the block and must stay in the
language the rest of the screen is still showing.
"""
def __init__(self, parent: QWidget) -> None:
"""Build the cover hidden; it sizes itself to the parent on every show."""
super().__init__(parent)
self.setObjectName("busyOverlay")
# A QWidget SUBCLASS ignores a stylesheet background without this
# attribute; a plain QWidget instance (the panel below) does not need it.
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.NoFocus)
# Chưa đo được lượt nào: xem mục ``run_blocking``.
self._last_ms: float | None = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
self._panel = QWidget()
self._panel.setObjectName("busyOverlayPanel")
inner = QHBoxLayout(self._panel)
inner.setContentsMargins(24, 18, 24, 18)
self._label = QLabel()
inner.addWidget(self._label)
row.addWidget(self._panel)
row.addStretch(1)
lay.addLayout(row)
lay.addStretch(1)
self.hide()
def text(self) -> str:
"""Chữ đang hiện trên lớp phủ (dùng cho test)."""
return self._label.text()
def run_blocking(self, message: str, work: Callable[[], None]) -> None:
"""Run ``work`` on the GUI thread, covered only when that is worth doing.
Nothing can time the freeze WHILE it happens: the GUI thread stops, so
no timer fires and no watchdog can raise the cover mid-way. The only
honest clock is the PREVIOUS run of this same call, so that is what
decides. No measurement yet (the first switch of a process) errs
towards showing: one flash is a smaller defect than a multi-second
freeze with nothing on screen to explain it.
The result is self-calibrating. A fast machine flashes once per launch
and then stays out of the way; a slow one, or a big skill library, gets
the cover on every switch from the second one on.
``work`` is timed and its exceptions propagate — the cover still comes
down, so a raising callback cannot leave it stuck on screen forever.
"""
if self._last_ms is None or self._last_ms >= _NOTICEABLE_MS:
self.show_busy(message)
started = perf_counter()
try:
work()
finally:
self._last_ms = (perf_counter() - started) * 1000.0
self.hide_busy()
def show_busy(self, message: str) -> None:
"""Show the cover and FORCE it onto the screen right now.
``repaint()``, not ``update()``: the caller is about to block the GUI
thread, so a queued paint would only run once the freeze is over — the
one moment the cover is no longer needed.
No animated progress bar on purpose: with no event loop running,
nothing would move; only static text is guaranteed to be readable.
"""
self._label.setText(message)
self.setGeometry(self.parent().rect())
self.show()
self.raise_()
self.repaint()
def hide_busy(self) -> None:
"""Release the cover. Call from ``finally`` so a raising callback
cannot leave it stuck on screen forever."""
self.hide()
+29 -11
View File
@@ -163,9 +163,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self._built.append(widget is not None)
self._build_nav_rail(right, rlay)
# Landing stays Workspace ▸ Project, exactly as before. Go through _goto
# so the page is actually shown — selecting the row alone only moves the
# highlight (its signals are blocked to avoid rebuild loops).
# Land on the Workspace screen. Go through _goto so the page is actually
# shown — selecting the row alone only moves the highlight (its signals
# are blocked to avoid rebuild loops). Which Workspace sub-view the user
# ends up looking at is settled after _restore_sessions(), below.
self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab())
self.toast = Toast(self) # top-left "task done" popup
# Floating in-app Help assistant — a robot icon pinned bottom-right on
@@ -176,14 +177,19 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self.help_agent.status_message.connect(self.statusBar().showMessage)
self.statusBar().showMessage(tr("app.status.ready"))
# Author credit, pinned to the bottom-right corner. A permanent status-bar
# widget sits at the right end and is never cleared by showMessage (which
# writes on the left).
self._credit = QLabel(tr("app.credit"))
self._credit.setObjectName("faint")
self._credit.setStyleSheet("padding: 0 10px;")
self.statusBar().addPermanentWidget(self._credit)
# Góc dưới phải: đúng một dòng phiên bản (cùng nguồn với tiêu đề cửa sổ).
# Tắt size grip — nó vẽ một vệt ngay bên phải chữ; cửa sổ vẫn kéo được cạnh.
self.statusBar().setSizeGripEnabled(False)
self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
self._restore_sessions()
# Open on "All projects…" — literally the same call the nav rail's link
# of that name makes, so the rail highlight and the content can never
# disagree. This runs AFTER the restore on purpose: _restore_sessions
# still reloads the last thread, because recovering it after a crash is
# the whole point of it, but the user should first see the list of
# everything rather than whichever conversation happened to be open when
# the app was last closed.
self.goto_all_projects()
self._tray.setup()
# Start the task scheduler last, once the whole window exists — it
# catches up any overdue tasks right away (first tick runs inline).
@@ -252,9 +258,14 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
self._nav_toggle_btn.setToolTip(
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
self._credit.setText(tr("app.credit"))
if hasattr(self, "provider_lbl"):
self.provider_lbl.setText(tr("app.provider"))
# The label is hidden — the combo names itself through its tooltip
# (see top_bar._build_account_row), so that is the one users read.
self.provider_combo.setToolTip(tr("app.provider"))
if hasattr(self, "nav_project"):
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_recents_hdr.setText(tr("app.nav.recents"))
if hasattr(self, "settings_btn"):
self.settings_btn.setText(tr("app.settings"))
if hasattr(self, "theme_btn"):
@@ -266,6 +277,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
if getattr(self, "help_agent", None) is not None:
self.help_agent.retranslate()
self._tray.retranslate()
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
self.statusBar().showMessage(tr("app.status.ready"))
# ---- system tray (run in background when the window is closed) ---
+17 -4
View File
@@ -163,6 +163,12 @@ class NavRailMixin:
self.split = QSplitter(Qt.Horizontal)
self.split.addWidget(self._nav_wrap)
self.split.addWidget(right)
# A QSplitter lets the user drag a pane past its own minimumWidth and
# snap it shut at zero — the minimum only governs LAYOUT, not the drag.
# So the rail set a 132px floor and still vanished entirely when dragged
# hard left, with no handle left wide enough to notice, let alone grab.
# Collapsing is what the MENU button is for, and that stops at 54px.
self.split.setChildrenCollapsible(False)
self.split.setStretchFactor(0, 0)
self.split.setStretchFactor(1, 1)
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
@@ -245,12 +251,19 @@ class NavRailMixin:
blocked = tree.blockSignals(True)
tree.addTopLevelItem(it)
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
# Rows plus frame, nothing else: the flat ``+ 8`` this replaces
# left 6px of dead space under the last row, and since Settings
# sits directly under nav_bottom it fell between Giám sát and
# Settings alone — 18/26px for rows that read as one list.
row_h = 0
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0
tree.setFixedHeight(n * row_h + 8)
row_h = tree.sizeHintForRow(0) if n else row_h
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
# Settings is one more row of the list, so it takes the rows' own
# height instead of paddings guessed to match it.
if row_h and hasattr(self, "_nav_settings_btn"):
self._nav_settings_btn.setFixedHeight(row_h)
if keep:
self._select_nav_row(*keep)
finally:
+54
View File
@@ -47,6 +47,60 @@ class PageRegistryMixin:
m.status_message.connect(self.statusBar().showMessage)
self.monitoring = m
return m
def current_screen_label(self) -> str:
"""Tên màn hình người dùng đang mở, dạng "Workspace ▸ Project".
Trợ lý Hỗ trợ không nhìn được màn hình, nên "tôi đang ở đâu" là câu nó
không thể tự trả lời — chuỗi này được ghép vào prompt của nó
(``core/help_knowledge.py``).
"""
row = self.pages.currentIndex()
if not (0 <= row < len(self._nav_defs)):
return ""
page = tr(self._nav_defs[row][0])
if row != self._ROW_WORKSPACE:
return page
sub = self.workspace.current_subtab()
keys = {self.workspace._project_tab_idx: "workspace.tab_project",
self.workspace._cowork_tab_idx: "workspace.tab_cowork",
self.workspace._co4e_tab_idx: "workspace.tab_co4e",
getattr(self.workspace, "_folder_tab_idx", -1): "workspace.tab_folder",
self.workspace._graphrag_tab_idx: "workspace.tab_graphrag"}
key = keys.get(sub)
return f"{page} ▸ {tr(key)}" if key else page
def help_context(self) -> str:
"""Ngữ cảnh sống cho Trợ lý Hỗ trợ: đang ở màn nào, và trên đó bấm được gì.
Vì sao đọc từ WIDGET ĐANG CHẠY chứ không từ ``docs/screens/controls.json``:
file đó được trích tự động nhưng đã cũ — 5/41 file trong đó không còn tồn
tại, và nó không có file nào trong ``presentation/`` (chưa sinh lại sau
refactor R08). Nạp nó vào prompt là dạy trợ lý về nút của những file đã
bị xoá. Cây widget thật thì không bao giờ cũ được.
"""
from PySide6.QtWidgets import QPushButton, QTabWidget
from ...core.help_knowledge import screen_context
page = self.pages.currentWidget()
nhan = []
if page is not None:
for tabs in page.findChildren(QTabWidget):
for i in range(tabs.count()):
if tabs.isTabVisible(i):
nhan.append(f"[tab] {tabs.tabText(i)}")
for btn in page.findChildren(QPushButton):
text = (btn.text() or "").replace("&", "").strip()
if text and btn.isVisible() and btn.isEnabled():
nhan.append(text)
# Bo trung, giu nguyen thu tu xuat hien tren man hinh.
thay, gon = set(), []
for n in nhan:
if n not in thay:
thay.add(n)
gon.append(n)
return screen_context(self.current_screen_label(), gon)
def _ensure_page(self, row: int) -> None:
"""Build a lazy nav page on first visit and swap it in for its placeholder."""
if not (0 <= row < len(self._built)) or self._built[row]:
+11 -2
View File
@@ -11,11 +11,20 @@ from PySide6.QtCore import Qt
from PySide6.QtWidgets import QStyledItemDelegate
# ---- kích thước ---------------------------------------------------------
_NAV_EXPANDED_WIDTH = 150
_NAV_EXPANDED_WIDTH = 232
_NAV_COLLAPSED_WIDTH = 54
_NAV_ROW_INSET = 8
_NAV_ROW_GAP = 6
_NAV_MIN_WIDTH = 132
# Khe TRÊN nút Cài đặt, tính bằng khoảng trống thật trong layout của rail.
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
# rộng đến mức trông như hai khu tách rời.
_NAV_SETTINGS_GAP = 4
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232
_NAV_MAX_SHARE = 0.22
_NAV_MAX_CEILING = 360
+31 -2
View File
@@ -114,12 +114,41 @@ class RailProjectMixin:
tree.blockSignals(blocked)
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
self.nav_recents.setVisible(not self._nav_collapsed)
def goto_all_projects(self) -> None:
"""Mở khung "Tất cả project…": Workspace ▸ Cowork, bảng lịch sử bung ra.
Phải đi qua ``_goto``, không được gọi thẳng ``show_history_pane()``:
``_goto`` là chỗ duy nhất dời vệt sáng trên thanh menu theo nội dung.
Gọi tắt thì nội dung sang Cowork còn vệt sáng ở lại Project — và vì
``QTreeWidget`` không phát ``currentItemChanged`` khi bấm lại đúng dòng
đang chọn, bấm "Project" sẽ KHÔNG có tác dụng gì cho tới khi người dùng
bấm sang mục khác rồi bấm về.
Gom vào một hàm vì có hai đường vào: link "Tất cả project…" ở mục GẦN
ĐÂY, và màn hình mặc định lúc mở app.
"""
sub = self.workspace._cowork_tab_idx
# Chua chon project thi cong project dang giu sub-tab Cowork dong.
# show_history_pane() mo no bang cua sau, bo qua cong — the la noi dung
# o Cowork trong khi ca cong lan vet sang deu noi la Project. Man hinh
# dung cho nguoi chua co project chinh la man quan ly project.
if sub < 0 or not self.workspace.subtab_available(sub):
self._goto(self._ROW_WORKSPACE, self.workspace._project_tab_idx)
return
self._goto(self._ROW_WORKSPACE, sub)
self.workspace.show_history_pane()
# Bang lich su duoc nhung trong sub-tab Cowork cua MOT project, nen
# WorkspaceTab._bind_project da dat set_project_filter(pid) — no chi hien
# thread cua project dang chon. Vao day bang link "Tat ca project…" ma van
# con bo loc thi nguoi dung tao 5 project chi thay 1. Xoa bo loc SAU
# show_history_pane(), vi ham do di qua duong doi sub-tab.
self.sidebar.set_project_filter("")
def _on_rail_recent(self, item, _col: int = 0) -> None:
"""Bấm một dòng trong mục GẦN ĐÂY: mở hội thoại đó, hoặc mở khung "Tất cả project…"."""
data = item.data(0, Qt.UserRole) or {}
if data.get("all"):
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
self.workspace.show_history_pane()
self.goto_all_projects()
return
path = data.get("path")
if path:
+57 -8
View File
@@ -35,7 +35,7 @@ class TopBarMixin:
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
from ...i18n import tr
from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
# Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in
@@ -54,7 +54,12 @@ class TopBarMixin:
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
# No vertical padding of its own: ``_rebuild_nav`` pins this button to the
# nav rows' OWN height, so the 6px a row pads with is already inside
# that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
@@ -63,6 +68,15 @@ class TopBarMixin:
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
srow.addStretch(1)
# The first _rebuild_nav() ran before this button existed (it is what
# fills the list this row belongs under), so take the height here too.
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
# Khe TRÊN hàng Cài đặt, xin thẳng từ layout — thanh rail đặt
# ``setSpacing(0)`` nên không có khoảng nào sẵn, và margin trong QSS thì
# không mua được pixel nào (xem ``_NAV_SETTINGS_GAP``). Cài đặt là việc
# khác với nhóm Dashboard/Giám sát ngay trên nó; dán sát vào thì hai thứ
# đọc thành một khối.
nvl.addSpacing(_NAV_SETTINGS_GAP)
nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row()
@@ -191,10 +205,42 @@ class TopBarMixin:
# Reload the Cowork tab's Agent (Model) list for the newly selected provider.
self.cowork.refresh_agents()
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
self.statusBar().showMessage(
tr("app.status.using_provider",
label=PROVIDER_LABELS.get(self.ctx.config.active_provider))
)
# Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon
# provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong
# bao chi nhac lai mot thu nguoi dung vua tu tay lam.
def _lang_busy_overlay(self):
"""The window's busy cover, built on first use.
Built lazily so a window that never changes language never gets one —
and so ``_open_settings`` can be checked for "no switch, no flash".
"""
overlay = getattr(self, "_lang_busy", None)
if overlay is None:
from .busy_overlay import BusyOverlay
overlay = BusyOverlay(self)
self._lang_busy = overlay
return overlay
def _switch_language(self, lang: str) -> None:
"""Apply a new UI language behind a busy cover.
``set_language`` runs every registered widget's re-translation on the
GUI thread, which on a large skill library takes long enough to look
like a hang. Nothing can raise a cover once that has started (no event
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
The message is read before the switch on purpose: mid-switch the only
language the user can still read is the one being left behind.
"""
message = tr("app.lang.switching")
self.language_combo.setEnabled(False)
try:
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
finally:
# In a ``finally`` so a listener that raises cannot leave the
# switcher locked for the rest of the session.
self.language_combo.setEnabled(True)
def _on_language_changed(self, _idx: int) -> None:
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
toàn bộ chữ vô ích.
@@ -204,7 +250,7 @@ class TopBarMixin:
return
self.ctx.config.language = lang
self.ctx.save()
set_language(lang) # notifies every registered persistent widget
self._switch_language(lang) # notifies every registered persistent widget
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
dlg = SettingsDialog(self.ctx, self)
@@ -215,7 +261,10 @@ class TopBarMixin:
from ...ui.icons import icon as _theme_icon
self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
set_language(self.ctx.config.language) # apply if changed in Settings
# Guarded, not left to set_language's own no-op check: the cover
# around the switch would otherwise flash on every Save.
if self.ctx.config.language != get_language():
self._switch_language(self.ctx.config.language)
# reflect provider/theme/language changes
i = self.provider_combo.findData(self.ctx.config.active_provider)
if i >= 0: