CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
214 lines
9.5 KiB
Python
214 lines
9.5 KiB
Python
"""Add/edit one External Connector entry (CAD/CAE/Office) — Settings'
|
|
"🏭 External Connectors" section (see settings_dialog.py for the list/CRUD).
|
|
|
|
Two connection modes, switched via ``mode_combo``:
|
|
- MCP Server (stdio) — same shape as the plain "MCP Servers" section.
|
|
- REST API — base URL + API key; the connector exposes ONE generic
|
|
HTTP-request tool scoped to that base URL (see core/ext_connectors.py).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import shlex
|
|
from typing import Optional
|
|
|
|
from PySide6.QtWidgets import (
|
|
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
|
|
QLineEdit, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..core.ext_connectors import CATEGORIES, PRESETS
|
|
from ..i18n import tr
|
|
|
|
# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects
|
|
# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker.
|
|
_PICKABLE_CATEGORIES = tuple(c for c in CATEGORIES if c != "ms365")
|
|
_CATEGORY_LABEL = {"cad": "CAD", "cae": "CAE", "other": "Other"}
|
|
|
|
|
|
class ExtConnectorEditDialog(QDialog):
|
|
"""Hộp thoại thêm/sửa một connector ngoài: MCP server hoặc REST API."""
|
|
def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None):
|
|
"""Form thêm/sửa một connector ngoài. ``connector`` để None thì đây là form thêm
|
|
mới; nhóm (CAD/CAE/MS365/Other) lấy từ connector cũ nếu đang sửa.
|
|
"""
|
|
super().__init__(parent)
|
|
connector = connector or {}
|
|
self.category = connector.get("category", category)
|
|
editing = bool(connector)
|
|
self.setWindowTitle(tr("ext.edit_title") if editing else tr("ext.add_title"))
|
|
self.setMinimumWidth(480)
|
|
|
|
lay = QVBoxLayout(self)
|
|
|
|
form = QFormLayout()
|
|
# Category picker — the single "+ Thêm connector…" button (Monitoring ▸
|
|
# Công cụ ▸ Connector) has no tree selection to infer this from anymore,
|
|
# so the dialog itself asks. Fixed once created, same as the preset.
|
|
self.category_combo = QComboBox()
|
|
for cat in _PICKABLE_CATEGORIES:
|
|
self.category_combo.addItem(_CATEGORY_LABEL.get(cat, cat), cat)
|
|
idx = self.category_combo.findData(self.category)
|
|
self.category_combo.setCurrentIndex(max(0, idx))
|
|
self.category_combo.setEnabled(not editing)
|
|
self.category_combo.currentIndexChanged.connect(self._on_category_changed)
|
|
form.addRow(tr("ext.category_label"), self.category_combo)
|
|
|
|
self.preset_combo = QComboBox()
|
|
self._reload_presets()
|
|
if editing:
|
|
self.preset_combo.setEnabled(False) # identity fixed once created
|
|
form.addRow(tr("ext.preset_label"), self.preset_combo)
|
|
|
|
self.name_edit = QLineEdit(connector.get("name", ""))
|
|
self.name_edit.setPlaceholderText(tr("ext.name_placeholder"))
|
|
form.addRow(tr("ext.name_label"), self.name_edit)
|
|
|
|
self.mode_combo = QComboBox()
|
|
self.mode_combo.addItem(tr("ext.mode_mcp"), "mcp_stdio")
|
|
self.mode_combo.addItem(tr("ext.mode_rest"), "rest_api")
|
|
idx = self.mode_combo.findData(connector.get("mode", "mcp_stdio"))
|
|
self.mode_combo.setCurrentIndex(max(0, idx))
|
|
form.addRow(tr("ext.mode_label"), self.mode_combo)
|
|
lay.addLayout(form)
|
|
|
|
self.stack = QStackedWidget()
|
|
|
|
mcp_page = QWidget()
|
|
mcp_form = QFormLayout(mcp_page)
|
|
self.command_edit = QLineEdit(connector.get("command", ""))
|
|
self.command_edit.setPlaceholderText(tr("ext.command_placeholder"))
|
|
mcp_form.addRow(tr("ext.command_label"), self.command_edit)
|
|
self.args_edit = QLineEdit(" ".join(connector.get("args", []) or []))
|
|
self.args_edit.setPlaceholderText(tr("ext.args_placeholder"))
|
|
mcp_form.addRow(tr("ext.args_label"), self.args_edit)
|
|
self.stack.addWidget(mcp_page)
|
|
|
|
rest_page = QWidget()
|
|
rest_form = QFormLayout(rest_page)
|
|
self.base_url_edit = QLineEdit(connector.get("base_url", ""))
|
|
self.base_url_edit.setPlaceholderText(tr("ext.base_url_placeholder"))
|
|
rest_form.addRow(tr("ext.base_url_label"), self.base_url_edit)
|
|
self.api_key_edit = QLineEdit(connector.get("api_key", ""))
|
|
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
|
rest_form.addRow(tr("ext.api_key_label"), self.api_key_edit)
|
|
self.auth_header_edit = QLineEdit(connector.get("auth_header", "Authorization"))
|
|
rest_form.addRow(tr("ext.auth_header_label"), self.auth_header_edit)
|
|
self.auth_scheme_edit = QLineEdit(connector.get("auth_scheme", "Bearer"))
|
|
rest_form.addRow(tr("ext.auth_scheme_label"), self.auth_scheme_edit)
|
|
self.stack.addWidget(rest_page)
|
|
|
|
lay.addWidget(self.stack)
|
|
self.mode_combo.currentIndexChanged.connect(
|
|
lambda i: self.stack.setCurrentIndex(self.mode_combo.currentData() != "mcp_stdio"))
|
|
self.stack.setCurrentIndex(0 if self.mode_combo.currentData() == "mcp_stdio" else 1)
|
|
|
|
self.status_label = QLabel("")
|
|
self.status_label.setObjectName("hint")
|
|
self.status_label.setWordWrap(True)
|
|
test_row = QHBoxLayout()
|
|
test_btn = QPushButton(tr("ext.test_btn"))
|
|
test_btn.clicked.connect(self._test_connection)
|
|
test_row.addWidget(test_btn)
|
|
test_row.addWidget(self.status_label, 1)
|
|
lay.addLayout(test_row)
|
|
|
|
self.preset_combo.currentIndexChanged.connect(self._apply_preset)
|
|
idx = self.preset_combo.findData(connector.get("id", "") if editing else "")
|
|
if idx > 0:
|
|
self.preset_combo.setCurrentIndex(idx)
|
|
|
|
self._enabled = bool(connector.get("enabled", False))
|
|
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
|
buttons.accepted.connect(self._on_accept)
|
|
buttons.rejected.connect(self.reject)
|
|
lay.addWidget(buttons)
|
|
|
|
def _reload_presets(self) -> None:
|
|
"""Nạp lại danh sách mẫu dựng sẵn theo nhóm đang chọn.
|
|
|
|
Chặn tín hiệu trong lúc nạp: ``clear()`` phát ``currentIndexChanged`` và sẽ
|
|
bị hiểu nhầm là người dùng vừa chọn mẫu.
|
|
"""
|
|
self.preset_combo.blockSignals(True)
|
|
self.preset_combo.clear()
|
|
self.preset_combo.addItem(tr("ext.preset_custom"), "")
|
|
for p in PRESETS.get(self.category, []):
|
|
self.preset_combo.addItem(p["name"], p["id"])
|
|
self.preset_combo.blockSignals(False)
|
|
|
|
def _on_category_changed(self) -> None:
|
|
"""Đổi nhóm (CAD/CAE/MS365/Khác) thì nạp lại danh sách mẫu tương ứng."""
|
|
self.category = self.category_combo.currentData() or self.category
|
|
self._reload_presets()
|
|
|
|
def _apply_preset(self) -> None:
|
|
"""Áp một mẫu dựng sẵn; chỉ điền tên khi người dùng chưa tự đặt tên."""
|
|
preset_id = self.preset_combo.currentData()
|
|
if preset_id and not self.name_edit.text().strip():
|
|
self.name_edit.setText(self.preset_combo.currentText())
|
|
|
|
def _current_entry(self) -> dict:
|
|
"""Bản ghi connector dựng từ nội dung đang có trên form."""
|
|
mode = self.mode_combo.currentData()
|
|
preset_id = self.preset_combo.currentData()
|
|
name = self.name_edit.text().strip()
|
|
cid = preset_id or name.strip().lower().replace(" ", "_")
|
|
args_text = self.args_edit.text().strip()
|
|
return {
|
|
"id": cid,
|
|
"name": name,
|
|
"category": self.category,
|
|
"enabled": self._enabled,
|
|
"mode": mode,
|
|
"command": self.command_edit.text().strip(),
|
|
"args": shlex.split(args_text) if args_text else [],
|
|
"env": {},
|
|
"base_url": self.base_url_edit.text().strip(),
|
|
"api_key": self.api_key_edit.text(),
|
|
"auth_header": self.auth_header_edit.text().strip() or "Authorization",
|
|
"auth_scheme": self.auth_scheme_edit.text().strip(),
|
|
}
|
|
|
|
def _test_connection(self) -> None:
|
|
"""Thử kết nối tới connector đang cấu hình và hiện kết quả."""
|
|
entry = self._current_entry()
|
|
if entry["mode"] == "rest_api":
|
|
from ..core.ext_connectors import RestApiConnector
|
|
|
|
ok, message = RestApiConnector(entry).test_connection()
|
|
else:
|
|
from ..core.mcp_client import McpServerConnection
|
|
|
|
if not entry["command"]:
|
|
ok, message = False, tr("ext.err_no_command")
|
|
else:
|
|
conn = McpServerConnection(entry["id"], entry["command"], entry["args"])
|
|
try:
|
|
conn.start(timeout=10)
|
|
ok, message = True, tr("ext.test_mcp_ok")
|
|
except Exception as exc: # noqa: BLE001
|
|
ok, message = False, str(exc)
|
|
finally:
|
|
conn.stop()
|
|
prefix = "✓" if ok else "✗"
|
|
self.status_label.setText(f"{prefix} {message}")
|
|
|
|
def _on_accept(self) -> None:
|
|
"""Kiểm tra bắt buộc có tên trước khi đóng hộp thoại."""
|
|
if not self.name_edit.text().strip():
|
|
self.name_edit.setFocus()
|
|
return
|
|
entry = self._current_entry()
|
|
if entry["mode"] == "mcp_stdio" and not entry["command"]:
|
|
self.command_edit.setFocus()
|
|
return
|
|
if entry["mode"] == "rest_api" and not entry["base_url"]:
|
|
self.base_url_edit.setFocus()
|
|
return
|
|
self.accept()
|
|
|
|
def result_connector(self) -> dict:
|
|
"""Bản ghi connector để chỗ gọi lưu lại."""
|
|
return self._current_entry()
|