Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## 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:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+96
View File
@@ -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"))
+27
View File
@@ -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
View File
@@ -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
-231
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+10 -504
View File
@@ -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 "&nbsp;"
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())
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18 -658
View File
@@ -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))
+37
View File
@@ -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
View File
@@ -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
-438
View File
@@ -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()
+15
View File
@@ -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()
+16
View File
@@ -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
+71
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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()
+6
View File
@@ -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:
+13
View File
@@ -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"))
+39
View File
@@ -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)
+45
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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()
-794
View File
@@ -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()
+134
View File
@@ -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
View File
@@ -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()
+24
View File
@@ -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
+32
View File
@@ -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
+36
View File
@@ -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
+10
View File
@@ -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
+22
View File
@@ -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"))
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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