Files
cowork-local/ui/connectors_panel.py
T
thanhnvandClaude Opus 5 c2a9ea22f3 fix(jira-knowledge): use QDialog popup instead of QFrame+Qt.Popup for macOS compatibility
Qt.Popup flag is unreliable on macOS inside QDialog parents — popup may
appear behind the parent or not receive clicks. Replace with a small
QDialog using Qt.Tool|Qt.FramelessWindowHint which is stable on macOS.

- Add explicit close button (✕) in the popup
- Call activateWindow() to ensure popup gets focus
- Position 4px below the help button for visual separation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:16:47 +09:00

688 lines
29 KiB
Python

"""Connectors (MCP / REST API) management — the setup UI.
Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings).
Grouped by the four real categories (CAD / CAE / MS365 / Other): each is a
header (icon + name + the software it covers) above a left-aligned row of
cards, one per real connector, each with its own on/off switch — user-added
connectors (MCP-stdio or REST-API, via ExtConnectorEditDialog) also get
✎ Sửa/🗑 Xóa; the built-in ones (MS365 OneDrive/SharePoint, Jira under
"Other") only get what they actually support (Jira: ✎ Sửa only, opens its own
setup dialog; OneDrive/SharePoint: neither, they only toggle).
"""
from __future__ import annotations
from PySide6.QtCore import Qt, QPoint
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QCheckBox, QDialog, QFormLayout, QFrame, QGroupBox, QHBoxLayout, QLabel,
QLineEdit, QMessageBox, QPushButton, QScrollArea, QToolButton,
QVBoxLayout, QWidget, QApplication,
)
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .ext_connector_dialog import ExtConnectorEditDialog
from .icons import icon
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
class JiraConnectDialog(QDialog):
"""Jira connection and Project Knowledge configuration.
Extends the basic connection form with Project Knowledge settings:
enable/disable, project mapping, sync controls, and status display.
"""
def __init__(self, ctx: AppContext, parent=None):
"""Form khai báo kết nối Jira và cấu hình Project Knowledge."""
super().__init__(parent)
self.ctx = ctx
self.setWindowTitle(tr("connectors.jira_group"))
self.setMinimumWidth(520)
jira = ctx.config.data.get("jira", {})
jira_kb = ctx.config.data.get("jira_knowledge", {})
main_layout = QVBoxLayout(self)
# === Connection Section ===
conn_group = QGroupBox("Connection")
conn_form = QFormLayout(conn_group)
hint = QLabel(tr("connectors.jira_hint"))
hint.setObjectName("hint"); hint.setWordWrap(True); hint.setOpenExternalLinks(True)
conn_form.addRow(hint)
self.paste = QLineEdit()
self.paste.setPlaceholderText(tr("connectors.jira_paste_placeholder"))
self.paste.textChanged.connect(self._on_paste)
conn_form.addRow(tr("connectors.jira_paste"), self.paste)
self.url = QLineEdit(jira.get("base_url", ""))
self.url.setPlaceholderText("https://your-domain.atlassian.net")
self.email = QLineEdit(jira.get("email", ""))
self.token = QLineEdit(jira.get("api_token", ""))
self.token.setEchoMode(QLineEdit.Password)
conn_form.addRow(tr("connectors.jira_url"), self.url)
conn_form.addRow(tr("connectors.jira_email"), self.email)
conn_form.addRow(tr("connectors.jira_token"), self.token)
self.conn_status = QLabel()
self.conn_status.setObjectName("hint")
self.conn_status.setWordWrap(True)
conn_form.addRow(self.conn_status)
conn_row = QHBoxLayout()
self.test_btn = QPushButton(tr("connectors.jira_test"))
self.test_btn.clicked.connect(self._test)
conn_row.addWidget(self.test_btn)
conn_row.addStretch(1)
conn_group.setLayout(conn_form)
main_layout.addWidget(conn_group)
# === Project Knowledge Section ===
kb_group = QGroupBox(tr("connectors.jira_kb_section"))
kb_layout = QVBoxLayout(kb_group)
self.kb_enabled = QCheckBox(tr("connectors.jira_kb_enable"))
self.kb_enabled.setChecked(jira_kb.get("enabled", False))
kb_layout.addWidget(self.kb_enabled)
kb_hint = QLabel(tr("connectors.jira_kb_mapping_hint"))
kb_hint.setObjectName("hint")
kb_hint.setWordWrap(True)
kb_layout.addWidget(kb_hint)
# Project mapping input with help icons for Project ID and Jira Key
mapping_form = QFormLayout()
self.project_mapping = QLineEdit()
# Load existing mappings
existing_projects = jira_kb.get("projects", {})
if existing_projects:
mapping_str = ", ".join(f"{k}:{v}" for k, v in existing_projects.items())
self.project_mapping.setText(mapping_str)
self.project_mapping.setPlaceholderText("proj-alpha:ALPHA, proj-beta:BETA")
# Label with help icon explaining both Project ID and Jira Key
mapping_label = QLabel(tr("connectors.jira_kb_mapping_label"))
self.help_icon = QToolButton()
self.help_icon.setText("?")
self.help_icon.setStyleSheet("""
QToolButton {
border: 1px solid #5B9BD5;
border-radius: 10px;
background: transparent;
color: #5B9BD5;
font-weight: bold;
padding: 2px 6px;
min-width: 18px;
min-height: 18px;
}
QToolButton:hover {
background: #5B9BD5;
color: white;
}
QToolButton:pressed {
background: #4A8BC7;
color: white;
}
""")
# Click-based popover: build help content once, show on click
self._help_text = (
"<b>" + tr("connectors.jira_kb_project_id_title") + "</b><br>"
+ tr("connectors.jira_kb_project_id_help")
+ "<br><br><b>" + tr("connectors.jira_kb_jira_key_title") + "</b><br>"
+ tr("connectors.jira_kb_jira_key_help")
)
self._help_popup = None
self.help_icon.clicked.connect(self._toggle_help_popup)
label_row = QHBoxLayout()
label_row.setSpacing(4)
label_row.addWidget(mapping_label)
label_row.addWidget(self.help_icon)
label_row.addStretch(1)
label_widget = QWidget()
label_widget.setLayout(label_row)
mapping_form.addRow(label_widget, self.project_mapping)
# Validation hint for common mistakes (e.g., entering ABC-123 instead of ABC)
self.mapping_validation = QLabel()
self.mapping_validation.setObjectName("hint")
self.mapping_validation.setWordWrap(True)
self.mapping_validation.hide()
mapping_form.addRow("", self.mapping_validation)
self.project_mapping.textChanged.connect(self._validate_mapping)
kb_layout.addLayout(mapping_form)
# Sync controls
sync_row = QHBoxLayout()
self.sync_btn = QPushButton(tr("connectors.jira_kb_sync_now"))
self.sync_btn.clicked.connect(self._trigger_sync)
self.sync_btn.setEnabled(False)
sync_row.addWidget(self.sync_btn)
self.sync_status = QLabel(tr("connectors.jira_kb_not_configured"))
self.sync_status.setObjectName("hint")
sync_row.addWidget(self.sync_status)
sync_row.addStretch(1)
kb_layout.addLayout(sync_row)
main_layout.addWidget(kb_group)
# === Save/Close Row ===
row = QHBoxLayout()
self.save_btn = QPushButton(tr("connectors.jira_save"))
self.save_btn.setObjectName("primary")
self.save_btn.setIcon(icon("save"))
self.save_btn.clicked.connect(self._save_close)
row.addStretch(1)
row.addWidget(self.save_btn)
rw = QWidget()
rw.setLayout(row)
main_layout.addWidget(rw)
# Update sync button state
self.kb_enabled.toggled.connect(self._update_sync_state)
self._update_sync_state(self.kb_enabled.isChecked())
def _toggle_help_popup(self) -> None:
"""Show/hide a floating help popup when the ? button is clicked."""
if self._help_popup is not None and self._help_popup.isVisible():
self._help_popup.close()
self._help_popup = None
return
# Use a small QDialog (not QFrame+Qt.Popup) — more reliable on macOS
popup = QDialog(self)
popup.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint)
popup.setAttribute(Qt.WA_TranslucentBackground, False)
popup.setStyleSheet("""
QDialog {
background: #2D2D2D;
border: 1px solid #5B9BD5;
border-radius: 8px;
}
""")
popup_layout = QVBoxLayout(popup)
popup_layout.setContentsMargins(14, 12, 14, 12)
content = QLabel(self._help_text)
content.setWordWrap(True)
content.setMaximumWidth(380)
content.setStyleSheet(
"color: #E0E0E0; font-size: 12px; background: transparent; border: none;"
)
content.setTextFormat(Qt.RichText)
content.setOpenExternalLinks(True)
popup_layout.addWidget(content)
# Close button row
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton("✕")
close_btn.setFixedSize(24, 24)
close_btn.setStyleSheet("""
QPushButton {
border: none; background: transparent; color: #999;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { color: #fff; }
""")
close_btn.clicked.connect(popup.close)
close_row.addWidget(close_btn)
popup_layout.addLayout(close_row)
# Position below the help button
btn_global = self.help_icon.mapToGlobal(
QPoint(0, self.help_icon.height() + 4)
)
popup.move(btn_global)
popup.show()
popup.raise_()
popup.activateWindow()
self._help_popup = popup
def _on_paste(self, text: str) -> None:
"""Auto-fill Base URL from a pasted Jira link."""
import re
# Extract base URL from patterns like https://example.atlassian.net/browse/ABC-123
match = re.search(r"(https?://[^/\s]+\.atlassian\.net)", text.strip())
if match and not self.url.text().strip():
self.url.setText(match.group(1))
def _update_sync_state(self, enabled: bool) -> None:
"""Enable/disable sync controls based on KB checkbox."""
self.sync_btn.setEnabled(enabled)
if not enabled:
self.sync_status.setText(tr("connectors.jira_kb_disabled"))
def _validate_mapping(self, text: str) -> None:
"""Show inline hint if user appears to enter an Issue Key (ABC-123) instead of just Jira Key (ABC)."""
import re
# Detect pattern like "proj:ABC-123" or "proj:ABC-123, proj2:DEF-456"
# A Jira project key should be uppercase letters only (e.g., ABC), not ABC-123
issue_key_pattern = re.compile(r':\s*[A-Z]+-\d+')
if issue_key_pattern.search(text):
self.mapping_validation.setText(tr("connectors.jira_kb_validation_issue_key"))
self.mapping_validation.setStyleSheet("color: #D4A017; font-style: italic;")
self.mapping_validation.show()
else:
self.mapping_validation.hide()
def _trigger_sync(self) -> None:
"""Trigger a background sync job using JiraSyncService."""
self.sync_status.setText(tr("connectors.jira_kb_syncing"))
self.sync_btn.setEnabled(False)
def job(_w):
from ..application.jira_knowledge.sync_service import JiraSyncService
from ..application.jira_knowledge.target_resolver import JiraTargetResolver
from ..application.jira_knowledge.credential_resolver import JiraCredentialResolver
from ..infrastructure.secrets.keyring_adapter import KeyringAdapter
from ..mcp_servers.project_context.foundation import IdentityContext
# Resolve identity from config or use a default for the current project
# In a real multi-user app, this would come from the logged-in user session
jira_kb = self.ctx.config.data.get("jira_knowledge", {})
projects = jira_kb.get("projects", {})
if not projects:
return {"status": "error", "message": "No project mapping configured"}
# Use the first mapped project for this demo/trigger
# Ideally, the UI would let you select which project to sync
cowork_project_id = list(projects.keys())[0]
identity = IdentityContext(
actor_id="ui-user",
org_unit="local",
customer="internal",
project=cowork_project_id,
granted_scopes=frozenset({"read"})
)
service = JiraSyncService(
target_resolver=JiraTargetResolver(),
credential_resolver=JiraCredentialResolver(KeyringAdapter())
)
result = service.full_sync(identity)
return {
"status": "success",
"count": result.processed,
"failed": result.failed,
"duration": result.duration_seconds
}
def done(r):
self.sync_btn.setEnabled(True)
status = r.get("status", "unknown")
if status == "success":
count = r.get("count", 0)
failed = r.get("failed", 0)
duration = r.get("duration", 0)
msg = f"Success: {count} issues synced"
if failed > 0:
msg += f" ({failed} failed)"
msg += f" in {duration:.1f}s"
self.sync_status.setText(msg)
else:
self.sync_status.setText(f"Failed: {r.get('message', 'Unknown error')}")
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(lambda e: (self.sync_btn.setEnabled(True),
self.sync_status.setText(f"Error: {str(e)[:100]}")))
self._sync_worker = w
w.start()
def _save(self) -> None:
"""Ghi thông tin Jira và Project Knowledge vào cấu hình."""
j = self.ctx.config.data.setdefault("jira", {})
j.update({"base_url": self.url.text().strip(), "email": self.email.text().strip(),
"api_token": self.token.text().strip()})
j.setdefault("enabled", True)
# Save Jira Knowledge config
jira_kb = self.ctx.config.data.setdefault("jira_knowledge", {})
jira_kb["enabled"] = self.kb_enabled.isChecked()
# Parse project mapping
mapping_str = self.project_mapping.text().strip()
projects = {}
if mapping_str:
for pair in mapping_str.split(","):
if ":" in pair:
k, v = pair.split(":", 1)
projects[k.strip()] = v.strip()
jira_kb["projects"] = projects
self.ctx.save()
def _save_close(self) -> None:
"""Lưu rồi đóng hộp thoại."""
self._save()
self.accept()
def _test(self) -> None:
"""Thử kết nối bằng một truy vấn tối thiểu, chạy ở luồng nền."""
from ..core import jira_tool
self._save()
cfg = self.ctx.config.data.get("jira", {})
if not jira_tool.configured(cfg):
self.conn_status.setText(tr("connectors.jira_need_fields"))
return
self.conn_status.setText(tr("connectors.jira_testing"))
self.test_btn.setEnabled(False)
def job(_w):
"""Chạy nền: tìm đúng 1 issue mới nhất để xác nhận kết nối sống."""
return {"out": jira_tool.search(cfg, "order by created DESC", 1)}
def done(r):
"""Hiện kết quả thử: coi là lỗi khi thông điệp bắt đầu bằng câu báo chưa cấu
hình hoặc tìm kiếm thất bại.
"""
self.test_btn.setEnabled(True)
out = r.get("out", "")
ok = not out.lower().startswith(("jira is not configured", "jira search failed"))
self.conn_status.setText(tr("connectors.jira_ok") if ok
else tr("connectors.jira_fail", err=out[:200]))
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(lambda e: (self.test_btn.setEnabled(True),
self.conn_status.setText(tr("connectors.jira_fail", err=str(e)[:200]))))
self._jira_worker = w
w.start()
class ConnectorsPanel(QWidget):
"""Bảng Connectors trong Cài đặt: MS365 dựng sẵn, Jira, và connector MCP tự thêm,
gom theo bốn nhóm CAD / CAE / MS365 / Khác.
Có một công tắc tổng: tắt là agent không nối ra connector ngoài nào cả.
"""
_EXT_CATEGORY_LABELS = {
"cad": "CAD (NX / CATIA / SolidWorks / AutoCAD)",
"cae": "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)",
"ms365": "MS365 (Microsoft 365 / OneDrive / SharePoint)",
"other": "Other (any generic MCP server)",
}
_EXT_CATEGORY_ICONS = {"cad": "wrench", "cae": "ruler", "ms365": "cloud", "other": "plug"}
# TEMPORARY: only OneDrive + SharePoint (auto-connect via locally-synced
# OneDrive folders, no sign-in). Restore Outlook/Teams/Meeting when cloud
# OAuth is re-enabled.
_MS365_BUILTIN_LABELS = {"onedrive": "OneDrive", "sharepoint": "SharePoint"}
def __init__(self, ctx: AppContext):
"""Panel quản lý connector ngoài.
Công tắc tổng ở trên cùng: tắt là agent KHÔNG nối tới connector nào, bất kể
từng connector bên dưới có bật hay không.
"""
super().__init__()
self.ctx = ctx
lay = QVBoxLayout(self)
# Master switch: connect to external connectors at all (default ON).
# Off = the agent connects to NO external connector/MCP (see
# AppContext.build_mcp_tools), regardless of the per-connector checks below.
self.connect_external_sw = ToggleSwitch(tr("connectors.connect_external"))
self.connect_external_sw.setChecked(self.ctx.config.connect_external)
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
lay.addWidget(self.connect_external_sw)
hint = QLabel(tr("settings.ext_hint"))
hint.setObjectName("hint")
hint.setWordWrap(True)
lay.addWidget(hint)
# Each category (CAD/CAE/MS365/Other) is a header + a left-aligned,
# wrapping row of cards — one per real connector — instead of a tree
# the admin had to expand to see what was inside.
self._cat_scroll = QScrollArea()
self._cat_scroll.setWidgetResizable(True)
self._cat_scroll.setFrameShape(QScrollArea.NoFrame)
cat_host = QWidget()
enable_height_for_width(cat_host) # holds several height-for-width sections — see FlowLayout
self._cat_lay = QVBoxLayout(cat_host)
self._cat_lay.setContentsMargins(0, 4, 0, 4)
self._cat_lay.setSpacing(10)
self._cat_scroll.setWidget(cat_host)
lay.addWidget(self._cat_scroll, 1)
self.add_btn = QPushButton(tr("settings.ext_add_btn"))
self.add_btn.setIcon(icon("plus"))
self.add_btn.setObjectName("primary")
self.add_btn.setCursor(Qt.PointingHandCursor)
self.add_btn.clicked.connect(self._ext_add)
add_row = QHBoxLayout()
add_row.addWidget(self.add_btn)
add_row.addStretch(1)
lay.addLayout(add_row)
self.ms365_local_status = QLabel()
self.ms365_local_status.setObjectName("hint")
self.ms365_local_status.setWordWrap(True)
lay.addWidget(self.ms365_local_status)
# on_language_changed() below already invokes _retranslate() once
# immediately (see i18n.py), which itself calls _reload_connectors() —
# calling it again here would rebuild the category cards twice
# back-to-back with no event-loop turn in between, so the first pass's
# widgets are only QUEUED for deleteLater() (not yet gone) when the
# second pass adds new ones on top: the two rows visually overlap
# (the exact bug agents_admin_tab.py hit the same way).
self._refresh_ms365_local_status()
on_language_changed(self._retranslate)
# ---- rendering ------------------------------------------------------------
def _clear_categories(self) -> None:
"""Xoá sạch các mục nhóm trước khi dựng lại."""
while self._cat_lay.count():
item = self._cat_lay.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
def _reload_connectors(self) -> None:
"""Dựng lại toàn bộ bảng từ cấu hình mới nhất."""
self._clear_categories()
for cat in EXT_CATEGORIES:
self._cat_lay.addWidget(self._category_section(cat))
def _category_section(self, cat: str) -> QWidget:
"""Dựng một mục nhóm kèm lưới thẻ connector bên trong."""
section = QWidget()
enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout
sl = QVBoxLayout(section)
sl.setContentsMargins(0, 0, 0, 0)
sl.setSpacing(6)
hdr = QHBoxLayout()
hdr.setSpacing(6)
icon_lbl = QLabel()
icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
hdr.addWidget(icon_lbl)
name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
name_lbl = QLabel(name)
name_lbl.setStyleSheet("font-weight:700;")
hdr.addWidget(name_lbl)
if subtitle:
sub_lbl = QLabel("(" + subtitle)
sub_lbl.setObjectName("hint")
hdr.addWidget(sub_lbl)
hdr.addStretch(1)
sl.addLayout(hdr)
flow_host = QWidget()
flow = FlowLayout(flow_host, margin=0, h_spacing=10, v_spacing=10)
if cat == "ms365":
conns = self.ctx.config.ms365.get("connectors", {})
for key, label in self._MS365_BUILTIN_LABELS.items():
flow.addWidget(self._connector_card(
label, tr("connectors.builtin_auto"), bool(conns.get(key)),
lambda on, k=key: self._toggle_ms365_builtin(k, on)))
for entry in self.ctx.config.ext_connectors.get(cat, []):
mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest")
flow.addWidget(self._connector_card(
entry.get("name", ""), mode_label, bool(entry.get("enabled")),
lambda on, e=entry: self._toggle_ext_entry(e, on),
# QPushButton.clicked emits a `checked` bool — a lambda whose
# ONLY parameter is a defaulted capture (`e=entry`) looks like
# it accepts that bool, so Qt hands it the click state instead
# of using the default, silently replacing the captured dict
# with False. An explicit leading `checked=False` soaks up the
# signal's argument so `e` keeps the entry it was defined with.
edit_cb=lambda checked=False, e=entry: self._edit_ext_entry(cat, e),
delete_cb=lambda checked=False, e=entry: self._delete_ext_entry(cat, e)))
if cat == "other":
# Jira is a built-in "Other" connector (like OneDrive under MS365):
# switch = enabled; ✎ Sửa opens its own minimal setup dialog — no
# 🗑 Xóa, same as OneDrive/SharePoint have neither (nothing to delete).
jira = self.ctx.config.data.get("jira", {})
configured = bool(jira.get("base_url") and jira.get("email") and jira.get("api_token"))
jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set")
flow.addWidget(self._connector_card(
"Jira", f"{tr('ext.mode_builtin').capitalize()} · {jstate}",
configured and jira.get("enabled", True), self._toggle_jira,
edit_cb=self._open_jira_dialog))
sl.addWidget(flow_host)
return section
def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle,
edit_cb=None, delete_cb=None) -> QWidget:
"""Dựng một thẻ connector: tiêu đề, dòng phụ, công tắc bật/tắt và nút sửa/xoá
(nếu có).
"""
card = QFrame()
card.setFrameShape(QFrame.NoFrame)
style_card(card)
lay = QVBoxLayout(card)
lay.setContentsMargins(10, 8, 10, 8)
lay.setSpacing(4)
hdr = QHBoxLayout()
name_lbl = QLabel(title)
name_lbl.setStyleSheet("font-weight:700; border: none;")
hdr.addWidget(name_lbl)
hdr.addStretch(1)
sw = ToggleSwitch()
sw.setChecked(checked)
sw.toggled.connect(on_toggle)
hdr.addWidget(sw)
lay.addLayout(hdr)
sub_lbl = QLabel(subtitle)
sub_lbl.setObjectName("hint")
sub_lbl.setStyleSheet("border: none;")
lay.addWidget(sub_lbl)
if edit_cb is not None or delete_cb is not None:
actions = QHBoxLayout()
actions.setContentsMargins(0, 2, 0, 0)
actions.setSpacing(2)
if edit_cb is not None:
b = QPushButton(tr("settings.ext_edit_btn"))
b.setIcon(icon("edit"))
b.setFlat(True)
b.setCursor(Qt.PointingHandCursor)
b.clicked.connect(edit_cb)
actions.addWidget(b)
if delete_cb is not None:
b = QPushButton(tr("settings.ext_delete_btn"))
b.setIcon(icon("trash"))
b.setFlat(True)
b.setCursor(Qt.PointingHandCursor)
b.clicked.connect(delete_cb)
actions.addWidget(b)
actions.addStretch(1)
lay.addLayout(actions)
return card
def _toggle_ms365_builtin(self, key: str, checked: bool) -> None:
"""Bật/tắt một connector MS365 dựng sẵn và lưu ngay."""
self.ctx.config.ms365.setdefault("connectors", {})[key] = checked
self.ctx.save()
def _toggle_jira(self, checked: bool) -> None:
"""Bật/tắt connector Jira và lưu ngay."""
self.ctx.config.data.setdefault("jira", {})["enabled"] = checked
self.ctx.save()
def _toggle_ext_entry(self, entry: dict, checked: bool) -> None:
"""Bật/tắt một connector MCP tự thêm và lưu ngay."""
entry["enabled"] = checked
self.ctx.save()
# ---- CRUD -----------------------------------------------------------------
def _ext_add(self) -> None:
"""Thêm một connector MCP mới qua hộp thoại."""
dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0])
if dlg.exec():
entry = dlg.result_connector()
self.ctx.config.ext_connectors.setdefault(entry["category"], []).append(entry)
self.ctx.save()
self._reload_connectors()
def _edit_ext_entry(self, cat: str, entry: dict) -> None:
"""Sửa một connector MCP đã có."""
dlg = ExtConnectorEditDialog(self, category=cat, connector=entry)
if dlg.exec():
entry.update(dlg.result_connector())
self.ctx.save()
self._reload_connectors()
def _open_jira_dialog(self) -> None:
"""Mở hộp thoại cấu hình Jira rồi dựng lại bảng."""
JiraConnectDialog(self.ctx, self).exec()
self._reload_connectors()
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
"""Xoá một connector MCP sau khi hỏi xác nhận."""
if QMessageBox.question(
self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
return
self.ctx.config.ext_connectors[cat].remove(entry)
self.ctx.save()
self._reload_connectors()
def _refresh_ms365_local_status(self) -> None:
"""Cập nhật dòng trạng thái MS365 cục bộ theo việc có tìm thấy thư mục OneDrive hay không."""
from .. import paths
root = paths.primary_onedrive_root()
if root is not None:
self.ms365_local_status.setText(tr("settings.ms365_local_connected", path=str(root)))
else:
self.ms365_local_status.setText(tr("settings.ms365_local_none"))
def _on_connect_external_toggled(self, on: bool) -> None:
"""Bật/tắt công tắc tổng cho connector ngoài, và khoá/mở cả bảng theo đó."""
self.ctx.config.set_connect_external(on)
self._apply_connect_external_enabled(on)
def _apply_connect_external_enabled(self, on: bool) -> None:
"""Grey out the per-connector setup when the master switch is off — the
agent won't connect to any of them anyway."""
for w in (self._cat_scroll, self.add_btn):
w.setEnabled(on)
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.connect_external_sw.setText(tr("connectors.connect_external"))
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
self.add_btn.setText(tr("settings.ext_add_btn"))
self._refresh_ms365_local_status()
self._reload_connectors()
self._apply_connect_external_enabled(self.ctx.config.connect_external)