diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingSourceComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingSourceComponent.tsx new file mode 100644 index 000000000..5a9e44fcf --- /dev/null +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingSourceComponent.tsx @@ -0,0 +1,222 @@ +import { Dropdown, IDropdownOption, Label, Stack, TextField } from "@fluentui/react"; +import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { VectorEmbeddingSource } from "Contracts/DataModels"; +import { + getAuthTypeOptions, + isValidHttpsUrl, + parseSourcePaths, +} from "Explorer/Controls/VectorSearch/VectorSearchUtils"; +import { dropdownStyles, labelStyles, textFieldStyles } from "Explorer/Controls/VectorSearch/vectorSearchStyles"; +import { Keys, t } from "Localization"; +import type { ResourceKey } from "Localization/t"; +import React, { FunctionComponent, useState } from "react"; + +export interface IVectorEmbeddingSourceComponentProps { + index: number; + disabled: boolean; + initialEmbeddingSource?: VectorEmbeddingSource; + discardChanges?: boolean; + onChange: (embeddingSource: VectorEmbeddingSource | undefined, isValid: boolean) => void; +} + +const defaultAuthType: VectorEmbeddingSource["authType"] = "Entra"; + +const validateSourcePaths = (raw: string): string => { + const parsed = parseSourcePaths(raw); + if (parsed.length === 0) { + return t(Keys.controls.vectorEmbeddingPolicies.sourcePathsRequiredError); + } + const seen = new Set(); + for (const p of parsed) { + if (seen.has(p)) { + return t(Keys.controls.vectorEmbeddingPolicies.sourcePathDuplicateError); + } + seen.add(p); + } + return ""; +}; + +const validateRequired = (value: string | undefined, errorKey: ResourceKey): string => { + if (!value || value.trim().length === 0) { + return t(errorKey); + } + return ""; +}; + +const validateEndpoint = (value: string | undefined): string => { + if (!value || value.trim().length === 0) { + return t(Keys.controls.vectorEmbeddingPolicies.endpointRequiredError); + } + if (!isValidHttpsUrl(value.trim())) { + return t(Keys.controls.vectorEmbeddingPolicies.endpointInvalidError); + } + return ""; +}; + +export const VectorEmbeddingSourceComponent: FunctionComponent = ({ + index, + disabled, + initialEmbeddingSource, + discardChanges, + onChange, +}): JSX.Element => { + // The integrated-embedding capability gate lives at the parent's call site + // (VectorEmbeddingPoliciesComponent). Don't add a gate here — gating before the + // hooks below would violate the Rules of Hooks. + const suffix = index + 1; + + const [sourcePathsRaw, setSourcePathsRaw] = useState(initialEmbeddingSource?.sourcePaths?.join(", ") || ""); + const [deploymentName, setDeploymentName] = useState(initialEmbeddingSource?.deploymentName || ""); + const [modelName, setModelName] = useState(initialEmbeddingSource?.modelName || ""); + const [endpoint, setEndpoint] = useState(initialEmbeddingSource?.endpoint || ""); + const [authType, setAuthType] = useState( + initialEmbeddingSource?.authType || defaultAuthType, + ); + + const hasAnyValue = + sourcePathsRaw.trim().length > 0 || + deploymentName.trim().length > 0 || + modelName.trim().length > 0 || + endpoint.trim().length > 0; + + const sourcePathsError = hasAnyValue ? validateSourcePaths(sourcePathsRaw) : ""; + const deploymentNameError = hasAnyValue + ? validateRequired(deploymentName, Keys.controls.vectorEmbeddingPolicies.deploymentNameRequiredError) + : ""; + const modelNameError = hasAnyValue + ? validateRequired(modelName, Keys.controls.vectorEmbeddingPolicies.modelNameRequiredError) + : ""; + const endpointError = hasAnyValue ? validateEndpoint(endpoint) : ""; + + const isValid = !hasAnyValue || (!sourcePathsError && !deploymentNameError && !modelNameError && !endpointError); + + // Memoize the synthesized source so that equal field values yield the same object reference. + // Without this, every render would mint a new object literal, defeating the parent's + // reference-equality guard in onEmbeddingSourceChange and producing an infinite render loop + // once all five fields become valid. + const source = React.useMemo(() => { + if (!hasAnyValue || !isValid) { + return undefined; + } + return { + sourcePaths: parseSourcePaths(sourcePathsRaw), + deploymentName: deploymentName.trim(), + modelName: modelName.trim(), + endpoint: endpoint.trim(), + authType, + }; + }, [hasAnyValue, isValid, sourcePathsRaw, deploymentName, modelName, endpoint, authType]); + + React.useEffect(() => { + if (!discardChanges) { + return; + } + setSourcePathsRaw(initialEmbeddingSource?.sourcePaths?.join(", ") || ""); + setDeploymentName(initialEmbeddingSource?.deploymentName || ""); + setModelName(initialEmbeddingSource?.modelName || ""); + setEndpoint(initialEmbeddingSource?.endpoint || ""); + setAuthType(initialEmbeddingSource?.authType || defaultAuthType); + }, [discardChanges, initialEmbeddingSource]); + + React.useEffect(() => { + onChange(source, isValid); + // `onChange` is intentionally omitted from the dependency array. The parent recreates + // its inline arrow on every render, and including it here would re-fire this effect + // on every parent render — feeding back into the parent's setState and producing an + // infinite loop. The effect's behavior depends only on `source` / `isValid`, which are + // memoized from local state, so capturing a stale `onChange` reference is safe: the + // underlying state mutation (setVectorEmbeddingPolicyData) is identity-stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [source, isValid]); + + return ( + + + + + + setSourcePathsRaw(newValue || "")} + errorMessage={sourcePathsError} + /> + + + + setDeploymentName(newValue || "")} + errorMessage={deploymentNameError} + /> + + + + setModelName(newValue || "")} + errorMessage={modelNameError} + /> + + + + setEndpoint(newValue || "")} + errorMessage={endpointError} + /> + + + + , option?: IDropdownOption) => { + if (option) { + setAuthType(option.key as VectorEmbeddingSource["authType"]); + } + }} + /> + + + + + ); +}; diff --git a/src/Explorer/Controls/VectorSearch/vectorSearchStyles.ts b/src/Explorer/Controls/VectorSearch/vectorSearchStyles.ts new file mode 100644 index 000000000..262484b5c --- /dev/null +++ b/src/Explorer/Controls/VectorSearch/vectorSearchStyles.ts @@ -0,0 +1,35 @@ +import { IStyleFunctionOrObject, ITextFieldStyleProps, ITextFieldStyles } from "@fluentui/react"; + +export const labelStyles = { + root: { + fontSize: 12, + color: "var(--colorNeutralForeground1)", + }, +}; + +export const textFieldStyles: IStyleFunctionOrObject = { + fieldGroup: { + height: 27, + }, + field: { + fontSize: 12, + padding: "0 8px", + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + }, +}; + +export const dropdownStyles = { + title: { + height: 27, + lineHeight: "24px", + fontSize: 12, + }, + dropdown: { + height: 27, + lineHeight: "24px", + }, + dropdownItem: { + fontSize: 12, + }, +};