refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,11 +44,15 @@ class _OrgTree(QTreeWidget):
|
||||
member_dropped = Signal(str, str) # username, target_group_id ("" = ungrouped)
|
||||
|
||||
def __init__(self):
|
||||
"""Bật kéo–thả ngay từ đầu; quyền kéo thật sự do :meth:`set_drag_enabled` quyết
|
||||
định sau khi biết vai trò người đăng nhập.
|
||||
"""
|
||||
super().__init__()
|
||||
self.setDropIndicatorShown(True)
|
||||
self.setDragDropMode(QTreeWidget.DragDrop)
|
||||
|
||||
def set_drag_enabled(self, enabled: bool) -> None:
|
||||
"""Cho phép hay cấm kéo–thả. Chỉ Admin mới được chuyển người giữa các nhóm."""
|
||||
self.setDragEnabled(enabled)
|
||||
self.setAcceptDrops(enabled)
|
||||
|
||||
@@ -63,17 +67,24 @@ class _OrgTree(QTreeWidget):
|
||||
return target_kind, source_kind
|
||||
|
||||
def _drop_is_valid(self, event) -> bool:
|
||||
"""Chỉ nhận đúng một kiểu thả: một TÀI KHOẢN thả vào một NHÓM.
|
||||
|
||||
Mọi hướng khác (nhóm vào nhóm, tài khoản vào tài khoản) đều bị từ chối —
|
||||
cây này biểu diễn quan hệ nhóm chứa người, không có cấp lồng nhau.
|
||||
"""
|
||||
target_kind, source_kind = self._drop_kinds(event)
|
||||
return bool(target_kind and target_kind[0] == "group"
|
||||
and source_kind and source_kind[0] == "account")
|
||||
|
||||
def dragEnterEvent(self, event) -> None:
|
||||
"""Nhận con trỏ kéo vào cây nếu đây là kiểu thả hợp lệ."""
|
||||
if self.dragEnabled() and self._drop_is_valid(event):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event) -> None:
|
||||
"""Cập nhật con trỏ khi rê qua từng mục — chỉ mục hợp lệ mới nhận."""
|
||||
if self._drop_is_valid(event):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
@@ -100,6 +111,12 @@ class AccountEditDialog(QDialog):
|
||||
available_groups: Optional[List[groups.Group]] = None,
|
||||
allow_role_edit: bool = True, allow_group_edit: bool = True,
|
||||
fixed_group_id: str = ""):
|
||||
"""Dựng form thêm/sửa tài khoản.
|
||||
|
||||
``account`` là None thì đây là form thêm mới. ``allow_role_edit`` và
|
||||
``allow_group_edit`` tắt cho Sub-admin: họ sửa được thành viên nhóm mình
|
||||
nhưng không được tự nâng vai trò hay chuyển người sang nhóm khác.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._existing = account
|
||||
self.setWindowTitle(tr("accounts.edit_title") if account else tr("accounts.add_title"))
|
||||
@@ -140,6 +157,7 @@ class AccountEditDialog(QDialog):
|
||||
form.addRow(buttons)
|
||||
|
||||
def result_fields(self) -> Dict[str, str]:
|
||||
"""Nội dung form dưới dạng dict, đã cắt khoảng trắng thừa."""
|
||||
return {
|
||||
"username": self.user_edit.text().strip(),
|
||||
"display_name": self.name_edit.text().strip(),
|
||||
@@ -151,7 +169,16 @@ class AccountEditDialog(QDialog):
|
||||
|
||||
|
||||
class AccountsTab(QWidget):
|
||||
"""Màn Tài khoản: cây nhóm–người dùng, form sửa và bảng thống kê mức dùng.
|
||||
|
||||
Dữ liệu nằm ở thư mục dùng chung (OneDrive/ổ mạng) chứ không ở máy, nên mọi
|
||||
máy trỏ vào cùng thư mục đều thấy chung một danh sách.
|
||||
|
||||
Phân quyền chạy xuyên suốt màn này: Admin thấy và sửa tất cả; Sub-admin chỉ
|
||||
thấy nhóm của mình và chỉ sửa được thành viên thường trong đó.
|
||||
"""
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Dựng giao diện và nạp lần đầu."""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._current_username = ""
|
||||
@@ -275,24 +302,36 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- role-scoped repository access ---------------------------------
|
||||
def _shared_dir(self) -> str:
|
||||
"""Thư mục dùng chung đang cấu hình; '' nghĩa là chưa đặt và màn này không có
|
||||
gì để hiện.
|
||||
"""
|
||||
return self.ctx.config.shared_dir
|
||||
|
||||
def _accounts_dir(self):
|
||||
"""Thư mục chứa file tài khoản bên trong thư mục dùng chung."""
|
||||
return accounts.accounts_dir(self._shared_dir())
|
||||
|
||||
def _groups_dir(self):
|
||||
"""Thư mục chứa file nhóm bên trong thư mục dùng chung."""
|
||||
return groups.groups_dir(self._shared_dir())
|
||||
|
||||
def _is_admin(self) -> bool:
|
||||
"""Người đang đăng nhập có phải Admin không."""
|
||||
return self.ctx.role == "admin"
|
||||
|
||||
def _my_group(self) -> Optional[groups.Group]:
|
||||
"""Nhóm của người đang đăng nhập; ``None`` nếu chưa đăng nhập hoặc chưa vào
|
||||
nhóm nào.
|
||||
"""
|
||||
acc = self.ctx.account
|
||||
if acc is None:
|
||||
return None
|
||||
return groups.group_for_user(acc.username, self._groups_dir())
|
||||
|
||||
def _visible_groups(self) -> List[groups.Group]:
|
||||
"""Các nhóm người đang đăng nhập được phép thấy — Admin thấy hết, còn lại chỉ
|
||||
thấy nhóm của mình.
|
||||
"""
|
||||
all_groups = groups.list_groups(self._groups_dir())
|
||||
if self._is_admin():
|
||||
return all_groups
|
||||
@@ -300,6 +339,11 @@ class AccountsTab(QWidget):
|
||||
return [mine] if mine else []
|
||||
|
||||
def _visible_accounts(self) -> List[accounts.Account]:
|
||||
"""Các tài khoản người đang đăng nhập được phép thấy.
|
||||
|
||||
Lọc ngay tại đây chứ không lọc ở chỗ hiển thị: mọi thứ vẽ ra sau đó đều đi
|
||||
qua hàm này, nên không có đường nào lộ tài khoản ngoài phạm vi.
|
||||
"""
|
||||
all_accounts = accounts.list_accounts(self._accounts_dir())
|
||||
if self._is_admin():
|
||||
return all_accounts
|
||||
@@ -311,6 +355,9 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- tree ------------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
"""Nạp lại cả cây và bảng thống kê. Chưa đặt thư mục dùng chung thì chỉ hiện
|
||||
lời nhắc và để trống.
|
||||
"""
|
||||
shared_dir = self._shared_dir()
|
||||
if not shared_dir:
|
||||
self._shared_hint.setText(tr("accounts.no_shared_dir"))
|
||||
@@ -336,6 +383,7 @@ class AccountsTab(QWidget):
|
||||
self.group_filter_combo.blockSignals(False)
|
||||
|
||||
def _reload_tree(self) -> None:
|
||||
"""Dựng lại cây nhóm → thành viên từ dữ liệu trên đĩa."""
|
||||
self._reload_group_filter_combo()
|
||||
self.tree.clear()
|
||||
my_accounts = {a.username: a for a in self._visible_accounts()}
|
||||
@@ -364,6 +412,11 @@ class AccountsTab(QWidget):
|
||||
|
||||
def _add_account_item(self, parent: QTreeWidgetItem, acc: accounts.Account,
|
||||
is_subadmin: bool = False) -> None:
|
||||
"""Thêm một dòng tài khoản vào dưới một nhóm.
|
||||
|
||||
Sub-admin được đánh dấu bằng biểu tượng ngôi sao thay vì ký tự ★ — ký tự
|
||||
hiển thị khác nhau tuỳ phông của từng máy.
|
||||
"""
|
||||
label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}"
|
||||
item = QTreeWidgetItem([label])
|
||||
if is_subadmin: # subadmin badge → star icon instead of a ★ glyph
|
||||
@@ -374,6 +427,7 @@ class AccountsTab(QWidget):
|
||||
parent.addChild(item)
|
||||
|
||||
def _on_tree_select(self, *_a) -> None:
|
||||
"""Ghi nhớ tài khoản vừa chọn rồi vẽ lại (các nút sửa/xoá bật theo lựa chọn)."""
|
||||
item = self.tree.currentItem()
|
||||
kind_id = item.data(0, Qt.UserRole) if item else None
|
||||
self._current_username = kind_id[1] if kind_id and kind_id[0] == "account" else ""
|
||||
@@ -381,6 +435,10 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- account CRUD ------------------------------------------------------
|
||||
def _add_account(self) -> None:
|
||||
"""Mở form thêm tài khoản rồi lưu.
|
||||
|
||||
Sub-admin bị khoá cứng vào nhóm của chính họ, không chọn được nhóm khác.
|
||||
"""
|
||||
shared_dir = self._shared_dir()
|
||||
if not shared_dir:
|
||||
return
|
||||
@@ -417,11 +475,15 @@ class AccountsTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _selected_account(self) -> Optional[accounts.Account]:
|
||||
"""Tài khoản đang chọn trên cây, đọc lại từ đĩa; ``None`` nếu không chọn dòng
|
||||
tài khoản nào.
|
||||
"""
|
||||
if not self._current_username:
|
||||
return None
|
||||
return accounts.find_by_username(self._current_username, self._accounts_dir())
|
||||
|
||||
def _edit_account(self) -> None:
|
||||
"""Mở form sửa tài khoản đang chọn rồi lưu."""
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
@@ -457,6 +519,11 @@ class AccountsTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _delete_account(self) -> None:
|
||||
"""Xoá tài khoản đang chọn, có hỏi lại.
|
||||
|
||||
Sub-admin chỉ xoá được thành viên thường — không xoá được Admin hay một
|
||||
Sub-admin khác.
|
||||
"""
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
@@ -473,6 +540,11 @@ class AccountsTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _regenerate_code(self) -> None:
|
||||
"""Cấp lại mã đăng nhập mới cho tài khoản đang chọn và hiện mã ra một lần.
|
||||
|
||||
Mã mới phải khác mọi mã đang dùng, nên danh sách mã hiện có được truyền vào
|
||||
hàm sinh mã.
|
||||
"""
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
@@ -524,6 +596,7 @@ class AccountsTab(QWidget):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: nhờ model rút câu hỏi tự nhiên thành 1–3 từ khoá tìm kiếm."""
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([
|
||||
{"role": "system", "content":
|
||||
@@ -536,12 +609,16 @@ class AccountsTab(QWidget):
|
||||
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Đổ từ khoá vào ô tìm kiếm — chính việc gán chữ đã kích hoạt lọc lại cây."""
|
||||
self._ai_search_worker = None
|
||||
self.ai_search_btn.setEnabled(True)
|
||||
keyword = result.get("keyword") or query
|
||||
self.search_edit.setText(keyword) # textChanged re-applies the filter
|
||||
|
||||
def failed(_err: str) -> None:
|
||||
"""Gọi model hỏng thì lọc thẳng bằng câu người dùng gõ, không báo lỗi: tìm
|
||||
kiếm là tiện ích, không đáng chặn người dùng.
|
||||
"""
|
||||
self._ai_search_worker = None
|
||||
self.ai_search_btn.setEnabled(True)
|
||||
self._apply_tree_filter(query)
|
||||
@@ -554,6 +631,7 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- bulk import from Excel (Admin only) --------------------------------
|
||||
def _export_excel_template(self) -> None:
|
||||
"""Xuất file Excel mẫu để Admin điền danh sách tài khoản rồi nhập lại."""
|
||||
if not self._is_admin():
|
||||
return
|
||||
from ..core import account_excel
|
||||
@@ -569,6 +647,9 @@ class AccountsTab(QWidget):
|
||||
QMessageBox.warning(self, tr("accounts.excel_template_btn"), str(exc))
|
||||
|
||||
def _import_excel(self) -> None:
|
||||
"""Nhập tài khoản hàng loạt từ file Excel. Chỉ Admin, và phải đã đặt thư mục
|
||||
dùng chung.
|
||||
"""
|
||||
if not self._is_admin() or not self._shared_dir():
|
||||
return
|
||||
from ..core import account_excel
|
||||
@@ -598,6 +679,9 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- move a member between groups (Admin only, drag-and-drop) ---------
|
||||
def _on_member_dropped(self, username: str, target_group_id: str) -> None:
|
||||
"""Xử lý kéo một người sang nhóm khác: gỡ khỏi nhóm cũ, thêm vào nhóm mới rồi
|
||||
lưu cả ba nơi (tài khoản, nhóm cũ, nhóm mới).
|
||||
"""
|
||||
if not self._is_admin():
|
||||
return
|
||||
account = accounts.find_by_username(username, self._accounts_dir())
|
||||
@@ -616,6 +700,7 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- group CRUD (Admin only) -------------------------------------------
|
||||
def _add_group(self) -> None:
|
||||
"""Hỏi tên rồi tạo một nhóm mới. Chỉ Admin."""
|
||||
if not self._is_admin():
|
||||
return
|
||||
name, ok = QInputDialog.getText(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
|
||||
@@ -626,6 +711,7 @@ class AccountsTab(QWidget):
|
||||
self.refresh()
|
||||
|
||||
def _add_member_to_group(self, group_id: str, username: str) -> None:
|
||||
"""Thêm một người vào danh sách thành viên của nhóm, bỏ qua nếu đã có."""
|
||||
g = groups.load_group(group_id, self._groups_dir())
|
||||
if g is None:
|
||||
return
|
||||
@@ -634,6 +720,11 @@ class AccountsTab(QWidget):
|
||||
groups.save_group(g, self._groups_dir())
|
||||
|
||||
def _remove_member_from_group(self, group_id: str, username: str) -> None:
|
||||
"""Gỡ một người khỏi nhóm.
|
||||
|
||||
Người bị gỡ mà đang là Sub-admin của nhóm thì phải xoá luôn vai trò ấy —
|
||||
không thì nhóm còn trỏ tới một người không còn ở trong nó.
|
||||
"""
|
||||
g = groups.load_group(group_id, self._groups_dir())
|
||||
if g is None:
|
||||
return
|
||||
@@ -647,9 +738,13 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- usage/cost table --------------------------------------------------
|
||||
def _pricing(self) -> Dict:
|
||||
"""Bảng đơn giá đang dùng: mặc định chồng bởi phần người dùng tự đặt trong cấu
|
||||
hình.
|
||||
"""
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def _reload_usage_table(self) -> None:
|
||||
"""Nạp lại bảng mức dùng theo kỳ đang chọn, gom sự kiện theo từng tài khoản."""
|
||||
shared_dir = self._shared_dir()
|
||||
period = self.period_combo.currentData() or "day"
|
||||
start = date.today() - timedelta(days=_PERIOD_DAYS.get(period, 1) - 1)
|
||||
@@ -678,6 +773,7 @@ class AccountsTab(QWidget):
|
||||
|
||||
# ---- i18n --------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn, nút và gợi ý."""
|
||||
self._shared_hint.setText(tr("accounts.no_shared_dir"))
|
||||
self.add_btn.setText(tr("accounts.add_btn"))
|
||||
self.edit_btn.setText(tr("accounts.edit_btn"))
|
||||
|
||||
@@ -30,6 +30,7 @@ class AgentManagerTab(QWidget):
|
||||
own, independent of any single Flow."""
|
||||
|
||||
def __init__(self, ctx=None, parent=None):
|
||||
"""Dựng danh sách agent và form sửa."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx # for the AI "generate prompt from description" button
|
||||
self._gen_worker = None
|
||||
@@ -124,6 +125,11 @@ class AgentManagerTab(QWidget):
|
||||
|
||||
# ---- Agent (model) list, fetched live from the selected provider -----
|
||||
def _reload_models(self) -> None:
|
||||
"""Nạp danh sách model của provider đang chọn.
|
||||
|
||||
Model đang chọn được đưa vào danh sách trước, ngay trong lúc chờ: không thì
|
||||
ô model trống trơn vài giây và người dùng tưởng lựa chọn của mình đã mất.
|
||||
"""
|
||||
if self.ctx is None:
|
||||
return
|
||||
provider_key = self.provider_combo.currentData() or self.ctx.config.active_provider
|
||||
@@ -136,6 +142,7 @@ class AgentManagerTab(QWidget):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: hỏi provider danh sách model. Lỗi thì trả danh sách rỗng."""
|
||||
try:
|
||||
return {"models": ctx.build_provider_for(provider_key).list_models() or [],
|
||||
"provider": provider_key}
|
||||
@@ -143,6 +150,9 @@ class AgentManagerTab(QWidget):
|
||||
return {"models": [], "provider": provider_key}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Đổ danh sách model vào bộ chọn; bỏ qua nếu provider đã bị đổi lần nữa trong
|
||||
lúc chờ.
|
||||
"""
|
||||
if result.get("provider") != (self.provider_combo.currentData()
|
||||
or ctx.config.active_provider):
|
||||
return # provider changed again while fetching
|
||||
@@ -166,6 +176,7 @@ class AgentManagerTab(QWidget):
|
||||
|
||||
# ---- AI: draft the prompt from the short name/description -----------
|
||||
def _gen_prompt(self) -> None:
|
||||
"""Nhờ model viết nội dung prompt cho agent từ tên và mô tả."""
|
||||
name = self.name_edit.text().strip()
|
||||
desc = self.desc_edit.text().strip()
|
||||
if not name and not desc:
|
||||
@@ -178,6 +189,7 @@ class AgentManagerTab(QWidget):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gọi model sinh prompt cho agent."""
|
||||
return {"prompt": generate_agent_prompt(
|
||||
ctx.build_active_provider(), name, desc, worker.is_cancelled)}
|
||||
|
||||
@@ -188,17 +200,22 @@ class AgentManagerTab(QWidget):
|
||||
w.start()
|
||||
|
||||
def _on_gen_prompt(self, result) -> None:
|
||||
"""Đổ prompt model vừa sinh vào ô soạn."""
|
||||
text = (result or {}).get("prompt", "")
|
||||
if text:
|
||||
self.prompt_edit.setPlainText(text)
|
||||
self._reset_gen_prompt_btn()
|
||||
|
||||
def _reset_gen_prompt_btn(self) -> None:
|
||||
"""Bật lại nút sinh prompt và trả chữ về như cũ."""
|
||||
self._gen_prompt_btn.setEnabled(True)
|
||||
self._gen_prompt_btn.setText(tr("agentmgr.gen_prompt_btn"))
|
||||
|
||||
# ---- list <-> editor ------------------------------------------------
|
||||
def _reload_list(self, select_name: str = "") -> None:
|
||||
"""Nạp lại danh sách agent, chọn lại đúng agent theo tên nếu có yêu cầu — dùng
|
||||
sau khi lưu để con trỏ không nhảy về đầu danh sách.
|
||||
"""
|
||||
self.list.blockSignals(True)
|
||||
self.list.clear()
|
||||
agents = list_agents()
|
||||
@@ -217,6 +234,7 @@ class AgentManagerTab(QWidget):
|
||||
self._clear_editor()
|
||||
|
||||
def _current_agent(self) -> Optional[CustomAgent]:
|
||||
"""Agent đang chọn; ``None`` nếu chưa chọn dòng nào."""
|
||||
row = self.list.currentRow()
|
||||
agents = list_agents()
|
||||
if 0 <= row < len(agents):
|
||||
@@ -224,6 +242,11 @@ class AgentManagerTab(QWidget):
|
||||
return None
|
||||
|
||||
def _load_into_editor(self, _row: int) -> None:
|
||||
"""Đổ agent đang chọn vào form.
|
||||
|
||||
Model được nhớ riêng vào ``_pending_model`` vì danh sách model nạp bất đồng
|
||||
bộ — không giữ lại thì lựa chọn biến mất khi danh sách về tới nơi.
|
||||
"""
|
||||
agent = self._current_agent()
|
||||
if agent is None:
|
||||
self._clear_editor()
|
||||
@@ -240,6 +263,7 @@ class AgentManagerTab(QWidget):
|
||||
self.model_combo.setCurrentIndex(max(0, self.model_combo.findData(agent.model)))
|
||||
|
||||
def _clear_editor(self) -> None:
|
||||
"""Xoá trắng form và bỏ tên đang mở."""
|
||||
self._loaded_name = ""
|
||||
self.name_edit.clear()
|
||||
self.desc_edit.clear()
|
||||
@@ -249,11 +273,13 @@ class AgentManagerTab(QWidget):
|
||||
self.model_combo.setCurrentIndex(0)
|
||||
|
||||
def _new_agent(self) -> None:
|
||||
"""Bỏ chọn trong danh sách và mở một form trống để tạo agent mới."""
|
||||
self.list.setCurrentRow(-1)
|
||||
self._clear_editor()
|
||||
self.name_edit.setFocus()
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Lưu agent đang soạn. Chưa nhập tên thì con trỏ nhảy vào ô tên, không lưu."""
|
||||
name = self.name_edit.text().strip()
|
||||
if not name:
|
||||
self.name_edit.setFocus()
|
||||
@@ -270,6 +296,7 @@ class AgentManagerTab(QWidget):
|
||||
self._reload_list(select_name=agent.name)
|
||||
|
||||
def _delete(self) -> None:
|
||||
"""Xoá agent đang chọn, có hỏi lại."""
|
||||
agent = self._current_agent()
|
||||
if agent is None:
|
||||
return
|
||||
|
||||
@@ -21,7 +21,11 @@ from .icons import icon, icon_picker_combo
|
||||
|
||||
|
||||
class Co4EAgentDialog(QDialog):
|
||||
"""Hộp thoại tạo/sửa một agent tự tạo của Co4E: tên, vai trò, model, chỉ dẫn,
|
||||
skill và tệp đính kèm.
|
||||
"""
|
||||
def __init__(self, ctx, agent: CustomAgent, skill_names: List[str], parent=None):
|
||||
"""Form thêm/sửa một agent Co4E: tên, prompt và các skill nó được dùng."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._agent = agent
|
||||
@@ -134,11 +138,13 @@ class Co4EAgentDialog(QDialog):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: nhờ model soạn thử phần chỉ dẫn cho agent."""
|
||||
from ..core.ai_task_planner import generate_agent_prompt
|
||||
return {"text": generate_agent_prompt(ctx.build_active_provider(), name, role, hint,
|
||||
cancel=worker.is_cancelled)}
|
||||
|
||||
def done(result: dict):
|
||||
"""Đổ chỉ dẫn vừa soạn vào ô soạn thảo."""
|
||||
self.gen_btn.setEnabled(True)
|
||||
if result.get("text"):
|
||||
self.instructions_edit.setPlainText(result["text"])
|
||||
@@ -150,6 +156,7 @@ class Co4EAgentDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _refresh_attach_list(self) -> None:
|
||||
"""Vẽ lại danh sách tệp đính kèm (chỉ hiện tên tệp, đường dẫn để trong tooltip)."""
|
||||
from pathlib import Path as _P
|
||||
self.attach_list.clear()
|
||||
for p in self._attachments:
|
||||
@@ -158,6 +165,7 @@ class Co4EAgentDialog(QDialog):
|
||||
self.attach_list.addItem(item)
|
||||
|
||||
def _add_attachment(self) -> None:
|
||||
"""Thêm tệp đính kèm cho agent."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("co4e.attach_add"))
|
||||
for f in files:
|
||||
@@ -166,12 +174,18 @@ class Co4EAgentDialog(QDialog):
|
||||
self._refresh_attach_list()
|
||||
|
||||
def _del_attachment(self) -> None:
|
||||
"""Gỡ tệp đính kèm đang chọn."""
|
||||
row = self.attach_list.currentRow()
|
||||
if 0 <= row < len(self._attachments):
|
||||
self._attachments.pop(row)
|
||||
self._refresh_attach_list()
|
||||
|
||||
def result_agent(self) -> CustomAgent:
|
||||
"""Bản ghi agent dựng từ nội dung đang có trên form.
|
||||
|
||||
Vai trò luôn viết HOA và không bao giờ rỗng — prompt của bước dùng thẳng
|
||||
chuỗi này nên để trống sẽ sinh ra câu cụt.
|
||||
"""
|
||||
a = self._agent
|
||||
a.name = self.name_edit.text().strip() or "Agent"
|
||||
a.role = (self.role_edit.text().strip() or "AGENT").upper()
|
||||
@@ -187,6 +201,7 @@ class Co4EAgentDialog(QDialog):
|
||||
return a
|
||||
|
||||
def _load_models(self) -> None:
|
||||
"""Nạp danh sách model đang sống vào ô chọn, ở luồng nền."""
|
||||
if self.ctx is None:
|
||||
return
|
||||
from ..core import preview_ai
|
||||
@@ -196,9 +211,11 @@ class Co4EAgentDialog(QDialog):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_w):
|
||||
"""Chạy nền: hỏi mọi provider danh sách model đang dùng được."""
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict):
|
||||
"""Gộp model của mọi provider vào ô chọn, giữ nguyên thứ đang chọn."""
|
||||
self.load_btn.setEnabled(True)
|
||||
models = []
|
||||
for lst in (result or {}).values():
|
||||
|
||||
+39
-3
@@ -71,6 +71,10 @@ class _EqualTabBar(QTabBar):
|
||||
_GAP = 6 # px between tabs — matches the QSS margin-right below
|
||||
|
||||
def tabSizeHint(self, index): # noqa: N802
|
||||
"""Chia đều bề rộng cho mọi tab thay vì để Qt co theo độ dài nhãn.
|
||||
|
||||
Nhãn dài ngắn khác nhau làm dải tab nhấp nhô mỗi khi đổi tên luồng.
|
||||
"""
|
||||
base = super().tabSizeHint(index)
|
||||
n = self.count() or 1
|
||||
avail = self.width()
|
||||
@@ -81,6 +85,7 @@ class _EqualTabBar(QTabBar):
|
||||
return QSize(max(56, share), max(30, base.height()))
|
||||
|
||||
def resizeEvent(self, e): # noqa: N802
|
||||
"""Đổi bề rộng dải tab thì tính lại bề rộng từng tab."""
|
||||
super().resizeEvent(e)
|
||||
self.updateGeometry() # re-hint tab widths when resized
|
||||
|
||||
@@ -103,9 +108,20 @@ class Co4ETab(
|
||||
Co4ERunsMixin,
|
||||
Co4EChatMixin,
|
||||
QWidget):
|
||||
"""Màn Co4E Studio, ghép từ 7 mixin: dải tab luồng, cột trái, bố cục, CRUD luồng,
|
||||
quản lý agent, chạy luồng, và khung chat.
|
||||
|
||||
Tách thành mixin để mỗi phần nằm trong một file dưới trần 400 dòng; thứ tự
|
||||
kế thừa quan trọng — xem ghi chú ở đầu từng file mixin.
|
||||
"""
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx):
|
||||
"""Dựng Co4E Studio: khung vẽ workflow, cột nguyên liệu và bảng Flow Status.
|
||||
|
||||
Mở sẵn một workflow trống để người dùng kéo bước vào ngay, không phải bấm
|
||||
"tạo mới" trước.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._wf: co4e.Workflow = co4e.new_workflow(tr("co4e.untitled"))
|
||||
@@ -217,9 +233,10 @@ class Co4ETab(
|
||||
# history pane beside it), so the threshold is about its own screen only.
|
||||
_NARROW = 1300
|
||||
|
||||
def showEvent(self, e): # noqa: N802 - Qt override
|
||||
super().showEvent(e)
|
||||
self._narrow_guard.attach()
|
||||
# Chỗ này từng có một ``showEvent`` thứ hai chỉ gọi
|
||||
# ``self._narrow_guard.attach()``. Lớp này còn một ``showEvent`` nữa ở phía
|
||||
# dưới, và Python giữ định nghĩa SAU CÙNG — nên bản ở đây chưa bao giờ chạy.
|
||||
# Đã gộp cả hai việc vào đúng một ``showEvent`` (xem bên dưới).
|
||||
|
||||
|
||||
|
||||
@@ -309,6 +326,7 @@ class Co4ETab(
|
||||
return Path(base) / "co4e"
|
||||
|
||||
def _refresh_ws_folder_btn(self) -> None:
|
||||
"""Cập nhật nhãn nút thư mục làm việc, rút gọn còn hai cấp cuối cho đỡ dài."""
|
||||
root = self._flow_output_root()
|
||||
parts = root.parts
|
||||
short = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else str(root)
|
||||
@@ -316,6 +334,7 @@ class Co4ETab(
|
||||
self.ws_folder_btn.setToolTip(tr("co4e.tt_open_workspace", path=str(root)))
|
||||
|
||||
def _open_workspace_folder(self) -> None:
|
||||
"""Mở thư mục kết quả của luồng trong trình quản lý tệp của hệ điều hành."""
|
||||
from .osutil import open_location
|
||||
root = self._flow_output_root()
|
||||
try:
|
||||
@@ -331,14 +350,26 @@ class Co4ETab(
|
||||
|
||||
|
||||
def showEvent(self, e): # noqa: N802
|
||||
"""Mỗi lần tab được hiện lại: làm mới bảng Flow Status và gắn lại bộ canh
|
||||
bố cục hẹp.
|
||||
|
||||
Gắn lại bộ canh là cần: widget có thể đã được chuyển sang cửa sổ khác kể
|
||||
từ lần hiện trước, và bộ canh theo dõi CỬA SỔ chứ không theo dõi widget.
|
||||
"""
|
||||
# Guarantee the status list is current whenever the tab is shown again.
|
||||
self._refresh_runs()
|
||||
self._narrow_guard.attach()
|
||||
super().showEvent(e)
|
||||
|
||||
def _out_dir(self) -> Path:
|
||||
# Save chat/flow deliverables into the SELECTED workspace (the active
|
||||
# project's folder via _flow_output_root), so files land where the user
|
||||
# works with them — not in the config/install folder.
|
||||
"""Thư mục ghi kết quả của luồng đang mở, tạo sẵn nếu chưa có.
|
||||
|
||||
Ghi vào thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm
|
||||
việc, không rơi vào thư mục cài đặt.
|
||||
"""
|
||||
d = self._flow_output_root() / co4e.slugify(self._wf.name or "flow")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
@@ -359,6 +390,7 @@ class Co4ETab(
|
||||
|
||||
# ---- i18n -------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip."""
|
||||
for key in self._sections:
|
||||
self._sync_section_arrow(key)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
@@ -382,9 +414,13 @@ class Co4ETab(
|
||||
|
||||
|
||||
def _html_escape(text: str) -> str:
|
||||
"""Thoát ba ký tự HTML nguy hiểm, để nội dung người dùng không phá vỡ khung chat."""
|
||||
return (text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def _qcolor(hex_str: str):
|
||||
"""Đổi chuỗi màu hex thành ``QColor``. Import muộn để module này nạp được ở nơi
|
||||
chưa có Qt GUI.
|
||||
"""
|
||||
from PySide6.QtGui import QColor
|
||||
return QColor(hex_str)
|
||||
|
||||
+13
-1
@@ -1,11 +1,23 @@
|
||||
"""Vỏ chuyển tiếp — R08-T02.
|
||||
|
||||
Phần thân đã chuyển sang ``presentation/chat/composer_widget.py`` (thanh công
|
||||
cụ) và ``chat_input_box.py`` (ô nhập).
|
||||
cụ), ``chat_input_box.py`` (ô nhập) và ``composer_mime.py`` (đọc dữ liệu
|
||||
dán/kéo-thả).
|
||||
|
||||
Bốn hàm ``_save_pasted_image``/``_is_local_*_command``/``_paths_from_mime``
|
||||
được nối lại dưới đúng tên cũ: chúng là tên riêng tư nên không thuộc API công
|
||||
khai, nhưng file này vốn cũng đang chuyển tiếp ``_Input`` và ``_SkillPopup``
|
||||
— nối tiếp một nửa thì vỏ chuyển tiếp không còn trung thực với bản gốc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..presentation.chat.chat_input_box import _Input, _SkillPopup # noqa: F401
|
||||
from ..presentation.chat.composer_mime import ( # noqa: F401
|
||||
is_local_agent_command as _is_local_agent_command,
|
||||
is_local_skill_command as _is_local_skill_command,
|
||||
paths_from_mime as _paths_from_mime,
|
||||
save_pasted_image as _save_pasted_image,
|
||||
)
|
||||
from ..presentation.chat.composer_widget import ( # noqa: F401
|
||||
Composer,
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ class JiraConnectDialog(QDialog):
|
||||
read and processed automatically (no per-request setup)."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
"""Form khai báo kết nối Jira: địa chỉ, tài khoản và token."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.setWindowTitle(tr("connectors.jira_group"))
|
||||
@@ -68,12 +69,16 @@ class JiraConnectDialog(QDialog):
|
||||
form.addRow(rw)
|
||||
|
||||
def _on_paste(self, text: str) -> None:
|
||||
"""Dán một link Jira bất kỳ thì tự rút ra base URL — người dùng không phải
|
||||
biết đâu là phần gốc của địa chỉ.
|
||||
"""
|
||||
from ..core import jira_tool
|
||||
base = jira_tool.base_url_from_link(text)
|
||||
if base:
|
||||
self.url.setText(base)
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Ghi thông tin Jira vào cấu hình (chưa đóng hộp thoại)."""
|
||||
j = self.ctx.config.data.setdefault("jira", {})
|
||||
j.update({"base_url": self.url.text().strip(), "email": self.email.text().strip(),
|
||||
"api_token": self.token.text().strip()})
|
||||
@@ -81,10 +86,12 @@ class JiraConnectDialog(QDialog):
|
||||
self.ctx.save()
|
||||
|
||||
def _save_close(self) -> None:
|
||||
"""Lưu rồi đóng hộp thoại."""
|
||||
self._save()
|
||||
self.accept()
|
||||
|
||||
def _test(self) -> None:
|
||||
"""Thử kết nối bằng một truy vấn tối thiểu, chạy ở luồng nền."""
|
||||
from ..core import jira_tool
|
||||
self._save()
|
||||
cfg = self.ctx.config.data.get("jira", {})
|
||||
@@ -95,9 +102,13 @@ class JiraConnectDialog(QDialog):
|
||||
self.test_btn.setEnabled(False)
|
||||
|
||||
def job(_w):
|
||||
"""Chạy nền: tìm đúng 1 issue mới nhất để xác nhận kết nối sống."""
|
||||
return {"out": jira_tool.search(cfg, "order by created DESC", 1)}
|
||||
|
||||
def done(r):
|
||||
"""Hiện kết quả thử: coi là lỗi khi thông điệp bắt đầu bằng câu báo chưa cấu
|
||||
hình hoặc tìm kiếm thất bại.
|
||||
"""
|
||||
self.test_btn.setEnabled(True)
|
||||
out = r.get("out", "")
|
||||
ok = not out.lower().startswith(("jira is not configured", "jira search failed"))
|
||||
@@ -113,6 +124,11 @@ class JiraConnectDialog(QDialog):
|
||||
|
||||
|
||||
class ConnectorsPanel(QWidget):
|
||||
"""Bảng Connectors trong Cài đặt: MS365 dựng sẵn, Jira, và connector MCP tự thêm,
|
||||
gom theo bốn nhóm CAD / CAE / MS365 / Khác.
|
||||
|
||||
Có một công tắc tổng: tắt là agent không nối ra connector ngoài nào cả.
|
||||
"""
|
||||
_EXT_CATEGORY_LABELS = {
|
||||
"cad": "CAD (NX / CATIA / SolidWorks / AutoCAD)",
|
||||
"cae": "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)",
|
||||
@@ -126,6 +142,11 @@ class ConnectorsPanel(QWidget):
|
||||
_MS365_BUILTIN_LABELS = {"onedrive": "OneDrive", "sharepoint": "SharePoint"}
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Panel quản lý connector ngoài.
|
||||
|
||||
Công tắc tổng ở trên cùng: tắt là agent KHÔNG nối tới connector nào, bất kể
|
||||
từng connector bên dưới có bật hay không.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
lay = QVBoxLayout(self)
|
||||
@@ -185,6 +206,7 @@ class ConnectorsPanel(QWidget):
|
||||
|
||||
# ---- rendering ------------------------------------------------------------
|
||||
def _clear_categories(self) -> None:
|
||||
"""Xoá sạch các mục nhóm trước khi dựng lại."""
|
||||
while self._cat_lay.count():
|
||||
item = self._cat_lay.takeAt(0)
|
||||
w = item.widget()
|
||||
@@ -192,11 +214,13 @@ class ConnectorsPanel(QWidget):
|
||||
w.deleteLater()
|
||||
|
||||
def _reload_connectors(self) -> None:
|
||||
"""Dựng lại toàn bộ bảng từ cấu hình mới nhất."""
|
||||
self._clear_categories()
|
||||
for cat in EXT_CATEGORIES:
|
||||
self._cat_lay.addWidget(self._category_section(cat))
|
||||
|
||||
def _category_section(self, cat: str) -> QWidget:
|
||||
"""Dựng một mục nhóm kèm lưới thẻ connector bên trong."""
|
||||
section = QWidget()
|
||||
enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout
|
||||
sl = QVBoxLayout(section)
|
||||
@@ -260,6 +284,9 @@ class ConnectorsPanel(QWidget):
|
||||
|
||||
def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle,
|
||||
edit_cb=None, delete_cb=None) -> QWidget:
|
||||
"""Dựng một thẻ connector: tiêu đề, dòng phụ, công tắc bật/tắt và nút sửa/xoá
|
||||
(nếu có).
|
||||
"""
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
style_card(card)
|
||||
@@ -307,19 +334,23 @@ class ConnectorsPanel(QWidget):
|
||||
return card
|
||||
|
||||
def _toggle_ms365_builtin(self, key: str, checked: bool) -> None:
|
||||
"""Bật/tắt một connector MS365 dựng sẵn và lưu ngay."""
|
||||
self.ctx.config.ms365.setdefault("connectors", {})[key] = checked
|
||||
self.ctx.save()
|
||||
|
||||
def _toggle_jira(self, checked: bool) -> None:
|
||||
"""Bật/tắt connector Jira và lưu ngay."""
|
||||
self.ctx.config.data.setdefault("jira", {})["enabled"] = checked
|
||||
self.ctx.save()
|
||||
|
||||
def _toggle_ext_entry(self, entry: dict, checked: bool) -> None:
|
||||
"""Bật/tắt một connector MCP tự thêm và lưu ngay."""
|
||||
entry["enabled"] = checked
|
||||
self.ctx.save()
|
||||
|
||||
# ---- CRUD -----------------------------------------------------------------
|
||||
def _ext_add(self) -> None:
|
||||
"""Thêm một connector MCP mới qua hộp thoại."""
|
||||
dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0])
|
||||
if dlg.exec():
|
||||
entry = dlg.result_connector()
|
||||
@@ -328,6 +359,7 @@ class ConnectorsPanel(QWidget):
|
||||
self._reload_connectors()
|
||||
|
||||
def _edit_ext_entry(self, cat: str, entry: dict) -> None:
|
||||
"""Sửa một connector MCP đã có."""
|
||||
dlg = ExtConnectorEditDialog(self, category=cat, connector=entry)
|
||||
if dlg.exec():
|
||||
entry.update(dlg.result_connector())
|
||||
@@ -335,10 +367,12 @@ class ConnectorsPanel(QWidget):
|
||||
self._reload_connectors()
|
||||
|
||||
def _open_jira_dialog(self) -> None:
|
||||
"""Mở hộp thoại cấu hình Jira rồi dựng lại bảng."""
|
||||
JiraConnectDialog(self.ctx, self).exec()
|
||||
self._reload_connectors()
|
||||
|
||||
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
|
||||
"""Xoá một connector MCP sau khi hỏi xác nhận."""
|
||||
if QMessageBox.question(
|
||||
self, tr("settings.ext_delete_btn"),
|
||||
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
|
||||
@@ -348,6 +382,7 @@ class ConnectorsPanel(QWidget):
|
||||
self._reload_connectors()
|
||||
|
||||
def _refresh_ms365_local_status(self) -> None:
|
||||
"""Cập nhật dòng trạng thái MS365 cục bộ theo việc có tìm thấy thư mục OneDrive hay không."""
|
||||
from .. import paths
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
@@ -356,6 +391,7 @@ class ConnectorsPanel(QWidget):
|
||||
self.ms365_local_status.setText(tr("settings.ms365_local_none"))
|
||||
|
||||
def _on_connect_external_toggled(self, on: bool) -> None:
|
||||
"""Bật/tắt công tắc tổng cho connector ngoài, và khoá/mở cả bảng theo đó."""
|
||||
self.ctx.config.set_connect_external(on)
|
||||
self._apply_connect_external_enabled(on)
|
||||
|
||||
@@ -366,6 +402,7 @@ class ConnectorsPanel(QWidget):
|
||||
w.setEnabled(on)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self.connect_external_sw.setText(tr("connectors.connect_external"))
|
||||
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
|
||||
self.add_btn.setText(tr("settings.ext_add_btn"))
|
||||
|
||||
@@ -19,7 +19,15 @@ _FOLDER_LBL_MAX_CHARS = 42 # keep the composer's bottom row from crowding out A
|
||||
|
||||
|
||||
class CoworkTab(ChatPanel):
|
||||
"""Màn Cowork: khung chat chính, gắn với một project và một thư mục kết quả.
|
||||
|
||||
Khác :class:`ChatPanel` gốc ở chỗ kết quả được gom theo TỪNG hội thoại và
|
||||
chỉ hiện ra sau khi lượt chạy thành công — xem :meth:`register_output`.
|
||||
"""
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Màn Cowork — một ``ChatPanel`` kèm thanh công cụ riêng hiện provider, model và
|
||||
thư mục kết quả đang dùng.
|
||||
"""
|
||||
super().__init__(ctx, "cowork", "Cowork", placeholder_key="composer.placeholder_cowork")
|
||||
|
||||
self._title_lbl = QLabel()
|
||||
@@ -95,6 +103,7 @@ class CoworkTab(ChatPanel):
|
||||
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
||||
self.refresh_title()
|
||||
self.skills_btn.setText(tr("cowork.skills_btn"))
|
||||
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
||||
@@ -105,6 +114,7 @@ class CoworkTab(ChatPanel):
|
||||
|
||||
# ---- output folder --------------------------------------------------
|
||||
def _pick_output_folder(self) -> None:
|
||||
"""Chọn thư mục kết quả cho hội thoại này."""
|
||||
start = str(self._session_output_dir())
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("cowork.pick_folder_title"), start)
|
||||
if not chosen:
|
||||
@@ -126,6 +136,11 @@ class CoworkTab(ChatPanel):
|
||||
self._refresh_outputs_from_disk()
|
||||
|
||||
def _apply_output_folder_label(self) -> None:
|
||||
"""Cập nhật nhãn cạnh nút chọn thư mục.
|
||||
|
||||
Chỉ hiện TÊN thư mục; đường dẫn đầy đủ để trong tooltip — bày cả đường dẫn
|
||||
ra ngoài chỉ làm rối một giá trị mà người dùng vừa tự chọn.
|
||||
"""
|
||||
full = str(self._session_output_dir())
|
||||
# Only the folder NAME is shown beside the button — the full path
|
||||
# (still available on hover) reads as noisy clutter for a value the
|
||||
@@ -150,6 +165,7 @@ class CoworkTab(ChatPanel):
|
||||
|
||||
# ---- project (workspace) --------------------------------------------
|
||||
def _project(self):
|
||||
"""Project đang gắn với hội thoại này; ``None`` nếu không tìm thấy."""
|
||||
from ..core.projects import load_project
|
||||
|
||||
return load_project(self.project_id)
|
||||
@@ -187,6 +203,7 @@ class CoworkTab(ChatPanel):
|
||||
".sh", ".bat", ".ps1", ".rb", ".pl"}
|
||||
|
||||
def assistant_title(self) -> str:
|
||||
"""Nhãn hiện trên bong bóng trả lời của màn Cowork."""
|
||||
return tr("cowork.assistant_title")
|
||||
|
||||
def _session_output_dir(self):
|
||||
@@ -222,6 +239,7 @@ class CoworkTab(ChatPanel):
|
||||
return self.ctx.config.cowork_output_dir() / self.session_id
|
||||
|
||||
def workspace_dir(self):
|
||||
"""Thư mục agent được phép đọc/ghi trong hội thoại này."""
|
||||
return self._session_output_dir()
|
||||
|
||||
def _turn_output_dir(self, turn_id: str):
|
||||
@@ -234,6 +252,11 @@ class CoworkTab(ChatPanel):
|
||||
return self._session_output_dir() / ".turns" / turn_id
|
||||
|
||||
def _is_intermediate_output(self, path: str) -> bool:
|
||||
"""Tệp này có phải file trung gian (do bước dựng sinh ra) không.
|
||||
|
||||
File trung gian không hiện lên ô "Tệp đầu ra" — người dùng chỉ quan tâm
|
||||
sản phẩm cuối.
|
||||
"""
|
||||
from pathlib import Path
|
||||
return Path(path).suffix.lower() in self._INTERMEDIATE_EXTS
|
||||
|
||||
@@ -253,6 +276,12 @@ class CoworkTab(ChatPanel):
|
||||
# file it made earlier in this same conversation — e.g. "sửa lại tiêu đề
|
||||
# trong file báo cáo vừa tạo" — since it can read_file/edit_file/write_file
|
||||
# it directly by the exact name listed here.
|
||||
"""Ghi chú đính kèm vào prompt: danh sách tệp hội thoại này đã tạo.
|
||||
|
||||
Nhờ đó agent (và người dùng, không phải tải lên lại) tham chiếu và sửa được
|
||||
tệp nó vừa tạo ở lượt trước — ví dụ "sửa lại tiêu đề trong file báo cáo vừa
|
||||
tạo" — vì nó đọc/ghi được tệp đó theo đúng tên liệt kê ở đây.
|
||||
"""
|
||||
names = self._existing_output_names()
|
||||
if not names:
|
||||
return ""
|
||||
@@ -308,9 +337,13 @@ class CoworkTab(ChatPanel):
|
||||
def register_output(self, path: str) -> None:
|
||||
# Suppress live/intermediate updates during a run — the Output box is
|
||||
# rebuilt from the surviving files once the turn succeeds (see below).
|
||||
"""Không làm gì: ô "Tệp đầu ra" của Cowork được dựng lại từ các tệp còn sống
|
||||
sau khi lượt chạy THÀNH CÔNG, chứ không cập nhật từng tệp giữa chừng.
|
||||
"""
|
||||
return
|
||||
|
||||
def on_file_written(self, path: str) -> None:
|
||||
"""Không làm gì: Cowork không cập nhật danh sách tệp theo thời gian thực."""
|
||||
return # no live file updates in Cowork
|
||||
|
||||
def _refresh_outputs_from_disk(self) -> None:
|
||||
@@ -329,17 +362,22 @@ class CoworkTab(ChatPanel):
|
||||
|
||||
def new_session(self) -> None:
|
||||
# The new thread stays in the CURRENT project (Claude-style).
|
||||
"""Mở hội thoại mới trong ĐÚNG project đang chọn, rồi trỏ lại thư mục kết quả."""
|
||||
super().new_session()
|
||||
# Refresh the project/folder labels + watch the new session's folder.
|
||||
self._apply_output_folder_label()
|
||||
|
||||
def load_conversation(self, conv) -> None:
|
||||
"""Mở lại một hội thoại cũ: khôi phục project của nó, dựng lại danh sách tệp
|
||||
đầu ra từ đĩa và trỏ lại nhãn thư mục.
|
||||
"""
|
||||
super().load_conversation(conv) # restores this conversation's project_id
|
||||
self._refresh_outputs_from_disk() # show this session's deliverables from disk
|
||||
# Refresh the project/folder labels + watch this conversation's folder.
|
||||
self._apply_output_folder_label()
|
||||
|
||||
def refresh_header(self) -> None:
|
||||
"""Cập nhật dòng "provider · model" trên thanh công cụ Cowork."""
|
||||
cfg = self.ctx.config
|
||||
label = PROVIDER_LABELS.get(cfg.active_provider, cfg.active_provider)
|
||||
self.model_lbl.setText(f"{label} · {cfg.model_label()}")
|
||||
@@ -377,6 +415,11 @@ class CoworkTab(ChatPanel):
|
||||
model = self._routed_model or self._model or ""
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền một lượt Cowork qua ``ConversationApplicationService`` (R04-T04).
|
||||
|
||||
Sự kiện của service được dịch ngược về khuôn dict cũ để giao diện hiện tại
|
||||
không phải sửa.
|
||||
"""
|
||||
from ..application.conversations.core_runtime_adapter import (
|
||||
build_cowork_conversation_service,
|
||||
legacy_event_sink,
|
||||
|
||||
@@ -26,7 +26,11 @@ _CATEGORY_LABEL = {"cad": "CAD", "cae": "CAE", "other": "Other"}
|
||||
|
||||
|
||||
class ExtConnectorEditDialog(QDialog):
|
||||
"""Hộp thoại thêm/sửa một connector ngoài: MCP server hoặc REST API."""
|
||||
def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None):
|
||||
"""Form thêm/sửa một connector ngoài. ``connector`` để None thì đây là form thêm
|
||||
mới; nhóm (CAD/CAE/MS365/Other) lấy từ connector cũ nếu đang sửa.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
connector = connector or {}
|
||||
self.category = connector.get("category", category)
|
||||
@@ -121,6 +125,11 @@ class ExtConnectorEditDialog(QDialog):
|
||||
lay.addWidget(buttons)
|
||||
|
||||
def _reload_presets(self) -> None:
|
||||
"""Nạp lại danh sách mẫu dựng sẵn theo nhóm đang chọn.
|
||||
|
||||
Chặn tín hiệu trong lúc nạp: ``clear()`` phát ``currentIndexChanged`` và sẽ
|
||||
bị hiểu nhầm là người dùng vừa chọn mẫu.
|
||||
"""
|
||||
self.preset_combo.blockSignals(True)
|
||||
self.preset_combo.clear()
|
||||
self.preset_combo.addItem(tr("ext.preset_custom"), "")
|
||||
@@ -129,15 +138,18 @@ class ExtConnectorEditDialog(QDialog):
|
||||
self.preset_combo.blockSignals(False)
|
||||
|
||||
def _on_category_changed(self) -> None:
|
||||
"""Đổi nhóm (CAD/CAE/MS365/Khác) thì nạp lại danh sách mẫu tương ứng."""
|
||||
self.category = self.category_combo.currentData() or self.category
|
||||
self._reload_presets()
|
||||
|
||||
def _apply_preset(self) -> None:
|
||||
"""Áp một mẫu dựng sẵn; chỉ điền tên khi người dùng chưa tự đặt tên."""
|
||||
preset_id = self.preset_combo.currentData()
|
||||
if preset_id and not self.name_edit.text().strip():
|
||||
self.name_edit.setText(self.preset_combo.currentText())
|
||||
|
||||
def _current_entry(self) -> dict:
|
||||
"""Bản ghi connector dựng từ nội dung đang có trên form."""
|
||||
mode = self.mode_combo.currentData()
|
||||
preset_id = self.preset_combo.currentData()
|
||||
name = self.name_edit.text().strip()
|
||||
@@ -159,6 +171,7 @@ class ExtConnectorEditDialog(QDialog):
|
||||
}
|
||||
|
||||
def _test_connection(self) -> None:
|
||||
"""Thử kết nối tới connector đang cấu hình và hiện kết quả."""
|
||||
entry = self._current_entry()
|
||||
if entry["mode"] == "rest_api":
|
||||
from ..core.ext_connectors import RestApiConnector
|
||||
@@ -182,6 +195,7 @@ class ExtConnectorEditDialog(QDialog):
|
||||
self.status_label.setText(f"{prefix} {message}")
|
||||
|
||||
def _on_accept(self) -> None:
|
||||
"""Kiểm tra bắt buộc có tên trước khi đóng hộp thoại."""
|
||||
if not self.name_edit.text().strip():
|
||||
self.name_edit.setFocus()
|
||||
return
|
||||
@@ -195,4 +209,5 @@ class ExtConnectorEditDialog(QDialog):
|
||||
self.accept()
|
||||
|
||||
def result_connector(self) -> dict:
|
||||
"""Bản ghi connector để chỗ gọi lưu lại."""
|
||||
return self._current_entry()
|
||||
|
||||
@@ -51,6 +51,11 @@ class FileEditDialog(QDialog):
|
||||
"""Pick/view a file and apply AI edits to it (see module docstring)."""
|
||||
|
||||
def __init__(self, ctx=None, path: str = "", parent=None):
|
||||
"""Hộp thoại xem/sửa một tệp.
|
||||
|
||||
Mở ở chế độ CHỈ ĐỌC; muốn sửa phải bật rõ ràng, và lần ghi đầu tiên tự sao
|
||||
lưu bản gốc.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
@@ -121,11 +126,13 @@ class FileEditDialog(QDialog):
|
||||
|
||||
# ---- loading -----------------------------------------------------------
|
||||
def _browse(self) -> None:
|
||||
"""Chọn tệp cần sửa."""
|
||||
path, _ = QFileDialog.getOpenFileName(self, tr("fileedit.title"))
|
||||
if path:
|
||||
self.load_file(path)
|
||||
|
||||
def _reload(self) -> None:
|
||||
"""Đọc lại tệp đang mở từ đĩa, bỏ mọi sửa đổi chưa lưu."""
|
||||
if self.path_edit.text():
|
||||
self.load_file(self.path_edit.text())
|
||||
|
||||
@@ -167,6 +174,7 @@ class FileEditDialog(QDialog):
|
||||
+ (f" ({note})" if note else ""))
|
||||
|
||||
def _set_editable(self, editable: bool) -> None:
|
||||
"""Bật/tắt chế độ sửa được (tệp nhị phân hoặc chỉ đọc thì tắt)."""
|
||||
self._editable = editable
|
||||
self.editor.setReadOnly(not editable)
|
||||
self.save_btn.setEnabled(editable)
|
||||
@@ -175,6 +183,7 @@ class FileEditDialog(QDialog):
|
||||
|
||||
# ---- AI edit -------------------------------------------------------------
|
||||
def _ai_edit(self) -> None:
|
||||
"""Nhờ AI sửa tệp theo yêu cầu; thiếu yêu cầu hoặc đang chạy dở thì bỏ qua."""
|
||||
instruction = self.instruction_edit.text().strip()
|
||||
if (not instruction or not self._editable or self.ctx is None
|
||||
or self._worker is not None):
|
||||
@@ -188,6 +197,7 @@ class FileEditDialog(QDialog):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gọi model sửa nội dung tệp theo yêu cầu."""
|
||||
provider = ctx.build_active_provider()
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
@@ -199,6 +209,10 @@ class FileEditDialog(QDialog):
|
||||
return {"text": (a.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Đổ nội dung đã sửa vào ô soạn thảo, bỏ hàng rào code của model.
|
||||
|
||||
KHÔNG tự ghi đĩa: người dùng phải bấm Lưu.
|
||||
"""
|
||||
self._worker = None
|
||||
self.ai_btn.setEnabled(True)
|
||||
new_text = _strip_fences(result.get("text", ""))
|
||||
@@ -209,6 +223,7 @@ class FileEditDialog(QDialog):
|
||||
self.status_lbl.setText(tr("fileedit.ai_empty"))
|
||||
|
||||
def failed(err: str) -> None:
|
||||
"""Sửa lỗi: hiện lý do (cắt ở 300 ký tự) và mở khoá lại nút."""
|
||||
self._worker = None
|
||||
self.ai_btn.setEnabled(True)
|
||||
self.status_lbl.setText(tr("fileedit.ai_failed", err=err[:300]))
|
||||
@@ -221,6 +236,7 @@ class FileEditDialog(QDialog):
|
||||
|
||||
# ---- save -----------------------------------------------------------------
|
||||
def _save(self) -> None:
|
||||
"""Ghi nội dung đang sửa xuống tệp."""
|
||||
path = self.path_edit.text()
|
||||
if not path or not self._editable:
|
||||
return
|
||||
|
||||
@@ -32,7 +32,16 @@ from .skill_manager_tab import SkillManagerTab
|
||||
|
||||
|
||||
class FlowBuilderDialog(QDialog):
|
||||
"""Trình dựng luồng cũ: danh sách bước bên trái, form sửa bước bên phải.
|
||||
|
||||
Đã được Co4E Studio thay thế và không còn nơi nào mở nó ra; giữ lại làm bản
|
||||
đối chiếu cho phần luồng nhiều bước.
|
||||
|
||||
Một luồng là danh sách ``FlowStep`` chạy tuần tự; mỗi bước có thể chọn
|
||||
riêng skill, provider, model, tệp đính kèm và các sub-agent chạy kèm.
|
||||
"""
|
||||
def __init__(self, parent=None, ctx=None):
|
||||
"""Dựng hộp thoại và nạp sẵn luồng mẫu để người dùng có cái mà sửa ngay."""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(tr("flow.title"))
|
||||
self.setMinimumSize(820, 560)
|
||||
@@ -263,6 +272,10 @@ class FlowBuilderDialog(QDialog):
|
||||
|
||||
# ---- AI: generate task prompt from the hint ---------------------
|
||||
def _gen_prompt(self) -> None:
|
||||
"""Nhờ model viết nội dung bước từ tên bước và câu gợi ý ngắn.
|
||||
|
||||
Không có ``ctx`` (mở hộp thoại ngoài ứng dụng) thì nút này không làm gì.
|
||||
"""
|
||||
hint = self.step_hint.text().strip()
|
||||
name = self.step_name.text().strip()
|
||||
if not hint and not name:
|
||||
@@ -275,6 +288,7 @@ class FlowBuilderDialog(QDialog):
|
||||
ctx = self._ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gọi model sinh nội dung cho bước."""
|
||||
from ..core.flows import generate_task_prompt
|
||||
return {"prompt": generate_task_prompt(
|
||||
ctx.build_active_provider(), name, hint, worker.is_cancelled)}
|
||||
@@ -286,23 +300,27 @@ class FlowBuilderDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _on_gen_prompt(self, result) -> None:
|
||||
"""Đổ nội dung model vừa sinh vào ô soạn, rồi trả nút về trạng thái thường."""
|
||||
text = (result or {}).get("prompt", "")
|
||||
if text:
|
||||
self.step_prompt.setPlainText(text)
|
||||
self._reset_gen_prompt_btn()
|
||||
|
||||
def _reset_gen_prompt_btn(self) -> None:
|
||||
"""Bật lại nút sinh nội dung và trả chữ về như cũ."""
|
||||
self._gen_prompt_btn.setEnabled(True)
|
||||
self._gen_prompt_btn.setText(tr("flow.gen_task_from_hint"))
|
||||
|
||||
# ---- attachments (per stage) -------------------------------------
|
||||
def _pick_attachments(self) -> None:
|
||||
"""Chọn tệp đính kèm cho bước đang sửa."""
|
||||
chosen, _ = QFileDialog.getOpenFileNames(self, tr("flow.attach_files"))
|
||||
if chosen:
|
||||
self._step_attachments = chosen
|
||||
self._refresh_attach_btn()
|
||||
|
||||
def _refresh_attach_btn(self) -> None:
|
||||
"""Cập nhật nhãn nút đính kèm theo số tệp; tooltip liệt kê đủ đường dẫn."""
|
||||
n = len(self._step_attachments)
|
||||
label = tr("flow.attach_files_count", n=n) if n else tr("flow.attach_files")
|
||||
self.attach_btn.setText(label)
|
||||
@@ -312,6 +330,7 @@ class FlowBuilderDialog(QDialog):
|
||||
# ``self._step_subagents`` is the source of truth; ``subagents_list`` is
|
||||
# just its display — avoids any lossy re-parsing of the list widget text.
|
||||
def _add_subagent(self) -> None:
|
||||
"""Thêm một sub-agent tự nhập (tên + nội dung) vào bước đang sửa."""
|
||||
name = self.sub_name_edit.text().strip()
|
||||
if not name:
|
||||
self.sub_name_edit.setFocus()
|
||||
@@ -323,12 +342,14 @@ class FlowBuilderDialog(QDialog):
|
||||
self._refresh_subagents_list()
|
||||
|
||||
def _remove_subagent(self) -> None:
|
||||
"""Gỡ sub-agent đang chọn khỏi bước."""
|
||||
row = self.subagents_list.currentRow()
|
||||
if 0 <= row < len(self._step_subagents):
|
||||
self._step_subagents.pop(row)
|
||||
self._refresh_subagents_list()
|
||||
|
||||
def _refresh_subagents_list(self) -> None:
|
||||
"""Vẽ lại danh sách sub-agent của bước đang sửa."""
|
||||
self.subagents_list.clear()
|
||||
for sub in self._step_subagents:
|
||||
text = f"{sub.name}: {sub.prompt}" if sub.prompt else sub.name
|
||||
@@ -336,6 +357,9 @@ class FlowBuilderDialog(QDialog):
|
||||
|
||||
# ---- add a sub-agent from a saved custom Agent preset -------------
|
||||
def _reload_agent_picker(self) -> None:
|
||||
"""Nạp danh sách agent tự tạo vào bộ chọn; chưa có agent nào thì khoá bộ chọn
|
||||
lại kèm dòng giải thích, thay vì để một ô rỗng bấm được mà không ra gì.
|
||||
"""
|
||||
self.agent_picker.clear()
|
||||
agents = list_agents()
|
||||
if not agents:
|
||||
@@ -347,6 +371,9 @@ class FlowBuilderDialog(QDialog):
|
||||
self.agent_picker.addItem(a.name, a)
|
||||
|
||||
def _add_subagent_from_agent(self) -> None:
|
||||
"""Thêm sub-agent bằng cách lấy nguyên một agent tự tạo (kèm provider và model
|
||||
riêng của nó).
|
||||
"""
|
||||
agent = self.agent_picker.currentData()
|
||||
if agent is None:
|
||||
return
|
||||
@@ -371,6 +398,7 @@ class FlowBuilderDialog(QDialog):
|
||||
self.step_skill.blockSignals(False)
|
||||
|
||||
def _populate_combos(self) -> None:
|
||||
"""Nạp các bộ chọn của form bước: skill, provider và model."""
|
||||
self._reload_skill_combo()
|
||||
self.step_agent.blockSignals(True)
|
||||
self.step_agent.clear()
|
||||
@@ -395,6 +423,9 @@ class FlowBuilderDialog(QDialog):
|
||||
self.step_model.setCurrentIndex(1)
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: hỏi provider danh sách model. Lỗi thì trả danh sách rỗng — danh
|
||||
sách model chỉ là tiện ích, không đáng làm vỡ hộp thoại.
|
||||
"""
|
||||
try:
|
||||
provider = self._ctx.build_provider_for(provider_key)
|
||||
return {"models": provider.list_models() or [], "provider": provider_key}
|
||||
@@ -402,6 +433,11 @@ class FlowBuilderDialog(QDialog):
|
||||
return {"models": [], "provider": provider_key}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Đổ danh sách model vào bộ chọn.
|
||||
|
||||
Kết quả về mà provider đã bị đổi lần nữa thì bỏ — nếu không, danh sách của
|
||||
provider cũ sẽ đè lên provider người dùng vừa chọn.
|
||||
"""
|
||||
if result.get("provider") != (self.step_agent.currentData()
|
||||
or self._ctx.config.active_provider):
|
||||
return # provider changed again while fetching — stale reply
|
||||
@@ -425,6 +461,7 @@ class FlowBuilderDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _reload_templates(self) -> None:
|
||||
"""Nạp lại bộ chọn luồng đã lưu."""
|
||||
self.tpl_combo.blockSignals(True)
|
||||
self.tpl_combo.clear()
|
||||
self.tpl_combo.addItem(tr("flow.select_template"), None)
|
||||
@@ -433,20 +470,28 @@ class FlowBuilderDialog(QDialog):
|
||||
self.tpl_combo.blockSignals(False)
|
||||
|
||||
def _load_selected_template(self, _idx: int) -> None:
|
||||
"""Mở luồng vừa chọn trong bộ chọn.
|
||||
|
||||
Nạp một BẢN SAO SÂU: người dùng sửa rồi bỏ ngang thì luồng đã lưu vẫn
|
||||
nguyên vẹn.
|
||||
"""
|
||||
flow = self.tpl_combo.currentData()
|
||||
if isinstance(flow, Flow):
|
||||
self._loaded_name = flow.name
|
||||
self._bind_flow(copy.deepcopy(flow))
|
||||
|
||||
def _load_builtin(self) -> None:
|
||||
"""Nạp lại luồng mẫu dựng sẵn."""
|
||||
self._loaded_name = ""
|
||||
self._bind_flow(default_req_to_demo())
|
||||
|
||||
def _new_flow(self) -> None:
|
||||
"""Bắt đầu một luồng trống mới."""
|
||||
self._loaded_name = ""
|
||||
self._bind_flow(Flow(name=tr("flow.new_flow_name"), description="", steps=[]))
|
||||
|
||||
def _delete_template(self) -> None:
|
||||
"""Xoá luồng đang chọn khỏi danh sách đã lưu."""
|
||||
flow = self.tpl_combo.currentData()
|
||||
if isinstance(flow, Flow):
|
||||
delete_flow(flow.name)
|
||||
@@ -454,6 +499,7 @@ class FlowBuilderDialog(QDialog):
|
||||
|
||||
# ---- flow <-> widgets -------------------------------------------
|
||||
def _bind_flow(self, flow: Flow) -> None:
|
||||
"""Đổ một luồng vào toàn bộ giao diện và chọn sẵn bước đầu tiên."""
|
||||
self._flow = flow
|
||||
self.name_edit.setText(flow.name)
|
||||
self.desc_edit.setText(flow.description)
|
||||
@@ -464,6 +510,7 @@ class FlowBuilderDialog(QDialog):
|
||||
self._clear_editor()
|
||||
|
||||
def _refresh_steps(self) -> None:
|
||||
"""Vẽ lại danh sách bước, mỗi dòng kèm nhãn skill/provider/model của bước đó."""
|
||||
self.steps_list.blockSignals(True)
|
||||
self.steps_list.clear()
|
||||
for i, step in enumerate(self._flow.steps, 1):
|
||||
@@ -485,6 +532,7 @@ class FlowBuilderDialog(QDialog):
|
||||
self.steps_list.blockSignals(False)
|
||||
|
||||
def _clear_editor(self) -> None:
|
||||
"""Xoá trắng form sửa bước."""
|
||||
self.step_name.clear()
|
||||
self.step_prompt.clear()
|
||||
self.step_hint.clear()
|
||||
@@ -501,6 +549,12 @@ class FlowBuilderDialog(QDialog):
|
||||
self._refresh_subagents_list()
|
||||
|
||||
def _load_step_into_editor(self, row: int) -> None:
|
||||
"""Đổ một bước vào form sửa.
|
||||
|
||||
Model được nhớ riêng vào ``_pending_step_model`` vì danh sách model nạp bất
|
||||
đồng bộ — không giữ lại thì lựa chọn của người dùng biến mất khi danh sách
|
||||
về tới nơi.
|
||||
"""
|
||||
if not (0 <= row < len(self._flow.steps)):
|
||||
return
|
||||
step = self._flow.steps[row]
|
||||
@@ -522,6 +576,9 @@ class FlowBuilderDialog(QDialog):
|
||||
self._refresh_subagents_list()
|
||||
|
||||
def _editor_step(self) -> Optional[FlowStep]:
|
||||
"""Dựng ``FlowStep`` từ nội dung form; ``None`` nếu chưa nhập tên bước (con trỏ
|
||||
nhảy vào ô tên thay vì hiện hộp lỗi).
|
||||
"""
|
||||
name = self.step_name.text().strip()
|
||||
if not name:
|
||||
self.step_name.setFocus()
|
||||
@@ -541,6 +598,7 @@ class FlowBuilderDialog(QDialog):
|
||||
)
|
||||
|
||||
def _add_step(self) -> None:
|
||||
"""Thêm bước đang soạn vào cuối luồng và chọn nó."""
|
||||
step = self._editor_step()
|
||||
if step:
|
||||
self._flow.steps.append(step)
|
||||
@@ -548,6 +606,7 @@ class FlowBuilderDialog(QDialog):
|
||||
self.steps_list.setCurrentRow(len(self._flow.steps) - 1)
|
||||
|
||||
def _update_step(self) -> None:
|
||||
"""Ghi đè bước đang chọn bằng nội dung form."""
|
||||
row = self.steps_list.currentRow()
|
||||
step = self._editor_step()
|
||||
if step and 0 <= row < len(self._flow.steps):
|
||||
@@ -556,12 +615,16 @@ class FlowBuilderDialog(QDialog):
|
||||
self.steps_list.setCurrentRow(row)
|
||||
|
||||
def _remove_step(self) -> None:
|
||||
"""Xoá bước đang chọn khỏi luồng."""
|
||||
row = self.steps_list.currentRow()
|
||||
if 0 <= row < len(self._flow.steps):
|
||||
self._flow.steps.pop(row)
|
||||
self._refresh_steps()
|
||||
|
||||
def _move(self, delta: int) -> None:
|
||||
"""Đổi chỗ bước đang chọn lên/xuống một vị trí — thứ tự bước chính là thứ tự
|
||||
chạy.
|
||||
"""
|
||||
row = self.steps_list.currentRow()
|
||||
new = row + delta
|
||||
if 0 <= row < len(self._flow.steps) and 0 <= new < len(self._flow.steps):
|
||||
@@ -572,11 +635,15 @@ class FlowBuilderDialog(QDialog):
|
||||
|
||||
# ---- result -----------------------------------------------------
|
||||
def _collect(self) -> Flow:
|
||||
"""Lấy tên và mô tả từ form vào luồng rồi trả về nó. Tên bỏ trống thì dùng tên
|
||||
mặc định, không để luồng không tên.
|
||||
"""
|
||||
self._flow.name = self.name_edit.text().strip() or tr("flow.default_name")
|
||||
self._flow.description = self.desc_edit.text().strip()
|
||||
return self._flow
|
||||
|
||||
def _save_template(self) -> None:
|
||||
"""Lưu luồng hiện tại. Truyền ``old_name`` để đổi tên không sinh ra bản thứ hai."""
|
||||
flow = self._collect()
|
||||
save_flow(flow, old_name=self._loaded_name)
|
||||
self._loaded_name = flow.name
|
||||
@@ -586,6 +653,9 @@ class FlowBuilderDialog(QDialog):
|
||||
self.tpl_combo.setCurrentIndex(idx)
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Đóng hộp thoại kèm yêu cầu chạy luồng. Luồng chưa có bước nào thì không làm
|
||||
gì.
|
||||
"""
|
||||
flow = self._collect()
|
||||
if not flow.steps:
|
||||
return
|
||||
@@ -593,4 +663,5 @@ class FlowBuilderDialog(QDialog):
|
||||
self.accept()
|
||||
|
||||
def result_flow(self) -> Flow:
|
||||
"""Luồng cuối cùng bên gọi nhận về sau khi hộp thoại đóng."""
|
||||
return self._collect()
|
||||
|
||||
@@ -61,6 +61,7 @@ _HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel"
|
||||
|
||||
|
||||
def _current_user() -> str:
|
||||
"""Tên đăng nhập hệ điều hành, dùng để chào người dùng; '' nếu không đọc được."""
|
||||
return (os.environ.get("USERNAME") or os.environ.get("USER") or "").strip()
|
||||
|
||||
|
||||
@@ -84,11 +85,13 @@ class _HoverPill(QPushButton):
|
||||
"""
|
||||
|
||||
def __init__(self, owner):
|
||||
"""Nút tròn nổi ở góc màn hình, mở ra panel Trợ giúp khi bấm."""
|
||||
super().__init__(owner)
|
||||
self._owner = owner
|
||||
self.open = False
|
||||
|
||||
def _set_open(self, value: bool) -> None:
|
||||
"""Đổi trạng thái bung/co và vẽ lại; đã đúng trạng thái thì bỏ qua."""
|
||||
if value == self.open:
|
||||
return
|
||||
self.open = value
|
||||
@@ -96,19 +99,23 @@ class _HoverPill(QPushButton):
|
||||
self._owner._layout_launcher()
|
||||
|
||||
def enterEvent(self, e): # noqa: N802 - Qt override
|
||||
"""Rê chuột vào: bung ra."""
|
||||
self._set_open(True)
|
||||
super().enterEvent(e)
|
||||
|
||||
def leaveEvent(self, e): # noqa: N802 - Qt override
|
||||
"""Rời chuột: co lại — trừ khi đang giữ focus bàn phím."""
|
||||
if not self.hasFocus():
|
||||
self._set_open(False)
|
||||
super().leaveEvent(e)
|
||||
|
||||
def focusInEvent(self, e): # noqa: N802 - Qt override
|
||||
"""Nhận focus bàn phím: bung ra, để người dùng dùng Tab cũng thấy được nhãn."""
|
||||
self._set_open(True)
|
||||
super().focusInEvent(e)
|
||||
|
||||
def focusOutEvent(self, e): # noqa: N802 - Qt override
|
||||
"""Mất focus: co lại."""
|
||||
self._set_open(False)
|
||||
super().focusOutEvent(e)
|
||||
|
||||
@@ -120,6 +127,11 @@ class HelpAgentWidget(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx, parent=None, user_name: str = ""):
|
||||
"""Trợ lý Trợ giúp trong ứng dụng.
|
||||
|
||||
Giữ lịch sử hội thoại riêng (không kèm prompt hệ thống — cái đó ghép vào ở
|
||||
mỗi lượt gọi) để người dùng hỏi tiếp mà không phải nhắc lại bối cảnh.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._user_name = user_name or _current_user()
|
||||
@@ -204,6 +216,7 @@ class HelpAgentWidget(QWidget):
|
||||
|
||||
# ---- greeting / labels ------------------------------------------------
|
||||
def _greeting(self) -> str:
|
||||
"""Câu chào mở đầu, có tên người dùng nếu biết."""
|
||||
name = self._user_name or tr("help_agent.default_user")
|
||||
return tr("help_agent.greeting", name=name)
|
||||
|
||||
@@ -211,6 +224,7 @@ class HelpAgentWidget(QWidget):
|
||||
def _build_edge_tab(self) -> None:
|
||||
# Shown only while hidden: a thin tab at the right edge to bring the
|
||||
# assistant back (chevron points left = "slide out").
|
||||
"""Dựng thẻ mỏng ở mép phải — chỉ hiện khi trợ lý đang ẩn hẳn, bấm vào để gọi lại."""
|
||||
self.edge_tab = QPushButton(self)
|
||||
self.edge_tab.setObjectName("helpEdgeTab")
|
||||
self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT))
|
||||
@@ -222,6 +236,12 @@ class HelpAgentWidget(QWidget):
|
||||
# One control, one job: this opens the chat. The chevron that used to sit
|
||||
# beside it (a second 18px hit target for a second meaning of "closed")
|
||||
# is gone — hiding to the edge is now a line in the panel's ⋯ menu.
|
||||
"""Dựng nút mở chat.
|
||||
|
||||
Một nút, một việc: mở khung chat. Cái chevron từng đứng cạnh nó (thêm một
|
||||
vùng bấm 18px cho một nghĩa "đóng" thứ hai) đã bỏ — muốn ẩn hẳn thì vào
|
||||
menu ⋯ của panel.
|
||||
"""
|
||||
self.launcher = _HoverPill(self)
|
||||
self.launcher.setObjectName("helpLauncher")
|
||||
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
|
||||
@@ -231,6 +251,7 @@ class HelpAgentWidget(QWidget):
|
||||
self.launcher.clicked.connect(self._expand)
|
||||
|
||||
def _build_panel(self) -> None:
|
||||
"""Dựng panel chat: dòng thời gian, ô nhập, nút gửi và menu ⋯."""
|
||||
self.panel = QFrame(self)
|
||||
self.panel.setObjectName("helpPanel")
|
||||
|
||||
@@ -301,11 +322,13 @@ class HelpAgentWidget(QWidget):
|
||||
|
||||
# ---- state transitions ------------------------------------------------
|
||||
def _expand(self) -> None:
|
||||
"""Mở panel chat đầy đủ và đưa con trỏ vào ô nhập."""
|
||||
self._state = _PANEL
|
||||
self._apply_state()
|
||||
self.input.setFocus()
|
||||
|
||||
def _collapse(self) -> None:
|
||||
"""Thu panel về nút tròn."""
|
||||
self._state = _LAUNCHER_ST
|
||||
self._apply_state()
|
||||
|
||||
@@ -323,10 +346,12 @@ class HelpAgentWidget(QWidget):
|
||||
menu.exec(widget.mapToGlobal(pos))
|
||||
|
||||
def _hide_to_edge(self) -> None:
|
||||
"""Ẩn hẳn trợ lý, chỉ chừa thẻ mỏng ở mép phải."""
|
||||
self._state = _HIDDEN
|
||||
self._apply_state()
|
||||
|
||||
def _show_launcher(self) -> None:
|
||||
"""Gọi trợ lý trở lại từ trạng thái ẩn."""
|
||||
self._state = _LAUNCHER_ST
|
||||
self._apply_state()
|
||||
|
||||
@@ -346,6 +371,7 @@ class HelpAgentWidget(QWidget):
|
||||
self.raise_()
|
||||
|
||||
def _apply_state(self) -> None:
|
||||
"""Áp trạng thái hiện tại lên ba thành phần: thẻ mép, nút tròn và panel."""
|
||||
st = self._state
|
||||
self.edge_tab.setVisible(st == _HIDDEN)
|
||||
self.launcher.setVisible(st == _LAUNCHER_ST)
|
||||
@@ -412,6 +438,10 @@ class HelpAgentWidget(QWidget):
|
||||
)
|
||||
|
||||
def _render(self, pending: bool = False) -> None:
|
||||
"""Dựng lại toàn bộ dòng thời gian dưới dạng HTML.
|
||||
|
||||
``pending=True`` thêm một bong bóng "…" để báo đang chờ trả lời.
|
||||
"""
|
||||
parts = [self._bubble_html(m["role"], m["content"]) for m in self._history]
|
||||
if pending:
|
||||
parts.append(self._bubble_html("assistant", "…"))
|
||||
@@ -420,6 +450,7 @@ class HelpAgentWidget(QWidget):
|
||||
|
||||
# ---- send a message ---------------------------------------------------
|
||||
def _send(self) -> None:
|
||||
"""Gửi câu hỏi tới Help Agent ở luồng nền."""
|
||||
if self._busy:
|
||||
return
|
||||
text = self.input.text().strip()
|
||||
@@ -435,6 +466,7 @@ class HelpAgentWidget(QWidget):
|
||||
history = list(self._history)
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: gọi provider của Help Agent kèm prompt hệ thống của nó."""
|
||||
provider = admin_agents.build_agent_provider(self.ctx, agent)
|
||||
messages = [{"role": "system", "content": agent.effective_prompt()}] + history
|
||||
result = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
@@ -448,18 +480,21 @@ class HelpAgentWidget(QWidget):
|
||||
worker.start()
|
||||
|
||||
def _on_reply(self, result: Dict[str, Any]) -> None:
|
||||
"""Nhận trả lời và vẽ vào dòng thời gian; rỗng thì hiện câu thay thế."""
|
||||
content = (result or {}).get("content", "").strip() or tr("help_agent.empty_reply")
|
||||
self._history.append({"role": "assistant", "content": content})
|
||||
self._set_busy(False)
|
||||
self._render()
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
"""Gọi lỗi: hiện thông báo lỗi ngay trong khung chat thay vì im lặng."""
|
||||
self._history.append({"role": "assistant",
|
||||
"content": tr("help_agent.error", error=err)})
|
||||
self._set_busy(False)
|
||||
self._render()
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
"""Khoá/mở ô nhập và nút gửi trong lúc chờ trả lời."""
|
||||
self._busy = busy
|
||||
self.input.setEnabled(not busy)
|
||||
self.send_btn.setEnabled(not busy)
|
||||
@@ -468,6 +503,12 @@ class HelpAgentWidget(QWidget):
|
||||
# The transcript is rendered HTML, so switching language left the
|
||||
# greeting — and every "AI Assistant" speaker label — in the language
|
||||
# the panel was built in.
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn.
|
||||
|
||||
Dòng thời gian là HTML đã dựng sẵn, nên đổi ngôn ngữ mà không dựng lại sẽ
|
||||
để câu chào — và mọi nhãn người nói "AI Assistant" — nằm nguyên ở ngôn ngữ
|
||||
lúc panel được tạo.
|
||||
"""
|
||||
if self._history and self._history[0] is self._greet_msg:
|
||||
self._greet_msg["content"] = self._greeting()
|
||||
self._render()
|
||||
|
||||
@@ -351,6 +351,7 @@ class IconLabel(QWidget):
|
||||
|
||||
def __init__(self, name: str, text: str = "", *, size: int = 16,
|
||||
color: str | None = None, gap: int = 6, parent=None):
|
||||
"""Một nhãn có biểu tượng đứng trước chữ, dùng cho các hàng thông tin."""
|
||||
super().__init__(parent)
|
||||
self._size = size
|
||||
lay = QHBoxLayout(self)
|
||||
@@ -364,12 +365,17 @@ class IconLabel(QWidget):
|
||||
lay.addStretch(1)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
"""Đổi phần chữ của nhãn."""
|
||||
self._text.setText(text)
|
||||
|
||||
def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias
|
||||
"""Bí danh hợp chuẩn ``QLabel`` của :meth:`set_text`, để thay thế trực tiếp
|
||||
cho một ``QLabel`` mà không phải sửa chỗ gọi.
|
||||
"""
|
||||
self._text.setText(text)
|
||||
|
||||
def set_icon(self, name: str, color: str | None = None) -> None:
|
||||
"""Đổi icon (và màu icon) của nhãn."""
|
||||
self._icon.setPixmap(pixmap(name, self._size, color))
|
||||
|
||||
def text_label(self) -> QLabel:
|
||||
|
||||
@@ -21,6 +21,7 @@ from .icons import icon
|
||||
|
||||
|
||||
def _grid() -> QListWidget:
|
||||
"""Dựng lưới hiển thị icon dạng ô vuông có nhãn."""
|
||||
g = QListWidget()
|
||||
g.setObjectName("iconGrid") # accent border on hover/selection, see theme.py
|
||||
g.setViewMode(QListWidget.IconMode)
|
||||
@@ -33,7 +34,13 @@ def _grid() -> QListWidget:
|
||||
|
||||
|
||||
class IconsAdminTab(QWidget):
|
||||
"""Tab "Icon" trong màn Giám sát: xem bộ icon hệ thống và thay icon bằng ảnh riêng."""
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Tab quản trị biểu tượng: bộ dựng sẵn và bộ tự thêm.
|
||||
|
||||
Ba nút thao tác nằm cạnh tiêu đề chứ không nằm dưới hai lưới — đặt dưới thì
|
||||
chúng trông như chỉ áp cho lưới tự thêm.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
root = QVBoxLayout(self)
|
||||
@@ -81,6 +88,7 @@ class IconsAdminTab(QWidget):
|
||||
|
||||
# ---- rendering --------------------------------------------------------
|
||||
def _reload_builtin(self, *_a) -> None:
|
||||
"""Nạp lại lưới icon dựng sẵn, lọc theo ô tìm kiếm."""
|
||||
q = self.search.text().strip().lower()
|
||||
self.builtin_grid.clear()
|
||||
for name in sorted(icons_mod._PATHS):
|
||||
@@ -92,6 +100,7 @@ class IconsAdminTab(QWidget):
|
||||
self.builtin_grid.addItem(it)
|
||||
|
||||
def _reload_custom(self) -> None:
|
||||
"""Nạp lại lưới icon do người dùng thêm."""
|
||||
self.custom_grid.clear()
|
||||
for name in custom_icons.list_custom():
|
||||
it = QListWidgetItem(icon(name), name)
|
||||
@@ -102,6 +111,7 @@ class IconsAdminTab(QWidget):
|
||||
|
||||
# ---- actions ----------------------------------------------------------
|
||||
def _add_icon(self) -> None:
|
||||
"""Thêm icon mới từ một tệp SVG."""
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
path, _ = QFileDialog.getOpenFileName(self, tr("icons_admin.add"), "", "SVG (*.svg)")
|
||||
if not path:
|
||||
@@ -118,6 +128,7 @@ class IconsAdminTab(QWidget):
|
||||
self._reload_custom()
|
||||
|
||||
def _add_from_svg_text(self) -> None:
|
||||
"""Thêm icon bằng cách dán thẳng mã SVG."""
|
||||
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
|
||||
tr("icons_admin.name_prompt"))
|
||||
if not ok or not name.strip():
|
||||
@@ -134,6 +145,7 @@ class IconsAdminTab(QWidget):
|
||||
self._reload_custom()
|
||||
|
||||
def _delete_icon(self) -> None:
|
||||
"""Xoá icon tự thêm đang chọn; chưa chọn gì thì nhắc người dùng."""
|
||||
item = self.custom_grid.currentItem()
|
||||
if item is None:
|
||||
QMessageBox.information(self, tr("icons_admin.title"), tr("icons_admin.select_custom"))
|
||||
@@ -142,6 +154,7 @@ class IconsAdminTab(QWidget):
|
||||
self._reload_custom()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self._title.setText(tr("monitoring.tab_icons"))
|
||||
self._hint.setText(tr("icons_admin.hint"))
|
||||
self.search.setPlaceholderText(tr("icons_admin.search"))
|
||||
|
||||
@@ -37,6 +37,7 @@ DOC_SUFFIXES = {
|
||||
|
||||
|
||||
def is_document(path) -> bool:
|
||||
"""Đuôi tệp này có phải tài liệu LibreOffice mở được không."""
|
||||
return Path(path).suffix.lower() in DOC_SUFFIXES
|
||||
|
||||
|
||||
@@ -74,6 +75,11 @@ class LibreOfficeView(QWidget):
|
||||
MAX_TRIES = 40 # ~16s to find the window before giving up
|
||||
|
||||
def __init__(self):
|
||||
"""Khung nhúng cửa sổ LibreOffice vào trong ứng dụng.
|
||||
|
||||
Chưa chạy tiến trình nào; hồ sơ riêng và cửa sổ được dựng ở lần mở tệp đầu
|
||||
tiên.
|
||||
"""
|
||||
super().__init__()
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._profile_dir: Path | None = None
|
||||
@@ -108,10 +114,12 @@ class LibreOfficeView(QWidget):
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self._open_btn.setText(tr("libreoffice.open_btn"))
|
||||
|
||||
# ---- public API --------------------------------------------------
|
||||
def open_document(self, path: str) -> None:
|
||||
"""Mở một tài liệu: đóng cái đang mở, khởi động soffice rồi nhúng cửa sổ của nó."""
|
||||
self.close_document()
|
||||
self._path = str(path)
|
||||
soffice = find_soffice()
|
||||
@@ -124,6 +132,7 @@ class LibreOfficeView(QWidget):
|
||||
self._launch_and_embed(soffice)
|
||||
|
||||
def close_document(self) -> None:
|
||||
"""Đóng tài liệu: dừng bộ đếm dò, gỡ cửa sổ nhúng và tắt tiến trình soffice."""
|
||||
self._poll.stop()
|
||||
self._tries = 0
|
||||
if self._container is not None:
|
||||
@@ -141,6 +150,12 @@ class LibreOfficeView(QWidget):
|
||||
|
||||
# ---- launch + embed (Windows) ------------------------------------
|
||||
def _launch_and_embed(self, soffice: str) -> None:
|
||||
"""Khởi động soffice với một profile RIÊNG rồi bắt đầu dò cửa sổ của nó.
|
||||
|
||||
Profile riêng trong thư mục tạm là bắt buộc: dùng chung profile mặc định thì
|
||||
instance thứ hai sẽ nối vào instance đang chạy của người dùng và không có
|
||||
cửa sổ mới nào để nhúng.
|
||||
"""
|
||||
try:
|
||||
self._profile_dir = Path(tempfile.mkdtemp(prefix=f"lo-embed-{uuid.uuid4().hex[:8]}-"))
|
||||
profile_url = "file:///" + str(self._profile_dir).replace("\\", "/")
|
||||
@@ -160,6 +175,11 @@ class LibreOfficeView(QWidget):
|
||||
self._poll.start()
|
||||
|
||||
def _try_embed(self) -> None:
|
||||
"""Một nhịp dò: tìm cửa sổ soffice và nhúng, quá ``MAX_TRIES`` thì bỏ cuộc.
|
||||
|
||||
Phải dò theo nhịp vì soffice tạo cửa sổ sau khi tiến trình đã khởi động —
|
||||
không có tín hiệu nào báo "cửa sổ đã sẵn sàng".
|
||||
"""
|
||||
self._tries += 1
|
||||
if self._tries > self.MAX_TRIES:
|
||||
self._poll.stop()
|
||||
@@ -183,6 +203,7 @@ class LibreOfficeView(QWidget):
|
||||
|
||||
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
def _cb(hwnd, _lparam):
|
||||
"""Callback duyệt cửa sổ của Windows: nhận cửa sổ đang hiện và đúng lớp của soffice."""
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
cls = ctypes.create_unicode_buffer(256)
|
||||
@@ -204,6 +225,7 @@ class LibreOfficeView(QWidget):
|
||||
return found[0] if found else None
|
||||
|
||||
def _embed_hwnd(self, hwnd: int) -> None:
|
||||
"""Đổi cha của cửa sổ soffice sang widget này và bỏ khung viền của nó."""
|
||||
try:
|
||||
user32 = _win_user32()
|
||||
GWL_STYLE = -16
|
||||
@@ -229,6 +251,11 @@ class LibreOfficeView(QWidget):
|
||||
|
||||
# ---- cleanup -----------------------------------------------------
|
||||
def _post_close(self, hwnd: int) -> None:
|
||||
"""Gửi lệnh đóng tới cửa sổ soffice đang nhúng.
|
||||
|
||||
Đóng lịch sự thay vì giết tiến trình, để LibreOffice kịp dọn file khoá — bỏ
|
||||
qua bước này thì lần mở sau nó báo tài liệu đang bị dùng.
|
||||
"""
|
||||
try:
|
||||
WM_CLOSE = 0x0010
|
||||
_win_user32().PostMessageW(hwnd, WM_CLOSE, 0, 0)
|
||||
@@ -236,6 +263,11 @@ class LibreOfficeView(QWidget):
|
||||
pass
|
||||
|
||||
def _cleanup_proc(self, graceful: bool = True) -> None:
|
||||
"""Dọn tiến trình soffice và thư mục profile tạm của nó.
|
||||
|
||||
``graceful=True`` đóng lịch sự trước rồi mới giết; profile tạm luôn bị xoá,
|
||||
nếu không mỗi lần mở tài liệu lại để lại một thư mục rác.
|
||||
"""
|
||||
proc, self._proc = self._proc, None
|
||||
profile, self._profile_dir = self._profile_dir, None
|
||||
if proc is not None and proc.poll() is None:
|
||||
@@ -252,6 +284,9 @@ class LibreOfficeView(QWidget):
|
||||
shutil.rmtree(profile, ignore_errors=True)
|
||||
|
||||
def _open_external(self) -> None:
|
||||
"""Mở tài liệu bằng ứng dụng mặc định của hệ điều hành — lối thoát khi không
|
||||
nhúng được cửa sổ.
|
||||
"""
|
||||
if not self._path:
|
||||
return
|
||||
soffice = find_soffice()
|
||||
@@ -265,10 +300,14 @@ class LibreOfficeView(QWidget):
|
||||
|
||||
# ---- small helpers -----------------------------------------------
|
||||
def _show_message(self, text: str, offer_open: bool = False) -> None:
|
||||
"""Hiện một dòng thông báo thay cho khung xem, kèm nút mở bằng ứng dụng ngoài
|
||||
nếu có.
|
||||
"""
|
||||
self._info.setText(text)
|
||||
self._info.setVisible(True)
|
||||
self._open_btn.setVisible(bool(offer_open and self._path))
|
||||
|
||||
def _clear_info(self) -> None:
|
||||
"""Ẩn dòng thông báo và nút mở ngoài."""
|
||||
self._info.setVisible(False)
|
||||
self._open_btn.setVisible(False)
|
||||
|
||||
@@ -39,11 +39,18 @@ class _AccountEdit(QLineEdit):
|
||||
"""Account field: alnum/./- only, auto-lowercased as the user types."""
|
||||
|
||||
def __init__(self):
|
||||
"""Ô nhập tên tài khoản, tự chuẩn hoá theo từng ký tự gõ vào."""
|
||||
super().__init__()
|
||||
self.setMaxLength(64)
|
||||
self.textChanged.connect(self._normalize)
|
||||
|
||||
def _normalize(self, text: str) -> None:
|
||||
"""Ép về chữ thường và chỉ giữ chữ–số–dấu chấm–gạch.
|
||||
|
||||
Tên tài khoản thành tên file trên thư mục dùng chung, nên khoảng trắng và ký
|
||||
tự lạ phải chặn ngay tại ô nhập chứ không đợi tới lúc lưu. Vị trí con trỏ
|
||||
được đặt lại sau khi sửa chữ, nếu không nó nhảy về cuối sau mỗi ký tự.
|
||||
"""
|
||||
cleaned = re.sub(r"[^\w.\-]", "", text.lower())
|
||||
if cleaned == text:
|
||||
return
|
||||
@@ -55,7 +62,18 @@ class _AccountEdit(QLineEdit):
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
"""Hộp thoại đăng nhập, ba trang tuỳ tình trạng thư mục dùng chung.
|
||||
|
||||
* Chưa cấu hình thư mục, hoặc có mà chưa có tài khoản nào — trang khởi tạo,
|
||||
nơi người đầu tiên nhận suất Admin.
|
||||
* Thư mục tới được — trang đăng nhập bằng tên tài khoản và mã.
|
||||
* Thư mục không tới được — trang ngoại tuyến, cho vào bằng lần đăng nhập
|
||||
gần nhất đã nhớ, để mất mạng không đồng nghĩa với mất luôn ứng dụng.
|
||||
|
||||
Bản đang chạy không có lớp đăng nhập nên hộp thoại này không được mở ra.
|
||||
"""
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
"""Chọn và dựng đúng một trong ba trang theo tình trạng thư mục dùng chung."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.account: Optional[Account] = None
|
||||
@@ -91,6 +109,11 @@ class LoginDialog(QDialog):
|
||||
# ---- helpers -----------------------------------------------------
|
||||
@staticmethod
|
||||
def _is_reachable(shared_dir: str) -> bool:
|
||||
"""Thư mục dùng chung có tới được không (ổ mạng có thể rớt bất cứ lúc nào).
|
||||
|
||||
Mọi ``OSError`` đều tính là không tới được: đường dẫn sai, hết quyền hay ổ
|
||||
mạng rớt đều dẫn tới cùng một lối đi ngoại tuyến.
|
||||
"""
|
||||
if not shared_dir:
|
||||
return False
|
||||
try:
|
||||
@@ -99,6 +122,12 @@ class LoginDialog(QDialog):
|
||||
return False
|
||||
|
||||
def _finish_login(self, account: Account) -> None:
|
||||
"""Ghi nhận đăng nhập thành công rồi đóng hộp thoại.
|
||||
|
||||
Mã được cất vào keyring để lần sau khỏi gõ lại — nhưng chỉ khi có mã thật:
|
||||
lối vào ngoại tuyến không có mã, cất chuỗi rỗng vào sẽ hỏng lần đăng nhập
|
||||
tự động sau đó.
|
||||
"""
|
||||
self.account = account
|
||||
accounts.save_last_login(account.username, account.role)
|
||||
self.ctx.config.auth["last_account"] = account.username
|
||||
@@ -114,6 +143,7 @@ class LoginDialog(QDialog):
|
||||
|
||||
# ---- bootstrap (no shared folder / no accounts yet) ---------------
|
||||
def _build_bootstrap_page(self) -> QWidget:
|
||||
"""Trang khởi tạo: chọn thư mục dùng chung và tạo tài khoản Admin đầu tiên."""
|
||||
page = QWidget()
|
||||
lay = QVBoxLayout(page)
|
||||
lay.addWidget(QLabel(tr("login.bootstrap_hint")))
|
||||
@@ -141,11 +171,18 @@ class LoginDialog(QDialog):
|
||||
return page
|
||||
|
||||
def _bs_browse(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục dùng chung."""
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("login.shared_dir"))
|
||||
if chosen:
|
||||
self.bs_dir_edit.setText(chosen)
|
||||
|
||||
def _bs_create_admin(self) -> None:
|
||||
"""Tạo tài khoản Admin đầu tiên.
|
||||
|
||||
Suất Admin được giành bằng ``claim_admin_slot`` chứ không chỉ kiểm tra rồi
|
||||
ghi: hai máy cùng khởi tạo trên một thư mục dùng chung sẽ chỉ có một máy
|
||||
thắng, thay vì cả hai cùng thành Admin.
|
||||
"""
|
||||
shared_dir = self.bs_dir_edit.text().strip()
|
||||
username = self.bs_user_edit.text().strip()
|
||||
if not shared_dir or not username:
|
||||
@@ -175,6 +212,7 @@ class LoginDialog(QDialog):
|
||||
|
||||
# ---- normal login ---------------------------------------------------
|
||||
def _build_login_page(self, shared_dir: str) -> QWidget:
|
||||
"""Trang đăng nhập thường: tên tài khoản + mã, điền sẵn tài khoản lần trước."""
|
||||
page = QWidget()
|
||||
lay = QVBoxLayout(page)
|
||||
form = QFormLayout()
|
||||
@@ -214,6 +252,9 @@ class LoginDialog(QDialog):
|
||||
return page
|
||||
|
||||
def _do_login(self, shared_dir: str) -> None:
|
||||
"""Kiểm tra tên tài khoản và mã; sai thì báo ngay trên trang, không đóng hộp
|
||||
thoại.
|
||||
"""
|
||||
username = self.user_edit.text().strip()
|
||||
code = self.code_edit.text().strip()
|
||||
directory = accounts.accounts_dir(shared_dir)
|
||||
@@ -246,6 +287,9 @@ class LoginDialog(QDialog):
|
||||
|
||||
# ---- offline fallback (shared folder configured but unreachable) ----
|
||||
def _build_offline_page(self, shared_dir: str) -> QWidget:
|
||||
"""Trang ngoại tuyến: cho vào bằng vai trò của lần đăng nhập gần nhất, kèm nút
|
||||
thử lại.
|
||||
"""
|
||||
page = QWidget()
|
||||
lay = QVBoxLayout(page)
|
||||
lay.addWidget(QLabel(tr("login.unreachable", path=shared_dir)))
|
||||
@@ -266,6 +310,7 @@ class LoginDialog(QDialog):
|
||||
return page
|
||||
|
||||
def _retry(self) -> None:
|
||||
"""Kiểm tra lại thư mục dùng chung và dựng lại trang cho đúng tình trạng mới."""
|
||||
self._stack.removeWidget(self._stack.currentWidget())
|
||||
shared_dir = self.ctx.config.shared_dir
|
||||
reachable = self._is_reachable(shared_dir)
|
||||
|
||||
@@ -13,7 +13,12 @@ from ..i18n import tr
|
||||
|
||||
|
||||
class McpServerEditDialog(QDialog):
|
||||
"""Form thêm/sửa một máy chủ MCP: tên, lệnh chạy, tham số và công tắc bật.
|
||||
|
||||
Đã được ``ui/connectors_panel.py`` thay thế; giữ lại làm bản đối chiếu.
|
||||
"""
|
||||
def __init__(self, parent=None, server: Optional[dict] = None):
|
||||
"""Dựng form. ``server`` để None thì đây là form thêm mới."""
|
||||
super().__init__(parent)
|
||||
server = server or {}
|
||||
self.setWindowTitle(tr("mcp.edit_title") if server else tr("mcp.add_title"))
|
||||
@@ -42,12 +47,20 @@ class McpServerEditDialog(QDialog):
|
||||
lay.addWidget(buttons)
|
||||
|
||||
def _on_accept(self) -> None:
|
||||
"""Chỉ đóng khi đã có tên và lệnh chạy — thiếu một trong hai thì máy chủ không
|
||||
khởi động được. Con trỏ nhảy về ô tên thay vì hiện hộp lỗi.
|
||||
"""
|
||||
if not self.name.text().strip() or not self.command.text().strip():
|
||||
self.name.setFocus()
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def result_server(self) -> dict:
|
||||
"""Nội dung form dưới dạng dict.
|
||||
|
||||
Tham số được tách bằng ``shlex`` chứ không phải ``split()``: đường dẫn có
|
||||
khoảng trắng phải đặt trong dấu nháy và vẫn tính là MỘT tham số.
|
||||
"""
|
||||
args_text = self.args.text().strip()
|
||||
return {
|
||||
"name": self.name.text().strip(),
|
||||
|
||||
@@ -12,6 +12,7 @@ _URL_RE = re.compile(r"^https?://", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_image(path: str | Path) -> bool:
|
||||
"""Đuôi tệp này có phải ảnh không."""
|
||||
return Path(path).suffix.lower() in IMAGE_SUFFIXES
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,15 @@ from ..i18n import tr
|
||||
|
||||
|
||||
class PermissionDialog(QDialog):
|
||||
"""Hộp thoại xin phép chạy một tool có rủi ro: hiện xem trước hành động rồi hỏi
|
||||
Đồng ý / Từ chối.
|
||||
"""
|
||||
def __init__(self, action: Dict[str, Any], parent=None):
|
||||
"""Hộp thoại xin phép trước khi agent chạy một hành động nhạy cảm.
|
||||
|
||||
Hiện nguyên văn thứ sắp chạy: người dùng phải thấy đúng cái mình đang đồng ý,
|
||||
không phải một câu mô tả chung chung.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
preview = action.get("preview", {})
|
||||
self.setWindowTitle(tr("permission.title"))
|
||||
|
||||
@@ -51,6 +51,11 @@ class RoutingToggle(QWidget):
|
||||
get_mode: Optional[Callable[[], str]] = None,
|
||||
set_mode: Optional[Callable[[str], None]] = None,
|
||||
) -> None:
|
||||
"""Công tắc chế độ định tuyến cho một bề mặt chat.
|
||||
|
||||
``get_mode``/``set_mode`` tiêm được để dùng lại công tắc này ở chỗ đọc/ghi
|
||||
chế độ theo cách khác, mà không phải chép lại cả widget.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.surface = surface
|
||||
@@ -85,6 +90,7 @@ class RoutingToggle(QWidget):
|
||||
lay.addWidget(self._combo)
|
||||
|
||||
def current_mode(self) -> str:
|
||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
||||
return self._combo.currentData() or "off"
|
||||
|
||||
def refresh(self) -> None:
|
||||
@@ -109,6 +115,9 @@ class RoutingToggle(QWidget):
|
||||
self._combo.setItemText(i, tr(key))
|
||||
|
||||
def _on_changed(self, _idx: int) -> None:
|
||||
"""Lưu chế độ vừa chọn. Nuốt lỗi có chủ ý: một cú đổi công tắc không được
|
||||
phép làm vỡ giao diện.
|
||||
"""
|
||||
mode = self.current_mode()
|
||||
try:
|
||||
self._set_mode(mode)
|
||||
@@ -129,6 +138,7 @@ class AutoRunToggle(QWidget):
|
||||
toggled_auto = Signal(bool)
|
||||
|
||||
def __init__(self, ctx: Any, parent: Optional[QWidget] = None) -> None:
|
||||
"""Ô tick tự chạy: đổi model xong có tự tiếp tục lượt hay dừng lại hỏi."""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
lay = QHBoxLayout(self)
|
||||
@@ -141,6 +151,7 @@ class AutoRunToggle(QWidget):
|
||||
lay.addWidget(self._chk)
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""
|
||||
try:
|
||||
auto = bool(self.ctx.project_auto_run())
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -150,10 +161,12 @@ class AutoRunToggle(QWidget):
|
||||
self._chk.blockSignals(False)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self._chk.setText(tr("routing.autorun_label"))
|
||||
self._chk.setToolTip(tr("routing.autorun_tooltip"))
|
||||
|
||||
def _on_toggled(self, checked: bool) -> None:
|
||||
"""Ghi trạng thái tự chạy vào project đang mở."""
|
||||
try:
|
||||
self.ctx.set_project_auto_run(bool(checked))
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -194,6 +207,11 @@ def confirm_switch(parent: QWidget, decision: Any, timeout_sec: float) -> bool:
|
||||
timer.setInterval(1000)
|
||||
|
||||
def _tick() -> None:
|
||||
"""Đếm lùi mỗi giây; hết giờ thì tự đóng hộp thoại theo hướng GIỮ model hiện tại.
|
||||
|
||||
Hết giờ mà tự đổi model là quyết định thay người dùng — mặc định an toàn
|
||||
phải là không đổi gì.
|
||||
"""
|
||||
remaining["secs"] -= 1
|
||||
if remaining["secs"] <= 0:
|
||||
timer.stop()
|
||||
|
||||
@@ -33,7 +33,15 @@ from ..presentation.settings.parameter_settings_widget import ParameterSettingsW
|
||||
from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
|
||||
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
|
||||
"""
|
||||
def __init__(self, ctx, parent=None):
|
||||
"""Hộp thoại Cài đặt, ghép các nhóm thiết lập.
|
||||
|
||||
Có nút thu nhỏ (không phải mặc định của hộp thoại Qt) vì màn này hay được để
|
||||
mở trong lúc người dùng làm việc ở cửa sổ chính.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.setWindowTitle(tr("settings.title"))
|
||||
@@ -241,6 +249,7 @@ class SettingsDialog(QDialog):
|
||||
|
||||
@staticmethod
|
||||
def _group(title: str, rows) -> QGroupBox:
|
||||
"""Dựng một nhóm có tiêu đề chứa các hàng nhãn–điều khiển."""
|
||||
box = QGroupBox(title)
|
||||
form = QFormLayout(box)
|
||||
for label, widget in rows:
|
||||
@@ -261,6 +270,11 @@ class SettingsDialog(QDialog):
|
||||
|
||||
|
||||
def _sandbox_unlock(self) -> None:
|
||||
"""Mở khoá nhóm cài đặt sandbox bằng mật khẩu.
|
||||
|
||||
Đâ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 cơ chế bảo mật thật.
|
||||
"""
|
||||
pw = self.sandbox_pw_edit.text()
|
||||
if pw == self._sandbox_pw:
|
||||
self._sandbox_unlocked = True
|
||||
@@ -275,6 +289,7 @@ class SettingsDialog(QDialog):
|
||||
QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
|
||||
data = self.ctx.config.data
|
||||
self._provider_page.apply_to(data)
|
||||
self._general_box.apply_to(data)
|
||||
|
||||
@@ -26,6 +26,10 @@ class _SessionDelegate(QStyledItemDelegate):
|
||||
look."""
|
||||
|
||||
def paint(self, painter, option, index): # noqa: N802
|
||||
"""Vẽ một dòng hội thoại: tiêu đề, chấm trạng thái và nền của dòng đang mở.
|
||||
|
||||
Dòng tiêu đề nhóm (không mang dữ liệu hội thoại) để Qt vẽ mặc định.
|
||||
"""
|
||||
if not index.data(Qt.UserRole): # group header → default
|
||||
super().paint(painter, option, index)
|
||||
return
|
||||
@@ -58,6 +62,7 @@ class _SessionDelegate(QStyledItemDelegate):
|
||||
style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget)
|
||||
|
||||
def sizeHint(self, option, index): # noqa: N802
|
||||
"""Dòng hội thoại cao hơn dòng nhóm 16px để có chỗ cho dòng phụ."""
|
||||
size = super().sizeHint(option, index)
|
||||
if index.data(Qt.UserRole):
|
||||
size.setHeight(size.height() + 16)
|
||||
@@ -65,6 +70,10 @@ class _SessionDelegate(QStyledItemDelegate):
|
||||
|
||||
|
||||
class HistorySidebar(QWidget):
|
||||
"""Cột lịch sử hội thoại: tìm kiếm, gom nhóm theo project, ghim, xoá hàng loạt.
|
||||
|
||||
Gập lại được thành một dải mỏng để nhường chỗ cho khung chat.
|
||||
"""
|
||||
new_chat = Signal(str) # kind
|
||||
open_chat = Signal(str, dict) # kind, conversation
|
||||
collapse_requested = Signal() # in-header button: collapse to a strip
|
||||
@@ -73,6 +82,11 @@ class HistorySidebar(QWidget):
|
||||
history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Cột lịch sử hội thoại.
|
||||
|
||||
Nhúng trong tab Cowork của một dự án thì chỉ hiện hội thoại của dự án ấy;
|
||||
``_project_filter`` rỗng nghĩa là hiện tất cả, gom theo dự án.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.current_session_id = "" # conversation currently on screen (highlighted)
|
||||
@@ -155,6 +169,7 @@ class HistorySidebar(QWidget):
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, ô tìm và các tooltip."""
|
||||
self._strip.setToolTip(tr("sidebar.expand_tooltip"))
|
||||
self._collapse_btn.setToolTip(tr("sidebar.collapse_tooltip"))
|
||||
self._header.setText(tr("sidebar.header"))
|
||||
@@ -165,6 +180,7 @@ class HistorySidebar(QWidget):
|
||||
self.refresh() # re-render group headers / running suffix in the new language
|
||||
|
||||
def is_collapsed(self) -> bool:
|
||||
"""Cột đang ở trạng thái gập (chỉ còn dải mỏng) hay không."""
|
||||
return self._strip.isVisible()
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
@@ -200,6 +216,7 @@ class HistorySidebar(QWidget):
|
||||
query = self.search_box.text() if hasattr(self, "search_box") else ""
|
||||
|
||||
def _make_group(label: str) -> QTreeWidgetItem:
|
||||
"""Dựng một dòng tiêu đề nhóm (theo project) trong cây lịch sử."""
|
||||
node = QTreeWidgetItem([label])
|
||||
node.setIcon(0, icon("folder"))
|
||||
node.setFirstColumnSpanned(True)
|
||||
@@ -271,6 +288,11 @@ class HistorySidebar(QWidget):
|
||||
# Shift/Ctrl-click means "extend the multi-selection", not "open this
|
||||
# conversation" — otherwise every click while multi-selecting for a
|
||||
# bulk-delete would also jump into that conversation.
|
||||
"""Bấm một dòng: mở hội thoại đó.
|
||||
|
||||
Giữ Shift/Ctrl thì KHÔNG mở — lúc đó người dùng đang chọn nhiều dòng để
|
||||
xoá hàng loạt, mở hội thoại giữa chừng sẽ phá thao tác đang làm.
|
||||
"""
|
||||
if QApplication.keyboardModifiers() & (Qt.ShiftModifier | Qt.ControlModifier):
|
||||
return
|
||||
path = item.data(0, Qt.UserRole)
|
||||
@@ -281,6 +303,7 @@ class HistorySidebar(QWidget):
|
||||
self.open_chat.emit(kind, conv)
|
||||
|
||||
def _selected_conversation_items(self):
|
||||
"""Các dòng đang chọn thật sự là hội thoại (bỏ qua dòng tiêu đề nhóm)."""
|
||||
return [it for it in self.tree.selectedItems() if it.data(0, Qt.UserRole)]
|
||||
|
||||
@staticmethod
|
||||
@@ -291,6 +314,7 @@ class HistorySidebar(QWidget):
|
||||
return len(selected) > 1 and item in selected
|
||||
|
||||
def _context_menu(self, pos) -> None:
|
||||
"""Menu chuột phải: đổi tên, ghim, mở thư mục, xoá — hỗ trợ chọn nhiều dòng."""
|
||||
clicked = self.tree.itemAt(pos)
|
||||
if clicked is None:
|
||||
return
|
||||
|
||||
@@ -27,7 +27,13 @@ from .skills_dialog import SkillEditDialog
|
||||
|
||||
|
||||
class SkillManagerTab(QWidget):
|
||||
"""Màn quản lý skill cũ: danh sách có ô tick bật/tắt, kèm nút nhập/xuất/nhân
|
||||
bản và hai lối nhờ model tự sinh skill.
|
||||
|
||||
Đã được ``ui/skills_dialog.py`` thay thế; giữ lại làm bản đối chiếu.
|
||||
"""
|
||||
def __init__(self, ctx=None, parent=None):
|
||||
"""Dựng danh sách skill và hàng nút thao tác."""
|
||||
super().__init__(parent)
|
||||
self._ctx = ctx
|
||||
self._auto_worker: Optional[AgentWorker] = None
|
||||
@@ -85,6 +91,11 @@ class SkillManagerTab(QWidget):
|
||||
|
||||
# ---- AI: auto-generate a whole skill from a one-line description ----
|
||||
def _auto_generate(self) -> None:
|
||||
"""Nhờ model viết một skill mới từ mô tả người dùng gõ vào.
|
||||
|
||||
Không có ``ctx`` (mở tab ngoài ứng dụng) thì báo là chưa dùng được, chứ
|
||||
không im lặng.
|
||||
"""
|
||||
if self._ctx is None:
|
||||
QMessageBox.information(self, tr("skills.auto_generate_title"),
|
||||
tr("skills.auto_generate_unavailable"))
|
||||
@@ -100,6 +111,9 @@ class SkillManagerTab(QWidget):
|
||||
text = prompt.strip()
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gọi model sinh skill. Lỗi được gói vào kết quả thay vì ném ra, để
|
||||
luồng gọi lại luôn chạy tới nơi.
|
||||
"""
|
||||
try:
|
||||
skill = generate_skill(ctx.build_active_provider(), text, worker.is_cancelled)
|
||||
return {"skill": skill}
|
||||
@@ -113,6 +127,7 @@ class SkillManagerTab(QWidget):
|
||||
w.start()
|
||||
|
||||
def _on_auto_generated(self, result) -> None:
|
||||
"""Mở form sửa với skill model vừa sinh, để người dùng xem lại trước khi lưu."""
|
||||
self._auto_btn.setEnabled(True)
|
||||
self._auto_btn.setText(tr("skills.auto_generate"))
|
||||
skill = (result or {}).get("skill")
|
||||
@@ -127,6 +142,7 @@ class SkillManagerTab(QWidget):
|
||||
|
||||
# ---- AI: analyze a pptx/xlsx TEMPLATE file's structure into a skill ----
|
||||
def _from_template(self) -> None:
|
||||
"""Nhờ model dựng skill từ một file mẫu người dùng chọn."""
|
||||
if self._ctx is None:
|
||||
QMessageBox.information(self, tr("skills.from_template_title"),
|
||||
tr("skills.auto_generate_unavailable"))
|
||||
@@ -141,6 +157,7 @@ class SkillManagerTab(QWidget):
|
||||
ctx = self._ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: đọc file mẫu và nhờ model dựng skill từ đó."""
|
||||
try:
|
||||
skill = generate_skill_from_template(
|
||||
ctx.build_active_provider(), path, worker.is_cancelled)
|
||||
@@ -155,6 +172,7 @@ class SkillManagerTab(QWidget):
|
||||
w.start()
|
||||
|
||||
def _on_template_generated(self, result) -> None:
|
||||
"""Mở form sửa với skill dựng từ file mẫu."""
|
||||
self._template_btn.setEnabled(True)
|
||||
self._template_btn.setText(tr("skills.from_template"))
|
||||
skill = (result or {}).get("skill")
|
||||
@@ -168,6 +186,7 @@ class SkillManagerTab(QWidget):
|
||||
self.reload()
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Nạp lại danh sách skill từ đĩa, giữ đúng trạng thái bật/tắt của từng cái."""
|
||||
self.list.blockSignals(True)
|
||||
self.list.clear()
|
||||
for s in list_skills():
|
||||
@@ -184,6 +203,7 @@ class SkillManagerTab(QWidget):
|
||||
self.list.blockSignals(False)
|
||||
|
||||
def _on_check(self, item: QListWidgetItem) -> None:
|
||||
"""Tick/bỏ tick một skill là bật/tắt nó, ghi đĩa ngay."""
|
||||
skill = item.data(Qt.UserRole)
|
||||
if not isinstance(skill, Skill):
|
||||
return
|
||||
@@ -191,6 +211,7 @@ class SkillManagerTab(QWidget):
|
||||
save_skill(skill)
|
||||
|
||||
def _current_skill(self) -> Optional[Skill]:
|
||||
"""Skill đang chọn; ``None`` nếu chưa chọn dòng nào."""
|
||||
item = self.list.currentItem()
|
||||
if item is None:
|
||||
return None
|
||||
@@ -198,6 +219,7 @@ class SkillManagerTab(QWidget):
|
||||
return skill if isinstance(skill, Skill) else None
|
||||
|
||||
def _import(self) -> None:
|
||||
"""Nhập một skill từ file ngoài vào."""
|
||||
from ..core.skills import import_skill_file
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
@@ -211,6 +233,7 @@ class SkillManagerTab(QWidget):
|
||||
QMessageBox.warning(self, tr("skills.import_dialog_title"), tr("skills.import_failed", err=exc))
|
||||
|
||||
def _export_md(self) -> None:
|
||||
"""Xuất skill đang chọn ra file Markdown."""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
QMessageBox.information(self, tr("skills.export_btn"), tr("skills.export_pick"))
|
||||
@@ -229,6 +252,11 @@ class SkillManagerTab(QWidget):
|
||||
QMessageBox.warning(self, tr("skills.export_btn"), tr("skills.export_failed", err=exc))
|
||||
|
||||
def _duplicate(self) -> None:
|
||||
"""Nhân bản skill đang chọn.
|
||||
|
||||
Bản sao được tạo ở trạng thái TẮT rồi mở form sửa ngay: nhân bản là để lấy
|
||||
làm nền sửa tiếp, chưa phải để chạy.
|
||||
"""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
QMessageBox.information(self, tr("skills.duplicate_btn"), tr("skills.export_pick"))
|
||||
@@ -243,6 +271,9 @@ class SkillManagerTab(QWidget):
|
||||
self.reload()
|
||||
|
||||
def _edit(self) -> None:
|
||||
"""Mở form sửa skill đang chọn. Truyền ``old_name`` để đổi tên không sinh ra
|
||||
bản thứ hai.
|
||||
"""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
return
|
||||
@@ -252,6 +283,7 @@ class SkillManagerTab(QWidget):
|
||||
self.reload()
|
||||
|
||||
def _delete(self) -> None:
|
||||
"""Xoá skill đang chọn."""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
return
|
||||
|
||||
@@ -21,7 +21,13 @@ from .icons import icon
|
||||
|
||||
|
||||
class SkillEditDialog(QDialog):
|
||||
"""Hộp thoại thêm/sửa một Skill: tên, mô tả và phần chỉ dẫn."""
|
||||
def __init__(self, parent=None, skill: Optional[Skill] = None, ctx=None):
|
||||
"""Form thêm/sửa một skill.
|
||||
|
||||
Skill mới luôn bắt đầu ở trạng thái TẮT: vừa tạo xong đã tự tham gia vào mọi
|
||||
lượt chat là điều người dùng không lường trước.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(tr("skills.edit_title") if skill else tr("skills.add_title"))
|
||||
self.setMinimumWidth(520)
|
||||
@@ -59,6 +65,7 @@ class SkillEditDialog(QDialog):
|
||||
|
||||
# ---- AI: draft the instructions from the short description -------
|
||||
def _gen_instructions(self) -> None:
|
||||
"""Nhờ AI soạn phần chỉ dẫn từ tên và mô tả người dùng vừa gõ."""
|
||||
desc = self.desc.text().strip()
|
||||
name = self.name.text().strip()
|
||||
if not desc and not name:
|
||||
@@ -71,6 +78,7 @@ class SkillEditDialog(QDialog):
|
||||
ctx = self._ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gọi model sinh nội dung chỉ dẫn cho skill."""
|
||||
return {"text": generate_skill_instructions(
|
||||
ctx.build_active_provider(), desc, name, worker.is_cancelled)}
|
||||
|
||||
@@ -81,22 +89,26 @@ class SkillEditDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _on_gen(self, result) -> None:
|
||||
"""Đổ chỉ dẫn vừa sinh vào ô soạn thảo."""
|
||||
text = (result or {}).get("text", "")
|
||||
if text:
|
||||
self.instr.setPlainText(text)
|
||||
self._reset_gen_btn()
|
||||
|
||||
def _reset_gen_btn(self) -> None:
|
||||
"""Trả nút "Sinh từ mô tả" về trạng thái bấm được."""
|
||||
self._gen_btn.setEnabled(True)
|
||||
self._gen_btn.setText(tr("skills.gen_from_desc"))
|
||||
|
||||
def _on_accept(self) -> None:
|
||||
"""Kiểm tra bắt buộc có tên trước khi đóng hộp thoại."""
|
||||
if not self.name.text().strip():
|
||||
self.name.setFocus()
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def result_skill(self) -> Skill:
|
||||
"""Bản ghi Skill dựng từ nội dung đang có trên form."""
|
||||
return Skill(
|
||||
name=self.name.text().strip(),
|
||||
description=self.desc.text().strip(),
|
||||
@@ -106,7 +118,13 @@ class SkillEditDialog(QDialog):
|
||||
|
||||
|
||||
class SkillsDialog(QDialog):
|
||||
"""Trình quản lý Skill: liệt kê, bật/tắt, thêm/sửa/xoá, nhân bản, nhập/xuất,
|
||||
và hai lối tạo nhanh bằng AI (từ mô tả, hoặc từ một tệp mẫu).
|
||||
"""
|
||||
def __init__(self, parent=None, ctx=None):
|
||||
"""Hộp thoại quản lý skill: danh sách kèm nút nhập/xuất và hai lối nhờ model tự
|
||||
sinh skill.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._ctx = ctx # passed to SkillEditDialog for AI-assisted generation
|
||||
self._auto_worker = None
|
||||
@@ -169,6 +187,7 @@ class SkillsDialog(QDialog):
|
||||
|
||||
# ---- AI: auto-generate a whole skill from a one-line description ----
|
||||
def _auto_generate(self) -> None:
|
||||
"""Nhờ AI dựng một skill hoàn chỉnh từ vài dòng mô tả người dùng gõ."""
|
||||
if self._ctx is None:
|
||||
QMessageBox.information(self, tr("skills.auto_generate_title"),
|
||||
tr("skills.auto_generate_unavailable"))
|
||||
@@ -184,6 +203,7 @@ class SkillsDialog(QDialog):
|
||||
text = prompt.strip()
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: gọi model sinh skill; lỗi thì trả về kèm thông điệp để hiện ra."""
|
||||
try:
|
||||
skill = generate_skill(ctx.build_active_provider(), text, worker.is_cancelled)
|
||||
return {"skill": skill}
|
||||
@@ -197,6 +217,7 @@ class SkillsDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _on_auto_generated(self, result) -> None:
|
||||
"""Skill vừa sinh xong: mở hộp thoại sửa để người dùng xem lại trước khi lưu."""
|
||||
self._auto_btn.setEnabled(True)
|
||||
self._auto_btn.setText(tr("skills.auto_generate"))
|
||||
skill = (result or {}).get("skill")
|
||||
@@ -212,6 +233,7 @@ class SkillsDialog(QDialog):
|
||||
|
||||
# ---- AI: analyze a pptx/xlsx TEMPLATE file's structure into a skill ----
|
||||
def _from_template(self) -> None:
|
||||
"""Nhờ AI dựng skill từ một tệp mẫu người dùng chọn (quy trình, biểu mẫu…)."""
|
||||
if self._ctx is None:
|
||||
QMessageBox.information(self, tr("skills.from_template_title"),
|
||||
tr("skills.auto_generate_unavailable"))
|
||||
@@ -226,6 +248,7 @@ class SkillsDialog(QDialog):
|
||||
ctx = self._ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: đọc tệp mẫu và gọi model sinh skill tương ứng."""
|
||||
try:
|
||||
skill = generate_skill_from_template(
|
||||
ctx.build_active_provider(), path, worker.is_cancelled)
|
||||
@@ -240,6 +263,7 @@ class SkillsDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _on_template_generated(self, result) -> None:
|
||||
"""Skill từ mẫu đã sinh xong: mở hộp thoại sửa để xem lại trước khi lưu."""
|
||||
self._template_btn.setEnabled(True)
|
||||
self._template_btn.setText(tr("skills.from_template"))
|
||||
skill = (result or {}).get("skill")
|
||||
@@ -253,6 +277,11 @@ class SkillsDialog(QDialog):
|
||||
self._reload()
|
||||
|
||||
def _reload(self) -> None:
|
||||
"""Nạp lại danh sách skill.
|
||||
|
||||
Chặn tín hiệu trong lúc nạp: đặt lại ô đánh dấu sẽ phát ``itemChanged`` và bị
|
||||
hiểu nhầm là người dùng vừa bật/tắt skill.
|
||||
"""
|
||||
self.list.blockSignals(True)
|
||||
self.list.clear()
|
||||
for s in list_skills():
|
||||
@@ -269,6 +298,7 @@ class SkillsDialog(QDialog):
|
||||
self.list.blockSignals(False)
|
||||
|
||||
def _on_check(self, item: QListWidgetItem) -> None:
|
||||
"""Bật/tắt một skill và lưu ngay."""
|
||||
skill = item.data(Qt.UserRole)
|
||||
if not isinstance(skill, Skill):
|
||||
return
|
||||
@@ -276,6 +306,7 @@ class SkillsDialog(QDialog):
|
||||
save_skill(skill)
|
||||
|
||||
def _current_skill(self) -> Optional[Skill]:
|
||||
"""Skill đang chọn trong danh sách; ``None`` nếu chưa chọn gì."""
|
||||
item = self.list.currentItem()
|
||||
if item is None:
|
||||
return None
|
||||
@@ -283,6 +314,7 @@ class SkillsDialog(QDialog):
|
||||
return skill if isinstance(skill, Skill) else None
|
||||
|
||||
def _import(self) -> None:
|
||||
"""Nhập skill từ tệp ``.md`` hoặc gói ``.zip``."""
|
||||
from ..core.skills import import_skill_file
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
@@ -296,6 +328,7 @@ class SkillsDialog(QDialog):
|
||||
QMessageBox.warning(self, tr("skills.import_dialog_title"), tr("skills.import_failed", err=exc))
|
||||
|
||||
def _export_md(self) -> None:
|
||||
"""Xuất skill đang chọn ra tệp Markdown."""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
QMessageBox.information(self, tr("skills.export_btn"), tr("skills.export_pick"))
|
||||
@@ -313,6 +346,7 @@ class SkillsDialog(QDialog):
|
||||
QMessageBox.warning(self, tr("skills.export_btn"), tr("skills.export_failed", err=exc))
|
||||
|
||||
def _duplicate(self) -> None:
|
||||
"""Nhân bản skill đang chọn thành một skill mới."""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
QMessageBox.information(self, tr("skills.duplicate_btn"), tr("skills.export_pick"))
|
||||
@@ -328,6 +362,7 @@ class SkillsDialog(QDialog):
|
||||
self._reload()
|
||||
|
||||
def _edit(self) -> None:
|
||||
"""Mở hộp thoại sửa skill đang chọn."""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
return
|
||||
@@ -337,6 +372,7 @@ class SkillsDialog(QDialog):
|
||||
self._reload()
|
||||
|
||||
def _delete(self) -> None:
|
||||
"""Xoá skill đang chọn sau khi hỏi xác nhận."""
|
||||
skill = self._current_skill()
|
||||
if skill is None:
|
||||
return
|
||||
|
||||
@@ -53,7 +53,11 @@ def _catmull_rom(points: List[QPointF]) -> QPainterPath:
|
||||
|
||||
|
||||
class SplineChart(QWidget):
|
||||
"""Biểu đồ đường cong mượt cho Dashboard: vẽ tay bằng ``QPainter``, không dùng
|
||||
thư viện biểu đồ ngoài.
|
||||
"""
|
||||
def __init__(self):
|
||||
"""Biểu đồ đường cong mượt. Rỗng lúc đầu — dữ liệu vào qua ``set_data()``."""
|
||||
super().__init__()
|
||||
self._points: List[Tuple[str, float]] = []
|
||||
self._fmt: Callable[[float], str] = lambda v: f"{v:.2f}"
|
||||
@@ -63,6 +67,9 @@ class SplineChart(QWidget):
|
||||
|
||||
def set_data(self, points: List[Tuple[str, float]],
|
||||
value_fmt: Optional[Callable[[float], str]] = None, title: str = "") -> None:
|
||||
"""Đặt dữ liệu cho biểu đồ: danh sách (nhãn, giá trị), kèm hàm định dạng giá
|
||||
trị và tiêu đề.
|
||||
"""
|
||||
self._points = list(points or [])
|
||||
if value_fmt is not None:
|
||||
self._fmt = value_fmt
|
||||
@@ -76,6 +83,9 @@ class SplineChart(QWidget):
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _e): # noqa: N802
|
||||
"""Vẽ biểu đồ: lưới mờ, đường cong Bézier mượt qua các điểm, vùng tô dưới đường
|
||||
và nhãn trục.
|
||||
"""
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
tok = current_palette()
|
||||
|
||||
@@ -59,6 +59,12 @@ class TaskEditorDialog(QDialog):
|
||||
|
||||
def __init__(self, task: Optional[dict] = None, all_tasks: Optional[List[dict]] = None,
|
||||
parent=None, ctx=None):
|
||||
"""Form thêm/sửa một task theo lịch.
|
||||
|
||||
``all_tasks`` để kiểm trùng tên và dựng danh sách task phụ thuộc. Danh sách
|
||||
model của từng provider được nhớ lại sau khi nạp, để đổi provider qua lại
|
||||
không phải gọi mạng lần nữa.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._original = task
|
||||
self.ctx = ctx # for ✨ AI-generate buttons (optional)
|
||||
@@ -521,16 +527,19 @@ class TaskEditorDialog(QDialog):
|
||||
# ---- helpers ---------------------------------------------------------
|
||||
@staticmethod
|
||||
def _select(combo: QComboBox, data) -> None:
|
||||
"""Chọn mục mang dữ liệu ``data`` trong một combo; không có thì để nguyên."""
|
||||
idx = combo.findData(data)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
def _add_files(self) -> None:
|
||||
"""Thêm tệp vào danh sách dữ liệu đầu vào của task."""
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
for f in files:
|
||||
self.files_list.addItem(f)
|
||||
|
||||
def _add_link(self) -> None:
|
||||
"""Thêm một link vào danh sách dữ liệu đầu vào của task."""
|
||||
url, ok = QInputDialog.getText(self, tr("schedtask.add_link_title"),
|
||||
tr("schedtask.add_link_label"))
|
||||
url = url.strip()
|
||||
@@ -539,6 +548,7 @@ class TaskEditorDialog(QDialog):
|
||||
|
||||
@staticmethod
|
||||
def _remove_selected(list_widget: QListWidget) -> None:
|
||||
"""Gỡ các dòng đang chọn khỏi một danh sách."""
|
||||
for item in list_widget.selectedItems():
|
||||
list_widget.takeItem(list_widget.row(item))
|
||||
|
||||
@@ -563,11 +573,13 @@ class TaskEditorDialog(QDialog):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker):
|
||||
"""Chạy nền: hỏi mọi provider danh sách model đang dùng được."""
|
||||
from ..core import preview_ai
|
||||
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Ghi nhớ danh sách model rồi nạp vào ô chọn."""
|
||||
self.load_models_btn.setEnabled(True)
|
||||
self._live_models = result or {}
|
||||
self._refresh_model_combo()
|
||||
@@ -576,6 +588,7 @@ class TaskEditorDialog(QDialog):
|
||||
tr("schedtask.load_models_empty"))
|
||||
|
||||
def failed(err: str) -> None:
|
||||
"""Nạp model lỗi: hiện cảnh báo và mở khoá lại nút."""
|
||||
self.load_models_btn.setEnabled(True)
|
||||
QMessageBox.warning(self, tr("schedtask.editor_title_new"), err)
|
||||
|
||||
@@ -586,6 +599,11 @@ class TaskEditorDialog(QDialog):
|
||||
w.start()
|
||||
|
||||
def _check_chain(self) -> None:
|
||||
"""Kiểm tra chuỗi task nối tiếp có tạo thành vòng lặp không, và cảnh báo ngay
|
||||
trên form.
|
||||
|
||||
Không chặn thì hai task trỏ vào nhau sẽ chạy vòng vô tận.
|
||||
"""
|
||||
candidates = self.all_tasks + ([self._original] if self._original else [self.task])
|
||||
err = chain_error(candidates, self.task["task_id"], self.next_combo.currentData())
|
||||
nxt_id = self.next_combo.currentData()
|
||||
@@ -597,6 +615,7 @@ class TaskEditorDialog(QDialog):
|
||||
self.chain_warn.setText(warn)
|
||||
|
||||
def _checked_depends_on(self) -> list:
|
||||
"""Id các task đang được tick trong danh sách phụ thuộc."""
|
||||
ids = []
|
||||
for i in range(self.depends_list.count()):
|
||||
item = self.depends_list.item(i)
|
||||
@@ -621,12 +640,14 @@ class TaskEditorDialog(QDialog):
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: nhờ model soạn prompt cho task từ phần mô tả."""
|
||||
from ..core.ai_task_planner import generate_prompt_from_description
|
||||
|
||||
return {"prompt": generate_prompt_from_description(
|
||||
ctx.build_active_provider(), description, cancel=worker.is_cancelled)}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
"""Đổ prompt vừa soạn vào ô nội dung task."""
|
||||
self._gen_worker = None
|
||||
self.gen_desc_btn.setEnabled(True)
|
||||
self._apply_generated_prompt(result.get("prompt", ""), description)
|
||||
@@ -690,6 +711,7 @@ class TaskEditorDialog(QDialog):
|
||||
self.manual_text.setPlainText(prompt or description_fallback)
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Kiểm tra hợp lệ rồi dựng bản ghi task từ form; thiếu tiêu đề thì không đóng."""
|
||||
title = self.title_edit.text().strip()
|
||||
if not title:
|
||||
QMessageBox.warning(self, tr("schedtask.editor_title_new"), tr("schedtask.title_required"))
|
||||
|
||||
@@ -41,6 +41,7 @@ class _TermInput(QLineEdit):
|
||||
history_next = Signal()
|
||||
|
||||
def keyPressEvent(self, e): # noqa: N802 - Qt override
|
||||
"""Tab yêu cầu tự hoàn tất đường dẫn; Lên/Xuống duyệt lịch sử lệnh."""
|
||||
if e.key() == Qt.Key_Tab:
|
||||
self.complete_requested.emit()
|
||||
e.accept()
|
||||
@@ -62,6 +63,11 @@ class TerminalPanel(QWidget):
|
||||
expanded = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Terminal thu gọn ở đáy tab Thư mục.
|
||||
|
||||
Mở ở trạng thái gập lại; ``_hist_idx`` trỏ quá phần tử cuối khi không duyệt
|
||||
lịch sử, nên mũi tên lên lần đầu ra lệnh gần nhất.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._collapsed = True
|
||||
self._cwd = str(Path.home())
|
||||
@@ -141,12 +147,14 @@ class TerminalPanel(QWidget):
|
||||
|
||||
# ---- public API ----------------------------------------------------------
|
||||
def set_cwd(self, path: str) -> None:
|
||||
"""Đổi thư mục làm việc (bỏ qua nếu đường dẫn không tồn tại) và cập nhật dấu nhắc."""
|
||||
if path and os.path.isdir(path):
|
||||
self._cwd = os.path.normpath(str(path))
|
||||
self._cwd_lbl.setText(self._cwd)
|
||||
self._prompt.setText(_prompt_for(self._cwd))
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Gập/mở panel; mở ra thì phát ``expanded`` để chỗ gọi trỏ shell về đúng thư mục."""
|
||||
self._collapsed = not self._collapsed
|
||||
self._apply_collapsed()
|
||||
if not self._collapsed:
|
||||
@@ -154,10 +162,12 @@ class TerminalPanel(QWidget):
|
||||
self.input.setFocus()
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
"""Đặt thẳng trạng thái gập/mở, không phát tín hiệu."""
|
||||
self._collapsed = collapsed
|
||||
self._apply_collapsed()
|
||||
|
||||
def _apply_collapsed(self) -> None:
|
||||
"""Áp trạng thái gập/mở lên phần thân, icon và tooltip của nút."""
|
||||
self._body.setVisible(not self._collapsed)
|
||||
self._toggle_btn.setIcon(icon("chevron-right" if self._collapsed else "chevron-down"))
|
||||
self._toggle_btn.setToolTip(
|
||||
@@ -165,6 +175,7 @@ class TerminalPanel(QWidget):
|
||||
|
||||
# ---- history -------------------------------------------------------------
|
||||
def _history_move(self, direction: int) -> None:
|
||||
"""Đi lên/xuống trong lịch sử lệnh; đi quá cuối thì trả ô nhập về rỗng."""
|
||||
if not self._history:
|
||||
return
|
||||
self._hist_idx = max(0, min(len(self._history), self._hist_idx + direction))
|
||||
@@ -172,6 +183,7 @@ class TerminalPanel(QWidget):
|
||||
|
||||
# ---- Tab completion ------------------------------------------------------
|
||||
def _complete(self) -> None:
|
||||
"""Tự hoàn tất đường dẫn cho đoạn đang gõ (giống Tab của shell)."""
|
||||
text = self.input.text()
|
||||
head, sep, token = text.rpartition(" ")
|
||||
norm = token.replace("\\", "/")
|
||||
@@ -191,6 +203,7 @@ class TerminalPanel(QWidget):
|
||||
return
|
||||
|
||||
def _decorate(entry: str) -> str:
|
||||
"""Thêm dấu phân cách vào cuối tên nếu đó là thư mục."""
|
||||
full = os.path.join(base or self._cwd, entry)
|
||||
return entry + (os.sep if os.path.isdir(full) else "")
|
||||
|
||||
@@ -206,6 +219,7 @@ class TerminalPanel(QWidget):
|
||||
|
||||
# ---- running commands ----------------------------------------------------
|
||||
def _run_current(self) -> None:
|
||||
"""Chạy lệnh đang gõ trong ô nhập rồi xoá ô."""
|
||||
cmd = self.input.text().strip()
|
||||
if not cmd:
|
||||
return
|
||||
@@ -215,6 +229,9 @@ class TerminalPanel(QWidget):
|
||||
self.run_command(cmd)
|
||||
|
||||
def run_command(self, cmd: str) -> None:
|
||||
"""Chạy một lệnh: ``clear``/``cls`` và ``cd`` xử lý tại chỗ, còn lại giao cho
|
||||
tiến trình con.
|
||||
"""
|
||||
self._append(f"\n{_prompt_for(self._cwd)} {cmd}\n", role="cmd")
|
||||
stripped = cmd.strip()
|
||||
if stripped in ("clear", "cls"):
|
||||
@@ -229,6 +246,11 @@ class TerminalPanel(QWidget):
|
||||
self._start_process(cmd)
|
||||
|
||||
def _change_dir(self, target: str) -> None:
|
||||
"""Xử lý ``cd`` ngay trong panel.
|
||||
|
||||
Phải tự xử lý vì mỗi lệnh chạy trong một tiến trình riêng — ``cd`` giao cho
|
||||
tiến trình con sẽ đổi thư mục của chính nó rồi biến mất cùng nó.
|
||||
"""
|
||||
target = target.strip()
|
||||
if target.lower().startswith("/d "): # cmd's "cd /d X:\path" flag
|
||||
target = target[3:].strip()
|
||||
@@ -247,6 +269,7 @@ class TerminalPanel(QWidget):
|
||||
self._append(tr("terminal.cd_error", path=target) + "\n", role="err")
|
||||
|
||||
def _start_process(self, cmd: str) -> None:
|
||||
"""Khởi động tiến trình con chạy lệnh, đọc stdout/stderr theo luồng."""
|
||||
proc = QProcess(self)
|
||||
proc.setWorkingDirectory(self._cwd)
|
||||
proc.setProcessChannelMode(QProcess.SeparateChannels)
|
||||
@@ -268,17 +291,20 @@ class TerminalPanel(QWidget):
|
||||
proc.start(os.environ.get("SHELL", "/bin/sh"), ["-c", cmd])
|
||||
|
||||
def _on_finished(self, code: int, _status=None) -> None:
|
||||
"""Tiến trình kết thúc: in mã thoát (xanh nếu 0, đỏ nếu khác) và mở khoá ô nhập."""
|
||||
self._append(tr("terminal.exit", code=code) + "\n",
|
||||
role="ok" if code == 0 else "err")
|
||||
self._set_running(False)
|
||||
|
||||
def _set_running(self, running: bool) -> None:
|
||||
"""Khoá/mở ô nhập và nút Chạy theo trạng thái đang chạy."""
|
||||
self.input.setEnabled(not running)
|
||||
self._run_btn.setEnabled(not running)
|
||||
if not running:
|
||||
self.input.setFocus()
|
||||
|
||||
def _append(self, text: str, role: str = "out") -> None:
|
||||
"""Nối văn bản vào khung kết quả, tô màu theo vai trò (lệnh / ra / lỗi / mã thoát)."""
|
||||
if not text:
|
||||
return
|
||||
from PySide6.QtGui import QColor, QTextCursor
|
||||
@@ -295,10 +321,12 @@ class TerminalPanel(QWidget):
|
||||
|
||||
# ---- lifecycle -----------------------------------------------------------
|
||||
def stop(self) -> None:
|
||||
"""Giết tiến trình đang chạy, nếu có."""
|
||||
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
|
||||
self._proc.kill()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self._title.setText(tr("terminal.title"))
|
||||
self._run_btn.setText(tr("terminal.run"))
|
||||
self.input.setPlaceholderText(tr("terminal.placeholder"))
|
||||
@@ -306,11 +334,18 @@ class TerminalPanel(QWidget):
|
||||
|
||||
|
||||
def _prompt_for(cwd: str) -> str:
|
||||
"""Dấu nhắc lệnh theo thư mục hiện tại: ``tên >`` trên Windows, ``tên $`` nơi khác."""
|
||||
name = Path(cwd).name or cwd
|
||||
return f"{name} >" if _IS_WIN else f"{name} $"
|
||||
|
||||
|
||||
def _decode(data: bytes) -> str:
|
||||
"""Giải mã đầu ra tiến trình con: thử UTF-8 trước, rồi tới bảng mã mặc định
|
||||
của hệ điều hành.
|
||||
|
||||
Cần vì console Windows tiếng Việt/Nhật trả về cp1258/cp932 chứ không phải
|
||||
UTF-8, giải mã cứng một bảng mã là ra chữ rác.
|
||||
"""
|
||||
import locale
|
||||
encs = ["utf-8"]
|
||||
try:
|
||||
|
||||
+73
-3
@@ -40,6 +40,7 @@ class _ProjectRow(QWidget):
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, counts: str):
|
||||
"""Một dòng dự án trong danh sách: tên ở trên, số liệu tóm tắt ở dưới."""
|
||||
super().__init__()
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 4, 6, 4)
|
||||
@@ -52,6 +53,13 @@ class _ProjectRow(QWidget):
|
||||
|
||||
|
||||
class WorkspaceTab(QWidget):
|
||||
"""Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab
|
||||
(Dự án · Cowork · Co4E · Thư mục · GraphRAG).
|
||||
|
||||
Đây là chỗ CHỐT project đang hoạt động: :meth:`_bind_project` đặt
|
||||
``ctx.active_project_id``, và mọi chế độ theo-workspace (định tuyến, tự
|
||||
chạy) đều phân giải theo giá trị đó.
|
||||
"""
|
||||
status_message = Signal(str)
|
||||
open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork
|
||||
new_chat = Signal(str) # project_id — start a new thread in this project
|
||||
@@ -95,10 +103,12 @@ class WorkspaceTab(QWidget):
|
||||
return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
|
||||
|
||||
def select_subtab(self, index: int) -> None:
|
||||
"""Chuyển sang sub-tab thứ ``index`` (thanh menu bên trái gọi vào đây)."""
|
||||
if 0 <= index < self.tabs.count():
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
def current_subtab(self) -> int:
|
||||
"""Chỉ số sub-tab đang mở."""
|
||||
return self.tabs.currentIndex()
|
||||
|
||||
def hide_tab_bar(self) -> None:
|
||||
@@ -107,6 +117,11 @@ class WorkspaceTab(QWidget):
|
||||
self.tabs.tabBar().hide()
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None, structure=None, sidebar=None):
|
||||
"""Màn Workspace: danh sách dự án bên trái, các tab con của dự án bên phải.
|
||||
|
||||
Cột lịch sử mở ở trạng thái gập cho tới khi người dùng chủ động mở ra, đúng
|
||||
như bản vẽ bố cục màn này.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._current_id = ""
|
||||
@@ -255,6 +270,7 @@ class WorkspaceTab(QWidget):
|
||||
|
||||
# ---- project settings tab -------------------------------------------
|
||||
def _build_project_tab(self) -> QWidget:
|
||||
"""Dựng sub-tab "Dự án": tên, mô tả, chỉ dẫn chung và thư mục sandbox."""
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(8, 4, 4, 4)
|
||||
@@ -311,6 +327,7 @@ class WorkspaceTab(QWidget):
|
||||
|
||||
# ---- embedded sidebar (History inside the Cowork tab) ---------------
|
||||
def _wire_sidebar(self) -> None:
|
||||
"""Nối các tín hiệu của cột lịch sử vào màn Workspace."""
|
||||
sb = self._sidebar
|
||||
sb.open_chat.connect(self._on_sidebar_open)
|
||||
sb.new_chat.connect(self._on_sidebar_new)
|
||||
@@ -354,6 +371,12 @@ class WorkspaceTab(QWidget):
|
||||
self._split.setSizes(sizes)
|
||||
|
||||
def _on_sidebar_open(self, kind: str, conv: dict) -> None:
|
||||
"""Mở một hội thoại từ cột lịch sử, kèm chuyển sang đúng project của nó.
|
||||
|
||||
Id "default" (hội thoại cũ chưa gắn project) CỐ Ý không được coi là một
|
||||
project thật — Cowork xử lý riêng nó như phạm vi toàn cục, không có tri thức
|
||||
project nào.
|
||||
"""
|
||||
pid = conv.get("project_id", "") or "default"
|
||||
# The legacy "default"/no-project id is intentionally not a real
|
||||
# Project row (Cowork itself special-cases it as global/no-knowledge —
|
||||
@@ -370,23 +393,30 @@ class WorkspaceTab(QWidget):
|
||||
self.open_chat.emit(kind or "cowork", conv)
|
||||
|
||||
def _on_sidebar_new(self, kind: str) -> None:
|
||||
"""Bấm "chat mới" ở cột lịch sử: mở hội thoại mới rồi nhảy sang tab Cowork."""
|
||||
if self._cowork is not None:
|
||||
self._cowork.new_session()
|
||||
self._show_cowork_tab()
|
||||
|
||||
def _on_sidebar_refresh(self) -> None:
|
||||
"""Cột lịch sử yêu cầu làm mới: cập nhật lại trạng thái Cowork và danh sách."""
|
||||
if self._cowork is not None:
|
||||
self._cowork.refresh_status()
|
||||
if self._sidebar is not None:
|
||||
self._sidebar.refresh()
|
||||
|
||||
def _show_cowork_tab(self) -> None:
|
||||
"""Chuyển sang sub-tab Cowork nếu nó đang hiện."""
|
||||
if self._cowork_tab_idx >= 0:
|
||||
self.tabs.setCurrentIndex(self._cowork_tab_idx)
|
||||
|
||||
def _on_tab_changed(self, idx: int) -> None:
|
||||
# Entering GraphRAG builds its (lazy) WebEngine view and scans the
|
||||
# project's sandbox; entering it is what keeps startup RAM low.
|
||||
"""Đổi sub-tab: vào GraphRAG mới dựng khung WebEngine và quét sandbox.
|
||||
|
||||
Dựng lười như vậy chính là thứ giữ cho RAM lúc khởi động ở mức thấp.
|
||||
"""
|
||||
if idx == self._graphrag_tab_idx and self._structure is not None:
|
||||
self._structure.auto_scan_and_fit()
|
||||
self._apply_pane_visibility()
|
||||
@@ -450,6 +480,7 @@ class WorkspaceTab(QWidget):
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, gợi ý và tên các sub-tab."""
|
||||
self._header.setText(tr("workspace.header"))
|
||||
self._hint.setText(tr("workspace.hint"))
|
||||
self._projects_hdr.setText(tr("workspace.projects_heading").upper())
|
||||
@@ -484,6 +515,7 @@ class WorkspaceTab(QWidget):
|
||||
_NARROW = 1500
|
||||
|
||||
def showEvent(self, e): # noqa: N802 - Qt override
|
||||
"""Lần hiện đầu tiên mới gắn bộ canh bố cục hẹp — trước đó chưa biết bề rộng thật."""
|
||||
super().showEvent(e)
|
||||
if getattr(self, "_narrow", None) is None:
|
||||
from .widgets import narrow_guard
|
||||
@@ -511,6 +543,7 @@ class WorkspaceTab(QWidget):
|
||||
self._apply_pane_visibility()
|
||||
|
||||
def _set_projects_collapsed(self, collapsed: bool) -> None:
|
||||
"""Gập/mở cột project, đổi giữa panel đầy đủ và dải mỏng."""
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
self._projects_panel.setVisible(not collapsed)
|
||||
self._projects_strip.setVisible(collapsed)
|
||||
@@ -539,10 +572,25 @@ class WorkspaceTab(QWidget):
|
||||
def refresh_ai_models(self) -> None:
|
||||
"""Reload the Folder tab's AI-edit model picker for the active provider —
|
||||
called when the active provider changes so the picker never keeps a
|
||||
stale model list from the old provider."""
|
||||
stale model list from the old provider.
|
||||
|
||||
R08-T12 moved the picker off ``FolderTab`` and onto the AI-Edit panel
|
||||
(``ai_panel.resolver``). The old guard here tested for
|
||||
``folder.ai_model_combo``, an attribute that no longer exists on the
|
||||
tab, so this hook silently did nothing and a provider switch left the
|
||||
picker listing the previous provider's models. Reach the resolver
|
||||
directly instead.
|
||||
|
||||
Refreshes unconditionally, exactly as the pre-refactor tab did. Gating
|
||||
on "the picker already holds a list" looks tidier but breaks the case
|
||||
that matters most: the first fetch failing (endpoint down, no network)
|
||||
leaves the list empty, and the user switching provider afterwards is
|
||||
precisely when the retry has to happen."""
|
||||
folder = getattr(self, "_folder", None)
|
||||
if folder is not None and hasattr(folder, "ai_model_combo"):
|
||||
folder.refresh_ai_models()
|
||||
panel = getattr(folder, "ai_panel", None)
|
||||
resolver = getattr(panel, "resolver", None)
|
||||
if resolver is not None:
|
||||
resolver.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Re-list projects, keeping the current selection when possible. No
|
||||
@@ -594,10 +642,12 @@ class WorkspaceTab(QWidget):
|
||||
return out
|
||||
|
||||
def _selected_id(self) -> str:
|
||||
"""Id project đang chọn trong danh sách; '' nếu chưa chọn gì."""
|
||||
item = self.project_list.currentItem()
|
||||
return item.data(Qt.UserRole) if item else ""
|
||||
|
||||
def _select_project_row(self, project_id: str) -> bool:
|
||||
"""Chọn dòng ứng với một project id; trả về ``False`` nếu không tìm thấy."""
|
||||
for i in range(self.project_list.count()):
|
||||
if self.project_list.item(i).data(Qt.UserRole) == project_id:
|
||||
self.project_list.setCurrentRow(i)
|
||||
@@ -605,9 +655,11 @@ class WorkspaceTab(QWidget):
|
||||
return False
|
||||
|
||||
def _on_select(self, *_a) -> None:
|
||||
"""Đổi dòng chọn trong danh sách project: nạp project đó lên form."""
|
||||
self._load_current()
|
||||
|
||||
def _load_current(self) -> None:
|
||||
"""Nạp project đang chọn lên form và nối mọi sub-tab vào nó."""
|
||||
from ..core.projects import load_project
|
||||
|
||||
pid = self._selected_id()
|
||||
@@ -664,6 +716,7 @@ class WorkspaceTab(QWidget):
|
||||
return [(p.name, p.project_id) for p in list_projects()]
|
||||
|
||||
def selected_project_id(self) -> str:
|
||||
"""Id project đang chọn — lối vào công khai cho lớp ngoài."""
|
||||
return self._selected_id()
|
||||
|
||||
def choose_project(self, project_id: str) -> bool:
|
||||
@@ -736,6 +789,7 @@ class WorkspaceTab(QWidget):
|
||||
self._on_sidebar_new("cowork")
|
||||
|
||||
def _set_tabs_busy(self, busy: bool) -> None:
|
||||
"""Khoá/mở các sub-tab phụ thuộc project trong lúc đang chuyển project."""
|
||||
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):
|
||||
if idx >= 0:
|
||||
widget = self.tabs.widget(idx)
|
||||
@@ -760,6 +814,12 @@ class WorkspaceTab(QWidget):
|
||||
# This is THE central project-switch hook — make the selected project the
|
||||
# ACTIVE workspace so per-workspace modes (routing + auto-run) resolve
|
||||
# against it, then refresh every surface's toggles to show its modes.
|
||||
"""Đặt project đang hoạt động và làm mới mọi thứ phụ thuộc nó.
|
||||
|
||||
Đây là hook chuyển project TRUNG TÂM: đặt ``ctx.active_project_id`` để chế
|
||||
độ theo-workspace (định tuyến, tự chạy) phân giải đúng, rồi làm mới công
|
||||
tắc trên từng bề mặt cho khớp.
|
||||
"""
|
||||
self.ctx.active_project_id = pid or "default"
|
||||
self._refresh_mode_toggles()
|
||||
if self._structure is not None:
|
||||
@@ -794,6 +854,11 @@ class WorkspaceTab(QWidget):
|
||||
|
||||
def _reload_threads(self) -> None:
|
||||
# The threads list was removed from the Project tab; nothing to reload.
|
||||
"""Nạp lại danh sách luồng chat của project.
|
||||
|
||||
Danh sách này đã bị gỡ khỏi tab Dự án nên thường là no-op; giữ lại để mã
|
||||
cũ còn gọi tới không vỡ.
|
||||
"""
|
||||
if not hasattr(self, "threads"):
|
||||
return
|
||||
from ..core.history import list_conversations
|
||||
@@ -812,6 +877,7 @@ class WorkspaceTab(QWidget):
|
||||
|
||||
# ---- actions -----------------------------------------------------------
|
||||
def _create(self) -> None:
|
||||
"""Tạo project mới với tên mặc định rồi chọn nó."""
|
||||
from ..core.projects import new_project
|
||||
|
||||
project = new_project(tr("workspace.default_new_name"))
|
||||
@@ -822,6 +888,7 @@ class WorkspaceTab(QWidget):
|
||||
self.name_edit.selectAll()
|
||||
|
||||
def _delete(self) -> None:
|
||||
"""Xoá project đang chọn sau khi hỏi xác nhận."""
|
||||
from ..core.projects import delete_project, load_project
|
||||
|
||||
pid = self._selected_id()
|
||||
@@ -839,6 +906,7 @@ class WorkspaceTab(QWidget):
|
||||
self.status_message.emit(tr("workspace.deleted", name=project.name))
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Lưu tên, mô tả và chỉ dẫn chung của project đang mở."""
|
||||
from ..core.projects import load_project, save_project
|
||||
|
||||
pid = self._current_id
|
||||
@@ -854,6 +922,7 @@ class WorkspaceTab(QWidget):
|
||||
self.status_message.emit(tr("workspace.saved", name=project.name))
|
||||
|
||||
def _pick_folder(self) -> None:
|
||||
"""Chọn thư mục sandbox cho project đang mở."""
|
||||
from ..core.projects import load_project, save_project
|
||||
|
||||
pid = self._current_id
|
||||
@@ -870,6 +939,7 @@ class WorkspaceTab(QWidget):
|
||||
self.status_message.emit(tr("workspace.saved", name=project.name))
|
||||
|
||||
def _open_workspace(self) -> None:
|
||||
"""Mở thư mục sandbox của project trong trình quản lý tệp của hệ điều hành."""
|
||||
from ..core.projects import load_project
|
||||
|
||||
project = load_project(self._current_id) if self._current_id else None
|
||||
|
||||
Reference in New Issue
Block a user