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
+3
View File
@@ -0,0 +1,3 @@
"""Tám tab của màn Giám sát, mỗi tab một file: Tổng quan, Sandbox, Sự kiện bảo
mật, Lịch sử MCP, Nhật ký hành động, Trạng thái Agent, Quản trị Agent, Công cụ.
"""
@@ -0,0 +1,50 @@
"""Action Logs tab — the full audit log, newest first. Extracted from
``ui/monitoring_tab.py``'s action-table wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class ActionLogsTab(QWidget):
"""Tab "Nhật ký hành động": bảng mọi thao tác đọc/ghi tệp và chạy lệnh của agent."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Nhật ký hành động: bảng sự kiện kèm bộ lọc dùng chung."""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
self.table = EventTable()
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.action_logs_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,154 @@
"""Hộp thoại thêm/sửa một agent trong danh mục quản trị — R08-T08.
Tách khỏi ``agents_admin_tab.py``: bảng danh sách và hộp thoại sửa là hai
việc khác nhau, và hộp thoại còn tự đi hỏi provider xem có những model nào
(``_load_live_models``) — thứ bảng không cần biết.
"""
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 ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
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 = ""):
"""Form thêm/sửa một agent quản trị.
Danh sách model của từng provider được nạp nền và nhớ lại, để đổi qua đổi
lại giữa các provider không phải gọi mạng lần nữa.
"""
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:
"""Nạp danh sách model đang sống của mọi provider, ở luồng nền."""
if self.ctx is None:
return
self.load_models_btn.setEnabled(False)
ctx = self.ctx
def job(_worker: AgentWorker):
"""Chạy nền: hỏi từng provider danh sách model."""
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 theo provider đang chọn."""
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:
"""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("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:
"""Nạp lại ô chọn model theo provider đang chọn, giữ nguyên thứ đang gõ."""
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]:
"""Các trường agent lấy từ form, để chỗ gọi dựng bản ghi."""
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(),
}
@@ -0,0 +1,96 @@
"""Agent Status tab — which agent roles are currently running, read from the
existing ``ChatPanel``/``TaskScheduler``/GraphRAG-ask-worker state (no new
runtime tracking of its own). Extracted from ``ui/monitoring_tab.py``'s
status-table wiring + ``_refresh_agent_status``.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6.QtCore import QSize
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
from ....core import agent_roles
from ....i18n import tr
from ....ui.widgets import badge_pill_widget
from ..shared.filter_scaffold import build_filter_scaffold
from ..shared.formatters import agent_avatar_icon
class AgentStatusTab(QWidget):
"""Tab "Trạng thái Agent": đang có bao nhiêu lượt chat, task và luồng chạy."""
def __init__(self, ctx, on_refresh_all: Callable[[], None],
cowork=None, structure=None, task_scheduler=None):
"""Tab Tình trạng agent. Các widget truyền vào có thể là None khi dựng tab lẻ."""
super().__init__()
self._ctx = ctx
self._cowork = cowork
self._structure = structure
self._task_scheduler = task_scheduler
self.table = QTableWidget(0, 3)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
# No detail to open on click, no filtering — a plain read-only table,
# so selection is off rather than left dangling with no effect.
self.table.setSelectionMode(QTableWidget.NoSelection)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.table.setColumnWidth(1, 130) # status is a cell widget — size it explicitly
self.table.setIconSize(QSize(20, 20))
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.agent_status_title", with_search=False, with_detail=False)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.table.setHorizontalHeaderLabels([
tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"),
])
def refresh(self) -> None:
"""Đếm lại số việc đang chạy ở từng phân hệ và cập nhật bảng."""
cowork_n = len(self._cowork.active_workers()) if self._cowork is not None else 0
task_n = self._task_scheduler.running_count() if self._task_scheduler is not None else 0
ask_worker = getattr(self._structure, "_ask_worker", None)
knowledge_n = 1 if (ask_worker is not None and ask_worker.isRunning()) else 0
# Security is a system-management agent that runs INLINE on the
# active turn (agent_security prompt/command validation) — there is
# no separate worker to count, so its "active" cell shows On/Off
# from Settings instead of a live count.
sec_on = bool(self._ctx.config.agent_security.get("enabled"))
rows = [
(agent_roles.COWORK, cowork_n, tr("monitoring.source_cowork")),
(agent_roles.TASK, task_n, tr("monitoring.source_task")),
(agent_roles.KNOWLEDGE, knowledge_n, tr("monitoring.source_knowledge")),
(agent_roles.PLANNER, None, tr("monitoring.source_planner")),
(agent_roles.REASONING, None, tr("monitoring.source_reasoning")),
(agent_roles.SECURITY, None, tr("monitoring.source_security")),
]
self.table.setRowCount(len(rows))
for row, (role_key, count, source) in enumerate(rows):
label = agent_roles.label_for(role_key)
name_item = QTableWidgetItem(label)
name_item.setIcon(agent_avatar_icon(label))
self.table.setItem(row, 0, name_item)
if role_key == agent_roles.SECURITY:
running = sec_on
status_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
elif count is not None:
running = count > 0
status_text = tr("monitoring.active_n", n=count) if running else tr("monitoring.idle")
else:
running = False
status_text = "—"
badge_tone = "badgeSuccess" if running else "badgeNeutral"
self.table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone))
self.table.setItem(row, 2, QTableWidgetItem(source))
@@ -0,0 +1,353 @@
"""Agents Admin — Monitoring tab visible to the Admin role ONLY.
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.
Màu, huy hiệu và avatar theo loại việc nằm ở
``../shared/agent_kind_visuals.py``. File này từng giữ bản sao riêng của ba
hàm dùng chung — ``_initials``, ``_fmt_updated``, ``_kind_avatar_icon`` —
giống hệt ``shared/formatters.py`` đến từng dòng; nay dùng thẳng bản chung
để định dạng thời gian và avatar không lệch nhau giữa các bảng Giám sát.
"""
from __future__ import annotations
from datetime import datetime
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ....config import PROVIDER_LABELS
from ....core import admin_agents
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog
from ..shared.agent_kind_visuals import KIND_BADGE, STATUS_BADGE, kind_avatar_icon
from ..shared.formatters import fmt_event_time
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
class AgentsAdminTab(QWidget):
"""Tab "Quản trị Agent": cấu hình prompt, provider và model cho từng agent chuyên trách, kèm nút kiểm tra tình trạng hoạt động."""
def __init__(self, ctx: AppContext):
"""Dựng bảng agent quản trị.
Kết quả kiểm tra lần gần nhất được giữ trong ``_status`` và sống qua các lần
vẽ lại — người dùng bấm Kiểm tra một lần rồi lọc/sắp xếp vẫn thấy kết quả.
"""
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):
"""Thư mục chứa agent quản trị (dùng thư mục chia sẻ nếu có cấu hình)."""
return admin_agents.agents_admin_dir(self.ctx.config.shared_dir)
def _default_model_hint(self) -> str:
"""Model mặc định gợi ý cho agent mới — lấy từ provider đang chọn."""
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
return conf.get("model", "")
# ---- CRUD -------------------------------------------------------------
def _add(self) -> None:
"""Thêm một agent quản trị mới qua hộp thoại."""
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:
"""Sửa một agent; id không còn tồn tại thì bỏ qua."""
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:
"""Xoá một agent sau khi hỏi xác nhận."""
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:
"""Bật/tắt một agent; trạng thái không đổi thì không ghi đĩa."""
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:
"""Dựng ô công tắc bật/tắt cho một dòng trong bảng."""
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:
"""Dựng cụm nút sửa/xoá cho một dòng trong bảng."""
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).
"""Vẽ lại bảng agent."""
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_event_time(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:
"""Chạy nền: gọi thử từng agent để kiểm tra tình trạng hoạt động."""
return {a.agent_id: admin_agents.check_agent(ctx, a) for a in agents}
def done(result: dict) -> None:
"""Ghi kết quả kiểm tra vào bảng trạng thái rồi vẽ lại."""
self.check_btn.setEnabled(True)
self._status.update(result or {})
self.refresh()
def failed(err: str) -> None:
"""Kiểm tra lỗi: đánh dấu mọi agent là hỏng kèm lý do (cắt ở 200 ký tự)."""
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:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, gợi ý và tên cột."""
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()
+50
View File
@@ -0,0 +1,50 @@
"""MCP Call History tab — the audit log filtered to ``kind="mcp_call"``.
Extracted from ``ui/monitoring_tab.py``'s MCP-table wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class McpTab(QWidget):
"""Tab "Lịch sử MCP": bảng các lượt gọi tool MCP kèm độ trễ và kết quả."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Lịch sử MCP: bảng sự kiện kèm bộ lọc dùng chung."""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
self.table = EventTable()
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.mcp_history_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,350 @@
"""Overview tab — the card-based dashboard (Token Usage & Cost, Resource
Usage, Recent Activity, Sandbox Details + nested Permissions, Model Pricing,
Audit Log preview). Extracted from ``ui/monitoring_tab.py``'s
``_build_overview_page`` and the refresh/pricing/budget methods it wires to.
"""
from __future__ import annotations
import time
from typing import Callable, List
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QGridLayout, QGroupBox, QHBoxLayout, QLabel, QProgressBar, QPushButton,
QScrollArea, QVBoxLayout, QWidget,
)
from ....core import usage_tracker as ut
from ....i18n import tr
from ....theme import current_palette
from ....ui.icons import DOT_AMBER, DOT_GREEN, DOT_RED, icon
from ....ui.widgets import BudgetCard, StatCard, fmt_tokens
from ..shared.formatters import fmt_bytes, relative_time
from .pricing_panel import PricingPanel
from .sandbox_tab import SandboxDetailsCard
class OverviewTab(QWidget):
"""Tab "Tổng quan" của màn Giám sát: tài nguyên máy, thẻ token/chi phí, ngân
sách, trạng thái sandbox, hoạt động gần đây và trích nhật ký kiểm toán.
Một cột dọc duy nhất, mỗi phần là một mục có tiêu đề — bố cục này là chủ ý
để mắt quét từ trên xuống thay vì phải nhảy giữa hai cột.
"""
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
on_settings_changed: Callable[[], None],
on_view_all_action_logs: Callable[[], None],
action_logs_tab_visible: bool):
"""Tab Tổng quan.
Mọi việc ra ngoài tab (báo trạng thái, mở màn khác) đều đi qua callback
truyền vào, nên tab này không cần biết cửa sổ chính.
"""
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
self._on_view_all_action_logs = on_view_all_action_logs
self._last_io_sample = None
self._res_first = True
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
content = QWidget()
scroll.setWidget(content)
outer.addWidget(scroll)
# ONE main column, scrolled vertically, sections in a fixed order.
root = QVBoxLayout(content)
root.setSpacing(12)
self._build_usage_section(root)
self._build_activity_section() # added to `root` further down, beside the audit log
self._build_resource_section(root)
self.sandbox_card = SandboxDetailsCard(ctx, on_settings_changed)
root.addWidget(self.sandbox_card)
self.pricing_panel = PricingPanel(ctx, on_status_message)
root.addWidget(self.pricing_panel)
root.addWidget(self.activity_group)
self._build_audit_section(root, action_logs_tab_visible)
root.addStretch(1)
# ---- Token Usage & Cost ------------------------------------------------
def _build_usage_section(self, root: QVBoxLayout) -> None:
"""Dựng mục thẻ token/chi phí kèm thẻ ngân sách."""
self.usage_group = QGroupBox()
self.usage_group.setObjectName("monSection")
usage_lay = QGridLayout(self.usage_group)
usage_lay.setSpacing(8)
self.usage_total = StatCard()
self.usage_in = StatCard()
self.usage_out = StatCard()
self.usage_cache = StatCard()
self.usage_cost = StatCard()
self.usage_calls = StatCard()
for i, card in enumerate((self.usage_cost, self.usage_total,
self.usage_in, self.usage_out, self.usage_cache)):
usage_lay.addWidget(card, 0, i)
self.usage_calls.setVisible(False) # rides on the cost tile's label
self.budget_card = BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
usage_lay.addWidget(self.budget_card, 0, 3)
for col in range(4):
usage_lay.setColumnStretch(col, 1)
root.addWidget(self.usage_group)
# ---- Recent Activity ----------------------------------------------------
def _build_activity_section(self) -> None:
"""Dựng mục "Hoạt động gần đây" (thêm vào cột ở dưới, cạnh nhật ký kiểm toán)."""
self.activity_group = QGroupBox()
self.activity_group.setObjectName("monSection")
act_lay = QVBoxLayout(self.activity_group)
self.activity_lbl = QLabel()
self.activity_lbl.setWordWrap(True)
self.activity_lbl.setTextFormat(Qt.RichText)
act_lay.addWidget(self.activity_lbl)
# ---- Resource Usage -------------------------------------------------------
def _build_resource_section(self, root: QVBoxLayout) -> None:
"""Dựng mục tài nguyên: CPU, bộ nhớ, đĩa, mạng và trạng thái sandbox."""
self.resource_group = QGroupBox()
self.resource_group.setObjectName("monSection")
res_lay = QHBoxLayout(self.resource_group)
res_lay.setSpacing(6)
def _pair():
"""Thêm một cặp nhãn–giá trị vào hàng tài nguyên, tự chèn dấu phân cách giữa
các cặp.
"""
if not self._res_first:
sep = QLabel(chr(0xB7))
sep.setObjectName("hint")
res_lay.addWidget(sep)
self._res_first = False
lbl = QLabel()
lbl.setObjectName("hint")
val = QLabel()
res_lay.addWidget(lbl)
res_lay.addWidget(val)
return lbl, val
def _bar_row():
"""Thêm một cặp nhãn–giá trị kèm thanh tiến độ (CPU, bộ nhớ)."""
lbl, val = _pair()
bar = QProgressBar()
bar.setVisible(False)
return lbl, bar, val
self.cpu_lbl, self.cpu_bar, self.cpu_val = _bar_row()
self.mem_lbl, self.mem_bar, self.mem_val = _bar_row()
self.diskfree_lbl, self.diskfree_val = _pair()
self.disk_lbl, self.disk_val = QLabel(), QLabel()
self.network_lbl, self.network_val = QLabel(), QLabel()
res_lay.addStretch(1)
root.addWidget(self.resource_group)
# ---- Audit Log preview --------------------------------------------------
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
"""Dựng mục trích nhật ký kiểm toán; ẩn hẳn khi tab Nhật ký hành động bị tắt."""
self.audit_group = QGroupBox()
self.audit_group.setObjectName("monSection")
audit_lay = QVBoxLayout(self.audit_group)
self.audit_lbl = QLabel()
self.audit_lbl.setWordWrap(True)
self.audit_lbl.setTextFormat(Qt.RichText)
audit_lay.addWidget(self.audit_lbl)
self.view_all_btn = QPushButton()
self.view_all_btn.setFlat(True)
self.view_all_btn.clicked.connect(lambda: self._on_view_all_action_logs())
audit_lay.addWidget(self.view_all_btn, 0, Qt.AlignRight)
self.audit_group.setVisible(action_logs_tab_visible)
root.addWidget(self.audit_group)
# ---- budget --------------------------------------------------------------
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:
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt 85%.
Không ghi đè ô nhập khi người dùng đang gõ trong đó.
"""
from ....core import model_pricing as mp
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
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
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"])
if not self.budget_card.budget_spin.hasFocus():
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
# ---- resource usage --------------------------------------------------------
def _refresh_resource_usage(self) -> None:
"""Đọc CPU/RAM/đĩa/mạng của CHÍNH tiến trình app.
Đĩa và mạng là tốc độ tức thời, tính bằng hiệu hai lần lấy mẫu chia cho
khoảng thời gian giữa chúng — nên lần đo đầu tiên luôn hiện "—". Không có
``psutil`` thì cả mục chuyển sang "—" chứ không làm vỡ màn hình.
"""
try:
import psutil
except ImportError:
self._set_resource_na()
return
try:
own = psutil.Process()
own_cpu = own.cpu_percent(interval=None)
own_mem = own.memory_info().rss
except Exception:
own, own_cpu, own_mem = None, 0.0, 0
self.cpu_bar.setValue(int(min(own_cpu, 100)))
self.cpu_val.setText(f"{own_cpu:.0f}%")
try:
total_mem = psutil.virtual_memory().total
mem_pct = int(own_mem * 100 / total_mem) if total_mem else 0
except Exception:
mem_pct = 0
self.mem_bar.setValue(min(mem_pct, 100))
try:
self.mem_val.setText(f"{fmt_bytes(own_mem)}/{fmt_bytes(total_mem)}")
except Exception: # noqa: BLE001
self.mem_val.setText(fmt_bytes(own_mem))
try:
free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
self.diskfree_val.setText(tr("monitoring.overview_disk_free", size=fmt_bytes(free)))
except Exception: # noqa: BLE001
self.diskfree_val.setText(tr("monitoring.na"))
now = time.monotonic()
try:
io = own.io_counters() if own is not None else None
disk_bytes = (io.read_bytes + io.write_bytes) if io is not None else None
except Exception:
disk_bytes = None
try:
net = psutil.net_io_counters()
net_bytes = net.bytes_sent + net.bytes_recv
except Exception:
net_bytes = None
prev = self._last_io_sample
self._last_io_sample = (now, disk_bytes, net_bytes)
na = tr("monitoring.na")
if prev and disk_bytes is not None and prev[1] is not None and now > prev[0]:
rate = max(0.0, (disk_bytes - prev[1]) / (now - prev[0]))
self.disk_val.setText(f"{fmt_bytes(rate)}/s")
else:
self.disk_val.setText(na)
if prev and net_bytes is not None and prev[2] is not None and now > prev[0]:
rate = max(0.0, (net_bytes - prev[2]) / (now - prev[0]))
self.network_val.setText(f"{fmt_bytes(rate)}/s")
else:
self.network_val.setText(na)
def _set_resource_na(self) -> None:
"""Đặt mọi chỉ số tài nguyên về "—" (khi thiếu ``psutil``)."""
na = tr("monitoring.na")
self.cpu_bar.setValue(0)
self.cpu_val.setText(na)
self.mem_bar.setValue(0)
self.mem_val.setText(na)
self.disk_val.setText(na)
self.network_val.setText(na)
# ---- usage cards -----------------------------------------------------------
def _activity_line(self, event: dict) -> str:
"""Một dòng hoạt động: dấu ✓/!/✗ theo kết quả, tên việc, và thời gian tương đối."""
ok = event.get("ok", True)
if ok:
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
elif event.get("kind") == "security_block":
mark = f"<span style='color:{DOT_AMBER};'>!</span>"
else:
mark = f"<span style='color:{DOT_RED};'>✗</span>"
name = event.get("name", "") or event.get("kind", "")
rel = relative_time(event.get("ts", ""))
muted = current_palette().text_muted
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
"""Cập nhật các thẻ token và chi phí.
Đồng bộ bảng đơn giá trước rồi mới tính tiền, để con số chi phí luôn khớp
với bảng giá người dùng vừa sửa.
"""
from ....core import model_pricing as mp
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
events = ut.load_events()
s = ut.summarize(events)
costs = ut.cost_usd_events(events, pricing)
self.usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
self.usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
self.usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
ut.format_cost(costs["in"], pricing))
self.usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
ut.format_cost(costs["out"], pricing))
self.usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
ut.format_cost(costs["cache"], pricing))
self.usage_cost.set(
f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
ut.format_cost(sum(costs.values()), pricing, digits=2))
self._refresh_budget()
# ---- public API used by the container ---------------------------------
def refresh(self, events: List[dict]) -> None:
"""Full refresh — resource usage + usage cards + sandbox/permissions
+ recent activity + audit preview. ``events`` is the already-loaded
(local-or-shared) audit log, shared with the event-table tabs so the
decision of which source to read from is made exactly once per
refresh tick."""
self._refresh_resource_usage()
self._refresh_usage_cards()
self.sandbox_card.refresh()
recent = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
if recent:
self.activity_lbl.setText("<br>".join(self._activity_line(e) for e in recent[:6]))
self.audit_lbl.setText("<br>".join(
f"{e.get('ts', '')} — {e.get('name', '')}" for e in recent[:4]))
else:
self.activity_lbl.setText(tr("monitoring.overview_no_activity"))
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho mọi tiêu đề mục và nhãn."""
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
self.activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
self.resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
self.cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
self.mem_lbl.setText(tr("monitoring.overview_res_mem"))
self.diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
self.disk_lbl.setText(tr("monitoring.overview_res_disk"))
self.network_lbl.setText(tr("monitoring.overview_res_network"))
self.pricing_panel.retranslate()
self.sandbox_card.retranslate()
self.audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
self.view_all_btn.setText(tr("monitoring.overview_view_all"))
@@ -0,0 +1,198 @@
"""Model Pricing panel — the editable price-table card on the Overview page
(currency picker, import/export/add/auto-link/delete, and the table itself).
Extracted from ``ui/monitoring_tab.py``'s pricing-table construction and
``_reload_pricing_table``/``_import_pricing``/``_export_pricing``/
``_add_pricing_row``/``_autolink_pricing``/``_delete_pricing_row``.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtWidgets import (
QComboBox, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout,
)
from ....core import usage_tracker as ut
from ....i18n import tr
from ....ui.icons import icon
class PricingPanel(QGroupBox):
"""Bảng đơn giá model trong màn Giám sát: nhập, xuất, tự dò và sửa tay.
Đơn giá ở đây là thứ Dashboard dùng để quy token ra tiền, nên sửa ở đây là
mọi con số chi phí trong app đổi theo.
"""
def __init__(self, ctx, on_status_message: Callable[[str], None]):
"""Bảng đơn giá theo model, sửa được ngay tại chỗ."""
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
self._worker = None
self.setObjectName("monSection")
pg = QVBoxLayout(self)
phdr = QHBoxLayout()
self.ccy_lbl = QLabel()
self.ccy_lbl.setObjectName("hint")
self.ccy = QComboBox()
for cur in ut.SUPPORTED_CURRENCIES:
self.ccy.addItem(cur, cur)
pidx = self.ccy.findData((self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.ccy.setCurrentIndex(max(0, pidx))
self.ccy.currentIndexChanged.connect(self._reload_table)
phdr.addWidget(self.ccy_lbl)
phdr.addWidget(self.ccy)
phdr.addStretch(1)
self.import_btn = QPushButton()
self.import_btn.setIcon(icon("download"))
self.import_btn.clicked.connect(self._import_pricing)
self.export_btn = QPushButton()
self.export_btn.setIcon(icon("upload"))
self.export_btn.clicked.connect(self._export_pricing)
self.add_btn = QPushButton()
self.add_btn.setIcon(icon("plus"))
self.add_btn.clicked.connect(self._add_pricing_row)
self.link_btn = QPushButton()
self.link_btn.setIcon(icon("refresh"))
self.link_btn.clicked.connect(self._autolink_pricing)
self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash"))
self.del_btn.clicked.connect(self._delete_pricing_row)
for b in (self.import_btn, self.export_btn, self.add_btn, self.link_btn, self.del_btn):
phdr.addWidget(b)
pg.addLayout(phdr)
self.table = QTableWidget(0, 5)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
pg.addWidget(self.table, 1)
self._reload_table()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, nút và tên cột."""
self.setTitle(tr("monitoring.pricing_title").upper())
self.ccy_lbl.setText(tr("monitoring.pricing_currency"))
self.import_btn.setText(tr("monitoring.pricing_import"))
self.export_btn.setText(tr("monitoring.pricing_export"))
self.add_btn.setText(tr("monitoring.pricing_add"))
self.link_btn.setText(tr("monitoring.pricing_autolink"))
self.del_btn.setText(tr("monitoring.pricing_delete"))
self.table.setHorizontalHeaderLabels([
tr("monitoring.pricing_col_model"), tr("monitoring.pricing_col_context"),
tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"),
tr("monitoring.pricing_col_output")])
def _reload_table(self, *_a) -> None:
"""Nạp lại toàn bộ bảng đơn giá từ cấu hình."""
from ....core import model_pricing as mp
to_ccy = self.ccy.currentData() or "USD"
entries = mp.list_entries(self.ctx.config)
self.table.setRowCount(len(entries))
for r, e in enumerate(entries):
in_v = mp.convert(e.get("input_price", 0), e.get("input_ccy", "USD"), to_ccy, self.ctx.config)
out_v = mp.convert(e.get("output_price", 0), e.get("output_ccy", "USD"), to_ccy, self.ctx.config)
vals = [e.get("model", ""), e.get("context_length", ""), e.get("max_output", ""),
f"{mp.format_price(in_v, to_ccy)} / {e.get('input_unit', '')}",
f"{mp.format_price(out_v, to_ccy)} / {e.get('output_unit', '')}"]
for c, v in enumerate(vals):
self.table.setItem(r, c, QTableWidgetItem(str(v)))
def _import_pricing(self) -> None:
"""Nhập đơn giá từ file Excel/CSV, gộp vào bảng hiện có."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
from ....core import model_pricing as mp
path, _ = QFileDialog.getOpenFileName(
self, tr("monitoring.pricing_import"), "", "Pricing (*.xlsx *.csv)")
if not path:
return
default_ccy = self.ccy.currentData() or "USD"
try:
imported = mp.import_table(path, default_ccy=default_ccy)
except ValueError as exc:
QMessageBox.warning(self, tr("monitoring.pricing_title"), str(exc))
return
merged = {e["model"]: e for e in mp.list_entries(self.ctx.config)}
for e in imported:
merged[e["model"]] = e
mp.save_entries(self.ctx.config, list(merged.values()))
self.ctx.save()
self._reload_table()
self._on_status_message(tr("monitoring.pricing_imported", n=len(imported)))
def _export_pricing(self) -> None:
"""Xuất file mẫu để điền đơn giá rồi nhập ngược lại."""
from PySide6.QtWidgets import QFileDialog
from ....core import model_pricing as mp
path, _ = QFileDialog.getSaveFileName(
self, tr("monitoring.pricing_export"), "model_pricing_template.xlsx", "Excel (*.xlsx)")
if not path:
return
mp.export_template(path)
self._on_status_message(tr("monitoring.pricing_exported"))
def _add_pricing_row(self) -> None:
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
from PySide6.QtWidgets import QInputDialog
from ....core import model_pricing as mp
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name = (name or "").strip()
if not ok or not name:
return
ccy = self.ccy.currentData() or "USD"
mp.add_entry(self.ctx.config, mp.entry_from_row(
[name, "", "", "0", "Million tokens", "0", "Million tokens"], default_ccy=ccy))
self.ctx.save()
self._reload_table()
def _autolink_pricing(self) -> None:
"""Nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá, chạy ở luồng nền."""
from ....core import model_pricing as mp
from ....core.worker import AgentWorker
if self._worker is not None:
return
self.link_btn.setEnabled(False)
ctx = self.ctx
ccy = self.ccy.currentData() or "USD"
def job(_w):
"""Chạy nền: nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá."""
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
def done(r):
"""Đổ đơn giá vừa dò được vào bảng và lưu lại."""
self._worker = None
self.link_btn.setEnabled(True)
self.ctx.save()
self._reload_table()
self._on_status_message(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
def failed(_e):
"""Dò giá lỗi: chỉ mở khoá lại nút, giữ nguyên bảng đang có."""
self._worker = None
self.link_btn.setEnabled(True)
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._worker = w
w.start()
def _delete_pricing_row(self) -> None:
"""Xoá dòng đơn giá đang chọn."""
from ....core import model_pricing as mp
row = self.table.currentRow()
entries = mp.list_entries(self.ctx.config)
if 0 <= row < len(entries):
del entries[row]
mp.save_entries(self.ctx.config, entries)
self.ctx.save()
self._reload_table()
+142
View File
@@ -0,0 +1,142 @@
"""Sandbox Details card — the current sandbox id/status/uptime/resource
limits/network state, with a collapsible fold that ALSO nests the
Permissions card inside it (see ``security_settings_tab.PermissionsCard``),
exactly matching the pre-refactor ``ui/monitoring_tab.py`` layout: Sandbox
and Permissions answer the same question ("what is the agent allowed to
touch?"), so they share one fold rather than being two independent
top-level sections. This card is embedded inside ``overview_tab.OverviewTab``
at the same position the original ``QGroupBox`` occupied — no new top-level
tab is added, so the visible UI is unchanged.
"""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
from ....i18n import tr
from ..shared.badges import apply_badge
from ..shared.layout_helpers import kv_row
from ..shared.open_settings import open_settings_and_notify
from .security_settings_tab import PermissionsCard
class SandboxDetailsCard(QGroupBox):
"""Thẻ "Trạng thái sandbox": backend cách ly đang dùng, giới hạn tài nguyên và
trạng thái chặn mạng.
"""
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
"""Thẻ thông tin sandbox: cơ chế cách ly đang dùng và các giới hạn của nó."""
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
self.setObjectName("monSection")
sbx_lay = QVBoxLayout(self)
self.summary_lbl = QLabel()
self.summary_lbl.setWordWrap(True)
sbx_lay.addWidget(self.summary_lbl)
self.more_btn = QPushButton()
self.more_btn.setObjectName("co4eSectionAction")
self.more_btn.setFlat(True)
self.more_btn.setCheckable(True)
self.more_btn.setCursor(Qt.PointingHandCursor)
sbx_lay.addWidget(self.more_btn, 0, Qt.AlignLeft)
self._detail = QWidget()
self._detail.setVisible(False)
self.more_btn.toggled.connect(self._detail.setVisible)
self.more_btn.toggled.connect(self._sync_more_label)
sbx_lay.addWidget(self._detail)
detail_lay = QVBoxLayout(self._detail)
detail_lay.setContentsMargins(0, 4, 0, 0)
self.id_lbl, self.id_val = kv_row(detail_lay)
self.status_lbl, self.status_val = kv_row(detail_lay)
self.status_val.setObjectName("badgeSuccess")
self.created_lbl, self.created_val = kv_row(detail_lay)
self.uptime_lbl, self.uptime_val = kv_row(detail_lay)
limits_row = QHBoxLayout()
self.limits_lbl = QLabel()
self.limits_lbl.setObjectName("hint")
self.limits_lbl.setWordWrap(True)
self.edit_btn = QPushButton()
self.edit_btn.setFlat(True)
self.edit_btn.clicked.connect(self._open_settings)
limits_row.addWidget(self.limits_lbl, 1)
limits_row.addWidget(self.edit_btn)
detail_lay.addLayout(limits_row)
self.net_lbl, self.net_val = kv_row(detail_lay)
# The Permissions card is nested inside THIS fold, not a sibling
# section — matches the original layout exactly.
self.permissions_card = PermissionsCard(ctx, on_settings_changed)
detail_lay.addWidget(self.permissions_card)
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def _sync_more_label(self, *_a) -> None:
"""Label the fold with what it will do next."""
open_ = self.more_btn.isChecked()
self.more_btn.setText(("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.setTitle(tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
self.id_lbl.setText(tr("monitoring.overview_sandbox_id"))
self.status_lbl.setText(tr("monitoring.overview_status"))
self.created_lbl.setText(tr("monitoring.overview_created"))
self.uptime_lbl.setText(tr("monitoring.overview_uptime"))
self.edit_btn.setText(tr("monitoring.overview_edit"))
self.net_lbl.setText(tr("monitoring.overview_network_label"))
self.permissions_card.retranslate()
self._sync_more_label()
def refresh(self) -> None:
"""Đọc lại cấu hình an toàn và cập nhật các dòng trạng thái."""
sec = self._ctx.config.agent_security
net_blocked = bool(sec.get("block_network"))
self.id_val.setText(f"sbx_{os.getpid():x}")
self.status_val.setText(tr("monitoring.overview_status_running"))
self.created_val.setText(datetime.fromtimestamp(self._ctx.started_at).strftime("%H:%M:%S"))
uptime_s = max(0, int(time.time() - self._ctx.started_at))
h, rem = divmod(uptime_s, 3600)
m, s = divmod(rem, 60)
self.uptime_val.setText(f"{h}h {m}m {s}s" if h else f"{m}m {s}s")
limit_parts = []
if sec.get("resource_limit_cpu_percent"):
limit_parts.append(f"CPU {sec['resource_limit_cpu_percent']}%")
if sec.get("resource_limit_memory_mb"):
limit_parts.append(f"MEM {sec['resource_limit_memory_mb']}MB")
if sec.get("resource_limit_disk_mb"):
limit_parts.append(f"DISK {sec['resource_limit_disk_mb']}MB")
limits_text = ", ".join(limit_parts) if limit_parts else tr("monitoring.na")
self.limits_lbl.setText(tr("monitoring.overview_resource_limits") + ": " + limits_text)
self.net_val.setText(
tr("monitoring.overview_network_disabled") if net_blocked
else tr("monitoring.overview_network_enabled"))
apply_badge(self.net_val, "badgeWarn" if net_blocked else "badgeSuccess")
# The one line the wireframe shows; the detail above stays a fold away.
self.summary_lbl.setText(" · ".join([
f'{tr("monitoring.overview_perm_fs")}: {tr("monitoring.overview_perm_fs_value")}',
f'{tr("monitoring.overview_perm_network")}: '
f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}',
f'{tr("monitoring.overview_perm_process")}: {tr("monitoring.overview_perm_process_value")}',
f'{tr("monitoring.overview_resource_limits")}: {limits_text}',
]))
self._sync_more_label()
self.permissions_card.refresh()
@@ -0,0 +1,56 @@
"""Security Events tab — the audit log filtered to ``kind="security_block"``.
Extracted from ``ui/monitoring_tab.py``'s security-events wiring inside
``MonitoringTab.__init__``.
"""
from __future__ import annotations
from typing import Callable, List
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
from ....i18n import tr
from ..shared.ai_filter import start_ai_filter
from ..shared.event_table import EventTable
from ..shared.filter_scaffold import build_filter_scaffold
class SecurityEventsTab(QWidget):
"""Tab "Sự kiện bảo mật": các lượt bị chặn hoặc phải xin phê duyệt."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Sự kiện bảo mật.
Bỏ cột Kết quả (mọi dòng đều là bị chặn) và thay bằng cột Hành động có tô
màu theo mức nghiêm trọng.
"""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
# Security events are always ok=False, so this table trades the
# tick/cross column for a tinted Action column (see EventTable).
self.table = EventTable(show_result=False)
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.security_events_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -0,0 +1,64 @@
"""Permissions card — "what is the agent allowed to touch?", displayed
nested inside the Sandbox Details card's expandable fold (see
``sandbox_tab.SandboxDetailsCard``), exactly as in the pre-refactor
``ui/monitoring_tab.py`` (``self._sbx_detail.layout().addWidget(self.ov_permissions_group)``).
Editing still opens the same Settings dialog as the Sandbox card's own
"Edit" button — this card only DISPLAYS ``ctx.config.agent_security``, it
does not host its own settings-editing UI.
"""
from __future__ import annotations
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QGroupBox, QPushButton, QVBoxLayout
from ....i18n import tr
from ..shared.badges import apply_badge
from ..shared.layout_helpers import kv_row
from ..shared.open_settings import open_settings_and_notify
class PermissionsCard(QGroupBox):
"""Thẻ "Quyền": agent đang được phép làm gì với tệp, mạng và lệnh shell."""
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
"""Thẻ thiết lập quyền: agent được đụng tới gì mà không phải hỏi."""
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
self.setObjectName("monSection")
perm_lay = QVBoxLayout(self)
self.fs_lbl, self.fs_val = kv_row(perm_lay)
self.network_lbl, self.network_val = kv_row(perm_lay)
self.process_lbl, self.process_val = kv_row(perm_lay)
self.env_lbl, self.env_val = kv_row(perm_lay)
self.edit_btn = QPushButton()
self.edit_btn.setFlat(True)
self.edit_btn.clicked.connect(self._open_settings)
perm_lay.addWidget(self.edit_btn, 0, Qt.AlignLeft)
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.setTitle(tr("monitoring.overview_permissions_title").upper())
self.fs_lbl.setText(tr("monitoring.overview_perm_fs"))
self.fs_val.setText(tr("monitoring.overview_perm_fs_value"))
self.network_lbl.setText(tr("monitoring.overview_perm_network"))
self.process_lbl.setText(tr("monitoring.overview_perm_process"))
self.process_val.setText(tr("monitoring.overview_perm_process_value"))
self.env_lbl.setText(tr("monitoring.overview_perm_env"))
self.env_val.setText(tr("monitoring.overview_perm_env_value"))
self.edit_btn.setText(tr("monitoring.overview_edit"))
def refresh(self) -> None:
"""Đọc lại cấu hình an toàn và cập nhật các dòng quyền."""
net_blocked = bool(self._ctx.config.agent_security.get("block_network"))
self.network_val.setText(
tr("monitoring.overview_perm_network_blocked") if net_blocked
else tr("monitoring.overview_perm_network_allowed"))
apply_badge(self.network_val, "badgeWarn" if net_blocked else "badgeSuccess")
@@ -0,0 +1,260 @@
"""Tools — Monitoring tab (Admin) to govern every agent capability.
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``).
"""
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 ....core.tools import TOOL_SPECS
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.connectors_panel import ConnectorsPanel
from ....ui.icons import icon
from ....ui.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:
"""Vẽ icon tròn cho một thẻ công cụ."""
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:
"""Xoá sạch mọi widget trong một ``FlowLayout`` trước khi dựng lại."""
while flow.count():
item = flow.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
class ToolsAdminTab(QWidget):
"""Tab "Công cụ" trong màn Giám sát: bật/tắt từng tool dựng sẵn và tool MCP.
Tắt một tool ở đây là agent không còn được gọi nó nữa — danh sách tắt lưu
trong cấu hình dưới dạng ``tools_disabled``.
"""
def __init__(self, ctx: AppContext):
"""Tab quản trị tool, chia thành các tab con theo nhóm tool."""
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:
"""Vẽ lại toàn bộ lưới thẻ công cụ theo cấu hình mới nhất."""
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:
"""Dựng một thẻ công cụ: icon, tên, mô tả và công tắc bật/tắt."""
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:
"""Bật/tắt một tool dựng sẵn và ghi ngay vào cấu hình."""
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):
"""Chạy nền: chẩn đoán kết nối Internet và tình trạng chứng chỉ TLS."""
from ....core import tls_trust
ok, message = tls_trust.diagnose_internet()
return {"ok": ok, "message": message}
def done(result):
"""Hiện kết quả chẩn đoán, tô xanh/đỏ theo kết quả."""
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):
"""Chẩn đoán ném lỗi: hiện lỗi bằng màu đỏ."""
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:
"""Áp lại chữ theo ngôn ngữ đang chọn rồi vẽ lại lưới thẻ."""
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()