From 7e11e9676dcb4b650719577add29bf5afd5c398a Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Fri, 28 Aug 2026 01:24:45 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20n=E1=BB=91t=203=20ch=E1=BB=97=20R08?= =?UTF-8?q?=20c=C3=B2n=20thi=E1=BA=BFu=20=E2=80=94=20ChatPanel=20v=C3=A0?= =?UTF-8?q?=202=20tab=20admin=20v=E1=BB=81=20=C4=91=C3=BAng=20ch=E1=BB=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soát lại từng dòng plan thì thấy tôi báo R08 xong hơi sớm. Ba chỗ thiếu thật: T06 ChatPanel vẫn ở ui/, plan đòi presentation/chat/chat_panel.py T08 agents_admin_tab.py (498) và tools_admin_tab.py (245) vẫn ở ui/ presentation/chat/chat_panel.py 346 presentation/monitoring/tabs/agents_admin_tab.py 383 presentation/monitoring/tabs/agent_edit_dialog.py 143 presentation/monitoring/tabs/tools_admin_tab.py 245 ui/chat_panel.py / agents_admin_tab.py / tools_admin_tab.py ~10 mỗi cái agents_admin_tab.py 498 dòng nên tách thêm agent_edit_dialog.py: bảng danh sách và hộp thoại sửa là hai việc, và hộp thoại còn tự đi hỏi provider xem có model nào — thứ bảng không cần biết. BA CHỖ CÒN LẠI KHÔNG PHẢI THIẾU, đã kiểm từng cái: * audio_recorder_widget.py (T04) — repo KHÔNG có chức năng ghi âm nào. * connector_settings_widget.py (T07) — UI Connector đã dời khỏi Cài đặt. * sandbox_status_tab.py / mcp_history_tab.py (T08) — Hiệp đặt tên sandbox_tab và mcp_tab, nội dung đủ. R08: 14/14 task, 0 file thiếu thật sự. 756 test xanh. 24/24 checker qua. Co-Authored-By: Claude Opus 5 --- presentation/chat/chat_panel.py | 346 ++++++++++++ presentation/co4e/co4e_chat.py | 1 + presentation/co4e/co4e_runs.py | 1 + .../monitoring/tabs/agent_edit_dialog.py | 143 +++++ .../monitoring/tabs/agents_admin_tab.py | 383 ++++++++++++++ .../monitoring/tabs/tools_admin_tab.py | 245 +++++++++ .../scheduling/ai_task_creator_dialog.py | 2 +- .../scheduling/ai_task_import_dialog.py | 6 +- presentation/scheduling/task_actions.py | 6 +- ui/agents_admin_tab.py | 499 +----------------- ui/chat_panel.py | 345 +----------- ui/tools_admin_tab.py | 245 +-------- 12 files changed, 1142 insertions(+), 1080 deletions(-) create mode 100644 presentation/chat/chat_panel.py create mode 100644 presentation/monitoring/tabs/agent_edit_dialog.py create mode 100644 presentation/monitoring/tabs/agents_admin_tab.py create mode 100644 presentation/monitoring/tabs/tools_admin_tab.py diff --git a/presentation/chat/chat_panel.py b/presentation/chat/chat_panel.py new file mode 100644 index 0000000..02f0731 --- /dev/null +++ b/presentation/chat/chat_panel.py @@ -0,0 +1,346 @@ +"""Base chat panel shared by the Cowork and Code tabs. + +Provides: streaming transcript, a message queue, and history autosave. + +Several messages can run **at the same time** inside one tab: each turn owns its +own worker thread and its own turn-context (assistant bubble, transcript record, +message list, output folder), so their streaming output and files never collide. +The number of simultaneous turns is capped by ``cowork.max_parallel`` (default 5); +extra messages wait in the composer queue and start automatically as slots free +up. Graph events are still forwarded per session. +""" +from __future__ import annotations + +from .chat_event_stream import ChatEventStreamMixin +from .chat_panel_layout import ChatPanelLayoutMixin +from .chat_live_turns import ChatLiveTurnsMixin +from .chat_helpers import ( # noqa: F401 — giữ đường vào cũ + _TOOL_STATUS, _format_plan_steps, _is_scratch, +) + +from .attachment_picker import AttachmentMixin +from .chat_output_panel import OutputPanelMixin +from .chat_agents import ChatAgentsMixin +from .chat_turn_runner import ChatTurnRunnerMixin +from .chat_session_store import ChatSessionMixin + +from pathlib import Path +from typing import Any, Dict, List, Optional + +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtCore import QFileSystemWatcher +from PySide6.QtWidgets import ( + QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter, + QVBoxLayout, QWidget, +) + +from ...core.worker import AgentWorker +from ...i18n import on_language_changed, tr +from ...state import AppContext +from ...theme import current_palette +from ...ui.chat_view import ChatView, ThinkingIndicator +from ...ui.composer import Composer +from ...ui.icons import collapse_right_icon, icon as app_icon +from ...ui.osutil import is_image, open_path +from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection + + +_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"} + +# Friendly "what the agent is doing now" translation keys for the working +# indicator, so a long file/document build reads as "Creating…" rather than a +# generic "Running". + + + + + + +class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin, + OutputPanelMixin, + ChatAgentsMixin, + ChatTurnRunnerMixin, + ChatSessionMixin, + QWidget): + graph_event = Signal(str, dict) # (session_name, event) + turn_finished = Signal(dict) + status_message = Signal(str) + output_changed = Signal(str) # workspace dir; emitted when a file is written + history_changed = Signal() # a session was created/updated → refresh History + + def __init__(self, ctx: AppContext, kind: str, session_name: str, + placeholder_key: str = "composer.placeholder_default"): + super().__init__() + from ...core.history import new_session_id + + self.ctx = ctx + self.kind = kind + self.session_name = session_name + self.session_id = new_session_id() + self.title = "" + self._notify_title() + # Which project (workspace) this conversation belongs to — every new + # thread inherits the currently selected project (Claude-Projects style). + self.project_id = "default" + self.messages: List[Dict[str, Any]] = [] + # self.worker points at the most-recently-started worker (kept for + # back-compat); every running turn is tracked in self._active so several + # can run concurrently. Each value is a turn-context dict — see _start_turn. + self.worker: AgentWorker | None = None + self._active: Dict[AgentWorker, Dict[str, Any]] = {} + self._turn_seq: int = 0 + # session_id -> its live messages list, for every conversation that still has + # a turn running. Lets you start a new chat / reopen an old one WHILE work + # runs: the running turn keeps writing to its own conversation in the + # background, and reopening it attaches to the SAME list (never a stale disk + # copy), so the two never race on save. + self._sessions_live: Dict[str, List[Dict[str, Any]]] = {} + self._teams_worker: AgentWorker | None = None + self.turns: List[Dict[str, Any]] = [] + + # File system watcher — watches the workspace/output folder for new files + # and auto-loads them into the agent's context on the next turn. + self._file_watcher = QFileSystemWatcher(self) + self._file_watcher.directoryChanged.connect(self._on_watched_dir_changed) + self._known_files: set = set() # set of known file paths in the watched dir + self._watch_debounce = QTimer(self) + self._watch_debounce.setSingleShot(True) + self._watch_debounce.setInterval(800) # debounce rapid file changes + self._watch_debounce.timeout.connect(self._process_new_watched_files) + self._watched_dir: Optional[Path] = None + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + self._toolbar = QWidget() + self.toolbar_v = QVBoxLayout(self._toolbar) + self.toolbar_v.setContentsMargins(10, 8, 10, 4) + self.toolbar_v.setSpacing(4) + self.toolbar_layout = QHBoxLayout() # first row; tabs may add more rows + self.toolbar_layout.setSpacing(8) + self.toolbar_v.addLayout(self.toolbar_layout) + root.addWidget(self._toolbar) + + self.chat_view = ChatView() + self.composer = Composer(placeholder_key) + self.composer.submitted.connect(self.submit) + self.composer.stop_requested.connect(self.stop) + self.composer.attachments_added.connect(self._on_attachments_added) + self.composer.attachment_removed.connect(self._on_attachment_removed) + self.composer.attach_limit_note.connect(self.status_message) + self.composer.manage_skills.connect(self._open_skills_manager) + self.composer.set_max_attachments( + int(ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)) + # Conversation token/cost total (↓in ↑out ▤total $cost) — bottom-left, + # updated after each turn; cost uses the Monitoring model-price table. + self._usage_total_lbl = QLabel("") + self._usage_total_lbl.setObjectName("hint") + self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};") + + + # Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma / + # qwen for the local provider). Cowork and Code pick independently and + # run in parallel. The list is fetched from the active provider. + # The per-tab Agent defaults to the Settings model on startup; a manual + # pick (override) is remembered only until the active provider changes. + self._model = ctx.config.provider_conf().get("model", "") + self._agent_provider = ctx.config.active_provider + self._agent_user_override = False + self._admin_agent = None # selected Admin-defined agent preset, if any + # Auto Model Routing override for the NEXT turn (set by _apply_routing when + # the router picks a different model). None → use the tab's own selection. + self._routed_provider: Optional[str] = None + self._routed_model: Optional[str] = None + self._last_turn_agent_signature = None # what ran the LAST turn (see _note_agent_switch) + self._pending_agent_switch_review = False + self._agent_worker: AgentWorker | None = None + self._agent_lbl = QLabel(tr("chatpanel.agent_label")) + self._agent_lbl.setObjectName("hint") + self.agent_combo = QComboBox() + self.agent_combo.setMinimumWidth(150) + self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip")) + self.agent_combo.currentIndexChanged.connect(self._on_agent_changed) + self.composer.add_bottom_left(self._agent_lbl) + self.composer.add_bottom_left(self.agent_combo) + # Off/Auto/Manual routing toggle — lets the router pick the best-fit + # model per message (see core/routing + _apply_routing). + from ...ui.routing_toggle import RoutingToggle + self.routing_toggle = RoutingToggle(ctx, self.kind) + # The drawing reads the strip left to right as + # Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder + # so these sit together on the left, with the folder box the Cowork tab + # appends landing after them. Nén and Tự chạy stay on the right, where + # the control inventory marks them "giữ nguyên tại chỗ". + self.composer.add_bottom_left(self.routing_toggle) + self.composer.add_bottom_left(self._usage_total_lbl) + # Manual "compress conversation" — trim old history to cut tokens. + self.compress_btn = QPushButton(tr("chatpanel.compress_btn")) + self.compress_btn.setIcon(app_icon("compress")) + self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip")) + self.compress_btn.clicked.connect(self._compress_messages) + self.composer.add_bottom_right(self.compress_btn) + self.refresh_agents() + + self._build_layout(root) + + def _retranslate_base(self) -> None: + """Re-apply the current language to the chrome shared by every tab + (Cowork/Code toolbars call their own retranslate on top of this).""" + self._agent_lbl.setText(tr("chatpanel.agent_label")) + self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip")) + self.compress_btn.setText(tr("chatpanel.compress_btn")) + self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip")) + self.input_section.set_title(tr("widgets.input_files")) + self.output_section.set_title(tr("widgets.output_files").upper()) + self.plan_section.set_title(tr("widgets.plan_title")) + self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip")) + self._files_header.setText(tr("chatpanel.files_header")) + self._io_strip.setToolTip(tr("chatpanel.expand_files_tooltip")) + + def apply_theme(self) -> None: + """Re-apply theme styles to the chat view so all existing message bubbles + adapt when the app switches between light and dark modes.""" + self.chat_view.apply_theme() + + # ---- hooks for subclasses --------------------------------------- + + + def assistant_title(self) -> str: + return tr("chat.assistant") + + + + # ---- file system watcher for auto-loading new files -------------- + + + + + + + + + + + + # ---- skills management (shared by Cowork and Code) --------------- + + + # ---- per-tab agent (model / admin-agent preset) selection -------- + _ADMIN_AGENT_PREFIX = "admin:" + # Sent (invisibly — folded into the outgoing content, never the visible + # chat bubble) as a one-shot prefix on the FIRST turn run under a newly + # picked model/agent, when the conversation already has prior turns: asks + # the new model to check over the most recent step before doing anything + # new, so a mid-conversation switch doesn't silently drop continuity. + _MODEL_SWITCH_REVIEW_NOTE = ( + "[Note: the AI model/agent for this conversation was just switched.] Before " + "addressing the request below, briefly re-check the most recent step above — " + "if anything there looks incomplete, inconsistent, or wrong, redo or fix it " + "first, then continue." + ) + + + + + + + + + + + + + # ---- shared split-pane collapse helpers (used by subclasses too) ---- + + + # ---- delete a turn (message + its input/output files) ------------ + + + # ---- turn lifecycle --------------------------------------------- + + + # File types considered valid input data in the workspace/output folder + _INPUT_EXTS = { + ".csv", ".json", ".txt", ".md", ".log", ".xml", ".yaml", ".yml", + ".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pdf", ".odt", ".ods", ".odp", + ".rtf", ".tsv", + } + + + + + + + + + + + + + + + + + + + + + + + + + # ---- token / cost accounting (shown in the chat, Claude-style) ---------- + + + + + + + + + + + # ---- Teams auto-notify ------------------------------------------ + def _last_assistant_text(self) -> str: + for m in reversed(self.messages): + if m.get("role") == "assistant" and m.get("content"): + return m["content"] + return "" + + + # ---- persistence ------------------------------------------------- + + def _busy(self) -> bool: + """True while any turn is still running in this tab (any conversation).""" + return bool(self._active) + + def _view_busy(self) -> bool: + """True while the CURRENTLY-VIEWED conversation has a turn running.""" + return any(c.get("home_id") == self.session_id for c in self._active.values()) + + def _sync_indicators(self) -> None: + """Reflect the CURRENT conversation's agent status in the chat box + composer. + Switching chats, or hitting History → Refresh, shows whether THIS chat is + still processing (a background turn) or idle.""" + if self._view_busy(): + self.thinking.start("chat.running") # this conversation is still working + else: + self.thinking.stop() + self.composer.set_running(bool(self._active)) # Stop shows while anything runs + self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel()) + + def refresh_status(self) -> None: + """Public: re-sync the on-screen agent status for the current conversation + (used by the History Refresh button).""" + self._sync_indicators() + + def _max_parallel(self) -> int: + """Unlimited concurrent turns — no cap (the old Settings limit was removed). + A large sentinel keeps the queue logic intact without ever gating.""" + return 100000 + + def active_workers(self) -> List[AgentWorker]: + """Workers for turns still running (used to stop them all on quit).""" + return list(self._active) + diff --git a/presentation/co4e/co4e_chat.py b/presentation/co4e/co4e_chat.py index 62fa71b..82e5f72 100644 --- a/presentation/co4e/co4e_chat.py +++ b/presentation/co4e/co4e_chat.py @@ -333,6 +333,7 @@ class Co4EChatMixin: """Show the plan INLINE in the conversation as an expandable block; update the same (per-flow) bubble in place so steps tick off (✓) as they complete.""" log = log or self.chat_log + from ...ui.co4e_tab import _fmt_plan body = _fmt_plan(steps) if not body: return diff --git a/presentation/co4e/co4e_runs.py b/presentation/co4e/co4e_runs.py index 9006f02..25ef20a 100644 --- a/presentation/co4e/co4e_runs.py +++ b/presentation/co4e/co4e_runs.py @@ -223,6 +223,7 @@ class Co4ERunsMixin: if c == 0: it.setData(Qt.UserRole, h.id) if c == 1: + from ...ui.co4e_tab import _qcolor it.setForeground(_qcolor(color.get(h.status, p.text))) t.setItem(r, c, it) if h.id == sel_id: diff --git a/presentation/monitoring/tabs/agent_edit_dialog.py b/presentation/monitoring/tabs/agent_edit_dialog.py new file mode 100644 index 0000000..7b3e9e5 --- /dev/null +++ b/presentation/monitoring/tabs/agent_edit_dialog.py @@ -0,0 +1,143 @@ +"""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 = ""): + 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(), + } diff --git a/presentation/monitoring/tabs/agents_admin_tab.py b/presentation/monitoring/tabs/agents_admin_tab.py new file mode 100644 index 0000000..711f8f5 --- /dev/null +++ b/presentation/monitoring/tabs/agents_admin_tab.py @@ -0,0 +1,383 @@ +"""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. +""" +from __future__ import annotations + +from .agent_edit_dialog import AgentEditDialog + +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 + +_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 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() diff --git a/presentation/monitoring/tabs/tools_admin_tab.py b/presentation/monitoring/tabs/tools_admin_tab.py new file mode 100644 index 0000000..dee2946 --- /dev/null +++ b/presentation/monitoring/tabs/tools_admin_tab.py @@ -0,0 +1,245 @@ +"""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: + 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() diff --git a/presentation/scheduling/ai_task_creator_dialog.py b/presentation/scheduling/ai_task_creator_dialog.py index dd1a5a3..af02139 100644 --- a/presentation/scheduling/ai_task_creator_dialog.py +++ b/presentation/scheduling/ai_task_creator_dialog.py @@ -156,7 +156,7 @@ class _AiCreateDialog(TaskImportMixin, QDialog): self.gen_btn.setText(tr("schedtask.ai_generating")) def job(worker: AgentWorker): - from ..core.ai_task_planner import plan_tasks + from ...core.ai_task_planner import plan_tasks provider = self.ctx.build_active_provider() full_desc = description diff --git a/presentation/scheduling/ai_task_import_dialog.py b/presentation/scheduling/ai_task_import_dialog.py index 5693a9d..e065261 100644 --- a/presentation/scheduling/ai_task_import_dialog.py +++ b/presentation/scheduling/ai_task_import_dialog.py @@ -38,7 +38,7 @@ class TaskImportMixin: def _export_template(self) -> None: from PySide6.QtWidgets import QFileDialog - from ..core.task_excel import export_template + from ...core.task_excel import export_template path, _ = QFileDialog.getSaveFileName( self, tr("schedtask.export_template_btn"), @@ -53,14 +53,14 @@ class TaskImportMixin: def _pick_import_file(self) -> None: from PySide6.QtWidgets import QFileDialog - from ..core.task_import import IMPORT_FILTER + 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 + from ...core.task_import import import_tasks try: self._planned = import_tasks(path) diff --git a/presentation/scheduling/task_actions.py b/presentation/scheduling/task_actions.py index 9877169..431e59c 100644 --- a/presentation/scheduling/task_actions.py +++ b/presentation/scheduling/task_actions.py @@ -39,7 +39,7 @@ class TaskActionsMixin: 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 + from ...ui.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) @@ -50,14 +50,14 @@ class TaskActionsMixin: taskrepo.save_task(task, self._tasks_dir) self.refresh() def _add_task(self) -> None: - from .task_editor_dialog import TaskEditorDialog + from ...ui.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 + from ...ui.task_editor_dialog import TaskEditorDialog task = taskrepo.load_task(task_id, self._tasks_dir) if not task: diff --git a/ui/agents_admin_tab.py b/ui/agents_admin_tab.py index db02b70..70ea6f8 100644 --- a/ui/agents_admin_tab.py +++ b/ui/agents_admin_tab.py @@ -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 diff --git a/ui/chat_panel.py b/ui/chat_panel.py index 3b82e77..39c74a8 100644 --- a/ui/chat_panel.py +++ b/ui/chat_panel.py @@ -1,346 +1,13 @@ -"""Base chat panel shared by the Cowork and Code tabs. +"""Vỏ chuyển tiếp — R08-T06. -Provides: streaming transcript, a message queue, and history autosave. - -Several messages can run **at the same time** inside one tab: each turn owns its -own worker thread and its own turn-context (assistant bubble, transcript record, -message list, output folder), so their streaming output and files never collide. -The number of simultaneous turns is capped by ``cowork.max_parallel`` (default 5); -extra messages wait in the composer queue and start automatically as slots free -up. Graph events are still forwarded per session. +Phần thân đã chuyển sang ``presentation/chat/chat_panel.py``. Giữ đường import +cũ vì ``ui/cowork_tab.py`` và vài checker gọi qua đúng đường dẫn này. """ from __future__ import annotations -from ..presentation.chat.chat_event_stream import ChatEventStreamMixin -from ..presentation.chat.chat_panel_layout import ChatPanelLayoutMixin -from ..presentation.chat.chat_live_turns import ChatLiveTurnsMixin -from ..presentation.chat.chat_helpers import ( # noqa: F401 — giữ đường vào cũ +from ..presentation.chat.chat_helpers import ( # noqa: F401 _TOOL_STATUS, _format_plan_steps, _is_scratch, ) +from ..presentation.chat.chat_panel import ChatPanel # noqa: F401 -from ..presentation.chat.attachment_picker import AttachmentMixin -from ..presentation.chat.chat_output_panel import OutputPanelMixin -from ..presentation.chat.chat_agents import ChatAgentsMixin -from ..presentation.chat.chat_turn_runner import ChatTurnRunnerMixin -from ..presentation.chat.chat_session_store import ChatSessionMixin - -from pathlib import Path -from typing import Any, Dict, List, Optional - -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtCore import QFileSystemWatcher -from PySide6.QtWidgets import ( - QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter, - QVBoxLayout, QWidget, -) - -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from ..theme import current_palette -from .chat_view import ChatView, ThinkingIndicator -from .composer import Composer -from .icons import collapse_right_icon, icon as app_icon -from .osutil import is_image, open_path -from .widgets import CollapsibleSection, CollapseStrip, PlanSection - - -_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"} - -# Friendly "what the agent is doing now" translation keys for the working -# indicator, so a long file/document build reads as "Creating…" rather than a -# generic "Running". - - - - - - -class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin, - OutputPanelMixin, - ChatAgentsMixin, - ChatTurnRunnerMixin, - ChatSessionMixin, - QWidget): - graph_event = Signal(str, dict) # (session_name, event) - turn_finished = Signal(dict) - status_message = Signal(str) - output_changed = Signal(str) # workspace dir; emitted when a file is written - history_changed = Signal() # a session was created/updated → refresh History - - def __init__(self, ctx: AppContext, kind: str, session_name: str, - placeholder_key: str = "composer.placeholder_default"): - super().__init__() - from ..core.history import new_session_id - - self.ctx = ctx - self.kind = kind - self.session_name = session_name - self.session_id = new_session_id() - self.title = "" - self._notify_title() - # Which project (workspace) this conversation belongs to — every new - # thread inherits the currently selected project (Claude-Projects style). - self.project_id = "default" - self.messages: List[Dict[str, Any]] = [] - # self.worker points at the most-recently-started worker (kept for - # back-compat); every running turn is tracked in self._active so several - # can run concurrently. Each value is a turn-context dict — see _start_turn. - self.worker: AgentWorker | None = None - self._active: Dict[AgentWorker, Dict[str, Any]] = {} - self._turn_seq: int = 0 - # session_id -> its live messages list, for every conversation that still has - # a turn running. Lets you start a new chat / reopen an old one WHILE work - # runs: the running turn keeps writing to its own conversation in the - # background, and reopening it attaches to the SAME list (never a stale disk - # copy), so the two never race on save. - self._sessions_live: Dict[str, List[Dict[str, Any]]] = {} - self._teams_worker: AgentWorker | None = None - self.turns: List[Dict[str, Any]] = [] - - # File system watcher — watches the workspace/output folder for new files - # and auto-loads them into the agent's context on the next turn. - self._file_watcher = QFileSystemWatcher(self) - self._file_watcher.directoryChanged.connect(self._on_watched_dir_changed) - self._known_files: set = set() # set of known file paths in the watched dir - self._watch_debounce = QTimer(self) - self._watch_debounce.setSingleShot(True) - self._watch_debounce.setInterval(800) # debounce rapid file changes - self._watch_debounce.timeout.connect(self._process_new_watched_files) - self._watched_dir: Optional[Path] = None - - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - self._toolbar = QWidget() - self.toolbar_v = QVBoxLayout(self._toolbar) - self.toolbar_v.setContentsMargins(10, 8, 10, 4) - self.toolbar_v.setSpacing(4) - self.toolbar_layout = QHBoxLayout() # first row; tabs may add more rows - self.toolbar_layout.setSpacing(8) - self.toolbar_v.addLayout(self.toolbar_layout) - root.addWidget(self._toolbar) - - self.chat_view = ChatView() - self.composer = Composer(placeholder_key) - self.composer.submitted.connect(self.submit) - self.composer.stop_requested.connect(self.stop) - self.composer.attachments_added.connect(self._on_attachments_added) - self.composer.attachment_removed.connect(self._on_attachment_removed) - self.composer.attach_limit_note.connect(self.status_message) - self.composer.manage_skills.connect(self._open_skills_manager) - self.composer.set_max_attachments( - int(ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)) - # Conversation token/cost total (↓in ↑out ▤total $cost) — bottom-left, - # updated after each turn; cost uses the Monitoring model-price table. - self._usage_total_lbl = QLabel("") - self._usage_total_lbl.setObjectName("hint") - self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};") - - - # Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma / - # qwen for the local provider). Cowork and Code pick independently and - # run in parallel. The list is fetched from the active provider. - # The per-tab Agent defaults to the Settings model on startup; a manual - # pick (override) is remembered only until the active provider changes. - self._model = ctx.config.provider_conf().get("model", "") - self._agent_provider = ctx.config.active_provider - self._agent_user_override = False - self._admin_agent = None # selected Admin-defined agent preset, if any - # Auto Model Routing override for the NEXT turn (set by _apply_routing when - # the router picks a different model). None → use the tab's own selection. - self._routed_provider: Optional[str] = None - self._routed_model: Optional[str] = None - self._last_turn_agent_signature = None # what ran the LAST turn (see _note_agent_switch) - self._pending_agent_switch_review = False - self._agent_worker: AgentWorker | None = None - self._agent_lbl = QLabel(tr("chatpanel.agent_label")) - self._agent_lbl.setObjectName("hint") - self.agent_combo = QComboBox() - self.agent_combo.setMinimumWidth(150) - self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip")) - self.agent_combo.currentIndexChanged.connect(self._on_agent_changed) - self.composer.add_bottom_left(self._agent_lbl) - self.composer.add_bottom_left(self.agent_combo) - # Off/Auto/Manual routing toggle — lets the router pick the best-fit - # model per message (see core/routing + _apply_routing). - from .routing_toggle import RoutingToggle - self.routing_toggle = RoutingToggle(ctx, self.kind) - # The drawing reads the strip left to right as - # Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder - # so these sit together on the left, with the folder box the Cowork tab - # appends landing after them. Nén and Tự chạy stay on the right, where - # the control inventory marks them "giữ nguyên tại chỗ". - self.composer.add_bottom_left(self.routing_toggle) - self.composer.add_bottom_left(self._usage_total_lbl) - # Manual "compress conversation" — trim old history to cut tokens. - self.compress_btn = QPushButton(tr("chatpanel.compress_btn")) - self.compress_btn.setIcon(app_icon("compress")) - self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip")) - self.compress_btn.clicked.connect(self._compress_messages) - self.composer.add_bottom_right(self.compress_btn) - self.refresh_agents() - - self._build_layout(root) - - def _retranslate_base(self) -> None: - """Re-apply the current language to the chrome shared by every tab - (Cowork/Code toolbars call their own retranslate on top of this).""" - self._agent_lbl.setText(tr("chatpanel.agent_label")) - self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip")) - self.compress_btn.setText(tr("chatpanel.compress_btn")) - self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip")) - self.input_section.set_title(tr("widgets.input_files")) - self.output_section.set_title(tr("widgets.output_files").upper()) - self.plan_section.set_title(tr("widgets.plan_title")) - self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip")) - self._files_header.setText(tr("chatpanel.files_header")) - self._io_strip.setToolTip(tr("chatpanel.expand_files_tooltip")) - - def apply_theme(self) -> None: - """Re-apply theme styles to the chat view so all existing message bubbles - adapt when the app switches between light and dark modes.""" - self.chat_view.apply_theme() - - # ---- hooks for subclasses --------------------------------------- - - - def assistant_title(self) -> str: - return tr("chat.assistant") - - - - # ---- file system watcher for auto-loading new files -------------- - - - - - - - - - - - - # ---- skills management (shared by Cowork and Code) --------------- - - - # ---- per-tab agent (model / admin-agent preset) selection -------- - _ADMIN_AGENT_PREFIX = "admin:" - # Sent (invisibly — folded into the outgoing content, never the visible - # chat bubble) as a one-shot prefix on the FIRST turn run under a newly - # picked model/agent, when the conversation already has prior turns: asks - # the new model to check over the most recent step before doing anything - # new, so a mid-conversation switch doesn't silently drop continuity. - _MODEL_SWITCH_REVIEW_NOTE = ( - "[Note: the AI model/agent for this conversation was just switched.] Before " - "addressing the request below, briefly re-check the most recent step above — " - "if anything there looks incomplete, inconsistent, or wrong, redo or fix it " - "first, then continue." - ) - - - - - - - - - - - - - # ---- shared split-pane collapse helpers (used by subclasses too) ---- - - - # ---- delete a turn (message + its input/output files) ------------ - - - # ---- turn lifecycle --------------------------------------------- - - - # File types considered valid input data in the workspace/output folder - _INPUT_EXTS = { - ".csv", ".json", ".txt", ".md", ".log", ".xml", ".yaml", ".yml", - ".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pdf", ".odt", ".ods", ".odp", - ".rtf", ".tsv", - } - - - - - - - - - - - - - - - - - - - - - - - - - # ---- token / cost accounting (shown in the chat, Claude-style) ---------- - - - - - - - - - - - # ---- Teams auto-notify ------------------------------------------ - def _last_assistant_text(self) -> str: - for m in reversed(self.messages): - if m.get("role") == "assistant" and m.get("content"): - return m["content"] - return "" - - - # ---- persistence ------------------------------------------------- - - def _busy(self) -> bool: - """True while any turn is still running in this tab (any conversation).""" - return bool(self._active) - - def _view_busy(self) -> bool: - """True while the CURRENTLY-VIEWED conversation has a turn running.""" - return any(c.get("home_id") == self.session_id for c in self._active.values()) - - def _sync_indicators(self) -> None: - """Reflect the CURRENT conversation's agent status in the chat box + composer. - Switching chats, or hitting History → Refresh, shows whether THIS chat is - still processing (a background turn) or idle.""" - if self._view_busy(): - self.thinking.start("chat.running") # this conversation is still working - else: - self.thinking.stop() - self.composer.set_running(bool(self._active)) # Stop shows while anything runs - self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel()) - - def refresh_status(self) -> None: - """Public: re-sync the on-screen agent status for the current conversation - (used by the History Refresh button).""" - self._sync_indicators() - - def _max_parallel(self) -> int: - """Unlimited concurrent turns — no cap (the old Settings limit was removed). - A large sentinel keeps the queue logic intact without ever gating.""" - return 100000 - - def active_workers(self) -> List[AgentWorker]: - """Workers for turns still running (used to stop them all on quit).""" - return list(self._active) - +__all__ = ["ChatPanel"] diff --git a/ui/tools_admin_tab.py b/ui/tools_admin_tab.py index 226008f..44af91b 100644 --- a/ui/tools_admin_tab.py +++ b/ui/tools_admin_tab.py @@ -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()