diff --git a/package.json b/package.json index d29fdebd6..abec04d59 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,7 @@ "test": "rimraf coverage && jest", "test:debug": "jest --runInBand", "test:e2e": "jest -c ./jest.config.playwright.js --detectOpenHandles", + "test:file": "jest --coverage=false", "watch": "npm run start", "wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/", "build:ase": "gulp build:ase", @@ -244,4 +245,4 @@ "printWidth": 120, "endOfLine": "auto" } -} \ No newline at end of file +} diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx index 207642d84..80da43414 100644 --- a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx +++ b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx @@ -1,4 +1,4 @@ -import { Icon, Label, Stack } from "@fluentui/react"; +import { DirectionalHint, Icon, Label, Stack, TooltipHost } from "@fluentui/react"; import * as React from "react"; import { NormalizedEventKey } from "../../../Common/Constants"; import { accordionStackTokens } from "../Settings/SettingsRenderUtils"; @@ -8,6 +8,7 @@ export interface CollapsibleSectionProps { isExpandedByDefault: boolean; onExpand?: () => void; children: JSX.Element; + tooltipContent?: string | JSX.Element | JSX.Element[]; } export interface CollapsibleSectionState { @@ -55,6 +56,19 @@ export class CollapsibleSectionComponent extends React.Component + {this.props.tooltipContent && ( + + + + )} {this.state.isExpanded && this.props.children} diff --git a/src/Explorer/Panes/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel.tsx index c4fa38ec4..f208720cf 100644 --- a/src/Explorer/Panes/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel.tsx @@ -21,7 +21,7 @@ import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils"; import { configContext, Platform } from "ConfigContext"; import * as DataModels from "Contracts/DataModels"; import { SubscriptionType } from "Contracts/SubscriptionType"; -import { EditorReact } from "Explorer/Controls/Editor/EditorReact"; +import { AddVectorEmbeddingPolicyForm } from "Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm"; import { useSidePanel } from "hooks/useSidePanel"; import { useTeachingBubble } from "hooks/useTeachingBubble"; import React from "react"; @@ -82,22 +82,6 @@ export const AllPropertiesIndexed: DataModels.IndexingPolicy = { excludedPaths: [], }; -const DefaultDatabaseVectorIndex: DataModels.IndexingPolicy = { - indexingMode: "consistent", - automatic: true, - includedPaths: [ - { - path: "/*", - }, - ], - excludedPaths: [ - { - path: '/"_etag"/?', - }, - ], - vectorIndexes: [], -}; - export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = { vectorEmbeddings: [], }; @@ -122,8 +106,9 @@ export interface AddCollectionPanelState { isExecuting: boolean; isThroughputCapExceeded: boolean; teachingBubbleStep: number; - vectorIndexingPolicy: string; - vectorEmbeddingPolicy: string; + vectorIndexingPolicy: DataModels.VectorIndex[]; + vectorEmbeddingPolicy: DataModels.VectorEmbedding[]; + vectorPolicyValidated: boolean; } export class AddCollectionPanel extends React.Component { @@ -159,8 +144,9 @@ export class AddCollectionPanel extends React.Component - { - this.scrollToSection("collapsibleVectorPolicySectionContent"); - }} - > - - - Learn more - - this.setVectorIndexingPolicy(newIndexingPolicy)} - /> - - { this.scrollToSection("collapsibleVectorPolicySectionContent"); }} + tooltipContent={this.getContainerVectorPolicyTooltipContent()} > - - Learn more - - - this.setVectorEmbeddingPolicy(newVectorEmbeddingPolicy) - } - /> + + { + this.setState({ vectorEmbeddingPolicy, vectorIndexingPolicy, vectorPolicyValidated }); + }} + /> + @@ -1159,13 +1113,13 @@ export class AddCollectionPanel extends React.Component + Describe any properties in your data that contain vectors, so that they can be made available for similarity + queries.{" "} + + Learn more + + + ); + } + private shouldShowCollectionThroughputInput(): boolean { if (isServerlessAccount()) { return false; @@ -1370,20 +1336,9 @@ export class AddCollectionPanel extends React.Component { + let component: RenderResult; + + beforeEach(() => { + component = render( + , + ); + }); + + test("renders correctly", () => { + expect(screen.getByText("Vector embedding 1")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("/vector1")).toBeInTheDocument(); + }); + + test("calls onVectorEmbeddingChange on adding a new vector embedding", () => { + fireEvent.click(screen.getByText("Add vector embedding")); + expect(mockOnVectorEmbeddingChange).toHaveBeenCalled(); + }); + + test("calls onDelete when delete button is clicked", async () => { + const deleteButton = component.container.querySelector("#delete-vector-policy-1"); + fireEvent.click(deleteButton); + expect(mockOnVectorEmbeddingChange).toHaveBeenCalled(); + expect(screen.queryByText("Vector embedding 1")).toBeNull(); + }); + + test("calls onVectorEmbeddingPathChange on input change", () => { + fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/newPath" } }); + expect(mockOnVectorEmbeddingChange).toHaveBeenCalled(); + }); + + test("validates input correctly", async () => { + fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "" } }); + await waitFor(() => expect(screen.getByText("Vector embedding path should not be empty")).toBeInTheDocument(), { + timeout: 1500, + }); + await waitFor( + () => + expect( + screen.getByText("Vector embedding dimension must be greater than 0 and less than or equal 4096"), + ).toBeInTheDocument(), + { + timeout: 1500, + }, + ); + fireEvent.change(component.container.querySelector("#vector-policy-dimension-1"), { target: { value: "4096" } }); + fireEvent.change(screen.getByPlaceholderText("/vector1"), { target: { value: "/vector1" } }); + await waitFor(() => expect(screen.queryByText("Vector embedding path should not be empty")).toBeNull(), { + timeout: 1500, + }); + await waitFor( + () => expect(screen.queryByText("Maximum allowed dimension for flat index is 505")).toBeInTheDocument(), + { + timeout: 1500, + }, + ); + }); + + test("duplicate vector path is not allowed", async () => { + fireEvent.click(screen.getByText("Add vector embedding")); + fireEvent.change(component.container.querySelector("#vector-policy-path-2"), { target: { value: "/vector1" } }); + await waitFor(() => expect(screen.queryByText("Vector embedding path is already defined")).toBeNull(), { + timeout: 1500, + }); + }); +}); diff --git a/src/Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm.tsx b/src/Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm.tsx new file mode 100644 index 000000000..a5012cad5 --- /dev/null +++ b/src/Explorer/Panes/VectorSearchPanel/AddVectorEmbeddingPolicyForm.tsx @@ -0,0 +1,300 @@ +import { + DefaultButton, + Dropdown, + IDropdownOption, + IStyleFunctionOrObject, + ITextFieldStyleProps, + ITextFieldStyles, + IconButton, + Label, + Stack, + TextField, +} from "@fluentui/react"; +import { VectorEmbedding, VectorIndex } from "Contracts/DataModels"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { + getDataTypeOptions, + getDistanceFunctionOptions, + getIndexTypeOptions, +} from "Explorer/Panes/VectorSearchPanel/VectorSearchUtils"; +import React, { FunctionComponent, useState } from "react"; + +export interface IAddVectorEmbeddingPolicyFormProps { + vectorEmbedding: VectorEmbedding[]; + vectorIndex: VectorIndex[]; + onVectorEmbeddingChange: ( + vectorEmbeddings: VectorEmbedding[], + vectorIndexingPolicies: VectorIndex[], + validationPassed: boolean, + ) => void; +} + +export interface VectorEmbeddingPolicyData { + path: string; + dataType: VectorEmbedding["dataType"]; + distanceFunction: VectorEmbedding["distanceFunction"]; + dimensions: number; + indexType: VectorIndex["type"] | "none"; + pathError: string; + dimensionsError: string; +} + +type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType"; + +const textFieldStyles: IStyleFunctionOrObject = { + fieldGroup: { + height: 27, + }, + field: { + fontSize: 12, + padding: "0 8px", + }, +}; + +const dropdownStyles = { + title: { + height: 27, + lineHeight: "24px", + fontSize: 12, + }, + dropdown: { + height: 27, + lineHeight: "24px", + }, + dropdownItem: { + fontSize: 12, + }, +}; + +export const AddVectorEmbeddingPolicyForm: FunctionComponent = ({ + vectorEmbedding, + vectorIndex, + onVectorEmbeddingChange, +}): JSX.Element => { + const onVectorEmbeddingPathError = (path: string, index?: number): string => { + let error = ""; + if (!path) { + error = "Vector embedding path should not be empty"; + } + if ( + index >= 0 && + vectorEmbeddingPolicyData?.find( + (vectorEmbedding: VectorEmbeddingPolicyData, dataIndex: number) => + dataIndex !== index && vectorEmbedding.path === path, + ) + ) { + error = "Vector embedding path is already defined"; + } + return error; + }; + + const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => { + let error = ""; + if (dimension <= 0 || dimension > 4096) { + error = "Vector embedding dimension must be greater than 0 and less than or equal 4096"; + } + if (indexType === "flat" && dimension > 505) { + error = "Maximum allowed dimension for flat index is 505"; + } + return error; + }; + + const initializeData = (vectorEmbedding: VectorEmbedding[], vectorIndex: VectorIndex[]) => { + const mergedData: VectorEmbeddingPolicyData[] = []; + vectorEmbedding.forEach((embedding) => { + const matchingIndex = vectorIndex.find((index) => index.path === embedding.path); + mergedData.push({ + ...embedding, + indexType: matchingIndex?.type || "none", + pathError: onVectorEmbeddingPathError(embedding.path), + dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"), + }); + }); + return mergedData; + }; + + const [vectorEmbeddingPolicyData, setVectorEmbeddingPolicyData] = useState( + initializeData(vectorEmbedding, vectorIndex), + ); + + React.useEffect(() => { + propagateData(); + }, [vectorEmbeddingPolicyData]); + + const propagateData = () => { + const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({ + dataType: policy.dataType, + dimensions: policy.dimensions, + distanceFunction: policy.distanceFunction, + path: policy.path, + })); + const vectorIndexingPolicies: VectorIndex[] = vectorEmbeddingPolicyData + .filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none") + .map( + (policy) => + ({ + path: policy.path, + type: policy.indexType, + }) as VectorIndex, + ); + const validationPassed = vectorEmbeddingPolicyData.every( + (policy: VectorEmbeddingPolicyData) => policy.pathError === "" && policy.dimensionsError === "", + ); + onVectorEmbeddingChange(vectorEmbeddings, vectorIndexingPolicies, validationPassed); + }; + + const onVectorEmbeddingPathChange = (index: number, event: React.ChangeEvent) => { + const value = event.target.value.trim(); + const vectorEmbeddings = [...vectorEmbeddingPolicyData]; + if (!vectorEmbeddings[index]?.path && !value.startsWith("/")) { + vectorEmbeddings[index].path = "/" + value; + } else { + vectorEmbeddings[index].path = value; + } + const error = onVectorEmbeddingPathError(value, index); + vectorEmbeddings[index].pathError = error; + setVectorEmbeddingPolicyData(vectorEmbeddings); + }; + + const onVectorEmbeddingDimensionsChange = (index: number, event: React.ChangeEvent) => { + const value = parseInt(event.target.value.trim()) || 0; + const vectorEmbeddings = [...vectorEmbeddingPolicyData]; + const vectorEmbedding = vectorEmbeddings[index]; + vectorEmbeddings[index].dimensions = value; + const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType); + vectorEmbeddings[index].dimensionsError = error; + setVectorEmbeddingPolicyData(vectorEmbeddings); + }; + + const onVectorEmbeddingIndexTypeChange = (index: number, option: IDropdownOption): void => { + const vectorEmbeddings = [...vectorEmbeddingPolicyData]; + const vectorEmbedding = vectorEmbeddings[index]; + vectorEmbeddings[index].indexType = option.key as never; + const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType); + vectorEmbeddings[index].dimensionsError = error; + setVectorEmbeddingPolicyData(vectorEmbeddings); + }; + + const onVectorEmbeddingPolicyChange = ( + index: number, + option: IDropdownOption, + property: VectorEmbeddingPolicyProperty, + ): void => { + const vectorEmbeddings = [...vectorEmbeddingPolicyData]; + vectorEmbeddings[index][property] = option.key as never; + setVectorEmbeddingPolicyData(vectorEmbeddings); + }; + + const onAdd = () => { + setVectorEmbeddingPolicyData([ + ...vectorEmbeddingPolicyData, + { + path: "", + dataType: "float32", + distanceFunction: "euclidean", + dimensions: 0, + indexType: "none", + pathError: onVectorEmbeddingPathError(""), + dimensionsError: onVectorEmbeddingDimensionError(0, "none"), + }, + ]); + }; + + const onDelete = (index: number) => { + const vectorEmbeddings = vectorEmbeddingPolicyData.filter((_uniqueKey, j) => index !== j); + setVectorEmbeddingPolicyData(vectorEmbeddings); + }; + + return ( + + {vectorEmbeddingPolicyData.length > 0 && + vectorEmbeddingPolicyData.map((vectorEmbeddingPolicy: VectorEmbeddingPolicyData, index: number) => ( + + + + + + ) => onVectorEmbeddingPathChange(index, event)} + value={vectorEmbeddingPolicy.path || ""} + errorMessage={vectorEmbeddingPolicy.pathError} + /> + + + + , option: IDropdownOption) => + onVectorEmbeddingPolicyChange(index, option, "dataType") + } + > + + + + , option: IDropdownOption) => + onVectorEmbeddingPolicyChange(index, option, "distanceFunction") + } + > + + + + ) => + onVectorEmbeddingDimensionsChange(index, event) + } + errorMessage={vectorEmbeddingPolicy.dimensionsError} + /> + + + + , option: IDropdownOption) => + onVectorEmbeddingIndexTypeChange(index, option) + } + > + + + onDelete(index)} + /> + + + ))} + + Add vector embedding + + + ); +}; diff --git a/src/Explorer/Panes/VectorSearchPanel/VectorSearchUtils.ts b/src/Explorer/Panes/VectorSearchPanel/VectorSearchUtils.ts new file mode 100644 index 000000000..0f5547aa7 --- /dev/null +++ b/src/Explorer/Panes/VectorSearchPanel/VectorSearchUtils.ts @@ -0,0 +1,16 @@ +import { IDropdownOption } from "@fluentui/react"; + +const dataTypes = ["float32", "uint8", "int8"]; +const distanceFunctions = ["euclidean", "cosine", "dotproduct"]; +const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"]; + +export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(dataTypes); +export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions); +export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes); + +function createDropdownOptionsFromLiterals(literals: T[]): IDropdownOption[] { + return literals.map((value) => ({ + key: value, + text: value, + })); +}