| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { literal, safeSql } from '@supabase/pg-meta'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { Plus, Trash2 } from 'lucide-react'
- import { parseAsBoolean, useQueryState } from 'nuqs'
- import { useEffect } from 'react'
- import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Form,
- FormControl,
- FormField,
- Input,
- RadioGroupStacked,
- RadioGroupStackedItem,
- Separator,
- Sheet,
- SheetContent,
- SheetFooter,
- SheetHeader,
- SheetSection,
- SheetTitle,
- SheetTrigger,
- } from 'ui'
- import { Admonition } from 'ui-patterns'
- import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
- import z from 'zod'
- import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils'
- import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance'
- import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
- import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { DOCS_URL } from '@/lib/constants'
- const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
- const BUCKET_INDEX_NAME_REGEX = /^[a-z0-9](?:[a-z0-9.-]{1,61})?[a-z0-9]$/
- const DISTANCE_METRICS = [
- {
- value: 'cosine',
- label: 'Cosine',
- description: 'Measures similarity between two vectors, based on directions, not magnitude.',
- },
- {
- value: 'euclidean',
- label: 'Euclidean',
- description:
- 'Measures straight-line distance between two vectors, using both directions and magnitudes.',
- },
- ] as const
- const FormSchema = z.object({
- name: z
- .string()
- .trim()
- .min(3, 'Name must be at least 3 characters')
- .max(63, 'Name must be below 63 characters')
- .refine(
- (value) => value !== 'public',
- '"public" is a reserved name. Please choose another name'
- )
- .superRefine((name, ctx) => {
- if (!BUCKET_INDEX_NAME_REGEX.test(name)) {
- const [match] = name.match(inverseValidBucketNameRegex) ?? []
- ctx.addIssue({
- path: [],
- code: z.ZodIssueCode.custom,
- message: !!match
- ? `Bucket name cannot contain the "${match}" character`
- : 'Bucket name contains an invalid special character',
- })
- }
- }),
- dimension: z
- .number()
- .int('Dimension must be an integer')
- .min(1, 'Dimension must be at least 1')
- .max(4096, 'Dimension must be at most 4096'),
- distanceMetric: z.enum(['cosine', 'euclidean'], {
- required_error: 'Please select a distance metric',
- }),
- metadataKeys: z
- .array(
- z.object({
- value: z.string().min(1, 'The metadata key needs to be at least 1 character long'),
- })
- )
- .default([]),
- })
- const formId = 'create-vector-table-form'
- export type CreateVectorTableForm = z.infer<typeof FormSchema>
- interface CreateVectorTableSheetProps {
- bucketName?: string
- }
- export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => {
- const { data: project } = useSelectedProjectQuery()
- const [visible, setVisible] = useQueryState(
- 'newTable',
- parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
- )
- const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
- const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId: bucketName })
- const schema = (wrapperInstance?.server_options ?? [])
- .find((x) => x.startsWith('briven_target_schema'))
- ?.split('briven_target_schema=')[1]
- // [Joshen] Can remove this once this restriction is removed
- const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1'
- const defaultValues = {
- name: '',
- dimension: undefined,
- distanceMetric: 'cosine' as 'cosine' | 'euclidean',
- metadataKeys: [],
- }
- const form = useForm<CreateVectorTableForm>({
- resolver: zodResolver(FormSchema as any),
- defaultValues,
- values: defaultValues as any,
- })
- const { fields, append, remove } = useFieldArray({
- control: form.control,
- name: 'metadataKeys',
- })
- const { mutateAsync: createVectorBucketTable, isPending: isCreatingVectorBucketTable } =
- useVectorBucketIndexCreateMutation()
- const { mutateAsync: importForeignSchema, isPending: isImportingForeignSchema } =
- useFDWImportForeignSchemaMutation({
- onError: () => {},
- })
- const isCreating = isCreatingVectorBucketTable || isImportingForeignSchema
- const onSubmit: SubmitHandler<CreateVectorTableForm> = async (values) => {
- if (!project?.ref) return console.error('Project ref is required')
- if (!bucketName) return console.error('Bucket name is required')
- try {
- await createVectorBucketTable({
- projectRef: project.ref,
- bucketName: bucketName,
- indexName: values.name,
- dataType: 'float32',
- dimension: values.dimension!,
- distanceMetric: values.distanceMetric,
- metadataKeys: values.metadataKeys.map((key) => key.value),
- })
- } catch (error: any) {
- toast.error(`Failed to create vector table: ${error.message}`)
- return
- }
- try {
- if (wrapperInstance && !!schema) {
- await importForeignSchema({
- projectRef: project.ref,
- connectionString: project?.connectionString,
- serverName: wrapperInstance.server_name,
- sourceSchema: schema,
- targetSchema: schema,
- schemaOptions: [safeSql`bucket_name ${literal(bucketName)}`],
- })
- }
- } catch (error: any) {
- toast.warning(`Failed to connect vector table to the database: ${error.message}`)
- }
- toast.success(`Successfully created vector table “${values.name}”`)
- form.reset()
- setVisible(false)
- }
- useEffect(() => {
- if (!visible) {
- form.reset()
- }
- }, [visible])
- return (
- <Sheet open={visible} onOpenChange={setVisible}>
- <SheetTrigger asChild>
- <ButtonTooltip
- block
- size="tiny"
- type="primary"
- className="w-fit"
- icon={<Plus size={14} />}
- disabled={!canCreateBuckets}
- onClick={() => setVisible(true)}
- tooltip={{
- content: {
- side: 'bottom',
- text: !canCreateBuckets
- ? 'You need additional permissions to create buckets'
- : undefined,
- },
- }}
- >
- Create table
- </ButtonTooltip>
- </SheetTrigger>
- <SheetContent size="default" className="flex flex-col gap-0 p-0">
- <SheetHeader>
- <SheetTitle>Create vector table</SheetTitle>
- </SheetHeader>
- {showIndexCreationNotice && (
- <Admonition
- type="warning"
- className="border-x-0 border-t-0 rounded-none"
- title="Vector table creation is currently only supported for projects in us-east-1"
- description={`This is only applicable to projects on local/staging (Project is currently in ${project.region})`}
- />
- )}
- <Form {...form}>
- <form
- id={formId}
- onSubmit={form.handleSubmit(onSubmit)}
- className="overflow-auto grow px-0"
- >
- <SheetSection className="flex flex-col gap-y-4">
- <FormField
- key="name"
- name="name"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- name="name"
- label="Name"
- description="Must be between 3–63 characters. Valid characters are a-z, 0-9, hyphens, and periods."
- layout="horizontal"
- >
- <FormControl>
- <Input
- id="name"
- data-1p-ignore
- data-lpignore="true"
- data-form-type="other"
- data-bwignore
- {...field}
- placeholder="Enter a table name"
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- <Separator />
- <SheetSection className="flex flex-col gap-y-4">
- <FormField
- key="dimension"
- name="dimension"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- name="dimension"
- label="Dimension"
- description="Must be an integer between 1–4096."
- layout="horizontal"
- >
- <FormControl>
- <Input
- id="dimension"
- type="number"
- placeholder="Enter a numeric value"
- {...field}
- onChange={(e) => {
- const value = e.target.value
- field.onChange(value === '' ? undefined : Number(value))
- }}
- value={field.value ?? ''}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- <FormField
- key="distanceMetric"
- name="distanceMetric"
- control={form.control}
- render={({ field }) => (
- <FormItemLayout
- name="distanceMetric"
- label="Distance metric"
- layout="horizontal"
- className="gap-1"
- >
- <FormControl>
- <RadioGroupStacked
- id="distance_metric"
- name="distance_metric"
- value={field.value}
- disabled={field.disabled}
- onValueChange={field.onChange}
- >
- {DISTANCE_METRICS.map((metric) => (
- <RadioGroupStackedItem
- key={metric.value}
- id={metric.value}
- value={metric.value}
- label={metric.label}
- description={metric.description}
- showIndicator={true}
- ></RadioGroupStackedItem>
- ))}
- </RadioGroupStacked>
- </FormControl>
- </FormItemLayout>
- )}
- />
- </SheetSection>
- <Separator />
- <SheetSection className="space-y-4">
- <div className="flex items-center justify-between">
- <label className="text-sm text-foreground">Metadata keys</label>
- <DocsButton
- href={`${DOCS_URL}/guides/storage/vector/storing-vectors#metadata-best-practices`}
- />
- </div>
- <div className="space-y-2">
- {fields.map((field, index) => (
- <div key={field.id} className="flex items-start gap-2">
- <div className="flex-1">
- <FormField
- control={form.control}
- name={`metadataKeys.${index}.value`}
- render={({ field }) => (
- <FormItemLayout
- name={`metadataKeys.${index}.value`}
- description={
- index === fields.length - 1
- ? 'Must be between 1–63 characters and unique within this table.'
- : undefined
- }
- layout="vertical"
- >
- <FormControl>
- <Input
- {...field}
- value={field.value}
- size="small"
- className="w-full"
- placeholder="Enter a metadata key name"
- data-1p-ignore
- data-lpignore="true"
- data-form-type="other"
- data-bwignore
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </div>
- <Button
- type="text"
- className="w-[34px] h-[34px]" // Match the height of the input
- size="tiny"
- icon={<Trash2 size={12} />}
- onClick={() => remove(index)}
- />
- </div>
- ))}
- </div>
- <div className="flex items-center justify-center rounded-sm border border-strong border-dashed py-3">
- <Button type="default" size="tiny" onClick={() => append({ value: '' })}>
- Add metadata key
- </Button>
- </div>
- </SheetSection>
- </form>
- </Form>
- <SheetFooter>
- <Button type="default" disabled={isCreating} onClick={() => setVisible(false)}>
- Cancel
- </Button>
- <Button
- form={formId}
- htmlType="submit"
- loading={isCreating}
- disabled={isCreating || !bucketName}
- >
- Create
- </Button>
- </SheetFooter>
- </SheetContent>
- </Sheet>
- )
- }
|