diff --git a/package-lock.json b/package-lock.json index 715eb9f9..851e1d24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7478,9 +7478,9 @@ } }, "node_modules/rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "bin": { "rollup": "dist/bin/rollup" }, @@ -8341,9 +8341,9 @@ } }, "node_modules/vite": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", - "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", + "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -8637,9 +8637,9 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "bin": { "rollup": "dist/bin/rollup" }, @@ -13954,9 +13954,9 @@ } }, "rollup": { - "version": "2.79.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", - "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "requires": { "fsevents": "~2.3.2" } @@ -14557,9 +14557,9 @@ } }, "vite": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", - "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.5.tgz", + "integrity": "sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ==", "requires": { "esbuild": "^0.18.10", "fsevents": "~2.3.2", @@ -14568,9 +14568,9 @@ }, "dependencies": { "rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "requires": { "fsevents": "~2.3.2" } diff --git a/src/components/CreateCollection/CreateCollectionForm.jsx b/src/components/CreateCollection/CreateCollectionForm.jsx new file mode 100644 index 00000000..6c6be876 --- /dev/null +++ b/src/components/CreateCollection/CreateCollectionForm.jsx @@ -0,0 +1,197 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { StepContent, Stepper, Typography, Box, StepLabel, Step, Paper, Button, TextField } from '@mui/material'; +import VectorConfig from './VectorConfig'; +import { useClient } from '../../context/client-context'; + +const CreateCollectionForm = ({ collections, onComplete, sx, handleCreated }) => { + const { client: qdrantClient } = useClient(); + const [activeStep, setActiveStep] = useState(0); + const [collectionName, setCollectionName] = useState(''); + const [vectors, setVectors] = useState([ + { dimension: '', distance: '', name: '', multivector_config: null, sparse_vectors: false }, + ]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleNext = () => { + setActiveStep((prevActiveStep) => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + useEffect(() => { + if (activeStep === 2) { + setLoading(true); + const sparseVectors = vectors.filter((vector) => vector.sparse_vectors); + const denseVectors = vectors.filter((vector) => !vector.sparse_vectors); + + const vectorConfig = + denseVectors.length === 1 + ? { + vectors: { + size: parseInt(denseVectors[0].dimension), + distance: denseVectors[0].distance, + multivector_config: denseVectors[0].multivector_config, + }, + } + : { + vectors: denseVectors.reduce((config, vector) => { + config[vector.name] = { + size: parseInt(vector.dimension), + distance: vector.distance, + multivector_config: vector.multivector_config, + }; + return config; + }, {}), + }; + vectorConfig.sparse_vectors = sparseVectors.reduce((sparseConfig, vector) => { + sparseConfig[vector.name] = {}; + return sparseConfig; + }, {}); + + qdrantClient + .createCollection(collectionName, vectorConfig) + .then(() => { + setLoading(false); + setError(null); + onComplete(); + handleCreated(); + }) + .catch((error) => { + setLoading(false); + setError(error); + }); + } + }, [activeStep]); + + return ( + + + + {activeStep === 0 ? 'Enter a collection name' : `Collection name: ${collectionName}`} + + + + Step 2 - Vector config + + + + {activeStep === 2 && ( + + {loading ? ( + Creating collection... + ) : error ? ( + {error.message} + ) : ( + Collection created successfully 🎉 + )} + + )} + + ); +}; + +// props validation +CreateCollectionForm.propTypes = { + collections: PropTypes.array, + onComplete: PropTypes.func.isRequired, + sx: PropTypes.object, + handleCreated: PropTypes.func, +}; + +export default CreateCollectionForm; + +const CollectionNameTextBox = ({ collectionName, setCollectionName, collections, handleNext, activeStep }) => { + const [formError, setFormError] = useState(false); + const [formMessage, setFormMessage] = useState(''); + const textFieldRef = useRef(null); + + function validateCollectionName(value) { + const INVALID_CHARS = ['<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', '\u{1F}']; + + const invalidChar = INVALID_CHARS.find((c) => value.includes(c)); + + if (invalidChar !== undefined) { + return `Collection name cannot contain "${invalidChar}" char`; + } else { + return null; + } + } + + function collectionExists(name) { + if (collections?.some((collection) => collection.name === name)) { + return `Collection with name "${name}" already exists`; + } + return null; + } + + const MAX_COLLECTION_NAME_LENGTH = 255; + + useEffect(() => { + if (activeStep === 0) { + textFieldRef.current.focus(); + } + return () => {}; + }, [activeStep]); + + const handleTextChange = (event) => { + // if there will be more forms use schema validation instead + const newCollectionName = event.target.value; + const hasForbiddenSymbolsMessage = validateCollectionName(newCollectionName); + const hasForbiddenSymbols = hasForbiddenSymbolsMessage !== null; + const collectionExistsMessage = collectionExists(newCollectionName); + const collectionExistsError = collectionExistsMessage !== null; + const isTooShort = newCollectionName?.length < 1; + const isTooLong = newCollectionName?.length > MAX_COLLECTION_NAME_LENGTH; + + setCollectionName(newCollectionName); + setFormError(isTooShort || isTooLong || hasForbiddenSymbols || collectionExistsError); + setFormMessage( + isTooShort + ? 'Collection name is too short' + : isTooLong + ? 'Collection name is too long' + : (hasForbiddenSymbolsMessage || collectionExistsMessage) ?? '' + ); + }; + + return ( + + + Collection name must be new + + + + + + + ); +}; + +CollectionNameTextBox.propTypes = { + collectionName: PropTypes.string.isRequired, + setCollectionName: PropTypes.func.isRequired, + collections: PropTypes.array, + handleNext: PropTypes.func.isRequired, + activeStep: PropTypes.number.isRequired, +}; diff --git a/src/components/CreateCollection/VectorConfig.jsx b/src/components/CreateCollection/VectorConfig.jsx new file mode 100644 index 00000000..b843d541 --- /dev/null +++ b/src/components/CreateCollection/VectorConfig.jsx @@ -0,0 +1,247 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + TextField, + StepContent, + InputLabel, + FormControl, + Select, + MenuItem, + FormHelperText, + IconButton, + Tooltip, + FormControlLabel, + Checkbox, +} from '@mui/material'; +import PropTypes from 'prop-types'; +import { AddCircleOutline, DeleteOutline } from '@mui/icons-material'; + +const VectorRow = ({ vectors, index, setVectors, errors, length, setErrors }) => { + const [distancesOptions, setDistancesOptions] = useState([]); + const handleAddVector = () => { + setVectors([ + ...vectors, + { dimension: '', distance: '', name: '', multivector_config: null, sparse_vectors: false }, + ]); + setErrors([...errors, { dimension: '', distance: '', name: '' }]); + }; + + const handleRemoveVector = (index) => { + const newVectors = vectors.filter((_, i) => i !== index); + const newErrors = errors.filter((_, i) => i !== index); + setVectors(newVectors); + setErrors(newErrors); + }; + + const validate = (index, field, value) => { + const newErrors = [...errors]; + if (!value) { + newErrors[index][field] = `${field} is required`; + } else if (field === 'dimension' && isNaN(value)) { + newErrors[index][field] = 'Dimension must be a number'; + } else { + newErrors[index][field] = ''; + } + setErrors(newErrors); + }; + + const handleVectorChange = (index, field, value) => { + const newVectors = [...vectors]; + newVectors[index][field] = value; + setVectors(newVectors); + validate(index, field, value); + }; + + const handleVectorConfigChange = (index, field, value) => { + if (value) { + const newVectors = [...vectors]; + + field === 'multivector_config' + ? (newVectors[index][field] = { comparator: 'max_sim' }) + : (newVectors[index][field] = true); + field === 'multivector_config' + ? (newVectors[index].sparse_vectors = false) + : (newVectors[index].multivector_config = null); + + setVectors(newVectors); + } else { + const newVectors = [...vectors]; + newVectors[index][field] = null; + setVectors(newVectors); + } + }; + + useEffect(() => { + const getDistanceOptions = async () => { + const response = await fetch(import.meta.env.BASE_URL + './openapi.json'); + const openapi = await response.json(); + setDistancesOptions(openapi.components.schemas.Distance.enum); + }; + getDistanceOptions(); + }, []); + + return ( + + {!vectors[index].sparse_vectors && ( + <> + + Distance + + {errors[index].distance} + + handleVectorChange(index, 'dimension', e.target.value)} + error={!!errors[index].dimension} + helperText={errors[index].dimension} + sx={{ mr: 2 }} + /> + + )} + + {(length > 1 || vectors[index].sparse_vectors) && ( + handleVectorChange(index, 'name', e.target.value)} + error={!!errors[index].name} + helperText={errors[index].name} + sx={{ mr: 2 }} + /> + )} + { + handleVectorConfigChange(index, 'multivector_config', e.target.checked); + }} + checked={!!vectors[index].multivector_config} + /> + } + label="Mutivector" + labelPlacement="start" + /> + { + handleVectorConfigChange(index, 'sparse_vectors', e.target.checked); + }} + checked={!!vectors[index].sparse_vectors} + /> + } + label="Sparse Vectors" + labelPlacement="start" + /> + + {length > 1 && ( + + handleRemoveVector(index)} color="error"> + + + + )} + {length - 1 === index && ( + + + + + + )} + + ); +}; + +VectorRow.propTypes = { + vectors: PropTypes.array.isRequired, + errors: PropTypes.array.isRequired, + index: PropTypes.number.isRequired, + length: PropTypes.number.isRequired, + setVectors: PropTypes.func.isRequired, + setErrors: PropTypes.func.isRequired, +}; + +const VectorConfig = ({ handleNext, handleBack, vectors, setVectors }) => { + const [errors, setErrors] = useState([{ dimension: '', distance: '', name: '' }]); + + const validateAll = () => { + const newErrors = vectors.map((vector) => { + const error = {}; + if (!vector.sparse_vectors) { + if (!vector.dimension) { + error.dimension = 'Dimension is required'; + } else if (isNaN(vector.dimension)) { + error.dimension = 'Dimension must be a number'; + } + if (!vector.distance) { + error.distance = 'Distance is required'; + } + if (vectors.length > 1 && !vector.name) { + error.name = 'Vector name is required'; + } + } else { + if (!vector.name) { + error.name = 'Vector name is required'; + } + } + return error; + }); + setErrors(newErrors); + return newErrors.every((error) => !Object.values(error).length); + }; + + const handleContinue = () => { + if (validateAll()) { + handleNext(); + } + }; + + return ( + + + {vectors.map((_, index) => ( + + ))} + + + + + + + ); +}; + +VectorConfig.propTypes = { + handleNext: PropTypes.func.isRequired, + handleBack: PropTypes.func.isRequired, + vectors: PropTypes.array.isRequired, + setVectors: PropTypes.func.isRequired, +}; + +export default VectorConfig; diff --git a/src/components/CreateCollection/index.jsx b/src/components/CreateCollection/index.jsx new file mode 100644 index 00000000..ed4446ef --- /dev/null +++ b/src/components/CreateCollection/index.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import { Add } from '@mui/icons-material'; +import CreateCollectionForm from './CreateCollectionForm'; +import { Alert } from '@mui/material'; +import { Link } from 'react-router-dom'; + +const CreateCollection = ({ onComplete, collections, sx }) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const [open, setOpen] = React.useState(false); + const handleCreateClick = () => { + setOpen(true); + }; + + const handleCreated = () => { + setTimeout(() => { + setOpen(false); + }, 1000); + }; + + return ( + + + + + setOpen(false)} + aria-labelledby="Create collection dialog" + aria-describedby="Create collection dialog" + > + Create Collection + + + Create a new collection with a name and vector configuration. For advanced options, use the{' '} + console. + + + + + + ); +}; + +// props validation +CreateCollection.propTypes = { + onComplete: PropTypes.func, + collections: PropTypes.array, + sx: PropTypes.object, +}; + +export default CreateCollection; diff --git a/src/pages/Collections.jsx b/src/pages/Collections.jsx index cb6f42b3..efe96764 100644 --- a/src/pages/Collections.jsx +++ b/src/pages/Collections.jsx @@ -8,6 +8,7 @@ import { SnapshotsUpload } from '../components/Snapshots/SnapshotsUpload'; import { getErrorMessage } from '../lib/get-error-message'; import CollectionsList from '../components/Collections/CollectionsList'; import { debounce } from 'lodash'; +import CreateCollection from '../components/CreateCollection/Index'; function Collections() { const [rawCollections, setRawCollections] = useState(null); @@ -114,8 +115,13 @@ function Collections() { Collections - + getCollectionsCall(currentPage)} key={'snapshots'} /> + getCollectionsCall(currentPage)} + key={'create-collection'} + collections={collections} + />