## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
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
|
||||
|
||||
+5
-494
@@ -1,498 +1,9 @@
|
||||
"""Agents Admin — Monitoring tab visible to the Admin role ONLY.
|
||||
"""Vỏ chuyển tiếp — R08-T08.
|
||||
|
||||
CRUD over the shared admin-agent catalog (``core/admin_agents.py``): each
|
||||
agent has a name, an app function from a fixed droplist (search / monitor /
|
||||
cowork / graphrag / schedule / security), optional extra instructions and a
|
||||
model (blank = the machine's Settings model). Saved straight into the shared
|
||||
accounts folder, so every machine pointed at the same share picks changes up
|
||||
automatically (OneDrive/network sync) — non-admin machines only ever READ the
|
||||
catalog (their pickers in Cowork / Schedule Task list the enabled agents).
|
||||
|
||||
The header's "Kiểm tra tất cả" icon probes each agent's effective provider
|
||||
(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
|
||||
checking…) — separate from the per-row Kích hoạt switch, which only toggles
|
||||
the config flag.
|
||||
Phần thân đã chuyển sang ``presentation/monitoring/tabs/agents_admin_tab.py``.
|
||||
Giữ đường import cũ cho container Monitoring và checker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
|
||||
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
from ..core import admin_agents, preview_ai
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .icons import icon
|
||||
from .widgets import ToggleSwitch, badge_pill_widget
|
||||
|
||||
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
|
||||
|
||||
# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
|
||||
# colour regardless of theme" convention as monitoring_tab.py's per-agent
|
||||
# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
|
||||
# kinds, seven distinct tones — no two kinds share a badge colour.
|
||||
_KIND_COLOUR = {
|
||||
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
|
||||
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
|
||||
"help": "#E3008C",
|
||||
}
|
||||
_KIND_BADGE = {
|
||||
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
|
||||
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
|
||||
"help": "badgePink",
|
||||
}
|
||||
_STATUS_BADGE = {
|
||||
"unchecked": "badgeNeutral", "checking": "badgeWarn",
|
||||
"ok": "badgeSuccess", "bad": "badgeDanger",
|
||||
}
|
||||
|
||||
|
||||
def _initials(name: str) -> str:
|
||||
return "".join(w[0] for w in name.split() if w)[:2].upper()
|
||||
|
||||
|
||||
def _fmt_updated(ts: str) -> str:
|
||||
""""dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
|
||||
Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return ts
|
||||
return dt.strftime("%d/%m %H:%M")
|
||||
|
||||
|
||||
def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4")))
|
||||
p.drawEllipse(0, 0, size, size)
|
||||
font = QFont()
|
||||
font.setPixelSize(max(7, size // 2))
|
||||
font.setBold(True)
|
||||
p.setFont(font)
|
||||
p.setPen(QColor("#FFFFFF"))
|
||||
p.drawText(pm.rect(), Qt.AlignCenter, _initials(name))
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
class AgentEditDialog(QDialog):
|
||||
"""Add/Edit one admin agent. The provider/model pickers are drop-lists,
|
||||
not free text — ``provider_combo`` offers the app's built-in providers
|
||||
(plus "machine default"), ``model_combo`` offers that provider's REAL
|
||||
model list once fetched via "Load models" (same on-demand fetch the
|
||||
Preview tab and Settings' own "Load" button use) — editable so an admin
|
||||
can still pin an exact model string that isn't in the fetched list yet."""
|
||||
|
||||
def __init__(self, parent=None, ctx: Optional[AppContext] = None,
|
||||
agent: Optional[admin_agents.AdminAgent] = None,
|
||||
default_model_hint: str = ""):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._existing = agent
|
||||
self._live_models: Dict[str, List[str]] = {}
|
||||
self._workers: List[AgentWorker] = []
|
||||
self.setWindowTitle(tr("agents_admin.edit_title") if agent
|
||||
else tr("agents_admin.add_title"))
|
||||
self.resize(420, 400)
|
||||
form = QFormLayout(self)
|
||||
self.name_edit = QLineEdit(agent.name if agent else "")
|
||||
form.addRow(tr("agents_admin.f_name"), self.name_edit)
|
||||
self.kind_combo = QComboBox()
|
||||
for kind in admin_agents.TASK_KINDS:
|
||||
self.kind_combo.addItem(tr(f"agents_admin.kind.{kind}"), kind)
|
||||
if agent:
|
||||
idx = self.kind_combo.findData(agent.task_kind)
|
||||
if idx >= 0:
|
||||
self.kind_combo.setCurrentIndex(idx)
|
||||
form.addRow(tr("agents_admin.f_kind"), self.kind_combo)
|
||||
self.prompt_edit = QPlainTextEdit(agent.prompt if agent else "")
|
||||
self.prompt_edit.setPlaceholderText(tr("agents_admin.f_prompt_placeholder"))
|
||||
self.prompt_edit.setMaximumHeight(110)
|
||||
form.addRow(tr("agents_admin.f_prompt"), self.prompt_edit)
|
||||
|
||||
self.provider_combo = QComboBox()
|
||||
self.provider_combo.addItem(tr("agents_admin.provider_default"), _PROVIDER_DEFAULT)
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
if agent and agent.provider:
|
||||
idx = self.provider_combo.findData(agent.provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
self.provider_combo.currentIndexChanged.connect(self._refresh_model_combo)
|
||||
form.addRow(tr("agents_admin.f_provider"), self.provider_combo)
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setEditable(True)
|
||||
if agent and agent.model:
|
||||
self.model_combo.addItem(agent.model)
|
||||
self.model_combo.setEditText(agent.model if agent else "")
|
||||
self.model_combo.lineEdit().setPlaceholderText(
|
||||
tr("agents_admin.f_model_placeholder", model=default_model_hint or "—"))
|
||||
self.load_models_btn = QPushButton()
|
||||
self.load_models_btn.setIcon(icon("download"))
|
||||
self.load_models_btn.setToolTip(tr("agents_admin.load_models_tooltip"))
|
||||
self.load_models_btn.clicked.connect(self._load_live_models)
|
||||
self.load_models_btn.setEnabled(self.ctx is not None)
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
form.addRow(tr("agents_admin.f_model"), model_row)
|
||||
|
||||
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
|
||||
self.enabled_chk.setChecked(agent.enabled if agent else True)
|
||||
form.addRow("", self.enabled_chk)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
def _load_live_models(self) -> None:
|
||||
if self.ctx is None:
|
||||
return
|
||||
self.load_models_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker):
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self.load_models_btn.setEnabled(True)
|
||||
self._live_models = result or {}
|
||||
self._refresh_model_combo()
|
||||
if not self._live_models:
|
||||
QMessageBox.information(self, tr("agents_admin.add_title"),
|
||||
tr("agents_admin.load_models_empty"))
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self.load_models_btn.setEnabled(True)
|
||||
QMessageBox.warning(self, tr("agents_admin.add_title"), err)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _refresh_model_combo(self) -> None:
|
||||
provider_key = self.provider_combo.currentData()
|
||||
current_text = self.model_combo.currentText().strip()
|
||||
models = self._live_models.get(provider_key, []) if provider_key else []
|
||||
self.model_combo.blockSignals(True)
|
||||
self.model_combo.clear()
|
||||
self.model_combo.addItems(models)
|
||||
self.model_combo.setEditText(current_text)
|
||||
self.model_combo.blockSignals(False)
|
||||
|
||||
def result_fields(self) -> Dict[str, str]:
|
||||
return {
|
||||
"name": self.name_edit.text().strip(),
|
||||
"task_kind": self.kind_combo.currentData(),
|
||||
"prompt": self.prompt_edit.toPlainText().strip(),
|
||||
"provider": self.provider_combo.currentData() or "",
|
||||
"model": self.model_combo.currentText().strip(),
|
||||
"enabled": self.enabled_chk.isChecked(),
|
||||
}
|
||||
|
||||
|
||||
class AgentsAdminTab(QWidget):
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
# Last operational-health result per agent_id → (ok, message). Populated
|
||||
# on demand by the "Check" button (see _check_all); survives refresh().
|
||||
self._status: Dict[str, tuple] = {}
|
||||
self._check_workers: List[AgentWorker] = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self._title_lbl = QLabel()
|
||||
self._title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
|
||||
hdr.addWidget(self._title_lbl)
|
||||
hdr.addStretch(1)
|
||||
# "Kiểm tra tất cả" keeps the real _check_all action reachable without
|
||||
# competing with the 2 primary header buttons (Làm mới / + Thêm) — a
|
||||
# flat, secondary-styled button rather than a 3rd primary one, but
|
||||
# still labelled: an icon-only button here was a mystery button.
|
||||
self.check_btn = QPushButton()
|
||||
self.check_btn.setIcon(icon("check"))
|
||||
self.check_btn.setFlat(True)
|
||||
self.check_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.check_btn.clicked.connect(self._check_all)
|
||||
hdr.addWidget(self.check_btn)
|
||||
self.refresh_btn = QPushButton()
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
hdr.addWidget(self.refresh_btn)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.add_btn.clicked.connect(self._add)
|
||||
hdr.addWidget(self.add_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
self._hint = QLabel("")
|
||||
self._hint.setObjectName("hint")
|
||||
self._hint.setWordWrap(True)
|
||||
root.addWidget(self._hint)
|
||||
|
||||
self.table = QTableWidget(0, 7)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
# Sửa/Xoá/Kích hoạt are now per-row widgets (button/switch), and Vai
|
||||
# trò/Trạng thái are pill cell widgets — none of those track a row
|
||||
# across a re-sort (a cell widget stays pinned to its screen position,
|
||||
# not to the item that moves — see monitoring_tab.py's _EventTable for
|
||||
# the same lesson learned the hard way), so this table doesn't sort.
|
||||
self.table.setSelectionMode(QTableWidget.NoSelection)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
# Fixed row height — letting Qt auto-size rows from content fights
|
||||
# with the toggle switch / badge cell widgets: their layout settles on
|
||||
# a stale, oversized geometry from an intermediate sizing pass, which
|
||||
# then overlaps neighbouring rows (same bug _EventTable hit for its
|
||||
# Hành động pill, fixed there the same way).
|
||||
self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
|
||||
self.table.verticalHeader().setDefaultSectionSize(32)
|
||||
self.table.setIconSize(QSize(20, 20))
|
||||
header = self.table.horizontalHeader()
|
||||
header.setStretchLastSection(False)
|
||||
for col in (0, 6):
|
||||
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
||||
# Vai trò/Trạng thái (1, 4) are pill cell widgets — ResizeToContents
|
||||
# only measures QTableWidgetItem content, so it kept fighting refresh()'s
|
||||
# manual sizeHint()-based setColumnWidth() and clipping the pill text.
|
||||
# Interactive leaves whatever width refresh() sets alone.
|
||||
for col in (1, 4):
|
||||
header.setSectionResizeMode(col, QHeaderView.Interactive)
|
||||
header.setSectionResizeMode(2, QHeaderView.Stretch) # Model
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
# on_language_changed() already invokes _retranslate() once immediately
|
||||
# (see i18n.py) — calling it again here was a harmless no-op back when
|
||||
# every column was a plain QTableWidgetItem, but now refresh() also
|
||||
# populates cell WIDGETS (toggle switch, pills, row actions): running
|
||||
# it twice back-to-back with no event-loop turn in between left the
|
||||
# first pass's widgets replaced but not yet deleted, so they briefly
|
||||
# painted overlapping the second pass's row 0.
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- storage ---------------------------------------------------------
|
||||
def _dir(self):
|
||||
return admin_agents.agents_admin_dir(self.ctx.config.shared_dir)
|
||||
|
||||
def _default_model_hint(self) -> str:
|
||||
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
|
||||
return conf.get("model", "")
|
||||
|
||||
# ---- CRUD -------------------------------------------------------------
|
||||
def _add(self) -> None:
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
|
||||
if not dlg.exec():
|
||||
return
|
||||
fields = dlg.result_fields()
|
||||
if not fields["name"]:
|
||||
return
|
||||
agent = admin_agents.new_agent(
|
||||
fields["name"], fields["task_kind"], fields["prompt"],
|
||||
provider=fields.get("provider", ""), model=fields["model"],
|
||||
updated_by=(getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else ""))
|
||||
agent.enabled = bool(fields["enabled"])
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _edit_agent(self, agent_id: str) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent,
|
||||
default_model_hint=self._default_model_hint())
|
||||
if not dlg.exec():
|
||||
return
|
||||
fields = dlg.result_fields()
|
||||
if not fields["name"]:
|
||||
return
|
||||
|
||||
agent.name = fields["name"]
|
||||
agent.task_kind = fields["task_kind"]
|
||||
agent.prompt = fields["prompt"]
|
||||
agent.provider = fields.get("provider", "")
|
||||
agent.model = fields["model"]
|
||||
agent.enabled = bool(fields["enabled"])
|
||||
agent.updated = datetime.now().isoformat(timespec="seconds")
|
||||
agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _delete_agent(self, agent_id: str) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
if QMessageBox.question(
|
||||
self, tr("agents_admin.delete_title"),
|
||||
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
|
||||
return
|
||||
admin_agents.delete_agent(agent.agent_id, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None or agent.enabled == enabled:
|
||||
return
|
||||
|
||||
agent.enabled = enabled
|
||||
agent.updated = datetime.now().isoformat(timespec="seconds")
|
||||
agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
# ---- view --------------------------------------------------------------
|
||||
def _status_cell(self, agent_id: str) -> tuple:
|
||||
"""(state_key, display_text, tooltip) for the Trạng thái pill —
|
||||
state_key indexes _STATUS_BADGE for the badge's colour tone."""
|
||||
res = self._status.get(agent_id)
|
||||
if res is None:
|
||||
return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
|
||||
tr("agents_admin.status_unchecked_tip"))
|
||||
ok, msg = res
|
||||
if msg == "checking":
|
||||
return "checking", tr("agents_admin.status_checking"), ""
|
||||
return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
|
||||
|
||||
def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(6, 0, 0, 0)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(enabled)
|
||||
sw.toggled.connect(lambda checked, aid=agent_id: self._set_enabled(aid, checked))
|
||||
lay.addWidget(sw, 0, Qt.AlignVCenter)
|
||||
lay.addStretch(1)
|
||||
return container
|
||||
|
||||
def _row_actions_widget(self, agent_id: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(2, 0, 2, 0)
|
||||
lay.setSpacing(2)
|
||||
edit_btn = QPushButton()
|
||||
edit_btn.setIcon(icon("edit"))
|
||||
edit_btn.setFlat(True)
|
||||
edit_btn.setCursor(Qt.PointingHandCursor)
|
||||
edit_btn.setToolTip(tr("agents_admin.edit_row_tooltip"))
|
||||
edit_btn.clicked.connect(lambda: self._edit_agent(agent_id))
|
||||
del_btn = QPushButton()
|
||||
del_btn.setIcon(icon("trash"))
|
||||
del_btn.setFlat(True)
|
||||
del_btn.setCursor(Qt.PointingHandCursor)
|
||||
del_btn.setToolTip(tr("agents_admin.delete_row_tooltip"))
|
||||
del_btn.clicked.connect(lambda: self._delete_agent(agent_id))
|
||||
lay.addWidget(edit_btn)
|
||||
lay.addWidget(del_btn)
|
||||
return container
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Make sure the built-in in-app Help assistant exists, so the Admin can
|
||||
# manage its provider/model here (the floating Help widget uses it).
|
||||
admin_agents.ensure_help_agent(self._dir())
|
||||
agents = admin_agents.list_agents(self._dir())
|
||||
self.table.setRowCount(len(agents))
|
||||
default_model = self._default_model_hint()
|
||||
for row, agent in enumerate(agents):
|
||||
if agent.model:
|
||||
provider_lbl = PROVIDER_LABELS.get(agent.provider, "") if agent.provider else ""
|
||||
model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model
|
||||
else:
|
||||
model = tr("agents_admin.default_model", model=default_model or "—")
|
||||
|
||||
name_item = QTableWidgetItem(agent.name)
|
||||
name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
|
||||
self.table.setItem(row, 0, name_item)
|
||||
|
||||
kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
|
||||
self.table.setCellWidget(
|
||||
row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
|
||||
|
||||
self.table.setItem(row, 2, QTableWidgetItem(model))
|
||||
self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
|
||||
|
||||
state_key, status_text, status_tip = self._status_cell(agent.agent_id)
|
||||
status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
|
||||
if status_tip:
|
||||
status_widget.setToolTip(status_tip)
|
||||
self.table.setCellWidget(row, 4, status_widget)
|
||||
|
||||
self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
|
||||
self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
|
||||
|
||||
# ResizeToContents doesn't measure a cell WIDGET's real width (only
|
||||
# QTableWidgetItem content) — size the Vai trò/Trạng thái pill columns
|
||||
# by hand, or their text clips against whatever width it guessed.
|
||||
if self.table.rowCount():
|
||||
for col in (1, 4):
|
||||
needed = max(self.table.cellWidget(r, col).sizeHint().width()
|
||||
for r in range(self.table.rowCount()))
|
||||
if needed + 24 > self.table.columnWidth(col):
|
||||
self.table.setColumnWidth(col, needed + 24)
|
||||
|
||||
def _check_all(self) -> None:
|
||||
"""Health-check every agent's effective provider off the UI thread and
|
||||
update the Status column with the result (🟢 reachable / 🔴 error)."""
|
||||
agents = admin_agents.list_agents(self._dir())
|
||||
if not agents:
|
||||
return
|
||||
for a in agents:
|
||||
self._status[a.agent_id] = (False, "checking")
|
||||
self.check_btn.setEnabled(False)
|
||||
self.refresh()
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker) -> dict:
|
||||
return {a.agent_id: admin_agents.check_agent(ctx, a) for a in agents}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self.check_btn.setEnabled(True)
|
||||
self._status.update(result or {})
|
||||
self.refresh()
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self.check_btn.setEnabled(True)
|
||||
for a in agents:
|
||||
self._status[a.agent_id] = (False, err[:200])
|
||||
self.refresh()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._check_workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title_lbl.setText(tr("agents_admin.page_title"))
|
||||
self._hint.setText(tr("agents_admin.hint"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("agents_admin.col_name"), tr("agents_admin.col_kind"),
|
||||
tr("agents_admin.col_model"), tr("agents_admin.col_enabled"),
|
||||
tr("agents_admin.col_status"), tr("agents_admin.col_updated"), "",
|
||||
])
|
||||
self.add_btn.setText(tr("agents_admin.add_btn"))
|
||||
self.refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.check_btn.setText(tr("agents_admin.check_btn"))
|
||||
self.check_btn.setToolTip(tr("agents_admin.check_tooltip"))
|
||||
self.refresh()
|
||||
from ..presentation.monitoring.tabs.agent_edit_dialog import AgentEditDialog # noqa: F401
|
||||
from ..presentation.monitoring.tabs.agents_admin_tab import AgentsAdminTab # noqa: F401
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board:
|
||||
Week / Month / Year granularity, each task placed on its scheduled date
|
||||
(``schedule.run_at``). Click a task to edit it (same editor the Kanban
|
||||
board's double-click opens); click a day's "+" to create a task pre-filled
|
||||
with that date. All grid/date math lives in ``core/calendar_grid.py`` (no Qt,
|
||||
directly unit-testable) — this module is just the Qt rendering of it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QListWidget,
|
||||
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.calendar_grid import (
|
||||
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
|
||||
)
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
|
||||
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||
|
||||
|
||||
class _DayCell(QFrame):
|
||||
add_requested = Signal(str) # "YYYY-MM-DD"
|
||||
task_clicked = Signal(str) # task_id
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("dayCell")
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self._date_str = ""
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(4, 4, 4, 4)
|
||||
lay.setSpacing(2)
|
||||
head = QHBoxLayout()
|
||||
self.date_lbl = QLabel()
|
||||
self.add_btn = QPushButton("+")
|
||||
self.add_btn.setFixedSize(20, 20)
|
||||
self.add_btn.clicked.connect(lambda: self.add_requested.emit(self._date_str))
|
||||
head.addWidget(self.date_lbl, 1)
|
||||
head.addWidget(self.add_btn)
|
||||
lay.addLayout(head)
|
||||
self.list = QListWidget()
|
||||
self.list.setFrameShape(QFrame.NoFrame)
|
||||
# Transparent so the cell's today/weekend tint shows through the task area.
|
||||
self.list.setStyleSheet("background: transparent;")
|
||||
self.list.itemClicked.connect(self._on_item_clicked)
|
||||
lay.addWidget(self.list, 1)
|
||||
|
||||
def set_day(self, d: date, tasks: List[dict], dim: bool,
|
||||
today: bool = False, weekend: bool = False) -> None:
|
||||
self._date_str = d.isoformat()
|
||||
self.date_lbl.setText(str(d.day))
|
||||
p = current_palette()
|
||||
num_color = p.accent if today else (p.text_faint if dim else p.text)
|
||||
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
|
||||
# Today is the only cell that gets a filled surface + accent border;
|
||||
# weekends are set apart by a recessed surface alone, so the eye lands
|
||||
# on "today" first and on the weekend block only when scanning.
|
||||
r = p.radius
|
||||
if today:
|
||||
css = (f"#dayCell {{ background: {p.accent_soft}; "
|
||||
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
|
||||
elif weekend:
|
||||
css = (f"#dayCell {{ background: {p.surface}; "
|
||||
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
|
||||
else:
|
||||
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
|
||||
self.setStyleSheet(css)
|
||||
self.list.clear()
|
||||
for t in tasks:
|
||||
item = QListWidgetItem(t.get("title") or tr("schedtask.no_title"))
|
||||
item.setData(Qt.UserRole, t.get("task_id"))
|
||||
self.list.addItem(item)
|
||||
|
||||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self.task_clicked.emit(tid)
|
||||
|
||||
|
||||
class CalendarView(QWidget):
|
||||
add_task_on_date = Signal(str) # "YYYY-MM-DD"
|
||||
edit_task = Signal(str) # task_id
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.granularity = "month"
|
||||
self.anchor = date.today()
|
||||
self._tasks: List[dict] = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
head = QHBoxLayout()
|
||||
self.prev_btn = QPushButton()
|
||||
self.prev_btn.setIcon(icon("chevron-left"))
|
||||
self.prev_btn.clicked.connect(lambda: self._shift(-1))
|
||||
self.today_btn = QPushButton()
|
||||
self.today_btn.clicked.connect(self._go_today)
|
||||
self.next_btn = QPushButton()
|
||||
self.next_btn.setIcon(icon("chevron-right"))
|
||||
self.next_btn.clicked.connect(lambda: self._shift(1))
|
||||
self.period_lbl = QLabel()
|
||||
self.period_lbl.setStyleSheet("font-weight:700;")
|
||||
self.granularity_combo = QComboBox()
|
||||
for g in GRANULARITIES:
|
||||
self.granularity_combo.addItem("", g)
|
||||
self.granularity_combo.currentIndexChanged.connect(self._on_granularity_changed)
|
||||
head.addWidget(self.prev_btn)
|
||||
head.addWidget(self.today_btn)
|
||||
head.addWidget(self.next_btn)
|
||||
head.addWidget(self.period_lbl, 1)
|
||||
head.addWidget(self.granularity_combo)
|
||||
root.addLayout(head)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
self._grid_host = QWidget()
|
||||
self._grid = QGridLayout(self._grid_host)
|
||||
self._grid.setSpacing(4)
|
||||
scroll.setWidget(self._grid_host)
|
||||
root.addWidget(scroll, 1)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.today_btn.setText(tr("schedtask.cal_today"))
|
||||
self.prev_btn.setToolTip(tr("schedtask.cal_prev"))
|
||||
self.next_btn.setToolTip(tr("schedtask.cal_next"))
|
||||
for i, g in enumerate(GRANULARITIES):
|
||||
self.granularity_combo.setItemText(i, tr(f"schedtask.cal_gran.{g}"))
|
||||
self._render()
|
||||
|
||||
# ---- public ------------------------------------------------------
|
||||
def set_tasks(self, tasks: List[dict]) -> None:
|
||||
self._tasks = tasks
|
||||
self._render()
|
||||
|
||||
def show_month(self, year: int, month: int) -> None:
|
||||
"""Switch to Month view centered on (year, month) — used when the
|
||||
user drills down from a Year-view row."""
|
||||
self.anchor = date(year, month, 1)
|
||||
self.granularity = "month"
|
||||
idx = self.granularity_combo.findData("month")
|
||||
if idx >= 0:
|
||||
self.granularity_combo.blockSignals(True)
|
||||
self.granularity_combo.setCurrentIndex(idx)
|
||||
self.granularity_combo.blockSignals(False)
|
||||
self._render()
|
||||
|
||||
# ---- navigation ---------------------------------------------------
|
||||
def _shift(self, direction: int) -> None:
|
||||
self.anchor = shift_period(self.anchor, self.granularity, direction)
|
||||
self._render()
|
||||
|
||||
def _go_today(self) -> None:
|
||||
self.anchor = date.today()
|
||||
self._render()
|
||||
|
||||
def _on_granularity_changed(self) -> None:
|
||||
data = self.granularity_combo.currentData()
|
||||
if data:
|
||||
self.granularity = data
|
||||
self._render()
|
||||
|
||||
# ---- rendering ------------------------------------------------------
|
||||
def _clear_grid(self) -> None:
|
||||
while self._grid.count():
|
||||
item = self._grid.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
def _render(self) -> None:
|
||||
self._update_period_label()
|
||||
self._clear_grid()
|
||||
by_date = group_tasks_by_date(self._tasks)
|
||||
if self.granularity == "week":
|
||||
self._render_days(week_days(self.anchor), by_date)
|
||||
elif self.granularity == "year":
|
||||
self._render_year(by_date)
|
||||
else:
|
||||
self._render_days(sum(month_grid(self.anchor), []), by_date, mark_month=self.anchor.month)
|
||||
|
||||
def _render_days(self, days: List[date], by_date: Dict[str, List[dict]],
|
||||
mark_month: Optional[int] = None) -> None:
|
||||
for col, key in enumerate(_WEEKDAY_KEYS):
|
||||
lbl = QLabel(tr(f"schedtask.cal_weekday.{key}"))
|
||||
lbl.setStyleSheet("font-weight:600;")
|
||||
lbl.setAlignment(Qt.AlignCenter)
|
||||
self._grid.addWidget(lbl, 0, col)
|
||||
today = date.today()
|
||||
rows = [days[i:i + 7] for i in range(0, len(days), 7)]
|
||||
for r, week in enumerate(rows, start=1):
|
||||
for c, d in enumerate(week):
|
||||
cell = _DayCell()
|
||||
dim = mark_month is not None and d.month != mark_month
|
||||
# _WEEKDAY_KEYS is Mon..Sun → columns 5 (Sat) and 6 (Sun) are the weekend.
|
||||
cell.set_day(d, by_date.get(d.isoformat(), []), dim,
|
||||
today=(d == today), weekend=(c in (5, 6)))
|
||||
cell.add_requested.connect(self.add_task_on_date.emit)
|
||||
cell.task_clicked.connect(self.edit_task.emit)
|
||||
self._grid.addWidget(cell, r, c)
|
||||
|
||||
def _render_year(self, by_date: Dict[str, List[dict]]) -> None:
|
||||
counts = month_task_counts(by_date, self.anchor.year)
|
||||
lst = QListWidget()
|
||||
for m in range(1, 13):
|
||||
label = date(self.anchor.year, m, 1).strftime("%B")
|
||||
n = counts[m]
|
||||
text = tr("schedtask.cal_month_count", month=label, n=n) if n else label
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, m)
|
||||
lst.addItem(item)
|
||||
lst.itemClicked.connect(lambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole)))
|
||||
self._grid.addWidget(lst, 0, 0)
|
||||
|
||||
def _update_period_label(self) -> None:
|
||||
if self.granularity == "week":
|
||||
days = week_days(self.anchor)
|
||||
self.period_lbl.setText(f"{days[0].isoformat()} - {days[-1].isoformat()}")
|
||||
elif self.granularity == "year":
|
||||
self.period_lbl.setText(str(self.anchor.year))
|
||||
else:
|
||||
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
|
||||
+7
-1789
File diff suppressed because it is too large
Load Diff
+10
-504
@@ -1,507 +1,13 @@
|
||||
"""Scrollable chat transcript built from message bubbles."""
|
||||
"""Vỏ chuyển tiếp — R08-T01.
|
||||
|
||||
Phần thân đã chuyển sang ``presentation/chat/chat_history_widget.py``.
|
||||
Giữ đường import cũ cho ``ui/chat_panel.py`` và checker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QPointF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser,
|
||||
QVBoxLayout, QWidget,
|
||||
from ..presentation.chat.chat_bubble_style import ( # noqa: F401
|
||||
ThinkingIndicator, _TimelineGutter, diff_to_html, format_status_line,
|
||||
)
|
||||
from ..presentation.chat.chat_history_widget import ( # noqa: F401
|
||||
ChatView, MessageBubble,
|
||||
)
|
||||
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import palette, resolve_theme
|
||||
from ..config import CONFIG_DIR
|
||||
from .osutil import is_image, open_folder, open_path
|
||||
|
||||
|
||||
def _app_theme() -> str:
|
||||
"""Resolve the current app theme (light or dark) from config."""
|
||||
try:
|
||||
import json
|
||||
with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return resolve_theme(data.get("theme", "dark"))
|
||||
except Exception: # noqa: BLE001
|
||||
return "dark"
|
||||
|
||||
|
||||
def _p():
|
||||
"""Design tokens for the theme in effect right now."""
|
||||
return palette(_app_theme())
|
||||
|
||||
|
||||
def _dot_color(role: str) -> str:
|
||||
"""Timeline dot colour for a message role."""
|
||||
p = _p()
|
||||
return {
|
||||
"user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
|
||||
"error": p.role_error, "success": p.role_result,
|
||||
}.get(role, p.text_faint)
|
||||
|
||||
|
||||
class _TimelineGutter(QWidget):
|
||||
"""The left rail of the point-conversation: a vertical connector line with a
|
||||
role-colored dot near the top, so stacked messages read as a timeline
|
||||
(Claude-Code style) instead of separate boxes."""
|
||||
|
||||
def __init__(self, role: str):
|
||||
super().__init__()
|
||||
self._role = role
|
||||
self.setFixedWidth(22)
|
||||
|
||||
def set_role(self, role: str) -> None:
|
||||
self._role = role
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _e): # noqa: N802
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
tok = _p()
|
||||
x = 11.0
|
||||
cy = 15.0
|
||||
# connector line (faint) running the full height → continuous rail
|
||||
p.setPen(QPen(QColor(tok.border), 2))
|
||||
p.drawLine(int(x), 0, int(x), self.height())
|
||||
# a background ring lifts the dot off the line
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(tok.bg))
|
||||
p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
|
||||
p.setBrush(QColor(_dot_color(self._role)))
|
||||
p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
|
||||
|
||||
|
||||
def _diff_legend(diff_text: str) -> str:
|
||||
"""A small badge pair labeling what the colors mean: 'Before → After' for
|
||||
an edit, or a single 'Added'/'Removed' badge for a pure create/delete —
|
||||
so the before/after distinction is explicit, not just implied by color."""
|
||||
has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
|
||||
has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
|
||||
p = _p()
|
||||
|
||||
def pill(bg: str, fg: str, key: str) -> str:
|
||||
return (f'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
|
||||
|
||||
if has_add and has_del:
|
||||
badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
|
||||
+ f'<span style="color:{p.text_muted};"> → </span>'
|
||||
+ pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
|
||||
elif has_add:
|
||||
badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
|
||||
elif has_del:
|
||||
badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
|
||||
else:
|
||||
return ""
|
||||
return f'<div style="margin-bottom:6px;">{badge}</div>'
|
||||
|
||||
|
||||
def diff_to_html(diff_text: str) -> str:
|
||||
"""Render a unified diff with GitHub/Claude-Code-style line coloring —
|
||||
additions green, deletions red, hunk headers highlighted — plus an
|
||||
explicit Before/After (or Added/Removed) legend, instead of a flat text
|
||||
block, so a before/after edit reads at a glance. A brand-new file (an
|
||||
empty 'before') naturally renders as all-green, which is exactly what
|
||||
``difflib.unified_diff`` already produces for it."""
|
||||
legend = _diff_legend(diff_text)
|
||||
p = _p()
|
||||
rows = []
|
||||
for ln in diff_text.splitlines():
|
||||
esc = html.escape(ln) if ln else " "
|
||||
if ln.startswith(("+++", "---")):
|
||||
rows.append(f'<div style="color:{p.text_muted};">{esc}</div>')
|
||||
elif ln.startswith("@@"):
|
||||
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
|
||||
elif ln.startswith("+"):
|
||||
rows.append(f'<div style="background:{p.diff_add_bg}; color:{p.diff_add_fg};">{esc}</div>')
|
||||
elif ln.startswith("-"):
|
||||
rows.append(f'<div style="background:{p.diff_del_bg}; color:{p.diff_del_fg};">{esc}</div>')
|
||||
else:
|
||||
rows.append(f"<div>{esc}</div>")
|
||||
body = "".join(rows) or "(no textual change)"
|
||||
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
|
||||
f'white-space:pre-wrap;">{body}</div>')
|
||||
|
||||
|
||||
def format_status_line(base: str, ticks: int) -> str:
|
||||
"""Animated status line for the working indicator, e.g. ``🤖 Running..`` and,
|
||||
once the wait is a few seconds long, ``🤖 Running. · 5s`` — so a slow
|
||||
synthesis clearly reads as still running. ``ticks`` advances every 500 ms."""
|
||||
dots = "." * (ticks % 4)
|
||||
secs = ticks // 2
|
||||
suffix = f" · {secs}s" if secs >= 3 else ""
|
||||
return f"{base}{dots}{suffix}"
|
||||
|
||||
|
||||
class ThinkingIndicator(QWidget):
|
||||
"""A small animated 'the agent is working' line shown while waiting for a
|
||||
result, so a long wait never looks like a frozen / empty screen.
|
||||
|
||||
Renders a bot icon + status (e.g. ``🤖 Running…``) and, once the wait passes
|
||||
a few seconds, the elapsed time — so a long synthesis clearly reads as still
|
||||
running rather than stuck."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(14, 2, 14, 4)
|
||||
lay.setSpacing(0)
|
||||
self._label = QLabel("")
|
||||
self._label.setObjectName("hint")
|
||||
lay.addWidget(self._label)
|
||||
lay.addStretch(1)
|
||||
self._base_key = "chat.running"
|
||||
self._override: str | None = None
|
||||
self._ticks = 0
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(500)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
self.setVisible(False)
|
||||
on_language_changed(self._render)
|
||||
|
||||
def start(self, label_key: str = "chat.running") -> None:
|
||||
self._base_key = label_key
|
||||
self._override = None
|
||||
self._ticks = 0
|
||||
self._render()
|
||||
self.setVisible(True)
|
||||
if not self._timer.isActive():
|
||||
self._timer.start()
|
||||
|
||||
def set_label(self, label_key: str) -> None:
|
||||
if label_key != self._base_key:
|
||||
self._base_key = label_key
|
||||
self._override = None
|
||||
self._render()
|
||||
|
||||
def set_progress_text(self, text: str) -> None:
|
||||
"""Show an already-formatted, literal status line (e.g. a live "reading
|
||||
page 12/40" or streamed command-output detail) instead of a translated
|
||||
key — used for fine-grained progress within a single step."""
|
||||
self._override = text
|
||||
self._render()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._timer.stop()
|
||||
self._override = None
|
||||
self.setVisible(False)
|
||||
|
||||
def _tick(self) -> None:
|
||||
self._ticks += 1
|
||||
self._render()
|
||||
|
||||
def _render(self) -> None:
|
||||
base = self._override if self._override is not None else tr(self._base_key)
|
||||
self._label.setText(format_status_line(base, self._ticks))
|
||||
|
||||
|
||||
class MessageBubble(QFrame):
|
||||
"""One message; assistant/tool bubbles render markdown via QTextBrowser."""
|
||||
|
||||
def __init__(self, role: str, title: str = "", collapsible: bool = False,
|
||||
collapsed: bool = True):
|
||||
super().__init__()
|
||||
self.role = role
|
||||
self._text = ""
|
||||
self._collapsible = collapsible
|
||||
self._title = title
|
||||
self._head = None
|
||||
# Point-conversation layout: [dot rail][content column].
|
||||
outer = QHBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(6)
|
||||
self._gutter = _TimelineGutter(role)
|
||||
outer.addWidget(self._gutter)
|
||||
content = QWidget()
|
||||
lay = QVBoxLayout(content)
|
||||
lay.setContentsMargins(2, 4, 8, 8)
|
||||
lay.setSpacing(4)
|
||||
self._content_layout = lay
|
||||
outer.addWidget(content, 1)
|
||||
|
||||
if title:
|
||||
if collapsible:
|
||||
# Clickable header that folds long tool output away to keep the
|
||||
# transcript short. Collapsed by default; click to expand.
|
||||
self._head = QPushButton(title)
|
||||
self._head.setCursor(Qt.PointingHandCursor)
|
||||
self._head.setStyleSheet(
|
||||
"QPushButton { text-align:left; border:none; background:transparent;"
|
||||
f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
|
||||
self._head.clicked.connect(self._toggle_body)
|
||||
lay.addWidget(self._head)
|
||||
else:
|
||||
head = QLabel(title)
|
||||
head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
|
||||
lay.addWidget(head)
|
||||
|
||||
self.body = QTextBrowser()
|
||||
self.body.setOpenExternalLinks(True)
|
||||
self.body.setFrameShape(QFrame.NoFrame)
|
||||
# Text color adapts to theme.
|
||||
self._apply_theme_styles(role)
|
||||
lay.addWidget(self.body)
|
||||
|
||||
self._apply_style(role)
|
||||
if collapsible and collapsed:
|
||||
self.body.setVisible(False)
|
||||
if collapsible:
|
||||
self._update_head()
|
||||
|
||||
def _toggle_body(self) -> None:
|
||||
self.body.setVisible(not self.body.isVisible())
|
||||
if self.body.isVisible():
|
||||
self._autosize()
|
||||
self._update_head()
|
||||
|
||||
def _update_head(self) -> None:
|
||||
if not self._head:
|
||||
return
|
||||
expanded = self.body.isVisible()
|
||||
arrow = "▾" if expanded else "▸"
|
||||
preview = ""
|
||||
if not expanded and self._text.strip():
|
||||
first = self._text.strip().splitlines()[0]
|
||||
if len(first) > 70:
|
||||
first = first[:70] + "…"
|
||||
preview = f" {first}"
|
||||
self._head.setText(f"{arrow} {self._title}{preview}")
|
||||
|
||||
def _current_theme(self) -> str:
|
||||
"""Resolve the current app theme (light or dark)."""
|
||||
return _app_theme()
|
||||
|
||||
def _apply_theme_styles(self, role: str) -> None:
|
||||
"""Apply text color to the body QTextBrowser based on current theme + role."""
|
||||
p = _p()
|
||||
text_color = {
|
||||
"success": p.success,
|
||||
"error": p.danger,
|
||||
"tool": p.text_muted, # secondary, like Claude's steps
|
||||
}.get(role, p.text)
|
||||
self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
|
||||
|
||||
def _apply_style(self, role: str) -> None:
|
||||
"""Flat timeline row — no bubble box; the left dot/rail conveys role and
|
||||
structure (Claude-Code style). The user's own message gets a faint tint
|
||||
so questions are easy to pick out when scanning."""
|
||||
p = _p()
|
||||
if role == "user":
|
||||
self.setStyleSheet(
|
||||
f"QFrame {{ background: {p.surface}; border: none; "
|
||||
f"border-radius: {p.radius}px; }}")
|
||||
else:
|
||||
self.setStyleSheet("QFrame { background: transparent; border: none; }")
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Re-apply theme-dependent styles so existing rows adapt when the app
|
||||
theme switches (light ↔ dark)."""
|
||||
self._apply_theme_styles(self.role)
|
||||
self._apply_style(self.role)
|
||||
self._gutter.set_role(self.role)
|
||||
|
||||
def chat_view(self):
|
||||
"""Walk up the parent chain to find the enclosing ChatView, if any."""
|
||||
p = self.parent()
|
||||
while p is not None:
|
||||
if isinstance(p, ChatView):
|
||||
return p
|
||||
p = p.parent()
|
||||
return None
|
||||
|
||||
def append_delta(self, delta: str) -> None:
|
||||
self._text += delta
|
||||
self.set_markdown(self._text)
|
||||
|
||||
def set_markdown(self, text: str) -> None:
|
||||
self._text = text
|
||||
self.body.setMarkdown(text)
|
||||
self._autosize()
|
||||
if self._collapsible:
|
||||
self._update_head()
|
||||
|
||||
def set_plain(self, text: str) -> None:
|
||||
self._text = text
|
||||
self.body.setPlainText(text)
|
||||
self._autosize()
|
||||
if self._collapsible:
|
||||
self._update_head()
|
||||
|
||||
def append_plain(self, delta: str) -> None:
|
||||
self._text += delta
|
||||
self.set_plain(self._text)
|
||||
|
||||
def set_diff(self, diff_text: str) -> None:
|
||||
"""Render a unified diff (see :func:`diff_to_html`) with colored
|
||||
before/after lines instead of a flat text block."""
|
||||
self._text = diff_text
|
||||
self.body.setHtml(diff_to_html(diff_text))
|
||||
self._autosize()
|
||||
if self._collapsible:
|
||||
self._update_head()
|
||||
|
||||
def add_usage(self, text: str) -> None:
|
||||
"""A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost),
|
||||
like Claude Code. Replaces any previous usage line on this bubble."""
|
||||
existing = getattr(self, "_usage_lbl", None)
|
||||
if existing is not None:
|
||||
existing.setText(text)
|
||||
return
|
||||
lbl = QLabel(text)
|
||||
lbl.setObjectName("faint")
|
||||
lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
|
||||
self._usage_lbl = lbl
|
||||
self._content_layout.addWidget(lbl)
|
||||
|
||||
def add_delete_link(self, callback) -> None:
|
||||
link = QLabel(f'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
|
||||
link.setToolTip(tr("chat.delete_tooltip"))
|
||||
link.linkActivated.connect(lambda *_: callback())
|
||||
self._content_layout.addWidget(link)
|
||||
|
||||
def add_folder_link(self, folder: str, label: str | None = None) -> None:
|
||||
label = label or tr("chat.open_workspace")
|
||||
link = QLabel(f'<a href="#open" style="color:{_p().accent};">{label}</a>')
|
||||
link.setToolTip(str(folder))
|
||||
link.linkActivated.connect(lambda *_: open_folder(folder))
|
||||
self._content_layout.addWidget(link)
|
||||
|
||||
def add_attachments(self, paths) -> None:
|
||||
"""Show attached files: images as thumbnails, others as clickable links."""
|
||||
for p in paths:
|
||||
path = str(p)
|
||||
name = Path(path).name
|
||||
if is_image(path):
|
||||
pix = QPixmap(path)
|
||||
if not pix.isNull():
|
||||
thumb = QLabel()
|
||||
thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation))
|
||||
thumb.setToolTip(name)
|
||||
thumb.setCursor(Qt.PointingHandCursor)
|
||||
self._content_layout.addWidget(thumb)
|
||||
continue
|
||||
file_link = QLabel(f'<a href="#open" style="color:{_p().accent};">{name}</a>')
|
||||
file_link.setToolTip(path)
|
||||
file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
|
||||
self._content_layout.addWidget(file_link)
|
||||
|
||||
def _autosize(self) -> None:
|
||||
width = self.body.viewport().width()
|
||||
if width <= 0:
|
||||
width = 560 # sensible default before the widget is laid out
|
||||
self.body.document().setTextWidth(width)
|
||||
height = int(self.body.document().size().height()) + 12
|
||||
self.body.setFixedHeight(max(28, min(height, 1200)))
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802 - re-flow on width change
|
||||
super().resizeEvent(event)
|
||||
self._autosize()
|
||||
|
||||
|
||||
class ChatView(QScrollArea):
|
||||
"""Scrollable chat transcript.
|
||||
|
||||
Emits ``theme_changed`` (via the apply_theme method) so every child
|
||||
``MessageBubble`` can re-apply its theme-aware inline styles when the
|
||||
app switches between light and dark modes."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWidgetResizable(True)
|
||||
self._container = QWidget()
|
||||
self._lay = QVBoxLayout(self._container)
|
||||
self._lay.setContentsMargins(12, 12, 12, 12)
|
||||
self._lay.setSpacing(10)
|
||||
self._lay.addStretch(1)
|
||||
self.setWidget(self._container)
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Ask every MessageBubble inside this view to re-apply theme styles.
|
||||
|
||||
Called from ``ChatPanel.apply_theme`` whenever the app theme changes."""
|
||||
for i in range(self._lay.count()):
|
||||
item = self._lay.itemAt(i)
|
||||
w = item.widget() if item else None
|
||||
if isinstance(w, MessageBubble):
|
||||
w.apply_theme()
|
||||
|
||||
def _add(self, bubble: MessageBubble) -> MessageBubble:
|
||||
# insert before the trailing stretch
|
||||
self._lay.insertWidget(self._lay.count() - 1, bubble)
|
||||
self._scroll_to_bottom()
|
||||
return bubble
|
||||
|
||||
def add_user(self, text: str) -> MessageBubble:
|
||||
b = MessageBubble("user", tr("chat.you"))
|
||||
b.set_plain(text)
|
||||
return self._add(b)
|
||||
|
||||
def add_assistant(self, title: str | None = None) -> MessageBubble:
|
||||
b = MessageBubble("assistant", title or tr("chat.assistant"))
|
||||
return self._add(b)
|
||||
|
||||
def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble:
|
||||
# Tool steps (run command, generated code/diff, output) are collapsible to
|
||||
# keep the transcript short — collapsed when OK, expanded on error.
|
||||
b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
|
||||
b.set_plain(body)
|
||||
return self._add(b)
|
||||
|
||||
def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble:
|
||||
"""Like :meth:`add_tool`, but renders ``diff_text`` as a colored
|
||||
before/after diff (see :func:`diff_to_html`) instead of flat text."""
|
||||
b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
|
||||
b.set_diff(diff_text)
|
||||
return self._add(b)
|
||||
|
||||
def add_plan(self, body: str) -> MessageBubble:
|
||||
"""The task plan shown INLINE in the timeline (never a pop-up or side
|
||||
panel) — a permanent, always-expanded row whose steps tick off as they
|
||||
complete. The agent re-sends the full list on each update; the caller
|
||||
updates this same row in place via ``set_plain``."""
|
||||
b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False)
|
||||
b.set_plain(body)
|
||||
return self._add(b)
|
||||
|
||||
def add_reasoning(self, title: str | None = None) -> MessageBubble:
|
||||
# The model's private reasoning — a collapsed, collapsible box so the user
|
||||
# can see it's thinking (and expand to read) without it flooding the chat.
|
||||
b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True)
|
||||
return self._add(b)
|
||||
|
||||
def add_error(self, text: str) -> MessageBubble:
|
||||
b = MessageBubble("error", tr("chat.error"))
|
||||
b.set_plain(text)
|
||||
return self._add(b)
|
||||
|
||||
def add_status(self, text: str) -> MessageBubble:
|
||||
"""A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành')."""
|
||||
b = MessageBubble("tool", "")
|
||||
b.set_plain(text)
|
||||
return self._add(b)
|
||||
|
||||
def add_success(self, text: str) -> MessageBubble:
|
||||
"""Like :meth:`add_status`, but styled green — used for the "turn done"
|
||||
marker so completion reads as an unmistakable success signal."""
|
||||
b = MessageBubble("success", "")
|
||||
b.set_plain(text)
|
||||
return self._add(b)
|
||||
|
||||
def clear(self) -> None:
|
||||
while self._lay.count() > 1:
|
||||
item = self._lay.takeAt(0)
|
||||
w = item.widget()
|
||||
if w:
|
||||
w.deleteLater()
|
||||
|
||||
def scroll_to_bottom(self) -> None:
|
||||
"""Scroll to the newest message, deferred so freshly-added bubbles have
|
||||
finished sizing (their height is computed after layout)."""
|
||||
QTimer.singleShot(0, self._scroll_to_bottom)
|
||||
QTimer.singleShot(80, self._scroll_to_bottom)
|
||||
|
||||
def _scroll_to_bottom(self) -> None:
|
||||
bar = self.verticalScrollBar()
|
||||
bar.setValue(bar.maximum())
|
||||
|
||||
@@ -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():
|
||||
|
||||
+22
-775
@@ -12,780 +12,27 @@ Kept UI-only; the graph model lives in ``core/co4e.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
|
||||
from PySide6.QtWidgets import (
|
||||
QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QGraphicsScene,
|
||||
QGraphicsView, QMenu,
|
||||
# _NodeItem/_EdgeItem (hằng số vẽ + hai lớp QGraphicsItem) đã dời sang
|
||||
# presentation/co4e/canvas_items.py; Co4ECanvas (mutation đồ thị) đã dời sang
|
||||
# presentation/co4e/co4e_canvas_widget.py, phần tương tác view (zoom/pan/
|
||||
# relayout/phím tắt/kéo-thả) nằm trong _CanvasInteractionMixin cùng thư mục.
|
||||
# Không đổi hành vi — xem characterization test cùng tên và docstring ở từng
|
||||
# file đích. Import ĐÍCH DANH tên gốc, không alias — nếu đổi thành
|
||||
# `import co4e_canvas_widget as _w` thì các chỗ gọi bên dưới (và cả test cũ)
|
||||
# vẫn chạy được vì Python cho phép, nhưng lại sai mục đích của việc giữ nguyên
|
||||
# tên: test characterization import trực tiếp các tên này TỪ module này,
|
||||
# alias sẽ làm test đó ngưng chứng minh được điều nó cần chứng minh.
|
||||
from ..presentation.co4e.canvas_items import (
|
||||
_NODE_H, _NODE_W, _PORT_HIT, _PORT_R, _EdgeItem, _NodeItem, _status_color,
|
||||
)
|
||||
|
||||
from ..core.co4e import (
|
||||
STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step,
|
||||
compute_waves, new_edge_id, new_node_id,
|
||||
# 8 hàm hình học thuần đã dời sang canvas_geometry.py (không đổi hành vi, xem
|
||||
# characterization test cùng tên). Import ĐÍCH DANH tên gốc, không alias — nếu
|
||||
# đổi thành `import canvas_geometry as _g` thì các chỗ gọi bên dưới (và cả
|
||||
# test cũ) vẫn chạy được vì Python cho phép, nhưng lại sai mục đích của việc
|
||||
# giữ nguyên tên: test characterization import trực tiếp các tên này TỪ module
|
||||
# này, alias sẽ làm test đó ngưng chứng minh được điều nó cần chứng minh.
|
||||
from ..presentation.co4e.canvas_geometry import (
|
||||
_dist, _elide, _hits, _ortho_path, _route, _rounded_path, _seg_hits_rect,
|
||||
_towards,
|
||||
)
|
||||
from ..theme import current_palette
|
||||
|
||||
|
||||
def _status_color(status: str) -> str:
|
||||
"""Accent colour for a step's run status. Resolved per paint so the canvas
|
||||
follows a live theme switch."""
|
||||
p = current_palette()
|
||||
return {
|
||||
"idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success,
|
||||
STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint,
|
||||
}.get(status, p.text_muted)
|
||||
|
||||
CO4E_MIME = "application/x-co4e-step"
|
||||
|
||||
_NODE_W, _NODE_H = 210, 96
|
||||
_PORT_R = 6 # output port radius (the drag-to-connect handle)
|
||||
_PORT_HIT = 15 # click tolerance around a port
|
||||
_CORNER_R = 12 # edge elbow corner radius
|
||||
|
||||
|
||||
class _NodeItem(QGraphicsObject):
|
||||
"""One draggable step card. Emits signals via the parent canvas."""
|
||||
|
||||
def __init__(self, node: Node, canvas: "Co4ECanvas"):
|
||||
super().__init__()
|
||||
self.node = node
|
||||
self.canvas = canvas
|
||||
self.status = "idle"
|
||||
self._porting = False
|
||||
self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsSelectable
|
||||
| QGraphicsItem.ItemSendsGeometryChanges)
|
||||
self.setAcceptHoverEvents(True)
|
||||
self.setPos(node.x, node.y)
|
||||
self.setZValue(2)
|
||||
|
||||
def boundingRect(self) -> QRectF:
|
||||
# slack left/right so the input/output ports (now on the sides) paint cleanly
|
||||
return QRectF(-_PORT_R - 2, -3, _NODE_W + 2 * _PORT_R + 4, _NODE_H + 6)
|
||||
|
||||
def _card_rect(self) -> QRectF:
|
||||
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
|
||||
|
||||
def paint(self, p, _opt, _widget=None):
|
||||
tok = current_palette()
|
||||
step = self.node.data
|
||||
accent = QColor(_status_color(self.status))
|
||||
body = QColor(tok.surface_raised)
|
||||
border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
rect = self._card_rect()
|
||||
path = QPainterPath()
|
||||
radius = float(tok.radius_lg)
|
||||
path.addRoundedRect(rect, radius, radius)
|
||||
p.fillPath(path, QBrush(body))
|
||||
p.setPen(QPen(border, 2 if self.isSelected() else 1))
|
||||
p.drawPath(path)
|
||||
# header stripe — a tint of the status colour, not the status colour
|
||||
# itself, so the card's own text stays the brightest thing on it.
|
||||
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
|
||||
hpath = QPainterPath()
|
||||
hpath.addRoundedRect(hdr, radius, radius)
|
||||
stripe = QColor(accent)
|
||||
stripe.setAlpha(48)
|
||||
p.fillPath(hpath, QBrush(stripe))
|
||||
# label
|
||||
p.setPen(QColor(tok.text))
|
||||
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
|
||||
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
|
||||
_elide(step.label, 26))
|
||||
# role badge + status
|
||||
f.setBold(False); f.setPointSize(8); p.setFont(f)
|
||||
p.setPen(accent)
|
||||
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
|
||||
# body: instructions preview OR sub-agent chips
|
||||
p.setPen(QColor(tok.text_muted))
|
||||
if step.is_parallel:
|
||||
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
|
||||
else:
|
||||
preview = step.instructions or "(no instructions)"
|
||||
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
|
||||
_elide(preview, 66))
|
||||
# footer: model + skills + status dot
|
||||
p.setPen(QColor(tok.text_faint))
|
||||
foot = []
|
||||
if step.model:
|
||||
foot.append(step.model)
|
||||
if step.skills:
|
||||
foot.append(f"skills:{len(step.skills)}")
|
||||
foot.append(self.status)
|
||||
p.drawText(QRectF(10, _NODE_H - 18, _NODE_W - 20, 14), Qt.AlignLeft,
|
||||
_elide(" · ".join(foot), 34))
|
||||
# ---- ports ---------------------------------------------------------
|
||||
# input port (top-center): hollow. output port (bottom-center): filled —
|
||||
# the drag handle you pull to wire an edge to another step.
|
||||
port_col = QColor(tok.accent)
|
||||
# input port (left-center): hollow. output port (right-center): filled —
|
||||
# the drag handle you pull to wire an edge to the next step (left→right).
|
||||
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
|
||||
p.drawEllipse(QPointF(1, _NODE_H / 2), _PORT_R - 1, _PORT_R - 1)
|
||||
p.setBrush(QBrush(port_col)); p.setPen(QPen(port_col, 1.4))
|
||||
p.drawEllipse(QPointF(_NODE_W - 1, _NODE_H / 2), _PORT_R, _PORT_R)
|
||||
|
||||
def _in_out_port(self, pos: QPointF) -> bool:
|
||||
d = pos - QPointF(_NODE_W, _NODE_H / 2)
|
||||
return (d.x() * d.x() + d.y() * d.y()) ** 0.5 <= _PORT_HIT
|
||||
|
||||
def itemChange(self, change, value):
|
||||
if change == QGraphicsItem.ItemPositionHasChanged:
|
||||
self.node.x = float(self.pos().x())
|
||||
self.node.y = float(self.pos().y())
|
||||
self.canvas._reposition_edges()
|
||||
self.canvas.graph_changed.emit()
|
||||
elif change == QGraphicsItem.ItemSelectedHasChanged:
|
||||
# a selected/edited node comes to the front (above the edges at z=3)
|
||||
self.setZValue(4 if value else 2)
|
||||
if value:
|
||||
self.canvas.node_selected.emit(self.node.id)
|
||||
return super().itemChange(change, value)
|
||||
|
||||
def hoverMoveEvent(self, e):
|
||||
# a hand cursor over the output port hints it's draggable-to-connect
|
||||
self.setCursor(Qt.PointingHandCursor if self._in_out_port(e.pos()) else Qt.ArrowCursor)
|
||||
super().hoverMoveEvent(e)
|
||||
|
||||
def mousePressEvent(self, e):
|
||||
if self.canvas._connect_from is not None:
|
||||
self.canvas._finish_connect(self.node.id)
|
||||
e.accept()
|
||||
return
|
||||
if e.button() == Qt.LeftButton and self._in_out_port(e.pos()):
|
||||
# start a manual drag-to-connect from this node's output port
|
||||
self._porting = True
|
||||
self.canvas.begin_port_drag(self.node.id, self.mapToScene(QPointF(_NODE_W, _NODE_H / 2)))
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._porting:
|
||||
self.canvas.update_port_drag(self.mapToScene(e.pos()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if self._porting:
|
||||
self._porting = False
|
||||
self.canvas.finish_port_drag(self.mapToScene(e.pos()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e):
|
||||
self.canvas.node_activated.emit(self.node.id)
|
||||
e.accept()
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
menu = QMenu()
|
||||
a_add = menu.addAction("+ Add next step")
|
||||
a_conn = menu.addAction("→ Connect from here")
|
||||
a_del = menu.addAction("🗑 Delete step")
|
||||
chosen = menu.exec(e.screenPos())
|
||||
if chosen is a_add:
|
||||
self.canvas.add_step_below(self.node.id)
|
||||
elif chosen is a_conn:
|
||||
self.canvas.begin_connect(self.node.id)
|
||||
elif chosen is a_del:
|
||||
self.canvas.delete_node(self.node.id)
|
||||
e.accept()
|
||||
|
||||
def center(self) -> QPointF:
|
||||
return self.pos() + QPointF(_NODE_W / 2, _NODE_H / 2)
|
||||
|
||||
|
||||
def _dist(a: QPointF, b: QPointF) -> float:
|
||||
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
|
||||
|
||||
|
||||
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
|
||||
dist = _dist(a, b)
|
||||
if dist < 1e-6:
|
||||
return QPointF(a)
|
||||
t = d / dist
|
||||
return QPointF(a.x() + (b.x() - a.x()) * t, a.y() + (b.y() - a.y()) * t)
|
||||
|
||||
|
||||
def _rounded_path(points, r: float = _CORNER_R) -> QPainterPath:
|
||||
"""Build a path through axis-aligned ``points`` with rounded corners at each
|
||||
bend ("vuông bo cong ở góc")."""
|
||||
if not points:
|
||||
return QPainterPath()
|
||||
path = QPainterPath(points[0])
|
||||
if len(points) == 1:
|
||||
return path
|
||||
for i in range(1, len(points) - 1):
|
||||
prev, cur, nxt = points[i - 1], points[i], points[i + 1]
|
||||
rr = min(r, _dist(prev, cur) / 2.0, _dist(cur, nxt) / 2.0)
|
||||
path.lineTo(_towards(cur, prev, rr))
|
||||
path.quadTo(cur, _towards(cur, nxt, rr))
|
||||
path.lineTo(points[-1])
|
||||
return path
|
||||
|
||||
|
||||
def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
|
||||
"""Axis-aligned segment vs rectangle overlap (all routed segments are H or V)."""
|
||||
x1, y1, x2, y2 = p1.x(), p1.y(), p2.x(), p2.y()
|
||||
if abs(y1 - y2) < 0.5: # horizontal
|
||||
if rect.top() <= y1 <= rect.bottom():
|
||||
lo, hi = sorted((x1, x2))
|
||||
return not (hi < rect.left() or lo > rect.right())
|
||||
return False
|
||||
if abs(x1 - x2) < 0.5: # vertical
|
||||
if rect.left() <= x1 <= rect.right():
|
||||
lo, hi = sorted((y1, y2))
|
||||
return not (hi < rect.top() or lo > rect.bottom())
|
||||
return False
|
||||
box = QRectF(QPointF(min(x1, x2), min(y1, y2)), QPointF(max(x1, x2), max(y1, y2)))
|
||||
return rect.intersects(box)
|
||||
|
||||
|
||||
def _hits(points, obstacles) -> bool:
|
||||
for i in range(len(points) - 1):
|
||||
for r in obstacles:
|
||||
if _seg_hits_rect(points[i], points[i + 1], r):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _route(src: QPointF, dst: QPointF, obstacles=None):
|
||||
"""Waypoints for a LEFT→RIGHT orthogonal edge from ``src`` (a node's right
|
||||
output) to ``dst`` (the next node's left input) that AVOIDS the other node
|
||||
rectangles: try the straight elbow, then a clear vertical band, then a
|
||||
top/bottom detour — so a connector never overlaps or hides behind a step."""
|
||||
obstacles = list(obstacles or [])
|
||||
if abs(src.y() - dst.y()) < 1.5:
|
||||
cand = [src, dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
mid_x = (src.x() + dst.x()) / 2.0
|
||||
base = [src, QPointF(mid_x, src.y()), QPointF(mid_x, dst.y()), dst]
|
||||
if not _hits(base, obstacles):
|
||||
return base
|
||||
# 1) slide the vertical run to a clear band between the two columns
|
||||
lo, hi = min(src.x(), dst.x()) + 6, max(src.x(), dst.x()) - 6
|
||||
if hi > lo:
|
||||
for frac in (0.5, 0.35, 0.65, 0.2, 0.8):
|
||||
x = lo + (hi - lo) * frac
|
||||
cand = [src, QPointF(x, src.y()), QPointF(x, dst.y()), dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
# 2) detour above/below every obstacle, then back in
|
||||
margin = 44.0
|
||||
ys = [src.y(), dst.y()] + [r.top() for r in obstacles] + [r.bottom() for r in obstacles]
|
||||
out_x, in_x = src.x() + 34, dst.x() - 34 # short stubs out of the side ports
|
||||
for side_y in (min(ys) - margin, max(ys) + margin):
|
||||
cand = [src, QPointF(out_x, src.y()), QPointF(out_x, side_y),
|
||||
QPointF(in_x, side_y), QPointF(in_x, dst.y()), dst]
|
||||
if not _hits(cand, obstacles):
|
||||
return cand
|
||||
return base
|
||||
|
||||
|
||||
def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPath:
|
||||
"""Rounded orthogonal elbow (no obstacle avoidance) — used for the transient
|
||||
drag-to-connect line and by callers that pass no obstacles."""
|
||||
return _rounded_path(_route(src, dst), r)
|
||||
|
||||
|
||||
class _EdgeItem(QGraphicsPathItem):
|
||||
def __init__(self, edge: Edge, canvas: "Co4ECanvas"):
|
||||
super().__init__()
|
||||
self.edge = edge
|
||||
self.canvas = canvas
|
||||
self._dst: Optional[QPointF] = None
|
||||
# Above node cards (z=2) so a connecting line is never hidden behind a
|
||||
# step; a selected node bumps itself to the front while being edited.
|
||||
self.setZValue(3)
|
||||
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
|
||||
self.setAcceptHoverEvents(True)
|
||||
self._hover = False
|
||||
self._apply_pen()
|
||||
|
||||
def _apply_pen(self):
|
||||
tok = current_palette()
|
||||
if self.isSelected():
|
||||
color, w = QColor(tok.accent), 3
|
||||
elif self._hover:
|
||||
color, w = QColor(tok.text_muted), 3
|
||||
else:
|
||||
color, w = QColor(tok.border_strong), 2
|
||||
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
||||
|
||||
def update_path(self, points):
|
||||
self._dst = points[-1] if points else None
|
||||
self.setPath(_rounded_path(points))
|
||||
|
||||
def boundingRect(self):
|
||||
return super().boundingRect().adjusted(-10, -10, 10, 10) # room for the arrowhead
|
||||
|
||||
def shape(self):
|
||||
# Widen the clickable/selectable area so a thin line is easy to grab.
|
||||
from PySide6.QtGui import QPainterPathStroker
|
||||
stroker = QPainterPathStroker()
|
||||
stroker.setWidth(14)
|
||||
return stroker.createStroke(self.path())
|
||||
|
||||
def hoverEnterEvent(self, e):
|
||||
self._hover = True
|
||||
self._apply_pen()
|
||||
self.update()
|
||||
super().hoverEnterEvent(e)
|
||||
|
||||
def hoverLeaveEvent(self, e):
|
||||
self._hover = False
|
||||
self._apply_pen()
|
||||
self.update()
|
||||
super().hoverLeaveEvent(e)
|
||||
|
||||
def paint(self, p, opt, widget=None):
|
||||
self._apply_pen()
|
||||
super().paint(p, opt, widget)
|
||||
# arrowhead at the target, pointing right into its (left) input port
|
||||
if self._dst is not None:
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
tip = self._dst
|
||||
s = 7.0
|
||||
tri = QPolygonF([
|
||||
QPointF(tip.x() + 1, tip.y()),
|
||||
QPointF(tip.x() - s, tip.y() - s * 0.7),
|
||||
QPointF(tip.x() - s, tip.y() + s * 0.7),
|
||||
])
|
||||
col = self.pen().color()
|
||||
p.setBrush(QBrush(col))
|
||||
p.setPen(QPen(col, 1))
|
||||
p.drawPolygon(tri)
|
||||
|
||||
def contextMenuEvent(self, e):
|
||||
menu = QMenu()
|
||||
act_del = menu.addAction("🗑 Delete connection")
|
||||
if menu.exec(e.screenPos()) is act_del:
|
||||
self.canvas.delete_edge(self.edge)
|
||||
e.accept()
|
||||
|
||||
|
||||
def _elide(text: str, n: int) -> str:
|
||||
text = (text or "").replace("\n", " ")
|
||||
return text if len(text) <= n else text[: n - 1] + "…"
|
||||
|
||||
|
||||
class Co4ECanvas(QGraphicsView):
|
||||
node_selected = Signal(str) # a node was clicked (→ config panel)
|
||||
node_activated = Signal(str) # double-clicked
|
||||
graph_changed = Signal() # nodes/edges/positions changed (autosave)
|
||||
|
||||
_ZOOM_MIN, _ZOOM_MAX = 0.3, 3.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("co4eCanvas") # themed frame (see theme.py)
|
||||
self._scene = QGraphicsScene(self)
|
||||
self.setScene(self._scene)
|
||||
self.setRenderHint(self.renderHints().Antialiasing)
|
||||
self.setDragMode(QGraphicsView.RubberBandDrag)
|
||||
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
||||
self.setAcceptDrops(True)
|
||||
self._nodes: Dict[str, _NodeItem] = {}
|
||||
self._edges: list[_EdgeItem] = []
|
||||
self._connect_from: Optional[str] = None
|
||||
self._zoom = 1.0
|
||||
self._panning = False # middle-mouse drag-to-pan
|
||||
self._pan_start = None
|
||||
self._overlay = None # bottom-left zoom/fit controls (parented to viewport)
|
||||
# manual drag-to-connect state
|
||||
self._port_src: Optional[str] = None
|
||||
self._port_src_pt: Optional[QPointF] = None
|
||||
self._temp_edge: Optional[QGraphicsPathItem] = None
|
||||
|
||||
# ---- bottom-left overlay (zoom / fit) --------------------------------
|
||||
def add_overlay(self, widget) -> None:
|
||||
self._overlay = widget
|
||||
widget.setParent(self.viewport())
|
||||
widget.show()
|
||||
widget.raise_()
|
||||
self._place_overlay()
|
||||
|
||||
def _place_overlay(self) -> None:
|
||||
if self._overlay is not None:
|
||||
self._overlay.adjustSize()
|
||||
vp = self.viewport()
|
||||
self._overlay.move(12, vp.height() - self._overlay.height() - 12)
|
||||
self._overlay.raise_()
|
||||
|
||||
def resizeEvent(self, e): # noqa: N802
|
||||
super().resizeEvent(e)
|
||||
self._place_overlay()
|
||||
|
||||
def scrollContentsBy(self, dx, dy): # noqa: N802
|
||||
# QGraphicsView scrolls the viewport's child widgets along with the
|
||||
# scene, so panning/scrolling would drag the zoom overlay off-corner.
|
||||
# Re-pin it after every scroll so +/−/fit stay fixed in place.
|
||||
super().scrollContentsBy(dx, dy)
|
||||
self._place_overlay()
|
||||
|
||||
def showEvent(self, e): # noqa: N802
|
||||
super().showEvent(e)
|
||||
self._place_overlay() # viewport size is final once shown
|
||||
|
||||
# ---- load / serialize -------------------------------------------------
|
||||
def load(self, nodes, edges) -> None:
|
||||
self._scene.clear()
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
self._connect_from = None
|
||||
self._port_src = None
|
||||
self._temp_edge = None
|
||||
for n in nodes:
|
||||
item = _NodeItem(n, self)
|
||||
self._nodes[n.id] = item
|
||||
self._scene.addItem(item)
|
||||
for e in edges:
|
||||
if e.source in self._nodes and e.target in self._nodes:
|
||||
self._add_edge_item(e)
|
||||
self._reposition_edges()
|
||||
|
||||
def nodes(self):
|
||||
return [it.node for it in self._nodes.values()]
|
||||
|
||||
def edges(self):
|
||||
return [it.edge for it in self._edges]
|
||||
|
||||
# ---- mutation ---------------------------------------------------------
|
||||
def add_node(self, step: Step, x: float = 60.0, y: float = 60.0,
|
||||
connect_from: str = "") -> str:
|
||||
node = Node(id=new_node_id(), x=x, y=y, data=step)
|
||||
item = _NodeItem(node, self)
|
||||
self._nodes[node.id] = item
|
||||
self._scene.addItem(item)
|
||||
if connect_from and connect_from in self._nodes:
|
||||
self._make_edge(connect_from, node.id)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
self.node_selected.emit(node.id)
|
||||
return node.id
|
||||
|
||||
def add_step_below(self, node_id: str) -> None:
|
||||
"""Add the next step to the RIGHT of ``node_id`` (horizontal flow)."""
|
||||
parent = self._nodes.get(node_id)
|
||||
if parent is None:
|
||||
return
|
||||
step = Step(label="New Step")
|
||||
self.add_node(step, x=parent.node.x + _NODE_W + 150, y=parent.node.y, connect_from=node_id)
|
||||
|
||||
def _chain_tail(self) -> str:
|
||||
"""A node with no outgoing edge (so a freshly added node chains on)."""
|
||||
sources = {e.edge.source for e in self._edges}
|
||||
tails = [nid for nid in self._nodes if nid not in sources]
|
||||
return tails[-1] if tails else (next(reversed(self._nodes), "") if self._nodes else "")
|
||||
|
||||
def add_palette_step(self, step: Step, pos: QPointF) -> None:
|
||||
tail = self._chain_tail()
|
||||
self.add_node(step, x=pos.x(), y=pos.y(), connect_from=tail)
|
||||
|
||||
def begin_connect(self, source_id: str) -> None:
|
||||
self._connect_from = source_id
|
||||
|
||||
def _finish_connect(self, target_id: str) -> None:
|
||||
src = self._connect_from
|
||||
self._connect_from = None
|
||||
if src and src != target_id:
|
||||
self._make_edge(src, target_id)
|
||||
|
||||
# ---- manual drag-to-connect (from a node's output port) ---------------
|
||||
def begin_port_drag(self, source_id: str, scene_pt: QPointF) -> None:
|
||||
self._port_src = source_id
|
||||
self._port_src_pt = scene_pt
|
||||
self._temp_edge = QGraphicsPathItem()
|
||||
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
|
||||
self._temp_edge.setPen(
|
||||
QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
|
||||
self._scene.addItem(self._temp_edge)
|
||||
|
||||
def update_port_drag(self, scene_pt: QPointF) -> None:
|
||||
if self._temp_edge is None or self._port_src_pt is None:
|
||||
return
|
||||
self._temp_edge.setPath(_ortho_path(self._port_src_pt, scene_pt))
|
||||
|
||||
def finish_port_drag(self, scene_pt: QPointF) -> None:
|
||||
src = self._port_src
|
||||
if self._temp_edge is not None:
|
||||
self._scene.removeItem(self._temp_edge)
|
||||
self._temp_edge = None
|
||||
self._port_src = None
|
||||
self._port_src_pt = None
|
||||
tgt = self._node_at(scene_pt)
|
||||
if src and tgt and tgt != src:
|
||||
self._make_edge(src, tgt)
|
||||
|
||||
def _node_at(self, scene_pt: QPointF) -> Optional[str]:
|
||||
for it in self._scene.items(scene_pt):
|
||||
if isinstance(it, _NodeItem):
|
||||
return it.node.id
|
||||
return None
|
||||
|
||||
def _make_edge(self, source: str, target: str) -> None:
|
||||
if source == target:
|
||||
return
|
||||
if any(e.edge.source == source and e.edge.target == target for e in self._edges):
|
||||
return
|
||||
edge = Edge(id=new_edge_id(source, target), source=source, target=target)
|
||||
self._add_edge_item(edge)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def _add_edge_item(self, edge: Edge) -> None:
|
||||
item = _EdgeItem(edge, self)
|
||||
self._edges.append(item)
|
||||
self._scene.addItem(item)
|
||||
|
||||
def delete_edge(self, edge: Edge) -> None:
|
||||
for e in list(self._edges):
|
||||
if e.edge is edge or (e.edge.source == edge.source and e.edge.target == edge.target):
|
||||
self._scene.removeItem(e)
|
||||
self._edges.remove(e)
|
||||
self.graph_changed.emit()
|
||||
|
||||
def delete_node(self, node_id: str) -> None:
|
||||
item = self._nodes.pop(node_id, None)
|
||||
if item is None:
|
||||
return
|
||||
self._scene.removeItem(item)
|
||||
for e in list(self._edges):
|
||||
if e.edge.source == node_id or e.edge.target == node_id:
|
||||
self._scene.removeItem(e)
|
||||
self._edges.remove(e)
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def delete_selected(self) -> None:
|
||||
for nid in [it.node.id for it in self._nodes.values() if it.isSelected()]:
|
||||
self.delete_node(nid)
|
||||
for e in [it.edge for it in self._edges if it.isSelected()]:
|
||||
self.delete_edge(e)
|
||||
|
||||
# ---- zoom / fit -------------------------------------------------------
|
||||
def _zoom_by(self, factor: float) -> None:
|
||||
# Derive the CURRENT scale from the live transform (never a separate
|
||||
# accumulator that can drift out of sync with fit_view/relayout/reset —
|
||||
# that drift is what made the +/− buttons and Ctrl+wheel randomly stop
|
||||
# working). Clamp the TARGET to the range and apply the exact factor to
|
||||
# reach it, so zooming still works right up to the limits.
|
||||
cur = self.transform().m11() or 1.0
|
||||
target = max(self._ZOOM_MIN, min(self._ZOOM_MAX, cur * factor))
|
||||
if abs(target - cur) < 1e-6:
|
||||
return
|
||||
self.scale(target / cur, target / cur)
|
||||
self._zoom = target
|
||||
|
||||
def zoom_in(self) -> None:
|
||||
self._zoom_by(1.15)
|
||||
|
||||
def zoom_out(self) -> None:
|
||||
self._zoom_by(1 / 1.15)
|
||||
|
||||
def reset_zoom(self) -> None:
|
||||
self.resetTransform()
|
||||
self._zoom = 1.0
|
||||
|
||||
def wheelEvent(self, e):
|
||||
# Ctrl+wheel = zoom (anchored under the cursor); Shift+wheel = pan
|
||||
# horizontally; plain wheel scrolls vertically.
|
||||
if e.modifiers() & Qt.ControlModifier:
|
||||
self._zoom_by(1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
e.accept()
|
||||
return
|
||||
if e.modifiers() & Qt.ShiftModifier:
|
||||
bar = self.horizontalScrollBar()
|
||||
bar.setValue(bar.value() - e.angleDelta().y())
|
||||
e.accept()
|
||||
return
|
||||
super().wheelEvent(e)
|
||||
|
||||
# ---- middle-mouse drag-to-pan ----------------------------------------
|
||||
def mousePressEvent(self, e):
|
||||
if e.button() == Qt.MiddleButton:
|
||||
self._panning = True
|
||||
self._pan_start = e.position().toPoint()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e):
|
||||
if self._panning and self._pan_start is not None:
|
||||
pos = e.position().toPoint()
|
||||
delta = pos - self._pan_start
|
||||
self._pan_start = pos
|
||||
self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x())
|
||||
self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y())
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e):
|
||||
if e.button() == Qt.MiddleButton and self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def fit_view(self) -> None:
|
||||
"""Auto-fit: zoom/pan so every node is visible with a small margin."""
|
||||
rect = self._scene.itemsBoundingRect()
|
||||
if rect.isNull():
|
||||
return
|
||||
self.setSceneRect(rect.adjusted(-60, -60, 60, 60))
|
||||
self.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
# keep the zoom accumulator in sync with the transform fitInView applied
|
||||
self._zoom = self.transform().m11() or 1.0
|
||||
|
||||
def relayout(self, hgap: float = 110.0, vgap: float = 40.0) -> None:
|
||||
"""Arrange nodes LEFT→RIGHT by dependency depth: each topological wave is
|
||||
a column (x = wave), siblings stacked vertically within it. Used to turn
|
||||
an old top-down graph into the horizontal flow layout."""
|
||||
nodes = [it.node for it in self._nodes.values()]
|
||||
edges = [it.edge for it in self._edges]
|
||||
if not nodes:
|
||||
return
|
||||
waves = compute_waves(nodes, edges)
|
||||
from collections import defaultdict
|
||||
cols: Dict[int, list] = defaultdict(list)
|
||||
for n in nodes:
|
||||
cols[waves.get(n.id, 0)].append(n)
|
||||
for w in sorted(cols):
|
||||
for row, n in enumerate(sorted(cols[w], key=lambda nn: (nn.y, nn.x))):
|
||||
item = self._nodes.get(n.id)
|
||||
if item is not None:
|
||||
item.setPos(w * (_NODE_W + hgap), row * (_NODE_H + vgap))
|
||||
self._reposition_edges()
|
||||
|
||||
def relayout_if_vertical(self) -> None:
|
||||
"""Convert a graph that's stacked vertically (the old top-down layout, or
|
||||
overlapping nodes) into the horizontal left→right layout — but leave a
|
||||
graph the user already arranged horizontally untouched."""
|
||||
nodes = [it.node for it in self._nodes.values()]
|
||||
if len(nodes) < 2:
|
||||
return
|
||||
xs = [n.x for n in nodes]
|
||||
if max(xs) - min(xs) < _NODE_W: # all in one column → it's vertical
|
||||
self.relayout()
|
||||
|
||||
def add_workflow(self, nodes, edges, at: Optional[QPointF] = None) -> None:
|
||||
"""Drop/merge a whole flow's nodes+edges onto the canvas with fresh ids
|
||||
(so the same template can be dropped several times). Offsets it near
|
||||
``at`` when given, else tiles it beside whatever is already there."""
|
||||
remap: Dict[str, str] = {}
|
||||
# offset so a dropped template doesn't land exactly on existing nodes
|
||||
ox = (at.x() - nodes[0].x) if (at and nodes) else (60 if self._nodes else 0)
|
||||
oy = (at.y() - nodes[0].y) if (at and nodes) else (60 if self._nodes else 0)
|
||||
for n in nodes:
|
||||
new = Node(id=new_node_id(), x=n.x + ox, y=n.y + oy, data=copy.deepcopy(n.data))
|
||||
remap[n.id] = new.id
|
||||
item = _NodeItem(new, self)
|
||||
self._nodes[new.id] = item
|
||||
self._scene.addItem(item)
|
||||
for e in edges:
|
||||
s, t = remap.get(e.source), remap.get(e.target)
|
||||
if s and t:
|
||||
self._add_edge_item(Edge(id=new_edge_id(s, t), source=s, target=t))
|
||||
self._reposition_edges()
|
||||
self.graph_changed.emit()
|
||||
|
||||
def update_node_status(self, node_id: str, status: str) -> None:
|
||||
item = self._nodes.get(node_id)
|
||||
if item is not None:
|
||||
item.status = status
|
||||
item.update()
|
||||
|
||||
def reset_statuses(self) -> None:
|
||||
for it in self._nodes.values():
|
||||
it.status = "idle"
|
||||
it.update()
|
||||
|
||||
def refresh_node(self, node_id: str) -> None:
|
||||
item = self._nodes.get(node_id)
|
||||
if item is not None:
|
||||
item.update()
|
||||
|
||||
def _node_rects(self, exclude):
|
||||
"""Rectangles of every node except ``exclude`` (inflated a little), used
|
||||
as obstacles the edge router steers around."""
|
||||
m = 12.0
|
||||
out = []
|
||||
for nid, item in self._nodes.items():
|
||||
if nid in exclude:
|
||||
continue
|
||||
p = item.pos()
|
||||
out.append(QRectF(p.x(), p.y(), _NODE_W, _NODE_H).adjusted(-m, -m, m, m))
|
||||
return out
|
||||
|
||||
def _reposition_edges(self) -> None:
|
||||
for e in self._edges:
|
||||
s = self._nodes.get(e.edge.source)
|
||||
t = self._nodes.get(e.edge.target)
|
||||
if s is None or t is None:
|
||||
continue
|
||||
src = s.pos() + QPointF(_NODE_W, _NODE_H / 2) # right-center (output)
|
||||
dst = t.pos() + QPointF(0, _NODE_H / 2) # left-center (input)
|
||||
obstacles = self._node_rects({e.edge.source, e.edge.target})
|
||||
e.update_path(_route(src, dst, obstacles))
|
||||
|
||||
# ---- key / drop -------------------------------------------------------
|
||||
def keyPressEvent(self, e):
|
||||
if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
|
||||
self.delete_selected()
|
||||
return
|
||||
if e.key() == Qt.Key_Escape:
|
||||
self._connect_from = None
|
||||
if self._temp_edge is not None:
|
||||
self._scene.removeItem(self._temp_edge)
|
||||
self._temp_edge = None
|
||||
self._port_src = None
|
||||
return
|
||||
if e.key() in (Qt.Key_Plus, Qt.Key_Equal) and (e.modifiers() & Qt.ControlModifier):
|
||||
self.zoom_in(); return
|
||||
if e.key() == Qt.Key_Minus and (e.modifiers() & Qt.ControlModifier):
|
||||
self.zoom_out(); return
|
||||
if e.key() == Qt.Key_0 and (e.modifiers() & Qt.ControlModifier):
|
||||
self.reset_zoom(); return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
def dragEnterEvent(self, e):
|
||||
if e.mimeData().hasFormat(CO4E_MIME):
|
||||
e.acceptProposedAction()
|
||||
else:
|
||||
super().dragEnterEvent(e)
|
||||
|
||||
def dragMoveEvent(self, e):
|
||||
if e.mimeData().hasFormat(CO4E_MIME):
|
||||
e.acceptProposedAction()
|
||||
else:
|
||||
super().dragMoveEvent(e)
|
||||
|
||||
def dropEvent(self, e):
|
||||
if not e.mimeData().hasFormat(CO4E_MIME):
|
||||
super().dropEvent(e)
|
||||
return
|
||||
try:
|
||||
payload = json.loads(bytes(e.mimeData().data(CO4E_MIME)).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return
|
||||
pos = self.mapToScene(e.position().toPoint())
|
||||
if isinstance(payload, dict) and payload.get("kind") == "workflow":
|
||||
# A whole flow dragged from the sidebar → merge its graph in.
|
||||
from ..core.co4e import workflow_from_dict
|
||||
wf = workflow_from_dict(payload.get("workflow", {}))
|
||||
if wf.nodes:
|
||||
self.add_workflow(wf.nodes, wf.edges, at=pos)
|
||||
else:
|
||||
from ..core.co4e import step_from_dict
|
||||
self.add_palette_step(step_from_dict(payload), pos)
|
||||
e.acceptProposedAction()
|
||||
from ..presentation.co4e.co4e_canvas_widget import Co4ECanvas, CO4E_MIME
|
||||
|
||||
+8
-522
@@ -1,528 +1,14 @@
|
||||
"""Co4E right-hand config panels — edit a selected step node's persona.
|
||||
|
||||
StepConfigPanel edits the fields of a ``core.co4e.Step`` in place and emits
|
||||
``changed`` (so the canvas repaints + the workflow autosaves) and ``run_node`` /
|
||||
``delete_node`` for the footer actions. Kept intentionally close to nova's
|
||||
config-panel.tsx field set: label, role, icon, instructions, model, permission
|
||||
preset, self-verify (+rounds), attached skills, and — for parallel nodes — the
|
||||
sub-agent list.
|
||||
StepConfigPanel has moved to ``presentation/co4e/node_property_panel.py``
|
||||
(split further into ``presentation/co4e/step_config_section.py`` and
|
||||
``presentation/co4e/node_property_actions_mixin.py`` to stay under the
|
||||
400-line-per-file cap). Re-exported here, unchanged in name and behaviour, so
|
||||
every existing ``from .co4e_config_panel import StepConfigPanel`` (e.g.
|
||||
``ui/co4e_tab.py``) keeps working without edits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from ..presentation.co4e.node_property_panel import StepConfigPanel
|
||||
|
||||
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
|
||||
QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
from ..core.co4e import PERMISSION_PRESETS, Step, SubAgent
|
||||
from ..i18n import tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon, icon_picker_combo
|
||||
|
||||
_SECTION_ANIM_MS = 180
|
||||
|
||||
|
||||
class _SectionHeader(QLabel):
|
||||
"""A clickable label — a QPushButton's own style chrome (border, native
|
||||
button margin, focus rect) always leaves a taller minimum height than a
|
||||
plain label, even once its QSS padding is zeroed out, so the header that
|
||||
needs to sit tight against its neighbours is a label, not a button."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802
|
||||
# fontMetrics() at construction time (before this label is ever part
|
||||
# of a shown top-level window) reflects the QSS font-size only if the
|
||||
# style has fully polished by then — on the very FIRST paint of the
|
||||
# Co4E screen it sometimes hasn't, so the fixed height computed in
|
||||
# _add_section is briefly wrong (too tall) until something else
|
||||
# triggers a relayout. Recomputing here, every time the label
|
||||
# actually becomes visible, means the first paint is never stale.
|
||||
self.setFixedHeight(self.fontMetrics().height())
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""One group of fields, collapsed to just its heading by default and
|
||||
independently expandable, so a long step config reads as a short list of
|
||||
group names until you open the one you need. Deliberately bare — no card
|
||||
border/background/box — the ▶/▼ marker and the heading text are the only
|
||||
things separating one group from the next; opening one never closes
|
||||
another (not an accordion, not a tab bar). Returns ``(form, card)``: add
|
||||
the group's rows to ``form``; ``card`` is the whole section (header +
|
||||
body) — hide it to remove the group entirely (e.g. for a section that
|
||||
only applies to some steps), rather than hiding individual rows inside
|
||||
an always-visible header."""
|
||||
p = current_palette()
|
||||
card = QWidget()
|
||||
card_lay = QVBoxLayout(card)
|
||||
card_lay.setContentsMargins(0, 0, 0, 0)
|
||||
card_lay.setSpacing(0)
|
||||
|
||||
header = _SectionHeader()
|
||||
header.setCursor(Qt.PointingHandCursor)
|
||||
header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;")
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
# QSS font-size only lands on the widget's actual QFont (and therefore
|
||||
# its fontMetrics()) once the style sheet is polished — ensurePolished()
|
||||
# forces that now, so the fixed height below is computed from the 12px
|
||||
# font just set above, not the default one this label was constructed
|
||||
# with. A label's natural sizeHint still reserves font leading above/
|
||||
# below the glyphs on top of the (now zeroed) QSS padding — pinning the
|
||||
# height to the text's actual cap-to-baseline span is what closes that
|
||||
# last gap without clipping the ▶ glyph, the title, or Vietnamese
|
||||
# diacritics.
|
||||
header.ensurePolished()
|
||||
header.setFixedHeight(header.fontMetrics().height())
|
||||
header.setText(f"▶ {title}")
|
||||
card_lay.addWidget(header)
|
||||
|
||||
body = QWidget()
|
||||
body.setVisible(False)
|
||||
body.setMaximumHeight(0)
|
||||
form = QFormLayout(body)
|
||||
form.setContentsMargins(0, 6, 0, 0)
|
||||
card_lay.addWidget(body)
|
||||
|
||||
anim = QPropertyAnimation(body, b"maximumHeight", body)
|
||||
anim.setDuration(_SECTION_ANIM_MS)
|
||||
anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
is_open = False
|
||||
|
||||
def _on_finished() -> None:
|
||||
if is_open:
|
||||
# Uncapped once open, so switching to a step whose fields make
|
||||
# this section taller/shorter (e.g. a parallel node's sub-agent
|
||||
# list appearing) is never clipped by the height this animation
|
||||
# last landed on.
|
||||
body.setMaximumHeight(16_777_215)
|
||||
else:
|
||||
body.setVisible(False)
|
||||
anim.finished.connect(_on_finished)
|
||||
|
||||
def _toggle() -> None:
|
||||
nonlocal is_open
|
||||
is_open = not is_open
|
||||
header.setText(f"{'▼' if is_open else '▶'} {title}")
|
||||
anim.stop()
|
||||
if is_open:
|
||||
body.setVisible(True)
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(body.sizeHint().height())
|
||||
else:
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(0)
|
||||
anim.start()
|
||||
header.clicked.connect(_toggle)
|
||||
|
||||
outer.addWidget(card)
|
||||
return form, card
|
||||
|
||||
|
||||
class StepConfigPanel(QScrollArea):
|
||||
changed = Signal() # any field edited → repaint node + autosave
|
||||
run_node = Signal(str) # "Run this step" (node id)
|
||||
run_from = Signal(str) # "Run from here"
|
||||
delete_node = Signal(str) # "Delete step"
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._step: Optional[Step] = None
|
||||
self._node_id = ""
|
||||
self._loading = False
|
||||
self.setWidgetResizable(True)
|
||||
host = QWidget()
|
||||
self.setWidget(host)
|
||||
outer = QVBoxLayout(host)
|
||||
outer.setSpacing(1)
|
||||
|
||||
# Grouped sections stacked on one scrolling page — same fields as
|
||||
# before, grouped by what they're for: identity, execution
|
||||
# (model/permission), and the extra resources fed to the step
|
||||
# (skills/files/sub-agents). No tabs/accordion: every group's border
|
||||
# and heading are what separate it from its neighbours, and all three
|
||||
# are on screen (or one scroll away) at once.
|
||||
form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_label"), self.label_edit)
|
||||
|
||||
self.role_edit = QLineEdit()
|
||||
self.role_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_role"), self.role_edit)
|
||||
|
||||
# Dropdown of every icon in the registry (Monitoring's Icon Management
|
||||
# set + built-ins), each row previewing its actual glyph — still
|
||||
# editable so a not-yet-added custom name can be typed directly.
|
||||
self.icon_edit = icon_picker_combo()
|
||||
self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder"))
|
||||
self.icon_edit.currentTextChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_icon"), self.icon_edit)
|
||||
|
||||
self.instructions_edit = QPlainTextEdit()
|
||||
self.instructions_edit.setMaximumHeight(120)
|
||||
self.instructions_edit.textChanged.connect(self._on_edit)
|
||||
self.gen_btn = QPushButton(tr("co4e.ai_draft"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip"))
|
||||
self.gen_btn.setEnabled(ctx is not None)
|
||||
self.gen_btn.clicked.connect(self._ai_draft)
|
||||
instr_box = QWidget()
|
||||
ib = QVBoxLayout(instr_box)
|
||||
ib.setContentsMargins(0, 0, 0, 0)
|
||||
ib.addWidget(self.instructions_edit)
|
||||
ib.addWidget(self.gen_btn, alignment=Qt.AlignRight)
|
||||
form.addRow(tr("co4e.f_instructions"), instr_box)
|
||||
|
||||
# Extra context — free-text background/info fed to the step at run time
|
||||
# (in addition to instructions, attachments and upstream outputs).
|
||||
self.context_edit = QPlainTextEdit()
|
||||
self.context_edit.setMaximumHeight(90)
|
||||
self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder"))
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setEditable(True)
|
||||
self.model_combo.editTextChanged.connect(self._on_edit)
|
||||
self.load_models_btn = QPushButton()
|
||||
self.load_models_btn.setIcon(icon("download"))
|
||||
self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip"))
|
||||
self.load_models_btn.clicked.connect(self._load_models)
|
||||
self.load_models_btn.setEnabled(ctx is not None)
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
mrow = QWidget(); mrow.setLayout(model_row)
|
||||
form2.addRow(tr("co4e.f_model"), mrow)
|
||||
|
||||
self.perm_combo = QComboBox()
|
||||
for preset in PERMISSION_PRESETS:
|
||||
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
|
||||
self.perm_combo.currentIndexChanged.connect(self._on_edit)
|
||||
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
|
||||
verify_row = QHBoxLayout()
|
||||
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
|
||||
self.verify_chk.toggled.connect(self._on_edit)
|
||||
self.rounds_spin = QSpinBox()
|
||||
self.rounds_spin.setRange(1, 5)
|
||||
self.rounds_spin.valueChanged.connect(self._on_edit)
|
||||
verify_row.addWidget(self.verify_chk)
|
||||
verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds")))
|
||||
verify_row.addWidget(self.rounds_spin)
|
||||
verify_row.addStretch(1)
|
||||
vrow = QWidget(); vrow.setLayout(verify_row)
|
||||
form2.addRow("", vrow)
|
||||
|
||||
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
|
||||
|
||||
# Skills checklist (registry skills)
|
||||
self.skills_list = QListWidget()
|
||||
self.skills_list.setMaximumHeight(110)
|
||||
self.skills_list.itemChanged.connect(self._on_edit)
|
||||
form3.addRow(tr("co4e.f_skills"), self.skills_list)
|
||||
|
||||
# Attachments — files whose extracted text is fed to this step at run time.
|
||||
self.attach_list = QListWidget()
|
||||
self.attach_list.setMaximumHeight(80)
|
||||
self.attach_add_btn = QPushButton(tr("co4e.attach_add"))
|
||||
self.attach_add_btn.setIcon(icon("plus"))
|
||||
self.attach_add_btn.clicked.connect(self._add_attachment)
|
||||
self.attach_del_btn = QPushButton(tr("co4e.attach_remove"))
|
||||
self.attach_del_btn.setIcon(icon("trash"))
|
||||
self.attach_del_btn.clicked.connect(self._del_attachment)
|
||||
att_btns = QHBoxLayout()
|
||||
att_btns.addWidget(self.attach_add_btn)
|
||||
att_btns.addWidget(self.attach_del_btn)
|
||||
att_btns.addStretch(1)
|
||||
abtn = QWidget(); abtn.setLayout(att_btns)
|
||||
form3.addRow(tr("co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow("", abtn)
|
||||
|
||||
# Parallel sub-agents get their OWN section — same header style as
|
||||
# Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside
|
||||
# Skills & Tệp, since it's really a distinct group, just one that
|
||||
# only applies to parallel-variant steps. load_step() hides the whole
|
||||
# card for a non-parallel step (see is_par below).
|
||||
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
|
||||
self.sub_list = QListWidget()
|
||||
self.sub_list.setMaximumHeight(90)
|
||||
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
|
||||
self.sub_add_btn = QPushButton(tr("co4e.add_subagent"))
|
||||
self.sub_add_btn.setIcon(icon("plus"))
|
||||
self.sub_add_btn.clicked.connect(self._add_subagent)
|
||||
self.sub_del_btn = QPushButton(tr("co4e.del_subagent"))
|
||||
self.sub_del_btn.setIcon(icon("trash"))
|
||||
self.sub_del_btn.clicked.connect(self._del_subagent)
|
||||
sub_btns = QHBoxLayout()
|
||||
sub_btns.addWidget(self.sub_add_btn)
|
||||
sub_btns.addWidget(self.sub_del_btn)
|
||||
sub_btns.addStretch(1)
|
||||
sbtn = QWidget(); sbtn.setLayout(sub_btns)
|
||||
form4.addRow(self.sub_list)
|
||||
form4.addRow("", sbtn)
|
||||
|
||||
# Footer actions — one compact row (Run · Run from here · Delete),
|
||||
# kept below every section, not inside one of the cards.
|
||||
self.run_btn = QPushButton(tr("co4e.run"))
|
||||
self.run_btn.setIcon(icon("play"))
|
||||
self.run_btn.setToolTip(tr("co4e.run_this_step"))
|
||||
self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id))
|
||||
self.run_from_btn = QPushButton(tr("co4e.run_from_here"))
|
||||
self.run_from_btn.setToolTip(tr("co4e.run_from_here"))
|
||||
self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id))
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setObjectName("danger")
|
||||
self.del_btn.setToolTip(tr("co4e.delete_step"))
|
||||
self.del_btn.setFixedWidth(38)
|
||||
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
|
||||
foot = QHBoxLayout()
|
||||
foot.addWidget(self.run_btn, 1)
|
||||
foot.addWidget(self.run_from_btn, 1)
|
||||
foot.addWidget(self.del_btn)
|
||||
foot_w = QWidget(); foot_w.setLayout(foot)
|
||||
outer.addWidget(foot_w)
|
||||
# Without this, QVBoxLayout hands every child widget an EQUAL share of
|
||||
# whatever extra height the scroll area's viewport has beyond the
|
||||
# content's own sizeHint (setWidgetResizable(True) stretches `host` to
|
||||
# fill it) — each collapsed header's card was measuring a true
|
||||
# sizeHint of ~17px but rendering over 100px taller, and no amount of
|
||||
# margin/padding/spacing on the header itself could touch that: the
|
||||
# surplus was being spent on the cards, not around them. One trailing
|
||||
# stretch absorbs all of it instead, so every section (and the
|
||||
# footer) renders at exactly its own natural height.
|
||||
outer.addStretch(1)
|
||||
|
||||
self.setEnabled(False)
|
||||
|
||||
# ---- load a step ------------------------------------------------------
|
||||
def load_step(self, node_id: str, step: Step, skill_names: List[str]) -> None:
|
||||
self._loading = True
|
||||
self._node_id = node_id
|
||||
self._step = step
|
||||
self.setEnabled(True)
|
||||
self.label_edit.setText(step.label)
|
||||
self.role_edit.setText(step.role)
|
||||
self.icon_edit.setCurrentText(step.icon)
|
||||
self.instructions_edit.setPlainText(step.instructions)
|
||||
self.context_edit.setPlainText(getattr(step, "context", ""))
|
||||
self.model_combo.setEditText(step.model)
|
||||
idx = self.perm_combo.findData(step.permission_preset)
|
||||
self.perm_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self.verify_chk.setChecked(step.self_verify)
|
||||
self.rounds_spin.setValue(max(1, step.max_verify_rounds))
|
||||
# skills checklist
|
||||
self.skills_list.clear()
|
||||
for name in skill_names:
|
||||
it = QListWidgetItem(name)
|
||||
it.setFlags(it.flags() | Qt.ItemIsUserCheckable)
|
||||
it.setCheckState(Qt.Checked if name in step.skills else Qt.Unchecked)
|
||||
self.skills_list.addItem(it)
|
||||
# attachments
|
||||
self.attach_list.clear()
|
||||
from pathlib import Path as _P
|
||||
for path in step.attachments:
|
||||
item = QListWidgetItem(_P(path).name)
|
||||
item.setToolTip(path)
|
||||
self.attach_list.addItem(item)
|
||||
# parallel sub-agents — the whole "Agent song song" section only
|
||||
# applies to parallel-variant steps, so the entire card (header
|
||||
# included) is hidden for any other step, not just its rows.
|
||||
is_par = step.is_parallel
|
||||
self._parallel_card.setVisible(is_par)
|
||||
self.sub_list.clear()
|
||||
if is_par:
|
||||
for sub in step.sub_agents:
|
||||
self.sub_list.addItem(sub.agent)
|
||||
self._loading = False
|
||||
|
||||
def clear_step(self) -> None:
|
||||
self._step = None
|
||||
self._node_id = ""
|
||||
self.setEnabled(False)
|
||||
|
||||
# ---- edits write back to the Step -------------------------------------
|
||||
def _on_edit(self, *_a) -> None:
|
||||
if self._loading or self._step is None:
|
||||
return
|
||||
s = self._step
|
||||
s.label = self.label_edit.text()
|
||||
s.role = self.role_edit.text().upper() or "AGENT"
|
||||
s.icon = self.icon_edit.currentText().strip()
|
||||
s.instructions = self.instructions_edit.toPlainText()
|
||||
s.context = self.context_edit.toPlainText()
|
||||
s.model = self.model_combo.currentText().strip()
|
||||
s.permission_preset = self.perm_combo.currentData() or "inherit"
|
||||
s.self_verify = self.verify_chk.isChecked()
|
||||
s.max_verify_rounds = self.rounds_spin.value()
|
||||
s.skills = [self.skills_list.item(i).text()
|
||||
for i in range(self.skills_list.count())
|
||||
if self.skills_list.item(i).checkState() == Qt.Checked]
|
||||
self.changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _available_agent_names() -> List[str]:
|
||||
"""Agents the user can pick as a parallel sub-agent: their own custom
|
||||
agents first, then the built-in personas (kept for resolution even
|
||||
though they're no longer in the palette)."""
|
||||
from ..core import co4e
|
||||
from ..core.co4e_builtins import BUILTIN_AGENTS
|
||||
|
||||
names = [a.name for a in co4e.list_custom_agents()]
|
||||
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
|
||||
return names
|
||||
|
||||
def _add_subagent(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
if names:
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names, 0, True) # editable: can type a new one
|
||||
else:
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
self._step.sub_agents.append(SubAgent(agent=name))
|
||||
self.sub_list.addItem(name)
|
||||
self.changed.emit()
|
||||
|
||||
def _edit_subagent(self, item) -> None:
|
||||
"""Double-click a sub-agent row → re-pick from the list."""
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.sub_list.row(item)
|
||||
if not (0 <= row < len(self._step.sub_agents)):
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
names = self._available_agent_names()
|
||||
cur = self._step.sub_agents[row].agent
|
||||
start = names.index(cur) if cur in names else 0
|
||||
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
|
||||
names or [cur], start, True)
|
||||
name = (name or "").strip()
|
||||
if ok and name:
|
||||
self._step.sub_agents[row].agent = name
|
||||
item.setText(name)
|
||||
self.changed.emit()
|
||||
|
||||
def _del_subagent(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.sub_list.currentRow()
|
||||
if 0 <= row < len(self._step.sub_agents):
|
||||
self._step.sub_agents.pop(row)
|
||||
self.sub_list.takeItem(row)
|
||||
self.changed.emit()
|
||||
|
||||
def _add_attachment(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
from pathlib import Path as _P
|
||||
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("co4e.attach_add"))
|
||||
for f in files:
|
||||
if f and f not in self._step.attachments:
|
||||
self._step.attachments.append(f)
|
||||
item = QListWidgetItem(_P(f).name)
|
||||
item.setToolTip(f)
|
||||
self.attach_list.addItem(item)
|
||||
if files:
|
||||
self.changed.emit()
|
||||
|
||||
def _del_attachment(self) -> None:
|
||||
if self._step is None:
|
||||
return
|
||||
row = self.attach_list.currentRow()
|
||||
if 0 <= row < len(self._step.attachments):
|
||||
self._step.attachments.pop(row)
|
||||
self.attach_list.takeItem(row)
|
||||
self.changed.emit()
|
||||
|
||||
def _ai_draft(self) -> None:
|
||||
"""Draft this step's instructions from its label (name) + role — first
|
||||
asking for an optional description so the generated instructions can be
|
||||
more specific/detailed than name+role alone would produce."""
|
||||
if self.ctx is None or self._step is None:
|
||||
return
|
||||
from ..core.worker import AgentWorker
|
||||
|
||||
name = self.label_edit.text().strip()
|
||||
role = self.role_edit.text().strip()
|
||||
if not name and not role:
|
||||
return
|
||||
hint, ok = QInputDialog.getMultiLineText(
|
||||
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
|
||||
if not ok:
|
||||
return
|
||||
hint = hint.strip()
|
||||
self.gen_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
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):
|
||||
self.gen_btn.setEnabled(True)
|
||||
if result.get("text"):
|
||||
self.instructions_edit.setPlainText(result["text"]) # _on_edit persists it
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: self.gen_btn.setEnabled(True))
|
||||
self._draft_worker = w
|
||||
w.start()
|
||||
|
||||
def _load_models(self) -> None:
|
||||
if self.ctx is None:
|
||||
return
|
||||
from ..core import preview_ai
|
||||
from ..core.worker import AgentWorker
|
||||
|
||||
self.load_models_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_w):
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict):
|
||||
self.load_models_btn.setEnabled(True)
|
||||
models = []
|
||||
for lst in (result or {}).values():
|
||||
models.extend(lst)
|
||||
cur = self.model_combo.currentText()
|
||||
self.model_combo.blockSignals(True)
|
||||
self.model_combo.clear()
|
||||
self.model_combo.addItems(sorted(set(models)))
|
||||
self.model_combo.setEditText(cur)
|
||||
self.model_combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: self.load_models_btn.setEnabled(True))
|
||||
self._model_worker = w
|
||||
w.start()
|
||||
__all__ = ["StepConfigPanel"]
|
||||
|
||||
+63
-1726
File diff suppressed because it is too large
Load Diff
+18
-658
@@ -1,663 +1,23 @@
|
||||
"""Message composer: multiline input, attachments, Send/Stop, message queue.
|
||||
"""Vỏ chuyển tiếp — R08-T02.
|
||||
|
||||
Several turns can run at once (up to the configured parallel limit). Once that
|
||||
limit is reached the composer switches to "Queue" mode: extra messages (with
|
||||
their attachments) are held in the queue and dispatched automatically as running
|
||||
turns finish and free up a slot. Files/images can be attached to a message.
|
||||
Phần thân đã chuyển sang ``presentation/chat/composer_widget.py`` (thanh công
|
||||
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 datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QImage, QKeyEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
|
||||
QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
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,
|
||||
)
|
||||
|
||||
from ..config import CONFIG_DIR
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon, IconLabel
|
||||
|
||||
|
||||
def _save_pasted_image(image) -> str | None:
|
||||
"""Save a clipboard/drag QImage to the config dir; return its path."""
|
||||
try:
|
||||
if not isinstance(image, QImage) or image.isNull():
|
||||
return None
|
||||
folder = CONFIG_DIR / "pasted"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png"
|
||||
path = folder / name
|
||||
if image.save(str(path), "PNG"):
|
||||
return str(path)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _is_local_skill_command(text: str) -> bool:
|
||||
"""True for a bare ``/skill`` (list) or ``/skill:<name>`` (select) command that
|
||||
is answered inline instantly — these must run even while a turn is busy, so they
|
||||
bypass the message queue (unlike ``/skill:<name> <request>``, which is a real
|
||||
turn and should queue)."""
|
||||
import re
|
||||
t = (text or "").strip()
|
||||
return t == "/skill" or bool(re.match(r"^/skill:[\w\-.]+$", t))
|
||||
|
||||
|
||||
def _is_local_agent_command(text: str) -> bool:
|
||||
"""Same as ``_is_local_skill_command`` but for the ``/agent`` directive: a bare
|
||||
``/agent`` (list) or ``/agent:<name>`` (select) is answered inline instantly."""
|
||||
import re
|
||||
t = (text or "").strip()
|
||||
return t == "/agent" or bool(re.match(r"^/agent:[\w\-.]+$", t))
|
||||
|
||||
|
||||
def _paths_from_mime(md) -> List[str]:
|
||||
paths: List[str] = []
|
||||
if md.hasUrls():
|
||||
for u in md.urls():
|
||||
if u.isLocalFile():
|
||||
paths.append(u.toLocalFile())
|
||||
if not paths and md.hasImage():
|
||||
p = _save_pasted_image(md.imageData())
|
||||
if p:
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
|
||||
class _SkillPopup(QListWidget):
|
||||
"""The ``/skill`` picker.
|
||||
|
||||
Shown as a NON-activating overlay (``WA_ShowWithoutActivating``) — crucially it
|
||||
does NOT grab the keyboard, so the input keeps focus and the user can keep
|
||||
typing their request after ``/skill``. Navigation / accept / Esc are handled by
|
||||
the parent ``_Input``'s key handler (which still receives every key); clicking
|
||||
an item selects it; the popup auto-hides when the input loses focus."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
|
||||
| Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
|
||||
self.setFocusPolicy(Qt.NoFocus)
|
||||
|
||||
|
||||
class _Input(QPlainTextEdit):
|
||||
"""Plain text edit: submits on Enter, accepts pasted/dropped images & files."""
|
||||
|
||||
submit = Signal()
|
||||
media_added = Signal(list)
|
||||
manage_skills = Signal() # user picked "Manage skills…" in the /skill popup
|
||||
|
||||
MIN_HEIGHT = 64 # ~2 lines
|
||||
MAX_HEIGHT = 220 # ~8 lines, then it scrolls
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAcceptDrops(True)
|
||||
# Use a clean Latin/Vietnamese-friendly UI font for the input (the global
|
||||
# '*' rule falls back to Japanese faces, which mis-render some glyphs).
|
||||
self.setStyleSheet(
|
||||
"font-family: 'Segoe UI', 'Helvetica Neue', 'Arial', sans-serif; font-size: 14px;"
|
||||
)
|
||||
# Grow with the text (up to MAX_HEIGHT), then scroll instead.
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.textChanged.connect(self._adjust_height)
|
||||
# "/skill" + "/agent" command popup — lists skills / agents inline.
|
||||
self._skill_popup = _SkillPopup(self)
|
||||
self._popup_kind = "skill" # which command the popup is showing
|
||||
self._skill_popup.itemClicked.connect(self._accept_item)
|
||||
self.textChanged.connect(self._maybe_show_skills)
|
||||
self._adjust_height()
|
||||
|
||||
# ---- /skill autocomplete ----------------------------------------
|
||||
def _skill_token(self):
|
||||
"""Locate a ``/skill[:partial]`` command the cursor is currently typing —
|
||||
ANYWHERE in the message, not just at the start (so "dùng /skill:foo …"
|
||||
with text typed before it still triggers the picker). Mirrors
|
||||
``core.skills.parse_skill_command``'s whitespace-boundary rule.
|
||||
|
||||
Returns ``(start_offset, partial_filter)`` — ``start_offset`` is where the
|
||||
``/skill`` token begins in the document, ``partial_filter`` is the text
|
||||
typed after ``:`` (``''`` while still typing the command word itself) — or
|
||||
``None`` when the cursor isn't inside a ``/skill`` token."""
|
||||
import re
|
||||
pos = self.textCursor().position()
|
||||
before = self.toPlainText()[:pos]
|
||||
# The token is the whitespace-delimited word ending at the cursor; its
|
||||
# start must be the document start or follow whitespace (same boundary
|
||||
# parse_skill_command enforces with its (?<!\S) lookbehind).
|
||||
start = re.search(r"\S*$", before).start()
|
||||
token = before[start:]
|
||||
if len(token) >= 2 and "/skill".startswith(token):
|
||||
return start, "" # typing "/s", "/sk", … "/skill" → show the whole list
|
||||
m = re.match(r"^/skill:?([\w\-.]*)$", token)
|
||||
return (start, m.group(1)) if m else None
|
||||
|
||||
def _skill_filter(self):
|
||||
"""Return the partial filter while a '/skill' command is being typed
|
||||
(anywhere in the message), or None."""
|
||||
tok = self._skill_token()
|
||||
return tok[1] if tok else None
|
||||
|
||||
def _agent_token(self):
|
||||
"""Locate a ``/agent[:partial]`` command the cursor is typing (mirror of
|
||||
``_skill_token``). Returns ``(start_offset, partial)`` or None."""
|
||||
import re
|
||||
pos = self.textCursor().position()
|
||||
before = self.toPlainText()[:pos]
|
||||
start = re.search(r"\S*$", before).start()
|
||||
token = before[start:]
|
||||
if len(token) >= 2 and "/agent".startswith(token):
|
||||
return start, ""
|
||||
m = re.match(r"^/agent:?([\w\-.]*)$", token)
|
||||
return (start, m.group(1)) if m else None
|
||||
|
||||
def _maybe_show_skills(self) -> None:
|
||||
# One popup serves both commands: show skills while typing /skill, agents
|
||||
# while typing /agent (Cowork parity with the Co4E chat).
|
||||
stok = self._skill_token()
|
||||
if stok is not None:
|
||||
self._popup_kind = "skill"
|
||||
self._populate_skill_popup(stok[1])
|
||||
self._show_cmd_popup()
|
||||
return
|
||||
atok = self._agent_token()
|
||||
if atok is not None:
|
||||
self._popup_kind = "agent"
|
||||
self._populate_agent_popup(atok[1])
|
||||
self._show_cmd_popup()
|
||||
return
|
||||
self._skill_popup.hide()
|
||||
|
||||
def _populate_skill_popup(self, filt: str) -> None:
|
||||
try:
|
||||
from ..core.skills import builtin_skills, list_skills
|
||||
# Include always-on built-ins so the picker is usable before the user
|
||||
# has created any custom skill.
|
||||
skills = list_skills() + builtin_skills()
|
||||
except Exception:
|
||||
skills = []
|
||||
f = (filt or "").lower()
|
||||
matches = [s for s in skills
|
||||
if f in s.name.lower() or f in s.slug.lower() or f in (s.description or "").lower()]
|
||||
self._skill_popup.clear()
|
||||
for s in matches:
|
||||
text = ("✓ " if s.enabled else " ") + s.name
|
||||
if s.description:
|
||||
text += f" — {s.description}"
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, s.slug)
|
||||
self._skill_popup.addItem(item)
|
||||
if not matches:
|
||||
empty = QListWidgetItem(tr("composer.no_skills"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
self._skill_popup.addItem(empty)
|
||||
manage = QListWidgetItem(tr("composer.manage_skills"))
|
||||
manage.setData(Qt.UserRole, "__manage__")
|
||||
self._skill_popup.addItem(manage)
|
||||
self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
|
||||
|
||||
def _populate_agent_popup(self, filt: str) -> None:
|
||||
try:
|
||||
from ..core.agent_command import collect_agents
|
||||
agents = collect_agents("") # built-ins + local admin + custom agents
|
||||
except Exception:
|
||||
agents = []
|
||||
f = (filt or "").lower()
|
||||
matches = [a for a in agents
|
||||
if f in a["slug"].lower() or f in a["name"].lower() or f in (a.get("desc") or "").lower()]
|
||||
self._skill_popup.clear()
|
||||
for a in matches:
|
||||
text = a["name"] + (f" — {a['desc']}" if a.get("desc") else "")
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, a["slug"])
|
||||
self._skill_popup.addItem(item)
|
||||
if not matches:
|
||||
empty = QListWidgetItem(tr("composer.no_agents"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
self._skill_popup.addItem(empty)
|
||||
self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
|
||||
|
||||
def _show_cmd_popup(self) -> None:
|
||||
rows = min(7, self._skill_popup.count())
|
||||
h = 10 + rows * 22
|
||||
self._skill_popup.resize(max(300, self.width()), h)
|
||||
top_left = self.mapToGlobal(self.rect().topLeft())
|
||||
self._skill_popup.move(top_left.x(), top_left.y() - h - 2)
|
||||
self._skill_popup.show()
|
||||
|
||||
def _dismiss_skill_popup(self) -> None:
|
||||
"""Hide the /skill picker (Esc)."""
|
||||
self._skill_popup.hide()
|
||||
|
||||
def focusOutEvent(self, e) -> None: # noqa: N802
|
||||
# The popup never grabs focus, so a click away lands here → dismiss it
|
||||
# (unless the click is on the popup itself, e.g. picking an item).
|
||||
if not self._skill_popup.underMouse():
|
||||
self._skill_popup.hide()
|
||||
super().focusOutEvent(e)
|
||||
|
||||
def _accept_item(self, item=None) -> None:
|
||||
"""Dispatch popup selection to the right handler based on which command
|
||||
(``/skill`` or ``/agent``) the popup is currently showing."""
|
||||
if self._popup_kind == "agent":
|
||||
self._accept_agent(item)
|
||||
else:
|
||||
self._accept_skill(item)
|
||||
|
||||
def _replace_token(self, tok, replacement: str) -> None:
|
||||
pos = self.textCursor().position()
|
||||
start = tok[0] if tok else pos
|
||||
full = self.toPlainText()
|
||||
new_text = full[:start] + replacement + full[pos:]
|
||||
new_pos = start + len(replacement)
|
||||
self.blockSignals(True)
|
||||
self.setPlainText(new_text)
|
||||
self.blockSignals(False)
|
||||
cur = self.textCursor()
|
||||
cur.setPosition(min(new_pos, len(new_text)))
|
||||
self.setTextCursor(cur)
|
||||
self._adjust_height()
|
||||
self.setFocus()
|
||||
|
||||
def _accept_skill(self, item=None) -> None:
|
||||
item = item or self._skill_popup.currentItem()
|
||||
self._skill_popup.hide()
|
||||
if item is None:
|
||||
return
|
||||
slug = item.data(Qt.UserRole)
|
||||
if slug == "__manage__":
|
||||
self.manage_skills.emit() # open the Skills manager
|
||||
return
|
||||
if not slug:
|
||||
return
|
||||
# Replace ONLY the /skill token the cursor is on — text typed before it
|
||||
# ("dùng …") and after it is preserved, so the command can sit mid-sentence.
|
||||
self._replace_token(self._skill_token(), f"/skill:{slug} ")
|
||||
|
||||
def _accept_agent(self, item=None) -> None:
|
||||
item = item or self._skill_popup.currentItem()
|
||||
self._skill_popup.hide()
|
||||
if item is None:
|
||||
return
|
||||
slug = item.data(Qt.UserRole)
|
||||
if not slug:
|
||||
return
|
||||
self._replace_token(self._agent_token(), f"/agent:{slug} ")
|
||||
|
||||
def _adjust_height(self, *_a) -> None:
|
||||
# QPlainTextEdit reports the document height in LINES (not pixels), so
|
||||
# convert via line spacing to get the real pixel height.
|
||||
lines = self.document().size().height() or 1
|
||||
line_px = self.fontMetrics().lineSpacing()
|
||||
h = int(lines * line_px + 2 * self.frameWidth() + 12)
|
||||
h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
|
||||
if h != self.height():
|
||||
self.setFixedHeight(h)
|
||||
|
||||
def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802
|
||||
if self._skill_popup.isVisible():
|
||||
k = e.key()
|
||||
if k in (Qt.Key_Down, Qt.Key_Up):
|
||||
n = self._skill_popup.count()
|
||||
if n:
|
||||
step = 1 if k == Qt.Key_Down else -1
|
||||
self._skill_popup.setCurrentRow((self._skill_popup.currentRow() + step) % n)
|
||||
return
|
||||
if k == Qt.Key_Tab:
|
||||
self._accept_item() # Tab = autocomplete the highlighted item
|
||||
return
|
||||
if k == Qt.Key_Escape:
|
||||
self._dismiss_skill_popup()
|
||||
return
|
||||
if k in (Qt.Key_Return, Qt.Key_Enter):
|
||||
item = self._skill_popup.currentItem()
|
||||
slug = item.data(Qt.UserRole) if item else None
|
||||
is_agent = self._popup_kind == "agent"
|
||||
tok = self._agent_token() if is_agent else self._skill_token()
|
||||
prefix = "/agent:" if is_agent else "/skill:"
|
||||
token = self.toPlainText()[tok[0]:self.textCursor().position()] if tok else ""
|
||||
exact = bool(slug) and slug != "__manage__" and token == f"{prefix}{slug}"
|
||||
if slug and slug != "__manage__" and not exact:
|
||||
# A suggestion is highlighted but not yet fully typed —
|
||||
# Enter completes it into the box first (same as Tab),
|
||||
# instead of submitting a partial/mistyped slug that
|
||||
# the parser would just reject as "not found".
|
||||
self._accept_item(item)
|
||||
return
|
||||
# Slug already fully typed (or nothing usable is highlighted,
|
||||
# e.g. the "no skills found" placeholder) — Enter RUNS the
|
||||
# /skill command as typed: hide the popup and fall through to
|
||||
# the normal submit below.
|
||||
self._skill_popup.hide()
|
||||
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
|
||||
self.submit.emit()
|
||||
return
|
||||
super().keyPressEvent(e)
|
||||
|
||||
def insertFromMimeData(self, source) -> None: # noqa: N802 - paste
|
||||
paths = _paths_from_mime(source)
|
||||
if paths:
|
||||
self.media_added.emit(paths)
|
||||
return
|
||||
super().insertFromMimeData(source)
|
||||
|
||||
def canInsertFromMimeData(self, source) -> bool: # noqa: N802
|
||||
if source.hasImage() or source.hasUrls():
|
||||
return True
|
||||
return super().canInsertFromMimeData(source)
|
||||
|
||||
def dragEnterEvent(self, e) -> None: # noqa: N802
|
||||
if e.mimeData().hasUrls() or e.mimeData().hasImage():
|
||||
e.acceptProposedAction()
|
||||
return
|
||||
super().dragEnterEvent(e)
|
||||
|
||||
def dragMoveEvent(self, e) -> None: # noqa: N802
|
||||
if e.mimeData().hasUrls() or e.mimeData().hasImage():
|
||||
e.acceptProposedAction()
|
||||
return
|
||||
super().dragMoveEvent(e)
|
||||
|
||||
def dropEvent(self, e) -> None: # noqa: N802
|
||||
paths = _paths_from_mime(e.mimeData())
|
||||
if paths:
|
||||
self.media_added.emit(paths)
|
||||
e.acceptProposedAction()
|
||||
return
|
||||
super().dropEvent(e)
|
||||
|
||||
|
||||
class Composer(QWidget):
|
||||
submitted = Signal(str, list) # (text, attachment paths)
|
||||
stop_requested = Signal()
|
||||
queue_changed = Signal(int)
|
||||
attachments_added = Signal(list) # current attachment paths (pushed to the Input box)
|
||||
attachment_removed = Signal(str) # a wrongly-added attachment was removed
|
||||
attach_limit_note = Signal(str) # shown when the attachment-count limit is hit
|
||||
manage_skills = Signal() # relayed from the /skill popup "Manage skills…"
|
||||
|
||||
def __init__(self, placeholder_key: str = "composer.placeholder_default"):
|
||||
super().__init__()
|
||||
self._placeholder_key = placeholder_key # i18n key, re-looked-up on language change
|
||||
self._queue: List[Dict] = [] # each: {"text": str, "attachments": [str]}
|
||||
self._attachments: List[str] = []
|
||||
self._max_attachments = 0 # 0 = unlimited; set from Settings
|
||||
self._busy = False
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(6)
|
||||
|
||||
# --- queue strip (hidden when empty) ---
|
||||
self.queue_box = QWidget()
|
||||
qlay = QVBoxLayout(self.queue_box)
|
||||
qlay.setContentsMargins(0, 0, 0, 0)
|
||||
self.queue_label = QLabel()
|
||||
self.queue_label.setObjectName("hint")
|
||||
self.queue_list = QListWidget()
|
||||
self.queue_list.setMaximumHeight(78)
|
||||
self.queue_list.itemDoubleClicked.connect(self._remove_queue_item)
|
||||
qlay.addWidget(self.queue_label)
|
||||
qlay.addWidget(self.queue_list)
|
||||
self.queue_box.setVisible(False)
|
||||
root.addWidget(self.queue_box)
|
||||
|
||||
# --- attachments strip (hidden when empty) ---
|
||||
self.attach_box = QWidget()
|
||||
alay = QVBoxLayout(self.attach_box)
|
||||
alay.setContentsMargins(0, 0, 0, 0)
|
||||
self.attach_label = QLabel()
|
||||
self.attach_label.setObjectName("hint")
|
||||
self.attach_list = QListWidget()
|
||||
# Single horizontal row of chips; scroll sideways when there are many.
|
||||
self.attach_list.setFlow(QListView.LeftToRight)
|
||||
self.attach_list.setWrapping(False)
|
||||
self.attach_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.attach_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.attach_list.setFixedHeight(40)
|
||||
self.attach_list.itemDoubleClicked.connect(self._remove_attachment)
|
||||
alay.addWidget(self.attach_label)
|
||||
alay.addWidget(self.attach_list)
|
||||
self.attach_box.setVisible(False)
|
||||
root.addWidget(self.attach_box)
|
||||
|
||||
# --- input row ---
|
||||
row = QHBoxLayout()
|
||||
self.input = _Input()
|
||||
self.input.setPlaceholderText(tr(self._placeholder_key))
|
||||
self.input.submit.connect(self._on_submit)
|
||||
self.input.media_added.connect(self._add_paths)
|
||||
self.input.manage_skills.connect(self.manage_skills.emit)
|
||||
row.addWidget(self.input, 1)
|
||||
|
||||
btns = QVBoxLayout()
|
||||
self.attach_btn = QPushButton("")
|
||||
self.attach_btn.setIcon(icon("attach"))
|
||||
self.attach_btn.clicked.connect(self._pick_attachments)
|
||||
self.send_btn = QPushButton()
|
||||
self.send_btn.setIcon(icon("upload"))
|
||||
self.send_btn.setObjectName("primary")
|
||||
self.send_btn.clicked.connect(self._on_submit)
|
||||
self.stop_btn = QPushButton()
|
||||
self.stop_btn.setIcon(icon("stop"))
|
||||
self.stop_btn.setObjectName("danger")
|
||||
self.stop_btn.setVisible(False)
|
||||
self.stop_btn.clicked.connect(self.stop_requested.emit)
|
||||
# Attach pinned to the input's top edge, Send (and Stop, once a turn
|
||||
# is running) pinned to its bottom edge — the gap between them is
|
||||
# absorbed by this stretch instead of splitting evenly above/below
|
||||
# the whole button column, which is what centering it did before.
|
||||
btns.addWidget(self.attach_btn)
|
||||
btns.addStretch(1)
|
||||
btns.addWidget(self.send_btn)
|
||||
btns.addWidget(self.stop_btn)
|
||||
row.addLayout(btns)
|
||||
root.addLayout(row)
|
||||
|
||||
# bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
|
||||
# right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
|
||||
# Its own strip UNDER the typing box, styled as a status line rather
|
||||
# than a second toolbar: the design asks for the typing area to be just
|
||||
# input · attach · send, with agent / routing / usage / folder reading
|
||||
# as status underneath. They stay interactive — only quieter.
|
||||
self._bottom_left_count = 0
|
||||
self.extra_bar = QWidget()
|
||||
self.extra_bar.setObjectName("composerStatus")
|
||||
self.extra_row = QHBoxLayout(self.extra_bar)
|
||||
self.extra_row.setContentsMargins(2, 2, 2, 0)
|
||||
self.extra_row.setSpacing(6)
|
||||
self.extra_row.addStretch(1)
|
||||
root.addWidget(self.extra_bar)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.queue_list.setToolTip(tr("composer.queue_tooltip"))
|
||||
self.attach_list.setToolTip(tr("composer.attachments_tooltip"))
|
||||
self.attach_btn.setToolTip(tr("composer.attach_btn_tooltip"))
|
||||
self.send_btn.setText(tr("composer.queue_btn") if self._busy else tr("composer.send"))
|
||||
self.stop_btn.setText(tr("composer.stop"))
|
||||
if self.input.toPlainText().strip() == "" and not self._attachments:
|
||||
self.input.setPlaceholderText(tr(self._placeholder_key))
|
||||
self._refresh_queue()
|
||||
self._refresh_attachments()
|
||||
|
||||
def add_bottom_right(self, widget) -> None:
|
||||
self.extra_row.addWidget(widget)
|
||||
|
||||
def add_bottom_left(self, widget) -> None:
|
||||
"""Insert before the stretch, after any previously-added left widget —
|
||||
so repeated calls read left-to-right in call order, same row as
|
||||
whatever add_bottom_right widgets (e.g. the Agent combo) sit on the
|
||||
right of the stretch."""
|
||||
self.extra_row.insertWidget(self._bottom_left_count, widget)
|
||||
self._bottom_left_count += 1
|
||||
|
||||
# ---- public API --------------------------------------------------
|
||||
def set_text(self, text: str) -> None:
|
||||
self.input.setPlainText(text)
|
||||
self.input.setFocus()
|
||||
|
||||
def reset_input(self) -> None:
|
||||
"""Clear the input + pending attachments and restore the default placeholder
|
||||
(used on New chat so no stale text or 'Attached: …' hint carries over)."""
|
||||
self.input.clear()
|
||||
self._attachments = []
|
||||
self._refresh_attachments()
|
||||
self.input.setPlaceholderText(tr(self._placeholder_key))
|
||||
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
"""Capacity gate: when True, new sends are queued (the Send button reads
|
||||
'Queue'). Independent of whether any turn is running — see set_running."""
|
||||
self._busy = busy
|
||||
self.send_btn.setText(tr("composer.queue_btn") if busy else tr("composer.send"))
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
"""Show the Stop button whenever at least one turn is running (may be True
|
||||
even when not at capacity, so a single in-flight message can be stopped)."""
|
||||
self.stop_btn.setVisible(running)
|
||||
|
||||
def has_queue(self) -> bool:
|
||||
return bool(self._queue)
|
||||
|
||||
def pop_next(self) -> Dict | None:
|
||||
if not self._queue:
|
||||
return None
|
||||
item = self._queue.pop(0)
|
||||
self._refresh_queue()
|
||||
return item
|
||||
|
||||
def clear_queue(self) -> None:
|
||||
self._queue.clear()
|
||||
self._refresh_queue()
|
||||
|
||||
def enqueue(self, text: str, attachments: List[str] | None = None) -> None:
|
||||
self._queue.append({"text": text, "attachments": list(attachments or [])})
|
||||
self._refresh_queue()
|
||||
|
||||
# ---- attachments -------------------------------------------------
|
||||
def set_max_attachments(self, n: int) -> None:
|
||||
self._max_attachments = max(0, int(n or 0))
|
||||
|
||||
def _add_one(self, path: str) -> bool:
|
||||
"""Add a file unless it's a duplicate or the count limit is reached.
|
||||
Returns False (and notifies) when the limit blocked it."""
|
||||
if not path or path in self._attachments:
|
||||
return True
|
||||
if self._max_attachments and len(self._attachments) >= self._max_attachments:
|
||||
self.attach_limit_note.emit(tr("chatpanel.attach_limit", n=self._max_attachments))
|
||||
return False
|
||||
self._attachments.append(path)
|
||||
return True
|
||||
|
||||
def _pick_attachments(self) -> None:
|
||||
files, _ = QFileDialog.getOpenFileNames(
|
||||
self, tr("composer.attach_dialog_title"), "",
|
||||
tr("composer.attach_dialog_filter"),
|
||||
)
|
||||
for f in files:
|
||||
if not self._add_one(f):
|
||||
break
|
||||
self._refresh_attachments()
|
||||
|
||||
def _add_paths(self, paths: List[str]) -> None:
|
||||
"""Add attachments from paste / drag-drop."""
|
||||
for p in paths:
|
||||
if not self._add_one(p):
|
||||
break
|
||||
self._refresh_attachments()
|
||||
if paths:
|
||||
names = ", ".join(Path(p).name for p in paths)
|
||||
self.input.setPlaceholderText(tr("chatpanel.attached_hint", names=names))
|
||||
|
||||
def _remove_attachment(self, item: QListWidgetItem) -> None:
|
||||
idx = self.attach_list.row(item)
|
||||
if 0 <= idx < len(self._attachments):
|
||||
self._remove_attachment_path(self._attachments[idx])
|
||||
|
||||
def _remove_attachment_path(self, path: str) -> None:
|
||||
"""Remove one wrongly-added file (✕ button or double-click)."""
|
||||
if path in self._attachments:
|
||||
self._attachments.remove(path)
|
||||
self._refresh_attachments()
|
||||
self.attachment_removed.emit(path) # also drop it from the Input panel
|
||||
|
||||
def _refresh_attachments(self) -> None:
|
||||
self.attach_list.clear()
|
||||
for p in self._attachments:
|
||||
item = QListWidgetItem()
|
||||
row = QWidget()
|
||||
_cp = current_palette()
|
||||
row.setStyleSheet(
|
||||
f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
|
||||
f" border-radius: {_cp.radius_sm}px;")
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(8, 2, 4, 2)
|
||||
h.setSpacing(4)
|
||||
short = Path(p).name
|
||||
if len(short) > 22:
|
||||
short = short[:19] + "…"
|
||||
name = IconLabel("attach", short, size=13)
|
||||
name.setToolTip(p)
|
||||
remove = QPushButton()
|
||||
remove.setIcon(icon("close", size=12))
|
||||
remove.setObjectName("danger")
|
||||
remove.setFixedSize(18, 18)
|
||||
remove.setToolTip(tr("composer.remove_tooltip"))
|
||||
remove.setCursor(Qt.PointingHandCursor)
|
||||
remove.clicked.connect(lambda _=False, path=p: self._remove_attachment_path(path))
|
||||
h.addWidget(name) # compact chip (no stretch → many fit in one row)
|
||||
h.addWidget(remove)
|
||||
item.setSizeHint(row.sizeHint())
|
||||
self.attach_list.addItem(item)
|
||||
self.attach_list.setItemWidget(item, row)
|
||||
self.attach_label.setText(tr("composer.attachments_label", n=len(self._attachments)))
|
||||
self.attach_box.setVisible(bool(self._attachments))
|
||||
if self._attachments:
|
||||
self.attachments_added.emit(list(self._attachments))
|
||||
|
||||
# ---- submit / queue ----------------------------------------------
|
||||
def _on_submit(self) -> None:
|
||||
text = self.input.toPlainText().strip()
|
||||
attachments = list(self._attachments)
|
||||
if not text and not attachments:
|
||||
return
|
||||
self.input.clear()
|
||||
self._attachments = []
|
||||
self._refresh_attachments()
|
||||
self.input.setPlaceholderText(tr(self._placeholder_key)) # clear any "Attached: …" hint
|
||||
# A local /skill or /agent list/select command is answered inline instantly
|
||||
# — run it now even while a turn is busy (don't bury it in the queue).
|
||||
if self._busy and not (_is_local_skill_command(text) or _is_local_agent_command(text)):
|
||||
self._queue.append({"text": text, "attachments": attachments})
|
||||
self._refresh_queue()
|
||||
else:
|
||||
self.submitted.emit(text, attachments)
|
||||
|
||||
def _remove_queue_item(self, item: QListWidgetItem) -> None:
|
||||
idx = self.queue_list.row(item)
|
||||
if 0 <= idx < len(self._queue):
|
||||
self._queue.pop(idx)
|
||||
self._refresh_queue()
|
||||
|
||||
def _refresh_queue(self) -> None:
|
||||
self.queue_list.clear()
|
||||
for i, entry in enumerate(self._queue, 1):
|
||||
text = entry.get("text", "")
|
||||
n = len(entry.get("attachments", []))
|
||||
preview = text if len(text) <= 70 else text[:70] + "…"
|
||||
if n:
|
||||
preview += f" (+{n})"
|
||||
self.queue_list.addItem(f"{i}. {preview}")
|
||||
self.queue_label.setText(tr("composer.queue_label", n=len(self._queue)))
|
||||
self.queue_box.setVisible(bool(self._queue))
|
||||
self.queue_changed.emit(len(self._queue))
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+101
-19
@@ -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,36 +362,72 @@ 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()}")
|
||||
self._apply_output_folder_label() # picks up edits made via Settings too
|
||||
|
||||
def build_job(self, text: str, messages, out_dir):
|
||||
# Each turn writes into its OWN isolated folder (out_dir) and works on its
|
||||
# OWN message list, so several turns can run in parallel without clobbering
|
||||
# each other's files or history. Deliverables are moved up to the session
|
||||
# Output root when the turn finishes (see _cleanup_turn).
|
||||
"""This turn's job: a frozen request run through the conversation service.
|
||||
|
||||
Since R04-T04 the widget no longer drives the turn loop. Every value a
|
||||
turn depends on is read HERE, on the UI thread at submit time, and packed
|
||||
into an immutable ``ConversationExecutionRequest`` — so clicking a
|
||||
different model or switching workspace mid-answer cannot reach work
|
||||
already in flight.
|
||||
"""
|
||||
output_dir = out_dir or self._session_output_dir()
|
||||
# The sandbox folder is named by the turn id ('.turns/t3'); with no
|
||||
# sandbox the session id identifies the turn well enough for the audit log.
|
||||
turn_id = out_dir.name if out_dir is not None else self.session_id
|
||||
session_id = self.session_id
|
||||
title = self.title
|
||||
project_id = self.project_id
|
||||
home_output_root = self.workspace_dir()
|
||||
# Captured at submit time (UI thread): the Admin-defined agent
|
||||
# preset's instructions, if one is selected in the Agent picker.
|
||||
agent_prompt = self.admin_agent_prompt()
|
||||
# Per-workspace Auto-run override wins, else the global "confirm before
|
||||
# running commands" setting. Frozen now, so a Settings change mid-turn
|
||||
# cannot flip the rules this turn started under.
|
||||
confirm_commands = self.ctx.project_confirm_commands()
|
||||
# What the turn is recorded as running on. A routing override (R03) wins
|
||||
# over the tab's own picker; '' means the provider's configured default.
|
||||
# Informational only — an Admin-agent preset builds its own provider
|
||||
# below, so treat these as the record, not the decision.
|
||||
provider_id = self._routed_provider or self.ctx.config.active_provider
|
||||
model = self._routed_model or self._model or ""
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..core.chat_agent import run_cowork
|
||||
"""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,
|
||||
)
|
||||
from ..application.conversations.cowork_turn_request import (
|
||||
build_cowork_turn_request,
|
||||
)
|
||||
from ..application.conversations.turn_runtime import combine_instructions
|
||||
from ..core.projects import load_project, project_context_text
|
||||
|
||||
provider = self.build_provider() # this tab's selected agent/model
|
||||
@@ -367,23 +436,36 @@ class CoworkTab(ChatPanel):
|
||||
# built-in MCP server auto-registered while signed in, see
|
||||
# AppContext._ms365_builtin_connection / mcp_servers/ms365_server.py).
|
||||
extra_tools, extra_exec = self.ctx.build_mcp_tools()
|
||||
# Shared project instructions (Claude-Projects style) — refreshed
|
||||
# each turn so edits in the Workspace screen apply immediately.
|
||||
proj_ctx = project_context_text(load_project(project_id))
|
||||
if agent_prompt:
|
||||
proj_ctx = f"{proj_ctx}\n\n{agent_prompt}" if proj_ctx else agent_prompt
|
||||
# Shared project instructions (Claude-Projects style) plus the Admin
|
||||
# agent's persona, refreshed each turn so edits in the Workspace
|
||||
# screen apply immediately.
|
||||
instructions = combine_instructions(
|
||||
project_context_text(load_project(project_id)), agent_prompt)
|
||||
# Permission Management (Sandbox Security Layer): off by default —
|
||||
# matches the pre-existing auto-run behavior. Now resolved PER
|
||||
# WORKSPACE: this project's Auto-run override wins, else the global
|
||||
# "confirm before running commands" setting (project_confirm_commands).
|
||||
# matches the pre-existing auto-run behavior. The gate lives on the
|
||||
# worker because the UI resolves it from the main thread.
|
||||
gate = None
|
||||
if self.ctx.project_confirm_commands():
|
||||
if confirm_commands:
|
||||
gate = worker.new_gate("confirm", agent_role=agent_roles.COWORK)
|
||||
run_cowork(provider, messages, output_dir, worker.emit_event,
|
||||
worker.is_cancelled, title=title,
|
||||
extra_tools=extra_tools, extra_executor=extra_exec,
|
||||
project_context=proj_ctx, security_config=self.ctx.config,
|
||||
gate=gate)
|
||||
|
||||
service = build_cowork_conversation_service(
|
||||
provider, output_dir, worker.emit_event, title=title,
|
||||
project_context=instructions, extra_tools=extra_tools,
|
||||
extra_executor=extra_exec, security_config=self.ctx.config,
|
||||
gate=gate, agent_role=agent_roles.COWORK,
|
||||
)
|
||||
request = build_cowork_turn_request(
|
||||
turn_id=turn_id, session_id=session_id, surface=self.kind,
|
||||
project_id=project_id, title=title, messages=messages,
|
||||
provider_id=provider_id, model=model, instructions=instructions,
|
||||
output_dir=output_dir, home_output_root=home_output_root,
|
||||
confirm_commands=gate is not None, agent_role=agent_roles.COWORK,
|
||||
)
|
||||
# Hand the widget's own list over: _reattach_running_turn replays
|
||||
# from it while the turn is still running, and _finalize_turn slices
|
||||
# it afterwards, so the service must append into that very object.
|
||||
service.execute(request, legacy_event_sink(worker.emit_event),
|
||||
cancel=worker.is_cancelled, messages=messages)
|
||||
return {"messages": messages, "turn_dir": str(output_dir)}
|
||||
|
||||
return job
|
||||
|
||||
@@ -1,438 +0,0 @@
|
||||
"""Dashboard tab — token usage & cost overview.
|
||||
|
||||
Top: header (period filter + display-currency picker + refresh), then stat
|
||||
cards (total, input, output, cache tokens, and cost per bucket). Unit prices
|
||||
still come from Monitoring's model pricing table (same ``usage.*`` config keys
|
||||
— both screens always agree); the currency picker itself lives HERE, beside
|
||||
refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens,
|
||||
average per prompt, busiest day/hour. Data comes from the local usage log (one
|
||||
event per model turn, recorded by the providers — real server counts when
|
||||
available, ~4 chars/token estimates otherwise).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QGridLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import usage_tracker as ut
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
from .spline_chart import SplineChart
|
||||
from .widgets import BudgetCard as _BudgetCard
|
||||
from .widgets import StatCard as _StatCard
|
||||
from .widgets import fmt_tokens as _fmt_tokens
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
_PERIODS = ("today", "week", "month", "all")
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
# ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard —
|
||||
# cards, chart and habits all follow the selected week/month) + refresh
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self._refresh_chart)
|
||||
# Display-currency picker — moved here from Monitoring's Token Usage
|
||||
# card, right beside refresh; both screens still share the same
|
||||
# usage.currency config key, so changing it here updates everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
self.currency_combo = QComboBox()
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
# Two rows, grouped by what the controls do, instead of nine widgets
|
||||
# strung across one line where the title, a date pager, two chart
|
||||
# selectors, a currency picker and Refresh all read as one undifferentiated
|
||||
# strip. Row 1 is "where am I"; row 2 is "what am I looking at".
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
controls.addWidget(self.chart_prev_btn) # period pager
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
controls.addWidget(self.gran_combo) # what the chart plots
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
controls.addWidget(self.currency_lbl) # how money is displayed
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
# ---- stat cards ---------------------------------------------------
|
||||
# Cost is the headline this screen exists for, so it gets a card twice
|
||||
# the height of the rest instead of being the fifth of five identical
|
||||
# tiles — with six equal cards nothing said which number mattered.
|
||||
cards_grid = QGridLayout()
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = _StatCard()
|
||||
self.card_in = _StatCard()
|
||||
self.card_out = _StatCard()
|
||||
self.card_cache = _StatCard()
|
||||
self.card_cost = _StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures fill
|
||||
# a 2×2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = _BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
# The hero and Budget columns get more room than the small tiles.
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
root.addLayout(cards_grid)
|
||||
|
||||
# ---- token/cost within the selected period (spline): WEEK → 7 days
|
||||
# (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines
|
||||
# compare the previous week / month. ----
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
root.addWidget(self.chart)
|
||||
|
||||
# ---- habits summary -------------------------------------------------
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy (enable auto-compress + tune
|
||||
# the compression threshold) — only after the user clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
# AI recommendations panel (filled by the ✨ button).
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
# ---- helpers -----------------------------------------------------------
|
||||
def _pricing(self) -> Dict:
|
||||
from ..core import model_pricing as mp
|
||||
mp.sync_to_usage(self.ctx.config) # cost/total comes straight from the price table
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
self.refresh()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from ..core import model_pricing as mp
|
||||
pricing = self._pricing()
|
||||
status = ut.budget_status(self.ctx.config)
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency) —
|
||||
# only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
def _period_range(self):
|
||||
"""The SELECTED period as an inclusive (start, end) date range — drives
|
||||
the whole dashboard (cards, chart, habits)."""
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
start, end = ut.period_bounds(gran, self._chart_offset)
|
||||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||||
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.refresh() # the filter drives the WHOLE dashboard
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
self._chart_offset -= 1 # page one period into the past
|
||||
self.refresh()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.refresh()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
def _refresh_chart(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK → 7 days (Mon–Sun) ·
|
||||
MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines mark the previous
|
||||
week's / month's average per point with the % change of the totals."""
|
||||
if not hasattr(self, "chart"):
|
||||
return
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
events = ut.load_events() # all events; breakdown slices by period
|
||||
pricing = self._pricing()
|
||||
parts = ut.period_breakdown(events, gran, pricing, offset=self._chart_offset)
|
||||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) → +1 for the value
|
||||
pts = [(row[0], float(row[mi + 1])) for row in parts]
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision (up
|
||||
# to 4 decimals for USD) overflowed it, clipping/obscuring the amount.
|
||||
fmt = _fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
# One dashed comparison line that FOLLOWS the filter: the selected period
|
||||
# vs the previous SAME-granularity one — "Last week" in week view,
|
||||
# "Last month" in month view, "Last year" in year view. Drawn at the
|
||||
# previous period's average per point so it sits on-scale; the label shows
|
||||
# the % change of the period totals.
|
||||
cur = ut.period_totals(events, gran, pricing, self._chart_offset)
|
||||
prev = ut.period_totals(events, gran, pricing, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(parts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
# ---- main refresh --------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
start, end = self._period_range()
|
||||
events = ut.load_events(start, end)
|
||||
|
||||
s = ut.summarize(events)
|
||||
pricing = self._pricing()
|
||||
costs = ut.cost_usd_events(events, pricing) # honors the per-model price table
|
||||
total_cost = sum(costs.values())
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(total_cost, pricing, digits=2), est_note)
|
||||
|
||||
# ---- habits -----------------------------------------------------------
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {_fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{_fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
self._refresh_chart()
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user approves:
|
||||
turn on auto-compress and compress earlier (lower threshold) + compress
|
||||
content before sending it to the agent — cutting tokens on every turn."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
# ---- AI habits analysis ----------------------------------------------------
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the active
|
||||
provider and show habit feedback + token-saving recommendations."""
|
||||
if getattr(self, "_ai_worker", None) is not None:
|
||||
return
|
||||
start, end = self._period_range()
|
||||
events = ut.load_events(start, end)
|
||||
if not events:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
summary = ut.summarize(events)
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(summary, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True) # offer to apply the saving strategy
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
@@ -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()
|
||||
|
||||
-1590
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||
|
||||
+9
-1541
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||
|
||||
+27
-5
@@ -1,12 +1,13 @@
|
||||
"""Off/Auto/Manual routing toggle + Auto-run toggle + Manual-mode confirm dialog.
|
||||
"""Off/Auto/Manual/Fallback routing toggle + Auto-run toggle + confirm dialog.
|
||||
|
||||
Dropped into every chat surface's composer (Cowork / Co4E / AI-Edit). By
|
||||
default a :class:`RoutingToggle` reads/writes the **per-workspace** mode via
|
||||
``AppContext.project_routing_mode`` / ``set_project_routing_mode`` (so each
|
||||
workspace keeps its own mode), but the storage is fully injectable through
|
||||
``get_mode``/``set_mode`` callables — all the real decision logic lives in
|
||||
``core/routing``. Call :meth:`refresh` when the active workspace changes so the
|
||||
control shows that workspace's mode.
|
||||
``application/model_routing`` (which the surfaces call through
|
||||
``RoutingApplicationService``). Call :meth:`refresh` when the active workspace
|
||||
changes so the control shows that workspace's mode.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -39,7 +40,7 @@ class RoutingToggle(QWidget):
|
||||
Emits :attr:`mode_changed`; call :meth:`refresh` after the workspace switches.
|
||||
"""
|
||||
|
||||
mode_changed = Signal(str) # "off" | "auto" | "manual"
|
||||
mode_changed = Signal(str) # "off" | "auto" | "manual" | "fallback"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,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
|
||||
@@ -65,11 +71,14 @@ class RoutingToggle(QWidget):
|
||||
self._label.setObjectName("hint")
|
||||
self._combo = QComboBox()
|
||||
self._combo.setToolTip(tr("routing.toggle_tooltip"))
|
||||
# (data value, i18n key) — data is the persisted mode string.
|
||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
||||
# the "only when something breaks" mode rather than a stronger Auto.
|
||||
self._modes = [
|
||||
("off", "routing.mode_off"),
|
||||
("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"),
|
||||
("fallback", "routing.mode_fallback"),
|
||||
]
|
||||
for value, key in self._modes:
|
||||
self._combo.addItem(tr(key), value)
|
||||
@@ -81,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:
|
||||
@@ -105,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)
|
||||
@@ -125,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)
|
||||
@@ -137,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
|
||||
@@ -146,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
|
||||
@@ -190,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()
|
||||
|
||||
@@ -1,794 +0,0 @@
|
||||
"""Schedule Task tab — Kanban board for scheduled/automated tasks.
|
||||
|
||||
Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed /
|
||||
Paused. Cards drag between columns (dropping = changing status), double-click
|
||||
edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View
|
||||
logs / Create-next-from-output. Header has search, a type filter, Add Task
|
||||
and AI Create Task (preview first — nothing is created until confirmed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
||||
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import tasks as taskrepo
|
||||
from ..core.projects import list_projects
|
||||
from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .calendar_view import CalendarView
|
||||
from .icons import icon
|
||||
from .osutil import open_path
|
||||
|
||||
_VIEWS = ("kanban", "calendar")
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
# elevated priorities get a visible marker; low/medium stay unmarked as before.
|
||||
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
||||
|
||||
|
||||
class _KanbanColumn(QListWidget):
|
||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||
'move this task to my status'."""
|
||||
|
||||
task_dropped = Signal(str, str) # task_id, new_status
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__()
|
||||
self.status = status
|
||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||
self.setDefaultDropAction(Qt.MoveAction)
|
||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.setWordWrap(True)
|
||||
# Cards wrap, so there is never anything to reach by scrolling sideways
|
||||
# — but QListWidget's own column hint runs 1-6px past the viewport, and
|
||||
# a lane sprouted a horizontal scrollbar at 36 of 38 window widths I
|
||||
# measured. Which lanes grew one changed with the width, which is why it
|
||||
# looked like it depended on the screen.
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
||||
# No pixel floor here. A fixed one is always wrong on some screen:
|
||||
# 190 lost the seventh lane, 150 still wanted 1242px where a 1280
|
||||
# window leaves 1091 — so the 1280 monitor scrolled sideways and the
|
||||
# 1920 one did not, same app, same build. The board divides whatever
|
||||
# width it has by seven instead; see _fit_lanes().
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
source = event.source()
|
||||
if isinstance(source, _KanbanColumn) and source is not self:
|
||||
item = source.currentItem()
|
||||
tid = item.data(Qt.UserRole) if item else None
|
||||
if tid:
|
||||
event.acceptProposedAction()
|
||||
self.task_dropped.emit(tid, self.status)
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
|
||||
class ScheduleTaskTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._tasks_dir: Optional[Path] = None # None → default repo dir
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# ---- header ----------------------------------------------------
|
||||
header = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.counts_lbl = QLabel("")
|
||||
self.counts_lbl.setObjectName("hint")
|
||||
# A one-line summary of every lane's count. Left to size itself it
|
||||
# reported a sizeHint wide enough to set the MINIMUM width of the whole
|
||||
# screen — 1285px at 150% scaling, which then became the window's
|
||||
# minimum and stopped the app fitting a 1280px laptop. It is a summary,
|
||||
# and the same numbers are on each lane header, so it gives way first.
|
||||
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||
self.counts_lbl.setMinimumWidth(0)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.clicked.connect(self._add_task)
|
||||
self.ai_btn = QPushButton()
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.clicked.connect(self._ai_create)
|
||||
# Two views of the same tasks, so they read as a pair of tabs rather
|
||||
# than a drop-list you have to open to discover the Calendar exists.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
for _v in _VIEWS:
|
||||
self.view_tabs.addTab("")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
||||
header.addWidget(self._title)
|
||||
header.addWidget(self.counts_lbl, 1)
|
||||
header.addWidget(self.view_tabs)
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.ai_btn)
|
||||
root.addLayout(header)
|
||||
|
||||
# ---- board / calendar (two views of the SAME tasks) -----------------
|
||||
self._view_stack = QStackedWidget()
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
board = QWidget()
|
||||
scroll.setWidget(board)
|
||||
cols = QHBoxLayout(board)
|
||||
# Gutters wide enough to read as a break between lanes without eating
|
||||
# too much of the seven-way split — they still share the board equally
|
||||
# (see _fit_lanes below), so a wider gutter narrows every lane by the
|
||||
# same share automatically; nothing else to compute here.
|
||||
cols.setSpacing(2)
|
||||
self.columns: Dict[str, _KanbanColumn] = {}
|
||||
self.column_headers: Dict[str, QLabel] = {}
|
||||
for status in STATUSES:
|
||||
box = QVBoxLayout()
|
||||
# The per-lane holder's own margins were the style's default
|
||||
# (~9px a side) on top of the inter-column gap — with seven lanes
|
||||
# that outweighs the gap itself. Zero it out and let the lane's
|
||||
# header/list fill the width _fit_lanes() hands them.
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
box.setSpacing(2)
|
||||
head = QLabel()
|
||||
head.setStyleSheet("font-weight:600;")
|
||||
col = _KanbanColumn(status)
|
||||
col.setObjectName("kanbanLane")
|
||||
col.task_dropped.connect(self._on_task_dropped)
|
||||
col.itemDoubleClicked.connect(self._on_double_click)
|
||||
col.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
col.customContextMenuRequested.connect(
|
||||
lambda pos, c=col: self._context_menu(c, pos))
|
||||
box.addWidget(head)
|
||||
box.addWidget(col, 1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(box)
|
||||
cols.addWidget(holder)
|
||||
self.columns[status] = col
|
||||
self.column_headers[status] = head
|
||||
self._board_scroll = scroll
|
||||
self._board_gap = cols.spacing()
|
||||
scroll.viewport().installEventFilter(self)
|
||||
self._view_stack.addWidget(scroll)
|
||||
self.calendar = CalendarView()
|
||||
self.calendar.edit_task.connect(self._edit_task)
|
||||
self.calendar.add_task_on_date.connect(self._add_task_on_date)
|
||||
self._view_stack.addWidget(self.calendar)
|
||||
root.addWidget(self._view_stack, 1)
|
||||
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.tasks_changed.connect(self.refresh)
|
||||
self.scheduler.task_started.connect(lambda _tid: self.refresh())
|
||||
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
|
||||
|
||||
# Belt-and-braces: also re-read the board every 10s so a card's lane
|
||||
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
|
||||
# change slipped past the signals (e.g. task files edited externally).
|
||||
from PySide6.QtCore import QTimer
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setInterval(10_000)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._refresh_timer.start()
|
||||
|
||||
self.refresh()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("schedtask.title"))
|
||||
self.add_btn.setText(tr("schedtask.add_btn"))
|
||||
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
|
||||
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
||||
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
||||
for i, v in enumerate(_VIEWS):
|
||||
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
# ---- lane widths ------------------------------------------------------
|
||||
#
|
||||
# The seven lanes share the board equally — that is the layout's stretch
|
||||
# doing the work, so the split is a proportion of whatever width there is,
|
||||
# on any monitor. The only pixel question left is how narrow a lane may get
|
||||
# before scrolling sideways beats squeezing, and that is a question about
|
||||
# TEXT: roughly eight characters of a task title plus its padding. Reading
|
||||
# it off the font keeps it right at 125%/150% scaling and at a user's own
|
||||
# font size, where a constant would not be.
|
||||
_LANE_FLOOR_CH = 8
|
||||
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
|
||||
self._fit_lanes()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _fit_lanes(self) -> None:
|
||||
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
|
||||
for col in self.columns.values():
|
||||
if col.minimumWidth() != floor:
|
||||
col.setMinimumWidth(floor)
|
||||
|
||||
# ---- board rendering ---------------------------------------------------
|
||||
def _card_text(self, t: dict) -> str:
|
||||
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
|
||||
ai = "[AI] " if t.get("is_ai_generated") else ""
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else None
|
||||
when_line = when or tr("schedtask.no_schedule")
|
||||
chain = ""
|
||||
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
|
||||
chain = " (linked)"
|
||||
last = t.get("logs", {}).get("last_status")
|
||||
last_line = {"success": tr("schedtask.last_success"),
|
||||
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
|
||||
# Card shows ONLY the task's own title (plus the [AI] marker and chain
|
||||
# note) — no "[Cowork]"/"[Code]" task-type tag cluttering it.
|
||||
return (f"{ai}{t.get('title', '')}{chain}\n"
|
||||
f"{when_line} {prio}\n{last_line}")
|
||||
|
||||
def refresh(self) -> None:
|
||||
all_tasks = taskrepo.list_tasks(self._tasks_dir)
|
||||
counts = {s: 0 for s in STATUSES}
|
||||
for col in self.columns.values():
|
||||
col.clear()
|
||||
for t in all_tasks:
|
||||
status = t.get("status", "backlog")
|
||||
if status not in self.columns:
|
||||
continue
|
||||
counts[status] += 1
|
||||
item = QListWidgetItem(self._card_text(t))
|
||||
item.setData(Qt.UserRole, t["task_id"])
|
||||
self.columns[status].addItem(item)
|
||||
pal = current_palette()
|
||||
for status, col in self.columns.items():
|
||||
self.column_headers[status].setText(
|
||||
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
||||
# Dropping a card into Running STARTS the task for real, so that
|
||||
# lane is outlined while it holds anything — the one column here
|
||||
# with a side effect should not look like the other six.
|
||||
if status == "running" and counts[status]:
|
||||
col.setStyleSheet(
|
||||
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
||||
self.column_headers[status].setStyleSheet(
|
||||
f"font-weight:600; color: {pal.warning};")
|
||||
else:
|
||||
col.setStyleSheet("")
|
||||
self.column_headers[status].setStyleSheet("font-weight:600;")
|
||||
if col.count() == 0:
|
||||
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
col.addItem(empty)
|
||||
summary = " ".join(
|
||||
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
|
||||
self.counts_lbl.setText(summary)
|
||||
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- actions --------------------------------------------------------
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _add_task(self) -> None:
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
def _edit_task(self, task_id: str) -> None:
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self._edit_task(tid)
|
||||
|
||||
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
||||
"""Dropping a card into a lane ACTS on the task, not just relabels it:
|
||||
→ Running actually runs it now; → Done marks it completed; → Scheduled
|
||||
puts it on the calendar (opening the editor if no time is set yet)."""
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
if task.get("status") == "running":
|
||||
self.refresh() # can't drag a running task
|
||||
return
|
||||
if new_status == "running":
|
||||
# Dropping into Running = "run it now" (counts as manual approval).
|
||||
self.refresh()
|
||||
self._run_now(task)
|
||||
return
|
||||
if new_status == "done":
|
||||
task["status"] = "done"
|
||||
task["schedule"]["enabled"] = False # done by hand → don't re-fire
|
||||
self._save_and_refresh(task)
|
||||
return
|
||||
task["status"] = new_status
|
||||
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
||||
if task["schedule"].get("run_at"):
|
||||
task["schedule"]["enabled"] = True
|
||||
else:
|
||||
# No time set yet — a silently-disabled "Scheduled" card would
|
||||
# never run and look broken. Open the editor so the user sets
|
||||
# the schedule right away.
|
||||
self._save_and_refresh(task)
|
||||
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
||||
self._edit_task(task_id)
|
||||
return
|
||||
self._save_and_refresh(task)
|
||||
|
||||
@staticmethod
|
||||
def _is_multi_selection(item, selected) -> bool:
|
||||
"""True when the right-clicked card is part of an existing multi-item
|
||||
selection — pure boolean, kept separate from _context_menu so it's
|
||||
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
||||
return len(selected) > 1 and item in selected
|
||||
|
||||
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
||||
item = col.itemAt(pos)
|
||||
if item is None or not item.data(Qt.UserRole):
|
||||
return
|
||||
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
||||
if self._is_multi_selection(item, selected):
|
||||
self._bulk_delete_menu(col, pos, selected)
|
||||
return
|
||||
tid = item.data(Qt.UserRole)
|
||||
task = taskrepo.load_task(tid, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
menu = QMenu(col)
|
||||
run_act = menu.addAction(tr("schedtask.menu_run"))
|
||||
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
||||
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
||||
paused = task.get("status") == "paused"
|
||||
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
||||
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
||||
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
||||
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == run_act:
|
||||
self._run_now(task)
|
||||
elif chosen == edit_act:
|
||||
self._edit_task(tid)
|
||||
elif chosen == dup_act:
|
||||
self._save_and_refresh(duplicate_task(task))
|
||||
elif chosen == pause_act:
|
||||
task["status"] = "backlog" if paused else "paused"
|
||||
self._save_and_refresh(task)
|
||||
elif chosen == logs_act:
|
||||
self._view_logs(task)
|
||||
elif chosen == hist_act:
|
||||
_RunHistoryDialog(task, self).exec()
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
"""Right-click on a multi-selection within one column (Shift/Ctrl-click
|
||||
several cards first): one action deletes every selected task. The
|
||||
popup itself is a thin wrapper — see _confirm_and_delete_selected for
|
||||
the actual (independently testable) confirm+delete logic."""
|
||||
menu = QMenu(col)
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._confirm_and_delete_selected(selected)
|
||||
|
||||
def _confirm_and_delete_selected(self, selected) -> bool:
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
return False
|
||||
for item in selected:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def _run_now(self, task: dict) -> None:
|
||||
if task.get("task_type") == "manual":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
return
|
||||
if self.scheduler is None:
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
return
|
||||
if self.scheduler.run_now(task["task_id"]):
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
|
||||
self.refresh()
|
||||
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
run_id = task.get("logs", {}).get("last_run_id")
|
||||
if not run_id:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
else:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
|
||||
def _create_next_from_output(self, task: dict) -> None:
|
||||
"""Scaffold a follow-up task pre-wired to consume this task's output."""
|
||||
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
||||
nxt["task_type"] = "cowork"
|
||||
nxt["input"]["mode"] = "previous_task_output"
|
||||
nxt["input"]["previous_task_id"] = task["task_id"]
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
|
||||
task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
taskrepo.save_task(nxt, self._tasks_dir)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
task["dependency"]["pass_output_to_next"] = True
|
||||
if task["dependency"].get("run_next_mode", "none") == "none":
|
||||
task["dependency"]["run_next_mode"] = "run_after_success"
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
self._edit_task(nxt["task_id"])
|
||||
|
||||
# ---- AI create ----------------------------------------------------------
|
||||
def _ai_create(self) -> None:
|
||||
dlg = _AiCreateDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
taskrepo.save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
|
||||
|
||||
class _RunHistoryDialog(QDialog):
|
||||
"""Run history of one task as a table (newest first): time, status, error;
|
||||
double-click a row to open that run's artifact folder."""
|
||||
|
||||
def __init__(self, task: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._task = task
|
||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||
self.resize(620, 380)
|
||||
root = QVBoxLayout(self)
|
||||
hint = QLabel(tr("schedtask.hist_hint"))
|
||||
hint.setObjectName("hint")
|
||||
root.addWidget(hint)
|
||||
|
||||
runs = list(reversed(task.get("runs", []) or []))
|
||||
self.table = QTableWidget(len(runs), 4)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||
])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
for row, run in enumerate(runs):
|
||||
ok = run.get("status") == "success"
|
||||
cells = (
|
||||
run.get("finished_at", ""),
|
||||
str(run.get("status", "")),
|
||||
run.get("run_id", ""),
|
||||
(run.get("error") or "")[:200],
|
||||
)
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
first = self.table.item(item.row(), 0)
|
||||
run_id = first.data(Qt.UserRole) if first else ""
|
||||
if not run_id:
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
|
||||
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setMinimumHeight(70)
|
||||
_p = current_palette()
|
||||
self.setStyleSheet(
|
||||
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
||||
f" border-radius: {_p.radius_lg}px;"
|
||||
f" color: {_p.text_muted}; padding: 10px; }}")
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls and urls[0].toLocalFile().lower().endswith(
|
||||
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_dropped.emit(urls[0].toLocalFile())
|
||||
|
||||
|
||||
class _AiCreateDialog(QDialog):
|
||||
"""Create tasks two ways, one tab each (both preview first — nothing is
|
||||
saved until the user confirms): ✨ AI gen from a natural-language
|
||||
description, or 📥 Import from a filled Excel template (pick or drag)."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
from PySide6.QtWidgets import QTabWidget
|
||||
|
||||
self.ctx = ctx
|
||||
self.created_tasks: List[dict] = []
|
||||
self._planned: List[dict] = []
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||
self.resize(600, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
ws_row = QHBoxLayout()
|
||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||
self.workspace_combo = QComboBox()
|
||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||
for p in list_projects():
|
||||
self.workspace_combo.addItem(p.name, p.project_id)
|
||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||
ws_row.addWidget(self.workspace_combo, 1)
|
||||
root.addLayout(ws_row)
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
# ---- tab 1: AI gen ------------------------------------------------
|
||||
ai_page = QWidget()
|
||||
al = QVBoxLayout(ai_page)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||
self.desc_edit = QPlainTextEdit()
|
||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||
self.desc_edit.setMaximumHeight(110)
|
||||
al.addWidget(self.desc_edit)
|
||||
# Attachments (files + links) — merged into every task this generates,
|
||||
# AND into the planning prompt so the AI knows they exist.
|
||||
attach_row = QHBoxLayout()
|
||||
self.ai_files_edit = QLineEdit()
|
||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||
ai_pick_btn.setIcon(icon("folder"))
|
||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||
attach_row.addWidget(self.ai_files_edit, 1)
|
||||
attach_row.addWidget(ai_pick_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||
al.addLayout(attach_row)
|
||||
self.ai_links_edit = QLineEdit()
|
||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||
al.addWidget(self.ai_links_edit)
|
||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setObjectName("primary")
|
||||
self.gen_btn.clicked.connect(self._generate)
|
||||
al.addWidget(self.gen_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
al.addWidget(self.preview, 1)
|
||||
self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
|
||||
|
||||
# ---- tab 2: Import from Excel --------------------------------------
|
||||
imp_page = QWidget()
|
||||
il = QVBoxLayout(imp_page)
|
||||
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
||||
tpl_btn.setIcon(icon("upload"))
|
||||
tpl_btn.clicked.connect(self._export_template)
|
||||
il.addWidget(tpl_btn)
|
||||
pick_row = QHBoxLayout()
|
||||
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
||||
pick_btn.setIcon(icon("folder"))
|
||||
pick_btn.clicked.connect(self._pick_import_file)
|
||||
pick_row.addWidget(pick_btn)
|
||||
pick_row.addStretch(1)
|
||||
il.addLayout(pick_row)
|
||||
self.drop_zone = _DropZone()
|
||||
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
||||
self.drop_zone.file_dropped.connect(self._load_import_file)
|
||||
il.addWidget(self.drop_zone)
|
||||
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.import_preview = QPlainTextEdit()
|
||||
self.import_preview.setReadOnly(True)
|
||||
il.addWidget(self.import_preview, 1)
|
||||
self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
# ---- Import tab ------------------------------------------------------
|
||||
def _export_template(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ..core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
export_template(path)
|
||||
open_path(str(Path(path).parent))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||
|
||||
def _pick_import_file(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ..core.task_import import IMPORT_FILTER
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
||||
if path:
|
||||
self._load_import_file(path)
|
||||
|
||||
def _load_import_file(self, path: str) -> None:
|
||||
from ..core.task_import import import_tasks
|
||||
|
||||
try:
|
||||
self._planned = import_tasks(path)
|
||||
except ValueError as exc:
|
||||
self.import_preview.setPlainText(str(exc))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
return
|
||||
by_id = {t["task_id"]: t["title"] for t in self._planned}
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
deps = t.get("dependency", {}).get("depends_on") or []
|
||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||
self.import_preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
if files:
|
||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||
self.ai_files_edit.setText("; ".join(existing + files))
|
||||
|
||||
def _attached_files(self) -> List[str]:
|
||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||
|
||||
def _attached_links(self) -> List[str]:
|
||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||
|
||||
def _generate(self) -> None:
|
||||
description = self.desc_edit.toPlainText().strip()
|
||||
if not description or self._worker is not None:
|
||||
return
|
||||
files, links = self._attached_files(), self._attached_links()
|
||||
self.gen_btn.setEnabled(False)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..core.ai_task_planner import plan_tasks
|
||||
|
||||
provider = self.ctx.build_active_provider()
|
||||
full_desc = description
|
||||
if files or links:
|
||||
attach_note = "; ".join(files + links)
|
||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||
planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
|
||||
# Attachments apply to every generated task so they're available
|
||||
# at RUN time too, not just visible to the planner.
|
||||
for t in planned:
|
||||
t["input"]["file_paths"] = list(files)
|
||||
t["input"]["links"] = list(links)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._on_planned)
|
||||
w.failed.connect(self._on_failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _on_planned(self, result: dict) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._planned = result.get("tasks") or []
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
dep = t.get("dependency", {})
|
||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||
f" {t.get('description', '')[:150]}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
def _confirm(self) -> None:
|
||||
project_id = self.workspace_combo.currentData() or ""
|
||||
for t in self._planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = self._planned
|
||||
self.accept()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Dải nút chọn một trong nhiều — tách khỏi ``ui/widgets.py``.
|
||||
|
||||
Thay ``QComboBox`` ở những chỗ chỉ có hai đến bốn lựa chọn và người dùng nên
|
||||
thấy hết cùng lúc: ngôn ngữ và giao diện trong Cài đặt. Mở một danh sách xổ
|
||||
xuống chỉ để biết trong đó có gì là một cú bấm thừa.
|
||||
|
||||
Tách ra vì hai lẽ. Một, ``ui/widgets.py`` đã chạm đúng trần nợ cũ của cổng LOC
|
||||
nên không nhận thêm được dòng nào. Hai, chỗ này có một luật riêng đáng đứng
|
||||
một mình: bề rộng nút phải chừa sẵn cho chữ IN ĐẬM — xem
|
||||
:meth:`SegmentedControl._reserve_bold_width`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont, QFontMetrics
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget
|
||||
|
||||
|
||||
class SegmentedControl(QWidget):
|
||||
"""Two-to-four choices shown side by side instead of hidden in a drop-list.
|
||||
|
||||
Exposes the slice of the QComboBox API this app's settings code uses
|
||||
(addItem / findData / currentData / setCurrentIndex / currentIndexChanged),
|
||||
so it drops into an existing form without touching the save/load paths.
|
||||
"""
|
||||
|
||||
currentIndexChanged = Signal(int)
|
||||
|
||||
#: Độ đậm mà ``theme/qss.py`` áp cho nút đang chọn
|
||||
#: (``QPushButton#segItem:checked { font-weight: 600 }``). Đổi ở QSS thì
|
||||
#: phải đổi cả ở đây, nếu không chữ lại bị cắt.
|
||||
_CHECKED_WEIGHT = QFont.DemiBold
|
||||
|
||||
def __init__(self, parent=None):
|
||||
"""Dải nút chọn một trong nhiều — thay ``QComboBox`` khi chỉ có vài lựa chọn và
|
||||
nên thấy hết cùng lúc.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._data: list = []
|
||||
self._buttons: list = []
|
||||
self._current = -1
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
self._lay = lay
|
||||
lay.addStretch(1)
|
||||
|
||||
def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name
|
||||
"""Thêm một lựa chọn kèm dữ liệu đi kèm."""
|
||||
btn = QPushButton(text)
|
||||
btn.setObjectName("segItem")
|
||||
btn.setCheckable(True)
|
||||
btn.setCursor(Qt.PointingHandCursor)
|
||||
index = len(self._buttons)
|
||||
btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i))
|
||||
self._lay.insertWidget(index, btn)
|
||||
self._buttons.append(btn)
|
||||
self._data.append(data)
|
||||
self._reserve_bold_width(btn)
|
||||
if self._current < 0:
|
||||
self.setCurrentIndex(0)
|
||||
|
||||
@staticmethod
|
||||
def _reserve_bold_width(btn: QPushButton) -> None:
|
||||
"""Chừa sẵn bề rộng cho chữ khi nút được chọn và bị in đậm.
|
||||
|
||||
``QPushButton`` tính ``sizeHint()`` theo phông ĐANG dùng, tức phông
|
||||
thường. Nhưng QSS lại đặt ``font-weight: 600`` cho nút đang chọn, và
|
||||
chữ đậm rộng hơn chữ thường — nên đúng lúc một mục được chọn thì nó
|
||||
không còn đủ chỗ và Qt cắt bớt chữ đi.
|
||||
|
||||
Nhãn càng dài, thiếu càng nhiều: đo trên bản 30/08 thì "Tiếng Việt"
|
||||
thiếu 2px, "English" 2px, còn "Tự động (theo hệ thống)" thiếu tới 7px.
|
||||
Tiếng Việt lộ rõ nhất vì nó vừa là nhãn dài vừa có dấu, và với người
|
||||
dùng tiếng Việt thì nó luôn là mục ĐANG được chọn.
|
||||
|
||||
Cách đo: lấy phần khung (viền + padding do QSS quy định) bằng cách trừ
|
||||
bề rộng chữ khỏi ``sizeHint()``, rồi cộng lại bề rộng của chính chữ ấy
|
||||
ở độ đậm khi được chọn. Không viết cứng con số padding nào — QSS đổi
|
||||
thì phép đo tự theo.
|
||||
"""
|
||||
btn.ensurePolished()
|
||||
text = btn.text()
|
||||
normal = btn.font()
|
||||
chrome = btn.sizeHint().width() - QFontMetrics(normal).horizontalAdvance(text)
|
||||
bold = QFont(normal)
|
||||
bold.setWeight(SegmentedControl._CHECKED_WEIGHT)
|
||||
btn.setMinimumWidth(chrome + QFontMetrics(bold).horizontalAdvance(text))
|
||||
|
||||
def findData(self, value) -> int: # noqa: N802
|
||||
"""Chỉ số của lựa chọn mang dữ liệu ``value``; -1 nếu không có."""
|
||||
return self._data.index(value) if value in self._data else -1
|
||||
|
||||
def currentData(self): # noqa: N802
|
||||
"""Dữ liệu của lựa chọn đang chọn; ``None`` nếu chưa chọn gì."""
|
||||
return self._data[self._current] if 0 <= self._current < len(self._data) else None
|
||||
|
||||
def currentIndex(self) -> int: # noqa: N802
|
||||
"""Chỉ số lựa chọn đang chọn; -1 nếu chưa chọn gì."""
|
||||
return self._current
|
||||
|
||||
def count(self) -> int:
|
||||
"""Số lựa chọn đang có."""
|
||||
return len(self._buttons)
|
||||
|
||||
def setItemText(self, index: int, text: str) -> None: # noqa: N802
|
||||
"""Đổi nhãn một lựa chọn (dùng khi đổi ngôn ngữ).
|
||||
|
||||
Tính lại bề rộng tối thiểu: nhãn mới dài ngắn khác nhau, giữ nguyên số
|
||||
cũ thì hoặc cắt chữ hoặc chừa một khoảng trống vô cớ.
|
||||
"""
|
||||
if 0 <= index < len(self._buttons):
|
||||
btn = self._buttons[index]
|
||||
btn.setText(text)
|
||||
btn.setMinimumWidth(0)
|
||||
self._reserve_bold_width(btn)
|
||||
|
||||
def setCurrentIndex(self, index: int) -> None: # noqa: N802
|
||||
"""Chọn một mục và phát tín hiệu đổi.
|
||||
|
||||
Chỉ số không hợp lệ hoặc trùng mục đang chọn thì chỉ đồng bộ lại trạng thái
|
||||
nút, không phát tín hiệu — tránh vòng lặp khi chỗ gọi lại đặt lại chỉ số.
|
||||
"""
|
||||
if not (0 <= index < len(self._buttons)) or index == self._current:
|
||||
for i, b in enumerate(self._buttons):
|
||||
b.setChecked(i == self._current)
|
||||
return
|
||||
self._current = index
|
||||
for i, b in enumerate(self._buttons):
|
||||
b.setChecked(i == index)
|
||||
self.currentIndexChanged.emit(index)
|
||||
|
||||
|
||||
__all__ = ["SegmentedControl"]
|
||||
+85
-495
@@ -1,31 +1,47 @@
|
||||
"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group
|
||||
(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place),
|
||||
and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps)."""
|
||||
from __future__ import annotations
|
||||
"""Hộp thoại Cài đặt — khung lắp ráp.
|
||||
|
||||
from typing import Dict
|
||||
Năm mục, mỗi mục một trang: Chung, AI Provider, Bảo mật sandbox, Tham số,
|
||||
Auto Model Routing. Bốn mục đầu... đúng hơn: bốn trong năm mục đã bóc sang
|
||||
``presentation/settings/`` (R08-T07); file này còn giữ mục Bảo mật sandbox,
|
||||
phần lắp ráp danh sách mục bên trái, và ``_save`` gọi ``apply_to`` của từng
|
||||
widget con.
|
||||
|
||||
Không còn phần Connector nào ở đây: nó đã dời sang Monitoring → Tools →
|
||||
Connector từ trước. Ngày 25/08 dọn nốt 108 dòng MS365 chết còn sót lại của
|
||||
lần dời đó — năm hàm gọi lẫn nhau, không đường vào, và đọc ba thuộc tính
|
||||
chưa từng được gán nên gọi vào là AttributeError.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||||
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTreeWidget,
|
||||
QMessageBox, QPushButton, QScrollArea, QSpinBox,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import LANGUAGES, tr
|
||||
from ..state import AppContext
|
||||
from .icons import icon, IconLabel
|
||||
from .widgets import SegmentedControl, ToggleSwitch
|
||||
from .ext_connector_dialog import ExtConnectorEditDialog
|
||||
from ..i18n import tr
|
||||
from .icons import IconLabel
|
||||
from .widgets import ToggleSwitch
|
||||
|
||||
|
||||
from ..presentation.settings.general_settings_widget import GeneralSettingsWidget
|
||||
from ..presentation.settings.provider_settings_widget import ProviderSettingsWidget
|
||||
from ..presentation.settings.parameter_settings_widget import ParameterSettingsWidget
|
||||
from ..presentation.settings.routing_settings_widget import RoutingSettingsWidget
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""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"))
|
||||
@@ -50,63 +66,17 @@ class SettingsDialog(QDialog):
|
||||
self._content = QWidget()
|
||||
root = QVBoxLayout(self._content)
|
||||
|
||||
# --- language + tray ---
|
||||
top = QFormLayout()
|
||||
self.language_combo = SegmentedControl()
|
||||
for key, label in LANGUAGES.items():
|
||||
self.language_combo.addItem(label, key)
|
||||
self._select_combo(self.language_combo, ctx.config.language)
|
||||
top.addRow(tr("settings.language"), self.language_combo)
|
||||
|
||||
# Theme belongs with the other per-account settings. It is also on the
|
||||
# rail's account row (one click for the common flip); this is the same
|
||||
# value, named and explained, for people who come looking in Settings.
|
||||
self.theme_combo = SegmentedControl()
|
||||
for key in ("system", "dark", "light"):
|
||||
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
|
||||
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
|
||||
top.addRow(tr("settings.theme"), self.theme_combo)
|
||||
|
||||
self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
|
||||
self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
|
||||
top.addRow("", self.tray_chk)
|
||||
self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
|
||||
self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
|
||||
top.addRow("", self.notify_chk)
|
||||
# Zero-height anchor so the index can scroll to this section, which is a
|
||||
# bare form rather than a group box.
|
||||
self._anchor_general = QWidget()
|
||||
self._anchor_general.setFixedHeight(0)
|
||||
root.addWidget(self._anchor_general)
|
||||
root.addLayout(top)
|
||||
# --- Chung: ngôn ngữ, giao diện, khay ---
|
||||
# Đã bóc sang presentation/settings/general_settings_widget.py (R08-T07).
|
||||
self._general_box = GeneralSettingsWidget(self.ctx)
|
||||
root.addWidget(self._general_box)
|
||||
|
||||
self._load_workers = []
|
||||
|
||||
# --- AI Provider ---
|
||||
self._prov_staging: Dict[str, dict] = {
|
||||
key: dict(conf) for key, conf in data["providers"].items()
|
||||
}
|
||||
self.provider_combo = QComboBox()
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
self._select_combo(self.provider_combo, ctx.config.active_provider)
|
||||
self._prov_current_key = self.provider_combo.currentData()
|
||||
|
||||
conf = self._prov_staging.get(self._prov_current_key, {})
|
||||
self.prov_base = QLineEdit(conf.get("base_url", ""))
|
||||
self.prov_key = self._secret(conf.get("api_key", ""))
|
||||
self.prov_model = self._model_combo(conf.get("model", ""))
|
||||
self.prov_status = QLabel("")
|
||||
self.prov_status.setObjectName("hint")
|
||||
self.prov_status.setWordWrap(True)
|
||||
prov_group = self._group(tr("settings.group.provider"), [
|
||||
(tr("settings.active_provider"), self.provider_combo),
|
||||
(tr("settings.base_url"), self.prov_base),
|
||||
(tr("settings.api_key"), self.prov_key),
|
||||
(tr("settings.model"), self._with_load(self.prov_model, self.prov_status)),
|
||||
])
|
||||
prov_group.layout().addRow("", self.prov_status)
|
||||
self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed)
|
||||
# Đã bóc sang presentation/settings/provider_settings_widget.py (R08-T07).
|
||||
prov_group = ProviderSettingsWidget(self.ctx)
|
||||
self._provider_page = prov_group
|
||||
root.addWidget(prov_group)
|
||||
|
||||
# --- Sandbox Security Layer ---
|
||||
@@ -177,141 +147,20 @@ class SettingsDialog(QDialog):
|
||||
root.addWidget(self.sandbox_group)
|
||||
|
||||
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
|
||||
# → Connector now — no connector UI in Settings. (_ms365_workers is kept
|
||||
# for the dead-but-retained MS365 OAuth sign-in handlers below.)
|
||||
self._ms365_workers = []
|
||||
|
||||
# --- Parameter ---
|
||||
param_group = QGroupBox(tr("settings.group.parameter"))
|
||||
pgl = QFormLayout(param_group)
|
||||
|
||||
def _param_section(key: str) -> None:
|
||||
lbl = QLabel(tr(key))
|
||||
lbl.setStyleSheet("font-weight:600; margin-top:6px;")
|
||||
pgl.addRow(lbl)
|
||||
|
||||
# Parallel-conversation limit removed — conversations and flows now run
|
||||
# unlimited in parallel (no cap, no Settings row).
|
||||
att = data.get("attachments", {})
|
||||
_param_section("settings.group.attachments")
|
||||
self.attach_files = QSpinBox()
|
||||
self.attach_files.setRange(1, 50)
|
||||
self.attach_files.setSuffix(tr("settings.max_files_suffix"))
|
||||
self.attach_files.setValue(max(1, int(att.get("max_files", 20))))
|
||||
self.attach_files.setToolTip(tr("settings.max_files_tooltip"))
|
||||
self.attach_tokens = QSpinBox()
|
||||
self.attach_tokens.setRange(1, 1000)
|
||||
self.attach_tokens.setSingleStep(5)
|
||||
self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix"))
|
||||
self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
|
||||
self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
|
||||
pgl.addRow(tr("settings.max_files"), self.attach_files)
|
||||
pgl.addRow(tr("settings.max_per_file"), self.attach_tokens)
|
||||
|
||||
st = data.get("structure", {})
|
||||
_param_section("settings.group.structure")
|
||||
self.struct_nodes = QSpinBox()
|
||||
self.struct_nodes.setRange(0, 100000)
|
||||
self.struct_nodes.setSpecialValueText(tr("settings.unlimited"))
|
||||
self.struct_nodes.setSuffix(tr("settings.nodes_suffix"))
|
||||
self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500))))
|
||||
self.struct_nodes.setToolTip(tr("settings.nodes_tooltip"))
|
||||
self.struct_edges = QSpinBox()
|
||||
self.struct_edges.setRange(0, 200000)
|
||||
self.struct_edges.setSpecialValueText(tr("settings.unlimited"))
|
||||
self.struct_edges.setSuffix(tr("settings.edges_suffix"))
|
||||
self.struct_edges.setValue(max(0, int(st.get("max_edges", 500))))
|
||||
self.struct_edges.setToolTip(tr("settings.edges_tooltip"))
|
||||
pgl.addRow(tr("settings.max_nodes"), self.struct_nodes)
|
||||
pgl.addRow(tr("settings.max_edges"), self.struct_edges)
|
||||
|
||||
# Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from
|
||||
# the Sandbox Security group; still stored under agent_security.*.
|
||||
_param_section("settings.group.sandbox_limits")
|
||||
self.sandbox_cpu = QSpinBox()
|
||||
self.sandbox_cpu.setRange(0, 100_000)
|
||||
self.sandbox_cpu.setSuffix(" %")
|
||||
self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited"))
|
||||
self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0))
|
||||
pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
|
||||
|
||||
self.sandbox_memory = QSpinBox()
|
||||
self.sandbox_memory.setRange(0, 1_000_000)
|
||||
self.sandbox_memory.setSuffix(" MB")
|
||||
self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited"))
|
||||
self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048))
|
||||
pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
|
||||
|
||||
self.sandbox_disk = QSpinBox()
|
||||
self.sandbox_disk.setRange(0, 1_000_000)
|
||||
self.sandbox_disk.setSuffix(" MB")
|
||||
self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited"))
|
||||
self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048))
|
||||
pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
|
||||
|
||||
# Đã bóc sang presentation/settings/parameter_settings_widget.py (R08-T07).
|
||||
param_group = ParameterSettingsWidget(self.ctx)
|
||||
self._param_page = param_group
|
||||
root.addWidget(param_group)
|
||||
|
||||
# ---- Auto Model Routing ------------------------------------------
|
||||
routing = self.ctx.config.routing
|
||||
routing_group = QGroupBox(tr("routing.settings_group"))
|
||||
rgl = QFormLayout(routing_group)
|
||||
|
||||
self.routing_mode = QComboBox()
|
||||
for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual")):
|
||||
self.routing_mode.addItem(tr(key), value)
|
||||
self._select_combo(self.routing_mode, routing.get("switch_mode", "off"))
|
||||
rgl.addRow(tr("routing.settings_mode"), self.routing_mode)
|
||||
|
||||
self.routing_policy = QComboBox()
|
||||
for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
||||
("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")):
|
||||
self.routing_policy.addItem(tr(key), value)
|
||||
self._select_combo(self.routing_policy, routing.get("policy", "balanced"))
|
||||
rgl.addRow(tr("routing.settings_policy"), self.routing_policy)
|
||||
|
||||
# Min score gain stored as a fraction (0..1); shown as a percentage.
|
||||
self.routing_min_gain = QSpinBox()
|
||||
self.routing_min_gain.setRange(0, 100)
|
||||
self.routing_min_gain.setSuffix(" %")
|
||||
self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
|
||||
rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain)
|
||||
|
||||
self.routing_timeout = QSpinBox()
|
||||
self.routing_timeout.setRange(5, 600)
|
||||
self.routing_timeout.setSuffix(" s")
|
||||
self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
|
||||
rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout)
|
||||
|
||||
self.routing_interval = QSpinBox()
|
||||
self.routing_interval.setRange(0, 720)
|
||||
self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled
|
||||
self.routing_interval.setSuffix(" h")
|
||||
self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
|
||||
rgl.addRow(tr("routing.settings_interval"), self.routing_interval)
|
||||
|
||||
self.routing_concurrency = QSpinBox()
|
||||
self.routing_concurrency.setRange(1, 16)
|
||||
self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
|
||||
rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency)
|
||||
|
||||
self.routing_judge = QLineEdit(routing.get("judge_model", ""))
|
||||
rgl.addRow(tr("routing.settings_judge"), self.routing_judge)
|
||||
|
||||
self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
|
||||
self.routing_reassess_btn.clicked.connect(self._routing_reassess_now)
|
||||
rgl.addRow("", self.routing_reassess_btn)
|
||||
|
||||
rhint = QLabel(tr("routing.settings_hint"))
|
||||
rhint.setObjectName("hint")
|
||||
rhint.setWordWrap(True)
|
||||
rgl.addRow(rhint)
|
||||
# Đã bóc sang presentation/settings/routing_settings_widget.py (R08-T07).
|
||||
routing_group = RoutingSettingsWidget(self.ctx)
|
||||
self._routing_page = routing_group
|
||||
root.addWidget(routing_group)
|
||||
|
||||
note = QLabel(tr("settings.tip"))
|
||||
note.setObjectName("hint")
|
||||
note.setWordWrap(True) # otherwise this one line sets the dialog's width
|
||||
root.addWidget(note)
|
||||
|
||||
# Left list + right panel: one group on screen at a time, the way the
|
||||
# audit page's mock-up shows it. The five rows are the five real group
|
||||
@@ -319,15 +168,6 @@ class SettingsDialog(QDialog):
|
||||
# glance instead of by scrolling to find out.
|
||||
from .widgets import section_panels
|
||||
|
||||
self._general_box = QWidget()
|
||||
gv = QVBoxLayout(self._general_box)
|
||||
gv.setContentsMargins(0, 0, 0, 0)
|
||||
root.removeWidget(self._anchor_general)
|
||||
root.removeItem(top)
|
||||
gv.addLayout(top)
|
||||
gv.addWidget(note) # the tip belongs with the general settings
|
||||
gv.addStretch(1)
|
||||
root.removeWidget(note)
|
||||
|
||||
pages = []
|
||||
for label, widget in ((tr("settings.group.general"), self._general_box),
|
||||
@@ -374,291 +214,67 @@ class SettingsDialog(QDialog):
|
||||
self.resize(640, min(740, avail.height() - 80))
|
||||
self.setMaximumHeight(avail.height())
|
||||
|
||||
# ---- cầu tương thích sau khi bóc Routing -----------------------------
|
||||
# Năm checker trong tools/ và bài đặc tả đọc thẳng self.routing_*. Giữ tên
|
||||
# cũ trỏ vào widget mới để việc bóc không kéo theo sửa chỗ khác — đây là
|
||||
# đổi chỗ ở, không đổi hành vi. Bỏ được khi tools/ chuyển sang đọc
|
||||
# self._routing_page.
|
||||
provider_combo = property(lambda self: self._provider_page.provider_combo)
|
||||
prov_base = property(lambda self: self._provider_page.prov_base)
|
||||
prov_key = property(lambda self: self._provider_page.prov_key)
|
||||
prov_model = property(lambda self: self._provider_page.prov_model)
|
||||
prov_status = property(lambda self: self._provider_page.prov_status)
|
||||
language_combo = property(lambda self: self._general_box.language_combo)
|
||||
theme_combo = property(lambda self: self._general_box.theme_combo)
|
||||
tray_chk = property(lambda self: self._general_box.tray_chk)
|
||||
notify_chk = property(lambda self: self._general_box.notify_chk)
|
||||
attach_files = property(lambda self: self._param_page.attach_files)
|
||||
attach_tokens = property(lambda self: self._param_page.attach_tokens)
|
||||
struct_nodes = property(lambda self: self._param_page.struct_nodes)
|
||||
struct_edges = property(lambda self: self._param_page.struct_edges)
|
||||
sandbox_cpu = property(lambda self: self._param_page.sandbox_cpu)
|
||||
sandbox_memory = property(lambda self: self._param_page.sandbox_memory)
|
||||
sandbox_disk = property(lambda self: self._param_page.sandbox_disk)
|
||||
routing_mode = property(lambda self: self._routing_page.mode)
|
||||
routing_policy = property(lambda self: self._routing_page.policy)
|
||||
routing_min_gain = property(lambda self: self._routing_page.min_gain)
|
||||
routing_timeout = property(lambda self: self._routing_page.timeout)
|
||||
routing_interval = property(lambda self: self._routing_page.interval)
|
||||
routing_concurrency = property(lambda self: self._routing_page.concurrency)
|
||||
routing_judge = property(lambda self: self._routing_page.judge)
|
||||
routing_reassess_btn = property(lambda self: self._routing_page.reassess_btn)
|
||||
|
||||
# ---- helpers -----------------------------------------------------
|
||||
@staticmethod
|
||||
def _secret(value: str) -> QLineEdit:
|
||||
edit = QLineEdit(value)
|
||||
edit.setEchoMode(QLineEdit.Password)
|
||||
return edit
|
||||
|
||||
@staticmethod
|
||||
def _select_combo(combo: QComboBox, value: str) -> None:
|
||||
idx = combo.findData(value)
|
||||
if idx >= 0:
|
||||
combo.setCurrentIndex(idx)
|
||||
|
||||
@staticmethod
|
||||
def _group(title: str, rows) -> QGroupBox:
|
||||
"""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:
|
||||
form.addRow(label, widget)
|
||||
return box
|
||||
|
||||
def _routing_reassess_now(self) -> None:
|
||||
"""Kick off a manual model reassessment in the background."""
|
||||
try:
|
||||
service = self.ctx.routing()
|
||||
if service.is_reassessing():
|
||||
return
|
||||
self.routing_reassess_btn.setEnabled(False)
|
||||
self.routing_reassess_btn.setText(tr("routing.reassessing"))
|
||||
|
||||
def _done(result) -> None:
|
||||
# Re-enable from the (worker) callback; label reflects the count.
|
||||
self.routing_reassess_btn.setEnabled(True)
|
||||
self.routing_reassess_btn.setText(
|
||||
tr("routing.reassess_done", count=len(result or {})))
|
||||
|
||||
service.reassess_background(on_done=_done)
|
||||
except Exception: # noqa: BLE001 — a reassess click must never crash Settings
|
||||
self.routing_reassess_btn.setEnabled(True)
|
||||
self.routing_reassess_btn.setText(tr("routing.settings_reassess_now"))
|
||||
|
||||
@staticmethod
|
||||
def _model_combo(value: str) -> QComboBox:
|
||||
combo = QComboBox()
|
||||
combo.setEditable(True)
|
||||
# A combo sizes itself to its longest entry by default; model ids are
|
||||
# long, so the row grew past the dialog and forced a sideways scrollbar
|
||||
# (worse at 125%/150% display scaling). Let it shrink and use a popup
|
||||
# wider than the closed box instead.
|
||||
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
||||
combo.setMinimumContentsLength(8)
|
||||
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
if value:
|
||||
combo.addItem(value)
|
||||
combo.setCurrentText(value)
|
||||
return combo
|
||||
|
||||
def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget:
|
||||
row = QWidget()
|
||||
lay = QHBoxLayout(row)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(combo, 1)
|
||||
btn = QPushButton(tr("settings.load"))
|
||||
btn.setIcon(icon("download"))
|
||||
btn.setToolTip(tr("settings.load_tooltip"))
|
||||
btn.clicked.connect(
|
||||
lambda: self._load_models(self.provider_combo.currentData(), combo, status))
|
||||
lay.addWidget(btn)
|
||||
test_btn = QPushButton(tr("settings.test_connection"))
|
||||
test_btn.setIcon(icon("flask"))
|
||||
test_btn.setToolTip(tr("settings.test_connection_tooltip"))
|
||||
test_btn.clicked.connect(
|
||||
lambda: self._test_connection(self.provider_combo.currentData(), status))
|
||||
lay.addWidget(test_btn)
|
||||
# The two buttons keep their natural size; the combo gives way. Without
|
||||
# this the row's minimum was combo + both buttons and nothing could
|
||||
# shrink, so the dialog scrolled sideways instead.
|
||||
for b in (btn, test_btn):
|
||||
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
return row
|
||||
|
||||
def _stash_provider_fields(self) -> None:
|
||||
staged = self._prov_staging.setdefault(self._prov_current_key, {})
|
||||
staged.update({
|
||||
"base_url": self.prov_base.text().strip(),
|
||||
"api_key": self.prov_key.text(),
|
||||
"model": self.prov_model.currentText().strip(),
|
||||
})
|
||||
|
||||
def _on_provider_edit_changed(self) -> None:
|
||||
self._stash_provider_fields()
|
||||
self._prov_current_key = self.provider_combo.currentData()
|
||||
conf = self._prov_staging.get(self._prov_current_key, {})
|
||||
self.prov_base.setText(conf.get("base_url", ""))
|
||||
self.prov_key.setText(conf.get("api_key", ""))
|
||||
self.prov_model.clear()
|
||||
if conf.get("model"):
|
||||
self.prov_model.addItem(conf["model"])
|
||||
self.prov_model.setCurrentText(conf["model"])
|
||||
else:
|
||||
self.prov_model.setCurrentText("")
|
||||
self.prov_status.setText("")
|
||||
|
||||
def _current_conf(self, provider: str) -> dict:
|
||||
if provider == self._prov_current_key:
|
||||
return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(),
|
||||
"model": self.prov_model.currentText().strip()}
|
||||
conf = self._prov_staging.get(provider, {})
|
||||
return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
|
||||
"model": conf.get("model", "")}
|
||||
|
||||
# ---- MS365 zero-config sign-in ("connect like Claude") ---------------
|
||||
def _refresh_ms365_status(self) -> None:
|
||||
from ..core.ms365_auth import current_identity
|
||||
who = current_identity(self.ctx.config)
|
||||
if who:
|
||||
self.ms365_status.setText(tr("settings.ms365_signed_in", who=who))
|
||||
self.ms365_signin_btn.setEnabled(False)
|
||||
self.ms365_signout_btn.setEnabled(True)
|
||||
else:
|
||||
self.ms365_status.setText(tr("settings.ms365_signed_out"))
|
||||
self.ms365_signin_btn.setEnabled(True)
|
||||
self.ms365_signout_btn.setEnabled(False)
|
||||
self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn"))
|
||||
self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn"))
|
||||
|
||||
def _ms365_sign_in(self) -> None:
|
||||
from ..core.ms365_auth import current_identity, sign_in
|
||||
self.ms365_signin_btn.setEnabled(False)
|
||||
self.ms365_status.setText(tr("settings.ms365_signing_in"))
|
||||
cfg = self.ctx.config
|
||||
|
||||
def job(worker):
|
||||
# on_code fires (worker thread) with the MSAL device-flow dict —
|
||||
# marshal it to the UI thread via the worker's event signal.
|
||||
return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg)
|
||||
|
||||
def on_event(ev: dict) -> None:
|
||||
if "device_flow" in ev:
|
||||
self._show_ms365_device_code(ev["device_flow"])
|
||||
|
||||
def done(_result) -> None:
|
||||
self._close_ms365_code_dialog()
|
||||
self.ctx.save()
|
||||
self._refresh_ms365_status()
|
||||
QMessageBox.information(
|
||||
self, tr("settings.ms365_signin_btn"),
|
||||
tr("settings.ms365_signed_in", who=current_identity(cfg)))
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._close_ms365_code_dialog()
|
||||
self._refresh_ms365_status()
|
||||
QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.event.connect(on_event)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ms365_workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _close_ms365_code_dialog(self) -> None:
|
||||
dlg = getattr(self, "_ms365_code_dialog", None)
|
||||
if dlg is not None:
|
||||
dlg.close()
|
||||
self._ms365_code_dialog = None
|
||||
|
||||
def _show_ms365_device_code(self, flow: dict) -> None:
|
||||
"""Auto-open the sign-in page + show the one-time code in a COPYABLE,
|
||||
non-modal dialog (so the worker keeps polling and can auto-close it on
|
||||
success). The code is also copied to the clipboard immediately."""
|
||||
import webbrowser
|
||||
|
||||
code = flow.get("user_code", "")
|
||||
url = flow.get("verification_uri", "https://microsoft.com/devicelogin")
|
||||
# Auto-copy the code so the user can just paste it.
|
||||
QGuiApplication.clipboard().setText(code)
|
||||
# Auto-open the browser to the (code-prefilled, if available) sign-in page.
|
||||
try:
|
||||
webbrowser.open(flow.get("verification_uri_complete") or url)
|
||||
except Exception: # noqa: BLE001 — a headless box just shows the link to click
|
||||
pass
|
||||
|
||||
self._close_ms365_code_dialog()
|
||||
dlg = QDialog(self)
|
||||
dlg.setWindowTitle(tr("settings.ms365_signin_btn"))
|
||||
dlg.setMinimumWidth(420)
|
||||
lay = QVBoxLayout(dlg)
|
||||
info = QLabel(tr("settings.ms365_code_hint", url=url))
|
||||
info.setWordWrap(True)
|
||||
info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction)
|
||||
info.setOpenExternalLinks(True)
|
||||
lay.addWidget(info)
|
||||
|
||||
code_row = QHBoxLayout()
|
||||
code_edit = QLineEdit(code)
|
||||
code_edit.setReadOnly(True)
|
||||
f = code_edit.font()
|
||||
f.setPointSize(f.pointSize() + 4)
|
||||
f.setBold(True)
|
||||
code_edit.setFont(f)
|
||||
code_edit.setCursorPosition(0)
|
||||
copy_btn = QPushButton(tr("settings.ms365_copy_code"))
|
||||
copy_btn.setIcon(icon("document"))
|
||||
copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code))
|
||||
open_btn = QPushButton(tr("settings.ms365_open_link"))
|
||||
open_btn.setIcon(icon("link"))
|
||||
open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url))
|
||||
code_row.addWidget(code_edit, 1)
|
||||
code_row.addWidget(copy_btn)
|
||||
code_row.addWidget(open_btn)
|
||||
lay.addLayout(code_row)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(dlg.reject)
|
||||
lay.addWidget(buttons)
|
||||
|
||||
self._ms365_code_dialog = dlg
|
||||
dlg.show() # non-modal — sign-in polling continues; done() closes it
|
||||
|
||||
def _ms365_sign_out(self) -> None:
|
||||
from ..core.ms365_auth import sign_out_default
|
||||
sign_out_default(self.ctx.config)
|
||||
self._refresh_ms365_status()
|
||||
|
||||
def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None:
|
||||
conf = self._current_conf(provider)
|
||||
|
||||
def job(worker):
|
||||
from ..providers import build_provider
|
||||
prov = build_provider(provider, conf)
|
||||
models = prov.list_models()
|
||||
return {"models": models, "error": getattr(prov, "last_error", "")}
|
||||
|
||||
def done(result):
|
||||
models = result.get("models") or []
|
||||
current = combo.currentText().strip()
|
||||
combo.clear()
|
||||
if current:
|
||||
combo.addItem(current)
|
||||
for m in models:
|
||||
if m != current:
|
||||
combo.addItem(m)
|
||||
combo.setCurrentText(current)
|
||||
error = result.get("error", "")
|
||||
if models:
|
||||
status.setText(tr("settings.loaded_models", n=len(models),
|
||||
provider=PROVIDER_LABELS.get(provider, provider)))
|
||||
else:
|
||||
status.setText(tr("settings.load_models_error", err=error or
|
||||
tr("settings.load_models_error_unknown")))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e)))
|
||||
self._load_workers.append(w)
|
||||
status.setText(tr("settings.loading_models"))
|
||||
w.start()
|
||||
|
||||
def _test_connection(self, provider: str, status: QLabel) -> None:
|
||||
conf = self._current_conf(provider)
|
||||
|
||||
def job(worker):
|
||||
from ..providers import build_provider
|
||||
ok, message = build_provider(provider, conf).test_connection()
|
||||
return {"ok": ok, "message": message}
|
||||
|
||||
def done(result):
|
||||
ok = result.get("ok")
|
||||
status.setText(result.get("message", ""))
|
||||
status.setStyleSheet("color: #090;" if ok else "color: #c00;")
|
||||
|
||||
def failed(e):
|
||||
status.setText(str(e))
|
||||
status.setStyleSheet("color: #c00;")
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._load_workers.append(w)
|
||||
status.setText(tr("settings.testing_connection"))
|
||||
w.start()
|
||||
|
||||
def _sandbox_unlock(self) -> None:
|
||||
"""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
|
||||
@@ -673,20 +289,11 @@ 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
|
||||
data["active_provider"] = self.provider_combo.currentData()
|
||||
data["language"] = self.language_combo.currentData()
|
||||
# MainWindow._open_settings re-applies the theme after this returns, so
|
||||
# writing the value here is enough to make it take effect.
|
||||
data["theme"] = self.theme_combo.currentData()
|
||||
self._provider_page.apply_to(data)
|
||||
self._general_box.apply_to(data)
|
||||
|
||||
self._stash_provider_fields()
|
||||
for key, staged in self._prov_staging.items():
|
||||
data["providers"].setdefault(key, {}).update({
|
||||
"base_url": staged.get("base_url", ""),
|
||||
"api_key": staged.get("api_key", ""),
|
||||
"model": staged.get("model", ""),
|
||||
})
|
||||
|
||||
# NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now
|
||||
# (persisted there directly), so it is intentionally not written here.
|
||||
@@ -696,28 +303,11 @@ class SettingsDialog(QDialog):
|
||||
"block_network": self.sandbox_block_network.isChecked(),
|
||||
"command_ai_check": self.ai_check.isChecked(),
|
||||
"command_whitelist": [],
|
||||
"resource_limit_cpu_percent": self.sandbox_cpu.value(),
|
||||
"resource_limit_memory_mb": self.sandbox_memory.value(),
|
||||
"resource_limit_disk_mb": self.sandbox_disk.value(),
|
||||
})
|
||||
att = data.setdefault("attachments", {})
|
||||
att["max_tokens"] = self.attach_tokens.value() * 1000
|
||||
att["max_files"] = self.attach_files.value()
|
||||
st = data.setdefault("structure", {})
|
||||
st["max_nodes"] = self.struct_nodes.value()
|
||||
st["max_edges"] = self.struct_edges.value()
|
||||
tray = data.setdefault("tray", {})
|
||||
tray["minimize_on_close"] = self.tray_chk.isChecked()
|
||||
tray["notify_on_done"] = self.notify_chk.isChecked()
|
||||
self._param_page.apply_limits_to(data["agent_security"])
|
||||
self._param_page.apply_to(data)
|
||||
|
||||
r = data.setdefault("routing", {})
|
||||
r["switch_mode"] = self.routing_mode.currentData()
|
||||
r["policy"] = self.routing_policy.currentData()
|
||||
r["min_score_gain"] = self.routing_min_gain.value() / 100.0
|
||||
r["confirm_timeout_sec"] = self.routing_timeout.value()
|
||||
r["reassess_interval_hours"] = self.routing_interval.value()
|
||||
r["per_provider_concurrency"] = self.routing_concurrency.value()
|
||||
r["judge_model"] = self.routing_judge.text().strip()
|
||||
self._routing_page.apply_to(data)
|
||||
|
||||
self.ctx.save()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
+5
-240
@@ -1,245 +1,10 @@
|
||||
"""Tools — Monitoring tab (Admin) to govern every agent capability.
|
||||
"""Vỏ chuyển tiếp — R08-T08.
|
||||
|
||||
Two sub-tabs:
|
||||
* "Tool" — built-in agent tools (read/write/edit files, run commands,
|
||||
install packages, fetch URLs) as a left-aligned card grid;
|
||||
toggling one OFF removes it from the agent's toolset
|
||||
(persisted in ``config.tools_disabled``).
|
||||
* "Connector" — the full Connectors (MCP / REST API) setup, moved here from
|
||||
Settings: add/edit/delete CAD/CAE/MS365/Other connectors and
|
||||
enable/disable each (``ConnectorsPanel``).
|
||||
Phần thân đã chuyển sang ``presentation/monitoring/tabs/tools_admin_tab.py``.
|
||||
Giữ đường import cũ cho container Monitoring và checker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget,
|
||||
QVBoxLayout, QWidget,
|
||||
from ..presentation.monitoring.tabs.tools_admin_tab import ( # noqa: F401
|
||||
ToolsAdminTab,
|
||||
)
|
||||
|
||||
from ..core.tools import TOOL_SPECS
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .connectors_panel import ConnectorsPanel
|
||||
from .icons import icon
|
||||
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
|
||||
|
||||
# Identity colour + icon per built-in tool — same "fixed colour regardless of
|
||||
# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's
|
||||
# kind avatars, grouped by what the tool actually touches (file i/o, shell,
|
||||
# packages, network, Jira).
|
||||
_TOOL_COLOUR = {
|
||||
"read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4",
|
||||
"edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8",
|
||||
"fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8",
|
||||
}
|
||||
_TOOL_ICON_NAME = {
|
||||
"read_file": "document", "list_dir": "folder", "write_file": "new",
|
||||
"edit_file": "edit", "run_command": "terminal", "install_package": "download",
|
||||
"fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link",
|
||||
}
|
||||
|
||||
|
||||
def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4")))
|
||||
r = size * 0.28
|
||||
p.drawRoundedRect(0, 0, size, size, r, r)
|
||||
inner = int(size * 0.58)
|
||||
glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner)
|
||||
p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph)
|
||||
p.end()
|
||||
return pm
|
||||
|
||||
|
||||
def _clear_flow(flow: FlowLayout) -> None:
|
||||
while flow.count():
|
||||
item = flow.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
class ToolsAdminTab(QWidget):
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
self.subtabs = QTabWidget()
|
||||
root.addWidget(self.subtabs, 1)
|
||||
|
||||
# ---- "Tool" sub-tab: built-in agent tools ------------------------
|
||||
tool_page = QWidget()
|
||||
tl = QVBoxLayout(tool_page)
|
||||
self._net_worker = None
|
||||
self._hint = QLabel()
|
||||
self._hint.setObjectName("hint")
|
||||
self._hint.setWordWrap(True)
|
||||
tl.addWidget(self._hint)
|
||||
|
||||
# A left-aligned, wrapping card grid — one card per built-in tool
|
||||
# (colour-coded icon + name + toggle switch + description), replacing
|
||||
# the old flat Name/Description/Enabled table.
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
cards_host = QWidget()
|
||||
self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10)
|
||||
scroll.setWidget(cards_host)
|
||||
tl.addWidget(scroll, 1)
|
||||
|
||||
# "Test Internet" self-test lives INSIDE the fetch_url tool's card now
|
||||
# (see refresh) instead of a separate boxed section — persistent
|
||||
# widgets so they survive card rebuilds.
|
||||
self.test_internet_btn = QPushButton(tr("settings.test_internet"))
|
||||
self.test_internet_btn.setIcon(icon("globe"))
|
||||
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
|
||||
self.test_internet_btn.clicked.connect(self._test_internet)
|
||||
self.test_internet_status = QLabel("")
|
||||
self.test_internet_status.setWordWrap(True)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
self.refresh_btn = QPushButton()
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(self.refresh_btn)
|
||||
tl.addLayout(btn_row)
|
||||
# Jira CONNECTION setup lives in the Connector sub-tab now; here the Tool
|
||||
# list just lets the admin turn the jira_* tools on/off. A pointer note:
|
||||
self.jira_note = QLabel()
|
||||
self.jira_note.setObjectName("hint")
|
||||
self.jira_note.setWordWrap(True)
|
||||
tl.addWidget(self.jira_note)
|
||||
self.subtabs.addTab(tool_page, "")
|
||||
|
||||
# ---- "Connector" sub-tab: MCP / REST API setup (moved from Settings) --
|
||||
self.connectors_panel = ConnectorsPanel(ctx)
|
||||
self.subtabs.addTab(self.connectors_panel, "")
|
||||
|
||||
# on_language_changed() already invokes _retranslate() once immediately
|
||||
# (see i18n.py) — a second explicit call here double-populates the
|
||||
# card grid back-to-back with no event-loop turn in between, so the
|
||||
# first pass's cards are only queued for deleteLater() (not yet gone)
|
||||
# when the second pass adds new ones on top (see connectors_panel.py's
|
||||
# ConnectorsPanel, which hit the exact same bug this same way).
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- built-in tools card grid ---------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
disabled = set(self.ctx.config.tools_disabled)
|
||||
_clear_flow(self._tool_flow)
|
||||
for spec in TOOL_SPECS:
|
||||
self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
|
||||
|
||||
def _tool_card(self, spec, enabled: bool) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
style_card(card)
|
||||
card.setFixedWidth(220)
|
||||
# The description below wraps to a variable number of lines at this
|
||||
# fixed width, so the card's own height depends on its width — without
|
||||
# this, the outer FlowLayout's QWidgetItem queries card.sizePolicy()
|
||||
# (not the description label's), gets a too-short sizeHint, and
|
||||
# squeezes the card into less height than its QVBoxLayout needs,
|
||||
# which is what overlapped the header onto the description text.
|
||||
enable_height_for_width(card)
|
||||
lay = QVBoxLayout(card)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(4)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setPixmap(_tool_icon_pixmap(spec.name))
|
||||
icon_lbl.setStyleSheet("border: none;")
|
||||
hdr.addWidget(icon_lbl)
|
||||
name_lbl = QLabel(spec.name)
|
||||
name_lbl.setStyleSheet("font-weight:700; border: none;")
|
||||
hdr.addWidget(name_lbl)
|
||||
hdr.addStretch(1)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(enabled)
|
||||
sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on))
|
||||
hdr.addWidget(sw)
|
||||
lay.addLayout(hdr)
|
||||
|
||||
desc = QLabel(spec.description)
|
||||
desc.setWordWrap(True)
|
||||
desc.setToolTip(spec.description)
|
||||
desc.setObjectName("hint")
|
||||
desc.setStyleSheet("border: none;")
|
||||
lay.addWidget(desc)
|
||||
|
||||
if spec.name == "fetch_url":
|
||||
# The live "Test Internet" self-test lives inside fetch_url's own
|
||||
# card — it tests THIS capability, not the tab as a whole.
|
||||
net = QWidget()
|
||||
net.setStyleSheet("border: none;")
|
||||
nl = QHBoxLayout(net)
|
||||
nl.setContentsMargins(0, 2, 0, 0)
|
||||
nl.addWidget(self.test_internet_btn)
|
||||
nl.addWidget(self.test_internet_status, 1)
|
||||
lay.addWidget(net)
|
||||
|
||||
return card
|
||||
|
||||
def _toggle_builtin(self, name: str, enabled: bool) -> None:
|
||||
self.ctx.config.set_tool_enabled(name, enabled)
|
||||
# For fetch_url, the Enabled toggle also governs the runtime web-access
|
||||
# gate (agent_security.allow_url_fetch) — one control for the capability.
|
||||
if name == "fetch_url":
|
||||
self.ctx.config.agent_security["allow_url_fetch"] = bool(enabled)
|
||||
self.ctx.config.save()
|
||||
|
||||
def _test_internet(self) -> None:
|
||||
"""Live-check the app's own outbound HTTPS path and report the concrete
|
||||
result. Respects the fetch_url toggle: when web access is OFF the agent
|
||||
cannot reach the internet, so the test reports that instead of probing."""
|
||||
disabled = ("fetch_url" in self.ctx.config.tools_disabled
|
||||
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
|
||||
if disabled:
|
||||
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
return
|
||||
|
||||
def job(worker):
|
||||
from ..core import tls_trust
|
||||
ok, message = tls_trust.diagnose_internet()
|
||||
return {"ok": ok, "message": message}
|
||||
|
||||
def done(result):
|
||||
ok = result.get("ok")
|
||||
self.test_internet_status.setText(result.get("message", ""))
|
||||
self.test_internet_status.setStyleSheet("color: #090;" if ok else "color: #c00;")
|
||||
self.test_internet_btn.setEnabled(True)
|
||||
|
||||
def failed(e):
|
||||
self.test_internet_status.setText(str(e))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
self.test_internet_btn.setEnabled(True)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._net_worker = w # keep a ref so the thread isn't GC'd mid-run
|
||||
self.test_internet_btn.setEnabled(False)
|
||||
self.test_internet_status.setStyleSheet("")
|
||||
self.test_internet_status.setText(tr("settings.testing_internet"))
|
||||
w.start()
|
||||
|
||||
# ---- i18n -----------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self.subtabs.setTabText(0, tr("tools_admin.subtab_tool"))
|
||||
self.subtabs.setTabText(1, tr("tools_admin.subtab_connector"))
|
||||
self._hint.setText(tr("tools_admin.hint"))
|
||||
self.test_internet_btn.setText(tr("settings.test_internet"))
|
||||
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
|
||||
self.refresh_btn.setText(tr("tools_admin.refresh"))
|
||||
self.jira_note.setText(tr("tools_admin.jira_note"))
|
||||
self.refresh()
|
||||
|
||||
+74
-62
@@ -18,6 +18,9 @@ from PySide6.QtWidgets import (
|
||||
from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
|
||||
from ..theme import current_palette
|
||||
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
|
||||
# Chuyen sang ui/segmented_control.py de file nay khong vuot tran no cu cua
|
||||
# cong LOC; noi lai duoi ten cu vi 2 cho goi dang import tu day.
|
||||
from .segmented_control import SegmentedControl # noqa: F401
|
||||
|
||||
|
||||
def badge_pill_widget(text: str, object_name: str) -> QWidget:
|
||||
@@ -59,6 +62,11 @@ class FlowLayout(QLayout):
|
||||
FlowLayout example, ported)."""
|
||||
|
||||
def __init__(self, parent=None, margin: int = 0, h_spacing: int = 8, v_spacing: int = 8):
|
||||
"""Layout tự xuống dòng khi hết bề ngang.
|
||||
|
||||
Phải bật ``heightForWidth`` trên widget cha, nếu không Qt không hỏi lại chiều
|
||||
cao và hàng tràn ra bị cắt mất.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._h_spacing = h_spacing
|
||||
self._v_spacing = v_spacing
|
||||
@@ -68,34 +76,44 @@ class FlowLayout(QLayout):
|
||||
enable_height_for_width(parent)
|
||||
|
||||
def addItem(self, item) -> None: # noqa: N802 - Qt override
|
||||
"""Thêm một item vào cuối dòng chảy."""
|
||||
self._items.append(item)
|
||||
|
||||
def count(self) -> int: # noqa: N802 - Qt override
|
||||
"""Số item đang có trong layout."""
|
||||
return len(self._items)
|
||||
|
||||
def itemAt(self, index: int): # noqa: N802 - Qt override
|
||||
"""Item ở vị trí ``index``; ``None`` nếu ngoài phạm vi."""
|
||||
return self._items[index] if 0 <= index < len(self._items) else None
|
||||
|
||||
def takeAt(self, index: int): # noqa: N802 - Qt override
|
||||
"""Lấy item ra khỏi layout và trả về; ``None`` nếu ngoài phạm vi."""
|
||||
return self._items.pop(index) if 0 <= index < len(self._items) else None
|
||||
|
||||
def expandingDirections(self): # noqa: N802 - Qt override
|
||||
"""Không tự bung theo hướng nào — chiều cao do ``heightForWidth`` quyết định."""
|
||||
return Qt.Orientations(Qt.Orientation(0))
|
||||
|
||||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override
|
||||
"""Luôn ``True``: chiều cao của layout phụ thuộc bề rộng được cấp."""
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override
|
||||
"""Chiều cao cần có nếu chỉ được cấp ``width`` — tính bằng cách xếp thử, không vẽ thật."""
|
||||
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
||||
|
||||
def setGeometry(self, rect) -> None: # noqa: N802 - Qt override
|
||||
"""Xếp lại các item vào vùng được cấp."""
|
||||
super().setGeometry(rect)
|
||||
self._do_layout(rect, test_only=False)
|
||||
|
||||
def sizeHint(self): # noqa: N802 - Qt override
|
||||
"""Kích thước mong muốn — bằng kích thước tối thiểu."""
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self): # noqa: N802 - Qt override
|
||||
"""Kích thước tối thiểu: đủ chứa item lớn nhất cộng lề."""
|
||||
size = QSize()
|
||||
for item in self._items:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
@@ -104,6 +122,11 @@ class FlowLayout(QLayout):
|
||||
return size
|
||||
|
||||
def _do_layout(self, rect, test_only: bool) -> int:
|
||||
"""Xếp item thành nhiều dòng, xuống dòng khi hết bề rộng.
|
||||
|
||||
``test_only=True`` chỉ TÍNH chiều cao mà không dời widget nào — dùng cho
|
||||
``heightForWidth``, vì Qt hỏi chiều cao trước khi thật sự cấp vùng.
|
||||
"""
|
||||
m = self.contentsMargins()
|
||||
effective = QRect(rect.x() + m.left(), rect.y() + m.top(),
|
||||
rect.width() - m.left() - m.right(),
|
||||
@@ -140,6 +163,7 @@ class StatCard(QFrame):
|
||||
shared by Dashboard and Monitoring's token/cost displays."""
|
||||
|
||||
def __init__(self):
|
||||
"""Thẻ một con số kèm nhãn — viên gạch của Bảng điều khiển và Giám sát."""
|
||||
super().__init__()
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
style_card(self)
|
||||
@@ -164,6 +188,7 @@ class StatCard(QFrame):
|
||||
lay.addWidget(self.sub_lbl)
|
||||
|
||||
def set(self, title: str, value: str, sub: str = "") -> None:
|
||||
"""Đặt tiêu đề, giá trị và dòng phụ cho thẻ."""
|
||||
self.title_lbl.setText(title)
|
||||
self.value_lbl.setText(value)
|
||||
self.sub_lbl.setText(sub)
|
||||
@@ -191,6 +216,7 @@ class BudgetCard(QFrame):
|
||||
(the app turns the remaining balance red past 85% budget used)."""
|
||||
|
||||
def __init__(self):
|
||||
"""Thẻ ngân sách: số đã dùng trên hạn mức, kèm thanh tiến độ."""
|
||||
super().__init__()
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
style_card(self)
|
||||
@@ -227,6 +253,7 @@ class BudgetCard(QFrame):
|
||||
lay.addLayout(row)
|
||||
|
||||
def set(self, title: str, value: str, sub: str, warn: bool = False) -> None:
|
||||
"""Đặt nội dung thẻ; ``warn=True`` tô con số bằng màu cảnh báo."""
|
||||
self.title_lbl.setText(title)
|
||||
self.value_lbl.setText(value)
|
||||
self.value_lbl.setStyleSheet(
|
||||
@@ -235,6 +262,7 @@ class BudgetCard(QFrame):
|
||||
|
||||
|
||||
def fmt_tokens(n: int) -> str:
|
||||
"""Rút gọn số token cho dễ đọc: ``1_500`` → '1.5K', ``2_000_000`` → '2.00M'."""
|
||||
if n >= 1_000_000:
|
||||
return f"{n / 1e6:.2f}M"
|
||||
if n >= 1_000:
|
||||
@@ -249,6 +277,11 @@ class _WheelGuard(QObject):
|
||||
spin box the cursor happens to pass over, silently changing values."""
|
||||
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
"""Chặn lăn chuột trên widget chưa có focus.
|
||||
|
||||
Không chặn thì lăn qua một combo box giữa trang sẽ âm thầm đổi giá trị của
|
||||
nó thay vì cuộn trang — nuốt sự kiện để vùng cuộn nhận được.
|
||||
"""
|
||||
if event.type() == QEvent.Wheel and not obj.hasFocus():
|
||||
event.ignore()
|
||||
return True # eat it → the scroll area scrolls instead
|
||||
@@ -324,6 +357,11 @@ class _NarrowGuard(QObject):
|
||||
"""
|
||||
|
||||
def __init__(self, owner: QWidget, threshold: int, apply):
|
||||
"""Tự gập một panel khi cửa sổ hẹp lại dưới ``threshold``.
|
||||
|
||||
``_auto`` phân biệt "ta đang giữ nó gập" với "người dùng tự gập": không
|
||||
phân biệt thì kéo rộng cửa sổ ra sẽ bung cả panel mà người dùng cố ý gập.
|
||||
"""
|
||||
super().__init__(owner)
|
||||
self._owner = owner
|
||||
self._threshold = threshold
|
||||
@@ -332,6 +370,7 @@ class _NarrowGuard(QObject):
|
||||
self._window = None
|
||||
|
||||
def attach(self) -> None:
|
||||
"""Bắt đầu theo dõi sự kiện đổi kích thước của cửa sổ chứa widget."""
|
||||
win = self._owner.window()
|
||||
if win is not None and win is not self._owner and win is not self._window:
|
||||
win.installEventFilter(self)
|
||||
@@ -344,11 +383,17 @@ class _NarrowGuard(QObject):
|
||||
self.check()
|
||||
|
||||
def eventFilter(self, obj, ev): # noqa: N802 - Qt override
|
||||
"""Cửa sổ đổi kích thước thì kiểm lại xem có phải chuyển sang bố cục hẹp không."""
|
||||
if ev.type() == QEvent.Resize and obj is self._window:
|
||||
self.check()
|
||||
return super().eventFilter(obj, ev)
|
||||
|
||||
def check(self) -> None:
|
||||
"""Áp bố cục hẹp/rộng theo bề rộng cửa sổ.
|
||||
|
||||
Ngưỡng được viết theo tỉ lệ hiển thị chuẩn và nhân lên theo tỉ lệ thật của
|
||||
máy (xem ``ui_scale()``), nên màn 125%/150% không bị chuyển nhầm sớm.
|
||||
"""
|
||||
win = self._owner.window()
|
||||
width = win.width() if win is not None else self._owner.width()
|
||||
# The threshold is written for the baseline scale and grows with the
|
||||
@@ -380,16 +425,19 @@ class ToggleSwitch(QCheckBox):
|
||||
_W, _H = 34, 18
|
||||
|
||||
def __init__(self, text: str = "", parent=None):
|
||||
"""Công tắc gạt kiểu iOS, vẽ thay cho ô tick."""
|
||||
super().__init__(text, parent)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
def sizeHint(self): # noqa: N802 - Qt override
|
||||
"""Chừa thêm chỗ cho phần gạt bên cạnh nhãn."""
|
||||
base = super().sizeHint()
|
||||
base.setWidth(base.width() + self._W)
|
||||
base.setHeight(max(base.height(), self._H + 4))
|
||||
return base
|
||||
|
||||
def paintEvent(self, _e): # noqa: N802 - Qt override
|
||||
"""Tự vẽ rãnh và núm gạt theo màu của theme đang dùng."""
|
||||
from ..theme import current_palette
|
||||
p = current_palette()
|
||||
painter = QPainter(self)
|
||||
@@ -416,68 +464,6 @@ class ToggleSwitch(QCheckBox):
|
||||
painter.end()
|
||||
|
||||
|
||||
class SegmentedControl(QWidget):
|
||||
"""Two-to-four choices shown side by side instead of hidden in a drop-list.
|
||||
|
||||
Exposes the slice of the QComboBox API this app's settings code uses
|
||||
(addItem / findData / currentData / setCurrentIndex / currentIndexChanged),
|
||||
so it drops into an existing form without touching the save/load paths.
|
||||
"""
|
||||
|
||||
currentIndexChanged = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._data: list = []
|
||||
self._buttons: list = []
|
||||
self._current = -1
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
self._lay = lay
|
||||
lay.addStretch(1)
|
||||
|
||||
def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
btn = QPushButton(text)
|
||||
btn.setObjectName("segItem")
|
||||
btn.setCheckable(True)
|
||||
btn.setCursor(Qt.PointingHandCursor)
|
||||
index = len(self._buttons)
|
||||
btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i))
|
||||
self._lay.insertWidget(index, btn)
|
||||
self._buttons.append(btn)
|
||||
self._data.append(data)
|
||||
if self._current < 0:
|
||||
self.setCurrentIndex(0)
|
||||
|
||||
def findData(self, value) -> int: # noqa: N802
|
||||
return self._data.index(value) if value in self._data else -1
|
||||
|
||||
def currentData(self): # noqa: N802
|
||||
return self._data[self._current] if 0 <= self._current < len(self._data) else None
|
||||
|
||||
def currentIndex(self) -> int: # noqa: N802
|
||||
return self._current
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._buttons)
|
||||
|
||||
def setItemText(self, index: int, text: str) -> None: # noqa: N802
|
||||
if 0 <= index < len(self._buttons):
|
||||
self._buttons[index].setText(text)
|
||||
|
||||
def setCurrentIndex(self, index: int) -> None: # noqa: N802
|
||||
if not (0 <= index < len(self._buttons)) or index == self._current:
|
||||
for i, b in enumerate(self._buttons):
|
||||
b.setChecked(i == self._current)
|
||||
return
|
||||
self._current = index
|
||||
for i, b in enumerate(self._buttons):
|
||||
b.setChecked(i == index)
|
||||
self.currentIndexChanged.emit(index)
|
||||
|
||||
|
||||
def section_panels(sections, width: int = 260):
|
||||
"""Left list + right panel: pick a section, see that section only.
|
||||
|
||||
@@ -544,6 +530,9 @@ def section_index(scroll, sections, width: int = 260):
|
||||
index.setFixedWidth(max(120, min(width, natural)))
|
||||
|
||||
def _jump(item):
|
||||
"""Bấm một mục trong cột mục lục: cuộn sao cho mép trên của mục đó lên đúng
|
||||
đỉnh vùng nhìn, chứ không chỉ "đâu đó trong tầm mắt".
|
||||
"""
|
||||
anchor = item.data(Qt.UserRole)
|
||||
if anchor is not None:
|
||||
# Scroll so the section's top edge lands at the top of the viewport,
|
||||
@@ -583,6 +572,11 @@ class CollapseStrip(QWidget):
|
||||
WIDTH = 18 # click target width; wide enough to show the expand arrow
|
||||
|
||||
def __init__(self, tooltip: str = "Click to expand", expand_dir: str = "right"):
|
||||
"""Dải mảnh còn lại sau khi gập một panel; bấm vào là bung ra.
|
||||
|
||||
``expand_dir`` quyết định mũi tên chỉ hướng nào — panel gập ở mép trái bung
|
||||
sang phải và ngược lại.
|
||||
"""
|
||||
super().__init__()
|
||||
self._hover = False
|
||||
self._dir = "left" if expand_dir == "left" else "right"
|
||||
@@ -592,21 +586,25 @@ class CollapseStrip(QWidget):
|
||||
self.setToolTip(tooltip)
|
||||
|
||||
def enterEvent(self, e) -> None: # noqa: N802
|
||||
"""Rê chuột vào thì làm nổi dải lên."""
|
||||
self._hover = True
|
||||
self.update()
|
||||
super().enterEvent(e)
|
||||
|
||||
def leaveEvent(self, e) -> None: # noqa: N802
|
||||
"""Rời chuột thì trả dải về trạng thái thường."""
|
||||
self._hover = False
|
||||
self.update()
|
||||
super().leaveEvent(e)
|
||||
|
||||
def mousePressEvent(self, e) -> None: # noqa: N802
|
||||
"""Bấm trái vào dải thì phát tín hiệu mở lại panel."""
|
||||
if e.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def paintEvent(self, e) -> None: # noqa: N802
|
||||
"""Vẽ dải: nền theo theme cộng mũi tên chỉ hướng sẽ bung ra."""
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
w = self.width()
|
||||
@@ -661,6 +659,7 @@ class PlanSection(QWidget):
|
||||
|
||||
@staticmethod
|
||||
def _step_icon(status: str):
|
||||
"""Icon tương ứng trạng thái một bước: đang chạy, xong, lỗi hay còn chờ."""
|
||||
if status == STEP_RUNNING:
|
||||
return icon("play", color=DOT_BLUE)
|
||||
if status == STEP_DONE:
|
||||
@@ -670,6 +669,9 @@ class PlanSection(QWidget):
|
||||
return dot_icon(DOT_GREY) # pending
|
||||
|
||||
def __init__(self, title: str = "Plan", max_height: int = 150):
|
||||
"""Khối kế hoạch nhiều bước trong bong bóng chat, có giới hạn chiều cao để một
|
||||
kế hoạch dài không đẩy phần trả lời ra khỏi màn hình.
|
||||
"""
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._count = 0
|
||||
@@ -715,6 +717,7 @@ class PlanSection(QWidget):
|
||||
self._update_header()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Xoá sạch kế hoạch và ẩn cả khối đi."""
|
||||
self.list.clear()
|
||||
self._count = 0
|
||||
self.setVisible(False)
|
||||
@@ -726,10 +729,12 @@ class PlanSection(QWidget):
|
||||
self._update_header()
|
||||
|
||||
def _toggle(self, on: bool) -> None:
|
||||
"""Gập/mở danh sách bước."""
|
||||
self.list.setVisible(on)
|
||||
self._update_header()
|
||||
|
||||
def _update_header(self) -> None:
|
||||
"""Cập nhật dòng tiêu đề: mũi tên gập/mở kèm số bước."""
|
||||
arrow = "▾" if self.header.isChecked() else "▸"
|
||||
self.header.setText(f"{arrow} {self._title} ({self._count})")
|
||||
|
||||
@@ -773,6 +778,7 @@ class CollapsibleSection(QWidget):
|
||||
self._update_header()
|
||||
|
||||
def add(self, path: str) -> None:
|
||||
"""Thêm một đường dẫn vào mục; đã có rồi thì bỏ qua."""
|
||||
if not path or path in self._paths:
|
||||
return
|
||||
self._paths.append(path)
|
||||
@@ -787,6 +793,7 @@ class CollapsibleSection(QWidget):
|
||||
self._update_header()
|
||||
|
||||
def remove(self, path: str) -> None:
|
||||
"""Gỡ một đường dẫn khỏi mục."""
|
||||
if path not in self._paths:
|
||||
return
|
||||
i = self._paths.index(path)
|
||||
@@ -797,9 +804,11 @@ class CollapsibleSection(QWidget):
|
||||
self._update_header()
|
||||
|
||||
def paths(self) -> list[str]:
|
||||
"""Bản sao danh sách đường dẫn đang hiện trong mục."""
|
||||
return list(self._paths)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Xoá sạch mục."""
|
||||
self._paths.clear()
|
||||
self.list.clear()
|
||||
self.setVisible(False)
|
||||
@@ -811,14 +820,17 @@ class CollapsibleSection(QWidget):
|
||||
self._update_header()
|
||||
|
||||
def _toggle(self, on: bool) -> None:
|
||||
"""Gập/mở danh sách."""
|
||||
self.list.setVisible(on)
|
||||
self._update_header()
|
||||
|
||||
def _update_header(self) -> None:
|
||||
"""Cập nhật dòng tiêu đề: mũi tên gập/mở kèm số mục."""
|
||||
arrow = "▾" if self.header.isChecked() else "▸"
|
||||
self.header.setText(f"{arrow} {self._title} ({len(self._paths)})")
|
||||
|
||||
def _emit(self, item: QListWidgetItem) -> None:
|
||||
"""Bấm một dòng: phát đường dẫn lên để chỗ gọi mở tệp."""
|
||||
path = item.data(Qt.UserRole)
|
||||
if path:
|
||||
self.activated.emit(path)
|
||||
|
||||
+74
-4
@@ -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 = ""
|
||||
@@ -220,7 +235,7 @@ class WorkspaceTab(QWidget):
|
||||
# Folder — a two-pane file explorer (tree + view/edit) placed right below
|
||||
# Co4E. Always available (not project-gated); its root follows the
|
||||
# selected project's workspace folder when one is chosen.
|
||||
from .folder_tab import FolderTab
|
||||
from ..presentation.folder.folder_tab import FolderTab
|
||||
|
||||
self._folder = FolderTab(self.ctx, cowork=self._cowork)
|
||||
self._folder.status_message.connect(self.status_message)
|
||||
@@ -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