mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-06-30 02:28:44 +01:00
Add the vector policy source embedding controls
This commit is contained in:
@@ -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<string>();
|
||||
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<IVectorEmbeddingSourceComponentProps> = ({
|
||||
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<string>(initialEmbeddingSource?.sourcePaths?.join(", ") || "");
|
||||
const [deploymentName, setDeploymentName] = useState<string>(initialEmbeddingSource?.deploymentName || "");
|
||||
const [modelName, setModelName] = useState<string>(initialEmbeddingSource?.modelName || "");
|
||||
const [endpoint, setEndpoint] = useState<string>(initialEmbeddingSource?.endpoint || "");
|
||||
const [authType, setAuthType] = useState<VectorEmbeddingSource["authType"]>(
|
||||
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<VectorEmbeddingSource | undefined>(() => {
|
||||
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 (
|
||||
<Stack style={{ marginTop: 8 }}>
|
||||
<CollapsibleSectionComponent
|
||||
disabled={disabled}
|
||||
title={`${t(Keys.controls.vectorEmbeddingPolicies.embeddingSourceSection)} (${t(Keys.common.preview)})`}
|
||||
tooltipContent={t(Keys.controls.vectorEmbeddingPolicies.embeddingSourceTooltip)}
|
||||
isExpandedByDefault={!!initialEmbeddingSource}
|
||||
dataTest={`VectorEmbeddingSource/Section/${suffix}`}
|
||||
>
|
||||
<Stack style={{ marginLeft: "10px", marginTop: 4 }} tokens={{ childrenGap: 4 }}>
|
||||
<Stack>
|
||||
<Label disabled={disabled} styles={labelStyles}>
|
||||
{t(Keys.controls.vectorEmbeddingPolicies.sourcePaths)}
|
||||
</Label>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
id={`vector-policy-embeddingSource-sourcePaths-${suffix}`}
|
||||
data-test={`VectorEmbeddingSource/SourcePaths/${suffix}`}
|
||||
placeholder={t(Keys.controls.vectorEmbeddingPolicies.sourcePathsPlaceholder)}
|
||||
styles={textFieldStyles}
|
||||
value={sourcePathsRaw}
|
||||
onChange={(_event, newValue) => setSourcePathsRaw(newValue || "")}
|
||||
errorMessage={sourcePathsError}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Label disabled={disabled} styles={labelStyles}>
|
||||
{t(Keys.controls.vectorEmbeddingPolicies.deploymentName)}
|
||||
</Label>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
id={`vector-policy-embeddingSource-deploymentName-${suffix}`}
|
||||
data-test={`VectorEmbeddingSource/DeploymentName/${suffix}`}
|
||||
styles={textFieldStyles}
|
||||
value={deploymentName}
|
||||
onChange={(_event, newValue) => setDeploymentName(newValue || "")}
|
||||
errorMessage={deploymentNameError}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Label disabled={disabled} styles={labelStyles}>
|
||||
{t(Keys.controls.vectorEmbeddingPolicies.modelName)}
|
||||
</Label>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
id={`vector-policy-embeddingSource-modelName-${suffix}`}
|
||||
data-test={`VectorEmbeddingSource/ModelName/${suffix}`}
|
||||
styles={textFieldStyles}
|
||||
value={modelName}
|
||||
onChange={(_event, newValue) => setModelName(newValue || "")}
|
||||
errorMessage={modelNameError}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Label disabled={disabled} styles={labelStyles}>
|
||||
{t(Keys.controls.vectorEmbeddingPolicies.endpoint)}
|
||||
</Label>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
id={`vector-policy-embeddingSource-endpoint-${suffix}`}
|
||||
data-test={`VectorEmbeddingSource/Endpoint/${suffix}`}
|
||||
placeholder={t(Keys.controls.vectorEmbeddingPolicies.endpointPlaceholder)}
|
||||
styles={textFieldStyles}
|
||||
value={endpoint}
|
||||
onChange={(_event, newValue) => setEndpoint(newValue || "")}
|
||||
errorMessage={endpointError}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Label disabled={disabled} styles={labelStyles}>
|
||||
{t(Keys.controls.vectorEmbeddingPolicies.authType)}
|
||||
</Label>
|
||||
<Dropdown
|
||||
disabled={disabled}
|
||||
id={`vector-policy-embeddingSource-authType-${suffix}`}
|
||||
data-test={`VectorEmbeddingSource/AuthType/${suffix}`}
|
||||
styles={dropdownStyles}
|
||||
options={getAuthTypeOptions()}
|
||||
selectedKey={authType}
|
||||
onChange={(_event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption) => {
|
||||
if (option) {
|
||||
setAuthType(option.key as VectorEmbeddingSource["authType"]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CollapsibleSectionComponent>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IStyleFunctionOrObject, ITextFieldStyleProps, ITextFieldStyles } from "@fluentui/react";
|
||||
|
||||
export const labelStyles = {
|
||||
root: {
|
||||
fontSize: 12,
|
||||
color: "var(--colorNeutralForeground1)",
|
||||
},
|
||||
};
|
||||
|
||||
export const textFieldStyles: IStyleFunctionOrObject<ITextFieldStyleProps, ITextFieldStyles> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user