diff --git a/src/renderer/src/components/Popups/GeneralPopup.tsx b/src/renderer/src/components/Popups/GeneralPopup.tsx new file mode 100644 index 0000000000..3307b10162 --- /dev/null +++ b/src/renderer/src/components/Popups/GeneralPopup.tsx @@ -0,0 +1,66 @@ +import { TopView } from '@renderer/components/TopView' +import { Modal, ModalProps } from 'antd' +import { ReactNode, useState } from 'react' + +interface ShowParams extends ModalProps { + content: ReactNode +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ content, resolve, ...rest }) => { + const [open, setOpen] = useState(true) + + const onOk = () => { + setOpen(false) + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + GeneralPopup.hide = onCancel + + return ( + + {content} + + ) +} + +const TopViewKey = 'GeneralPopup' + +/** 在这个 Popup 中展示任意内容 */ +export default class GeneralPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 571674567d..efcdd11158 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -820,6 +820,10 @@ "devtools": "Open debug panel", "message": "It seems that something went wrong...", "reload": "Reload" + }, + "details": "Details", + "mcp": { + "invalid": "Invalid MCP server" } }, "chat": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index cd4762ee83..1f4ef33d70 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -820,6 +820,10 @@ "devtools": "デバッグパネルを開く", "message": "何か問題が発生したようです...", "reload": "再読み込み" + }, + "details": "詳細情報", + "mcp": { + "invalid": "無効なMCPサーバー" } }, "chat": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 17036fd103..e65b979103 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -820,6 +820,10 @@ "devtools": "Открыть панель отладки", "message": "Похоже, возникла какая-то проблема...", "reload": "Перезагрузить" + }, + "details": "Подробности", + "mcp": { + "invalid": "Недействительный сервер MCP" } }, "chat": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 678cf39df3..9f330ab5fc 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -820,6 +820,10 @@ "devtools": "打开调试面板", "message": "似乎出现了一些问题...", "reload": "重新加载" + }, + "details": "详细信息", + "mcp": { + "invalid": "无效的MCP服务器" } }, "chat": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index c13cb0c370..e67daa2232 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -820,6 +820,10 @@ "devtools": "打開除錯面板", "message": "似乎出現了一些問題...", "reload": "重新載入" + }, + "details": "詳細信息", + "mcp": { + "invalid": "無效的MCP伺服器" } }, "chat": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index f50bcf3bd1..49bc277157 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -820,6 +820,10 @@ "devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης", "message": "Φαίνεται ότι προέκυψε κάποιο πρόβλημα...", "reload": "Επαναφόρτωση" + }, + "details": "Λεπτομέρειες", + "mcp": { + "invalid": "Μη έγκυρος διακομιστής MCP" } }, "chat": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 902aa3fdff..cdcce9bac7 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -820,6 +820,10 @@ "devtools": "Abrir el panel de depuración", "message": "Parece que ha surgido un problema...", "reload": "Recargar" + }, + "details": "Detalles", + "mcp": { + "invalid": "Servidor MCP no válido" } }, "chat": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index e5d9f07945..0615eb50c4 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -820,6 +820,10 @@ "devtools": "Ouvrir le panneau de débogage", "message": "Il semble que quelques problèmes soient survenus...", "reload": "Recharger" + }, + "details": "Détails", + "mcp": { + "invalid": "Serveur MCP invalide" } }, "chat": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 0c94d43538..3b8c07f7f5 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -820,6 +820,10 @@ "devtools": "Abrir o painel de depuração", "message": "Parece que ocorreu um problema...", "reload": "Recarregar" + }, + "details": "Detalhes", + "mcp": { + "invalid": "Servidor MCP inválido" } }, "chat": { diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx index 2e87204cdb..a3d63ea938 100644 --- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx @@ -5,7 +5,9 @@ import CodeEditor from '@renderer/components/CodeEditor' import { useTimer } from '@renderer/hooks/useTimer' import { useAppDispatch } from '@renderer/store' import { setMCPServerActive } from '@renderer/store/mcp' -import { MCPServer } from '@renderer/types' +import { MCPServer, objectKeys, safeValidateMcpConfig } from '@renderer/types' +import { parseJSON } from '@renderer/utils' +import { formatZodError } from '@renderer/utils/error' import { Button, Form, Modal, Upload } from 'antd' import { FC, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -80,6 +82,49 @@ const AddMcpServerModal: FC = ({ setImportMethod(initialImportMethod) }, [initialImportMethod]) + /** + * 从JSON字符串中解析MCP服务器配置 + * @param inputValue - JSON格式的服务器配置字符串 + * @returns 包含解析后的服务器配置和可能的错误信息的对象 + * - serverToAdd: 解析成功时返回服务器配置对象,失败时返回null + * - error: 解析失败时返回错误信息,成功时返回null + */ + const getServerFromJson = ( + inputValue: string + ): { serverToAdd: Partial; error: null } | { serverToAdd: null; error: string } => { + const trimmedInput = inputValue.trim() + const parsedJson = parseJSON(trimmedInput) + if (parsedJson === null) { + logger.error('Failed to parse json.', { input: trimmedInput }) + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } + } + + const { data: validConfig, error } = safeValidateMcpConfig(parsedJson) + if (error) { + logger.error('Failed to validate json.', { parsedJson, error }) + return { serverToAdd: null, error: formatZodError(error, t('settings.mcp.addServer.importFrom.invalid')) } + } + + let serverToAdd: Partial | null = null + + if (objectKeys(validConfig.mcpServers).length > 1) { + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') } + } + + if (objectKeys(validConfig.mcpServers).length > 0) { + const key = objectKeys(validConfig.mcpServers)[0] + serverToAdd = validConfig.mcpServers[key] + if (!serverToAdd.name) { + serverToAdd.name = key + } + } else { + return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } + } + + // zod 太好用了你们知道吗 + return { serverToAdd, error: null } + } + const handleOk = async () => { try { setLoading(true) @@ -194,9 +239,9 @@ const AddMcpServerModal: FC = ({ const values = await form.validateFields() const inputValue = values.serverConfig.trim() - const { serverToAdd, error } = parseAndExtractServer(inputValue, t) + const { serverToAdd, error } = getServerFromJson(inputValue) - if (error) { + if (error !== null) { form.setFields([ { name: 'serverConfig', @@ -208,11 +253,11 @@ const AddMcpServerModal: FC = ({ } // 檢查重複名稱 - if (existingServers && existingServers.some((server) => server.name === serverToAdd!.name)) { + if (existingServers && existingServers.some((server) => server.name === serverToAdd.name)) { form.setFields([ { name: 'serverConfig', - errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd!.name })] + errors: [t('settings.mcp.addServer.importFrom.nameExists', { name: serverToAdd.name })] } ]) setLoading(false) @@ -222,9 +267,9 @@ const AddMcpServerModal: FC = ({ // 如果成功解析並通過所有檢查,立即加入伺服器(非啟用狀態)並關閉對話框 const newServer: MCPServer = { id: nanoid(), - ...serverToAdd!, - name: serverToAdd!.name || t('settings.mcp.newServer'), - baseUrl: serverToAdd!.baseUrl ?? serverToAdd!.url ?? '', + ...serverToAdd, + name: serverToAdd.name || t('settings.mcp.newServer'), + baseUrl: serverToAdd.baseUrl ?? serverToAdd.url ?? '', isActive: false // 初始狀態為非啟用 } @@ -330,93 +375,4 @@ const AddMcpServerModal: FC = ({ ) } -// 解析 JSON 提取伺服器資料 -const parseAndExtractServer = ( - inputValue: string, - t: (key: string, options?: any) => string -): { serverToAdd: Partial | null; error: string | null } => { - const trimmedInput = inputValue.trim() - - let parsedJson - try { - parsedJson = JSON.parse(trimmedInput) - } catch (e) { - // JSON 解析失敗,返回錯誤 - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } - } - - let serverToAdd: Partial | null = null - - // 檢查是否包含多個伺服器配置 (適用於 JSON 格式) - if ( - parsedJson.mcpServers && - typeof parsedJson.mcpServers === 'object' && - Object.keys(parsedJson.mcpServers).length > 1 - ) { - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') } - } else if (Array.isArray(parsedJson) && parsedJson.length > 1) { - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.error.multipleServers') } - } - - if ( - parsedJson.mcpServers && - typeof parsedJson.mcpServers === 'object' && - Object.keys(parsedJson.mcpServers).length > 0 - ) { - // Case 1: {"mcpServers": {"serverName": {...}}} - const firstServerKey = Object.keys(parsedJson.mcpServers)[0] - const potentialServer = parsedJson.mcpServers[firstServerKey] - if (typeof potentialServer === 'object' && potentialServer !== null) { - serverToAdd = { ...potentialServer } - serverToAdd!.name = potentialServer.name ?? firstServerKey - } else { - logger.error('Invalid server data under mcpServers key:', potentialServer) - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } - } - } else if (Array.isArray(parsedJson) && parsedJson.length > 0) { - // Case 2: [{...}, ...] - 取第一個伺服器,確保它是物件 - if (typeof parsedJson[0] === 'object' && parsedJson[0] !== null) { - serverToAdd = { ...parsedJson[0] } - serverToAdd!.name = parsedJson[0].name ?? t('settings.mcp.newServer') - } else { - logger.error('Invalid server data in array:', parsedJson[0]) - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } - } - } else if ( - typeof parsedJson === 'object' && - !Array.isArray(parsedJson) && - !parsedJson.mcpServers // 確保是直接的伺服器物件 - ) { - // Case 3: {...} (單一伺服器物件) - // 檢查物件是否為空 - if (Object.keys(parsedJson).length > 0) { - serverToAdd = { ...parsedJson } - serverToAdd!.name = parsedJson.name ?? t('settings.mcp.newServer') - } else { - // 空物件,視為無效 - serverToAdd = null - } - } else { - // 無效結構或空的 mcpServers - serverToAdd = null - } - - // 確保 serverToAdd 存在且 name 存在 - if (!serverToAdd || !serverToAdd.name) { - logger.error('Invalid JSON structure for server config or missing name:', parsedJson) - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } - } - - // Ensure tags is string[] - if ( - serverToAdd.tags && - (!Array.isArray(serverToAdd.tags) || !serverToAdd.tags.every((tag) => typeof tag === 'string')) - ) { - logger.error('Tags must be an array of strings:', serverToAdd.tags) - return { serverToAdd: null, error: t('settings.mcp.addServer.importFrom.invalid') } - } - - return { serverToAdd, error: null } -} - export default AddMcpServerModal diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index fd02d9ae45..c1e5f1553c 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -3,7 +3,9 @@ import CodeEditor from '@renderer/components/CodeEditor' import { TopView } from '@renderer/components/TopView' import { useAppDispatch, useAppSelector } from '@renderer/store' import { setMCPServers } from '@renderer/store/mcp' -import { MCPServer } from '@renderer/types' +import { MCPServer, safeValidateMcpConfig } from '@renderer/types' +import { parseJSON } from '@renderer/utils' +import { formatZodError } from '@renderer/utils/error' import { Modal, Spin, Typography } from 'antd' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -62,15 +64,19 @@ const PopupContainer: React.FC = ({ resolve }) => { return } - const parsedConfig = JSON.parse(jsonConfig) - - if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') { + const parsedJson = parseJSON(jsonConfig) + if (parseJSON === null) { throw new Error(t('settings.mcp.addServer.importFrom.invalid')) } + const { data: parsedServers, error } = safeValidateMcpConfig(parsedJson) + if (error) { + throw new Error(formatZodError(error, t('settings.mcp.addServer.importFrom.invalid'))) + } + const serversArray: MCPServer[] = [] - for (const [id, serverConfig] of Object.entries(parsedConfig.mcpServers)) { + for (const [id, serverConfig] of Object.entries(parsedServers.mcpServers)) { const server: MCPServer = { id, isActive: false, @@ -117,13 +123,12 @@ const PopupContainer: React.FC = ({ resolve }) => { afterClose={onClose} maskClosable={false} width={800} - height="80vh" loading={jsonSaving} transitionName="animation-move-down" centered>
- - {jsonError ? {jsonError} : ''} + + {jsonError ?
{jsonError}
: ''}
{isLoading ? ( diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx index f8922d233d..518849d4f0 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpServerCard.tsx @@ -1,10 +1,15 @@ +import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { DeleteIcon } from '@renderer/components/Icons' +import GeneralPopup from '@renderer/components/Popups/GeneralPopup' import Scrollbar from '@renderer/components/Scrollbar' import { getMcpTypeLabel } from '@renderer/i18n/label' import { MCPServer } from '@renderer/types' -import { Button, Switch, Tag, Typography } from 'antd' -import { Settings2, SquareArrowOutUpRight } from 'lucide-react' -import { FC } from 'react' +import { formatErrorMessage } from '@renderer/utils/error' +import { Alert, Button, Space, Switch, Tag, Tooltip, Typography } from 'antd' +import { CircleXIcon, Settings2, SquareArrowOutUpRight } from 'lucide-react' +import { FC, useCallback } from 'react' +import { FallbackProps } from 'react-error-boundary' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' interface McpServerCardProps { @@ -26,6 +31,7 @@ const McpServerCard: FC = ({ onEdit, onOpenUrl }) => { + const { t } = useTranslation() const handleOpenUrl = (e: React.MouseEvent) => { e.stopPropagation() if (server.providerUrl) { @@ -33,61 +39,135 @@ const McpServerCard: FC = ({ } } + const Fallback = useCallback( + (props: FallbackProps) => { + const { error } = props + const errorDetails = formatErrorMessage(error) + + const ErrorDetails = () => { + return ( +
+ {errorDetails} +
+ ) + } + + const onClickDetails = (e: React.MouseEvent) => { + e.stopPropagation() + GeneralPopup.show({ content: }) + } + return ( + + {errorDetails} + + } + onClick={onClickDetails} + action={ + +