Files
cowork-local/ui/ext_connector_dialog.py
T
1419587401
CI / test (push) Canceled after 0s
Feature/fsg gamma team ui fix (#3)
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [x] 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: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Co-authored-by: NamPDT <minhanhpkpro@gmail.com>
Reviewed-on: #3
2026-08-20 12:12:56 +00:00

199 lines
8.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):
def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None):
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:
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:
self.category = self.category_combo.currentData() or self.category
self._reload_presets()
def _apply_preset(self) -> None:
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:
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:
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:
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:
return self._current_entry()