Files
TERES_web_frontend/src/pages/setting/mcp.tsx

484 lines
14 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Card,
CardContent,
Grid,
IconButton,
Menu,
MenuItem,
Checkbox,
Pagination,
CircularProgress,
Snackbar,
Alert,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions
} from '@mui/material';
import {
Add as AddIcon,
MoreVert as MoreVertIcon,
Delete as DeleteIcon,
FileDownload as ExportIcon,
FileUpload as ImportIcon,
Search as SearchIcon,
Edit as EditIcon
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import { useMcpSetting } from '@/hooks/setting-hooks';
import McpDialog from '@/pages/setting/components/McpDialog';
import type { IMcpServer } from '@/interfaces/database/mcp';
import type { IImportMcpServersRequestBody, ICreateMcpServerRequestBody, ITestMcpRequestBody } from '@/interfaces/request/mcp';
import { useMessage } from '@/hooks/useSnackbar';
import dayjs from 'dayjs';
const PageContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(3),
maxWidth: '1200px',
margin: '0 auto',
backgroundColor: '#f5f5f5',
minHeight: '100vh'
}));
const PageHeader = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[1],
}));
const SearchContainer = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shadows[1],
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
}));
export default function McpSettingPage() {
const handleRefreshServer = async (initial?: boolean) => {
if (initial) {
setCurrentPage(1);
} else {
await fetchMcpServers({
page: currentPage,
size: pageSize,
keyword: searchString,
});
}
}
const {
mcpServers,
loading,
total,
fetchMcpServers,
importMcpServers,
exportMcpServers,
testMcpServer,
getMcpServerDetail,
createMcpServer,
updateMcpServer,
deleteMcpServer,
batchDeleteMcpServers,
} = useMcpSetting({ refreshServer: handleRefreshServer });
// 本地状态
const [searchString, setSearchString] = useState('');
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [editingServer, setEditingServer] = useState<Partial<IMcpServer> | undefined | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(8);
const [importJson, setImportJson] = useState('');
const showMessage = useMessage()
// 初始化加载数据
useEffect(() => {
fetchMcpServers({
page: currentPage,
size: pageSize,
keyword: searchString,
});
}, [fetchMcpServers, currentPage, pageSize, searchString]);
const totalPages = Math.ceil(total / pageSize);
const paginatedServers = mcpServers.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchString(event.target.value);
setCurrentPage(1);
};
const handleSelectServer = (serverId: string) => {
setSelectedServers(prev =>
prev.includes(serverId)
? prev.filter(id => id !== serverId)
: [...prev, serverId]
);
};
const handleSelectAll = () => {
if (selectedServers.length === paginatedServers.length) {
setSelectedServers([]);
} else {
setSelectedServers(paginatedServers.map(server => server.id));
}
};
const handleMenuClick = (event: React.MouseEvent<HTMLElement>, serverId: string) => {
setAnchorEl(event.currentTarget);
setSelectedServerId(serverId);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedServerId(null);
};
const handleEdit = async () => {
// const server = mcpServers.find(s => s.id === selectedServerId);
const result = await getMcpServerDetail(selectedServerId || '');
if (result.success) {
const server = result.data;
if (server != null && server != undefined) {
const headers = server?.headers || {};
const authorization_token = headers['authorization_token'] || '';
if (authorization_token && authorization_token.length > 0) {
server!.variables = {
...server?.variables,
authorization_token,
}
}
}
setEditingServer(server);
setMcpDialogOpen(true);
}
handleMenuClose();
};
const handleAdd = () => {
setEditingServer(null);
setMcpDialogOpen(true);
};
const handleDelete = async () => {
if (selectedServerId) {
const result = await deleteMcpServer(selectedServerId);
if (result.success) {
showMessage.success('删除成功');
} else {
showMessage.error(result.error || '删除失败');
}
}
handleMenuClose();
};
const handleBulkDelete = async () => {
const result = await batchDeleteMcpServers(selectedServers);
if (result.success) {
showMessage.success('批量删除成功');
setSelectedServers([]);
} else {
showMessage.error(result.error || '批量删除失败');
}
};
const handleExport = async () => {
const result = await exportMcpServers(selectedServers);
if (result.success) {
showMessage.success('导出成功');
} else {
showMessage.error(result.error || '导出失败');
}
};
const handleMcpSave = async (data: ICreateMcpServerRequestBody) => {
try {
if (editingServer) {
if (!editingServer.id) {
showMessage.error('server id 不能为空');
return { success: false, error: 'server id 不能为空' };
}
const result = await updateMcpServer({ ...data, mcp_id: editingServer.id ?? '' });
if (result.success) {
showMessage.success('MCP 服务器更新成功');
setMcpDialogOpen(false);
setEditingServer(null);
return result;
} else {
return result;
}
} else {
const result = await createMcpServer(data);
if (result.success) {
showMessage.success('MCP 服务器创建成功');
setMcpDialogOpen(false);
setEditingServer(null);
return result;
} else {
return result;
}
}
} catch (error: any) {
const errorMessage = error.message || '操作失败';
return { success: false, error: errorMessage };
}
};
const handleTestMcpServer = async (data: ITestMcpRequestBody) => {
try {
const result = await testMcpServer(data);
if (result.success) {
showMessage.success('测试成功');
return result;
} else {
return result;
}
} catch (error: any) {
const errorMessage = error.message || '操作失败';
return { success: false, error: errorMessage };
}
};
const handleImport = async () => {
try {
const importData = JSON.parse(importJson);
if (importData.mcpServers) {
const requestData: IImportMcpServersRequestBody = {
mcpServers: Object.entries(importData.mcpServers).reduce((acc, [key, value]: [string, any]) => {
acc[key] = {
type: value.type,
url: value.url,
authorization_token: value.authorization_token || ''
};
return acc;
}, {} as any)
};
const result = await importMcpServers(requestData);
if (result.success) {
setImportDialogOpen(false);
setImportJson('');
}
} else {
showMessage.error('JSON 格式错误');
}
} catch (error) {
showMessage.error('JSON 格式错误');
}
};
return (
<PageContainer>
<PageHeader>
<Box>
<Typography variant="h4" fontWeight={600} color="#333">
MCP Servers
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Customize the list of MCP servers
</Typography>
</Box>
<Box display="flex" gap={2}>
<Button
variant="outlined"
startIcon={<ImportIcon />}
onClick={() => setImportDialogOpen(true)}
>
Import
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAdd}
>
Add MCP
</Button>
</Box>
</PageHeader>
<SearchContainer>
<Box>
<TextField
size="small"
placeholder="Search MCP servers..."
value={searchString}
onChange={handleSearchChange}
InputProps={{
startAdornment: <SearchIcon sx={{ mr: 1, color: 'text.secondary' }} />
}}
sx={{ width: 300 }}
/>
</Box>
{selectedServers.length > 0 && (
<Box display="flex" gap={1}>
<Button
size="small"
startIcon={<DeleteIcon />}
onClick={handleBulkDelete}
color="error"
>
Delete ({selectedServers.length})
</Button>
<Button
size="small"
startIcon={<ExportIcon />}
onClick={handleExport}
>
Export ({selectedServers.length})
</Button>
</Box>
)}
</SearchContainer>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
</Box>
) : (
<>
<Grid container spacing={3}>
{paginatedServers.map((server) => (
<Grid size={{ xs: 6, sm: 4, md: 3 }} key={server.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
border: selectedServers.includes(server.id) ? '2px solid #1976d2' : '1px solid #e0e0e0'
}}
>
<CardContent sx={{ flexGrow: 1 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Checkbox
checked={selectedServers.includes(server.id)}
onChange={() => handleSelectServer(server.id)}
size="small"
/>
<IconButton
size="small"
onClick={(e) => handleMenuClick(e, server.id)}
>
<MoreVertIcon />
</IconButton>
</Box>
<Typography variant="h6" component="h3" gutterBottom>
{server.name}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
: {server.server_type}
</Typography>
<Typography variant="body2" color="text.secondary">
: {dayjs(server.update_date).format('YYYY-MM-DD HH:mm:ss')}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* 分页 */}
<Box display="flex" justifyContent="center" alignItems="center" mt={3}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
color="primary"
/>
<Typography variant="body2" sx={{ ml: 2 }}>
{total}
</Typography>
</Box>
</>
)}
{/* 操作菜单 */}
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleEdit}>
<EditIcon sx={{ mr: 1 }} fontSize="small" />
Edit
</MenuItem>
<MenuItem onClick={handleDelete} sx={{ color: 'error.main' }}>
<DeleteIcon sx={{ mr: 1 }} fontSize="small" />
Delete
</MenuItem>
</Menu>
{/* MCP 对话框 */}
<McpDialog
open={mcpDialogOpen}
onClose={() => {
setMcpDialogOpen(false);
setEditingServer(null);
}}
onSave={handleMcpSave}
onTest={handleTestMcpServer}
initialData={editingServer || {}}
mode={editingServer ? 'edit' : 'create'}
/>
{/* 导入对话框 */}
<Dialog open={importDialogOpen} onClose={() => setImportDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Import MCP Servers</DialogTitle>
<DialogContent>
<Alert severity="info" sx={{ mb: 2 }}>
Paste your MCP servers JSON configuration below. The format should match the Mock.json structure.
</Alert>
<TextField
fullWidth
multiline
rows={10}
label="JSON Configuration"
value={importJson}
onChange={(e) => setImportJson(e.target.value)}
placeholder={`{
"mcpServers": {
"Time": {
"type": "sse",
"url": "http://localhost:8080",
"name": "Time Server",
"authorization_token": "",
"tool_configuration": {}
}
}
}`}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setImportDialogOpen(false)}>Cancel</Button>
<Button onClick={handleImport} variant="contained">
Import
</Button>
</DialogActions>
</Dialog>
</PageContainer>
);
}