[Search][Playground] Add connectors to sum models (#180428)

- Add state for connector page to have one source of truth and have
ability to control edit flyout by URL
- Add connectorId route to translate it to state and able easily control
edit flyout by URL
- Use new link in playground

Having a link like
`insightsAndAlerting/triggersActionsConnectors/connectors/:connectorId`
will transformed to
`insightsAndAlerting/triggersActionsConnectors/connectors#?_a=(initialConnector:(actionType:OpenAI,actionTypeId:.gen-ai,compatibility:!.....)`.

@elastic/response-ops could you check is it appropriate changes? If yes
I will proceed with adding tests for it
This commit is contained in:
Yan Savitski 2024-04-12 12:50:31 +02:00 committed by GitHub
parent 518b751beb
commit 0893fe72e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 239 additions and 152 deletions

View file

@ -106,6 +106,9 @@ export const Chat = () => {
borderRight: euiTheme.border.thin,
paddingTop: euiTheme.size.l,
paddingBottom: euiTheme.size.l,
// don't allow the chat to shrink below 66.6% of the screen
flexBasis: 0,
minWidth: '66.6%',
}}
>
<EuiFlexGroup direction="column" className="eui-fullHeight">
@ -210,7 +213,7 @@ export const Chat = () => {
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiFlexItem grow={1} css={{ flexBasis: 0, minWidth: '33.3%' }}>
<ChatSidebar selectedIndicesCount={selectedIndicesCount} />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -35,16 +35,12 @@ export const SetUpConnectorPanelForStartChat: React.FC = () => {
return connectors && !isConnectorListLoading ? (
<>
{!!Object.keys(connectors).length && showCallout && (
{!!connectors.length && showCallout && (
<EuiCallOut
title={i18n.translate('xpack.searchPlayground.emptyPrompts.setUpConnector.settled', {
defaultMessage:
'{connectorsNames} {count, plural, one {connector} other {connectors}} added',
defaultMessage: '{connectorName} connector added',
values: {
connectorsNames: Object.values(connectors)
.map((connector) => connector.title)
.join(', '),
count: Object.values(connectors).length,
connectorName: connectors[0].title,
},
})}
iconType="check"

View file

@ -30,8 +30,20 @@ describe('SummarizationModel', () => {
it('renders correctly with models', () => {
const models = [
{ name: 'Model1', disabled: false, icon: MockIcon, connectorId: 'connector1' },
{ name: 'Model2', disabled: true, icon: MockIcon, connectorId: 'connector2' },
{
name: 'Model1',
disabled: false,
icon: MockIcon,
connectorId: 'connector1',
connectorName: 'nameconnector1',
},
{
name: 'Model2',
disabled: true,
icon: MockIcon,
connectorId: 'connector2',
connectorName: 'nameconnector2',
},
];
const { getByTestId } = render(
<SummarizationModel selectedModel={models[1]} models={models} onSelect={jest.fn()} />

View file

@ -8,14 +8,15 @@
import React, { useMemo } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiIconTip,
EuiLink,
EuiSuperSelect,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -30,31 +31,64 @@ interface SummarizationModelProps {
models: LLMModel[];
}
const getOptionValue = (model: LLMModel): string => model.connectorId + model.name;
export const SummarizationModel: React.FC<SummarizationModelProps> = ({
selectedModel,
models,
onSelect,
}) => {
const managementLink = useManagementLink();
const onChange = (modelName: string) => {
const model = models.find(({ name }) => name === modelName);
const managementLink = useManagementLink(selectedModel.connectorId);
const onChange = (modelValue: string) => {
const newSelectedModel = models.find((model) => getOptionValue(model) === modelValue);
if (model) {
onSelect(model);
if (newSelectedModel) {
onSelect(newSelectedModel);
}
};
const modelsOption: Array<EuiSuperSelectOption<string>> = useMemo(
() =>
models.map(({ name, disabled, icon, connectorId }) => ({
value: name,
disabled,
models.map((model) => ({
value: getOptionValue(model),
disabled: model.disabled,
inputDisplay: (
<EuiFlexGroup alignItems="center">
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type={icon} />
<EuiIcon type={model.icon} />
</EuiFlexItem>
<EuiFlexItem>{name}</EuiFlexItem>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="s"
css={{ overflow: 'hidden' }}
>
<EuiText size="s">{model.name}</EuiText>
{model.showConnectorName && model.connectorName && (
<EuiText
size="xs"
color="subdued"
css={{ overflow: 'hidden', textOverflow: 'ellipsis', textWrap: 'nowrap' }}
>
<span title={model.connectorName}>{model.connectorName}</span>
</EuiText>
)}
</EuiFlexGroup>
</EuiFlexGroup>
),
dropdownDisplay: (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type={model.icon} />
</EuiFlexItem>
<EuiFlexGroup gutterSize="xs" direction="column">
<EuiText size="s">{model.name}</EuiText>
{model.showConnectorName && model.connectorName && (
<EuiText size="xs" color="subdued">
{model.connectorName}
</EuiText>
)}
</EuiFlexGroup>
</EuiFlexGroup>
),
})),
@ -63,6 +97,7 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
return (
<EuiFormRow
css={{ '.euiFormLabel': { display: 'flex', alignItems: 'center' } }}
label={
<>
<FormattedMessage
@ -77,20 +112,34 @@ export const SummarizationModel: React.FC<SummarizationModelProps> = ({
</>
}
labelAppend={
<EuiText size="xs">
<EuiLink target="_blank" href={managementLink} data-test-subj="manageConnectorsLink">
<FormattedMessage
id="xpack.searchPlayground.sidebar.summarizationModel.manageConnectors"
defaultMessage="Manage GenAI connectors"
/>
</EuiLink>
</EuiText>
<EuiToolTip
delay="long"
content={i18n.translate(
'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorTooltip',
{
defaultMessage: 'Manage connector',
}
)}
>
<EuiButtonIcon
target="_blank"
href={managementLink}
data-test-subj="manageConnectorsLink"
iconType="wrench"
aria-label={i18n.translate(
'xpack.searchPlayground.sidebar.summarizationModel.manageConnectorLink',
{
defaultMessage: 'Manage connector',
}
)}
/>
</EuiToolTip>
}
>
<EuiSuperSelect
data-test-subj="summarizationModelSelect"
options={modelsOption}
valueOfSelected={selectedModel.name}
valueOfSelected={getOptionValue(selectedModel)}
onChange={onChange}
/>
</EuiFormRow>

View file

@ -14,9 +14,7 @@ jest.mock('./use_load_connectors', () => ({
useLoadConnectors: jest.fn(),
}));
const mockConnectors = {
[LLMs.openai]: { id: 'connectorId1', title: 'OpenAI Connector' },
};
const mockConnectors = [{ id: 'connectorId1', title: 'OpenAI Connector', type: LLMs.openai }];
const mockUseLoadConnectors = (data: any) => {
(useLoadConnectors as jest.Mock).mockReturnValue({ data });
};
@ -32,57 +30,32 @@ describe('useLLMsModels Hook', () => {
const { result } = renderHook(() => useLLMsModels());
expect(result.current).toEqual([
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'Azure OpenAI',
value: undefined,
},
{
connectorId: 'connectorId1',
disabled: false,
icon: expect.any(Function),
name: 'gpt-3.5-turbo',
id: 'connectorId1gpt-3.5-turbo ',
name: 'gpt-3.5-turbo ',
showConnectorName: false,
value: 'gpt-3.5-turbo',
},
{
connectorId: 'connectorId1',
disabled: false,
icon: expect.any(Function),
name: 'gpt-4',
id: 'connectorId1gpt-4 ',
name: 'gpt-4 ',
showConnectorName: false,
value: 'gpt-4',
},
]);
});
it('returns LLMModels as disabled when no connectors are available', () => {
mockUseLoadConnectors({});
it('returns emptyd when connectors not available', () => {
mockUseLoadConnectors([]);
const { result } = renderHook(() => useLLMsModels());
expect(result.current).toEqual([
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'Azure OpenAI',
value: undefined,
},
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'gpt-3.5-turbo',
value: 'gpt-3.5-turbo',
},
{
connectorId: undefined,
disabled: true,
icon: expect.any(Function),
name: 'gpt-4',
value: 'gpt-4',
},
]);
expect(result.current).toEqual([]);
});
});

View file

@ -7,48 +7,82 @@
import { i18n } from '@kbn/i18n';
import { OpenAILogo } from '@kbn/stack-connectors-plugin/public/common';
import { ComponentType } from 'react';
import { ComponentType, useMemo } from 'react';
import { LLMs } from '../../common/types';
import { LLMModel, SummarizationModelName } from '../types';
import { LLMModel } from '../types';
import { useLoadConnectors } from './use_load_connectors';
const llmModels: Array<{
llm: LLMs;
icon: ComponentType;
models: Array<{ label: string; value?: string }>;
}> = [
const mapLlmToModels: Record<
LLMs,
{
llm: LLMs.openai_azure,
icon: ComponentType;
getModels: (
connectorName: string,
includeName: boolean
) => Array<{ label: string; value?: string }>;
}
> = {
[LLMs.openai_azure]: {
icon: OpenAILogo,
models: [
getModels: (connectorName, includeName) => [
{
label: i18n.translate('xpack.searchPlayground.openAIAzureModel', {
defaultMessage: 'Azure OpenAI',
defaultMessage: 'Azure OpenAI {name}',
values: { name: includeName ? `(${connectorName})` : '' },
}),
},
],
},
{
llm: LLMs.openai,
[LLMs.openai]: {
icon: OpenAILogo,
models: Object.values(SummarizationModelName).map((model) => ({ label: model, value: model })),
getModels: (connectorName, includeName) =>
['gpt-3.5-turbo', 'gpt-4'].map((model) => ({
label: `${model} ${includeName ? `(${connectorName})` : ''}`,
value: model,
})),
},
];
};
export const useLLMsModels = (): LLMModel[] => {
const { data: connectors } = useLoadConnectors();
return llmModels.reduce<LLMModel[]>(
(result, { llm, icon, models }) => [
...result,
...models.map(({ label, value }) => ({
name: label,
value,
icon,
disabled: !connectors?.[llm],
connectorId: connectors?.[llm]?.id,
})),
],
[]
const mapConnectorTypeToCount = useMemo(
() =>
connectors?.reduce<Partial<Record<LLMs, number>>>(
(result, connector) => ({
...result,
[connector.type]: (result[connector.type] || 0) + 1,
}),
{}
),
[connectors]
);
return useMemo(
() =>
connectors?.reduce<LLMModel[]>((result, connector) => {
const llmParams = mapLlmToModels[connector.type];
if (!llmParams) {
return result;
}
const showConnectorName = Number(mapConnectorTypeToCount?.[connector.type]) > 1;
return [
...result,
...llmParams.getModels(connector.name, false).map(({ label, value }) => ({
id: connector?.id + label,
name: label,
value,
connectorName: connector.name,
showConnectorName,
icon: llmParams.icon,
disabled: !connector,
connectorId: connector.id,
})),
];
}, []) || [],
[connectors, mapConnectorTypeToCount]
);
};

View file

@ -73,8 +73,8 @@ describe('useLoadConnectors', () => {
const { result, waitForNextUpdate } = renderHook(() => useLoadConnectors());
await waitForNextUpdate();
await expect(result.current).resolves.toStrictEqual({
openai: {
await expect(result.current).resolves.toStrictEqual([
{
actionTypeId: '.gen-ai',
config: {
apiProvider: 'OpenAI',
@ -82,8 +82,9 @@ describe('useLoadConnectors', () => {
id: '1',
isMissingSecrets: false,
title: 'OpenAI',
type: 'openai',
},
openai_azure: {
{
actionTypeId: '.gen-ai',
config: {
apiProvider: 'Azure OpenAI',
@ -91,8 +92,9 @@ describe('useLoadConnectors', () => {
id: '3',
isMissingSecrets: false,
title: 'OpenAI Azure',
type: 'openai_azure',
},
});
]);
});
});

View file

@ -27,46 +27,44 @@ type OpenAIConnector = UserConfiguredActionConnector<
Record<string, unknown>
>;
const mapLLMToActionParam: Record<
LLMs,
const connectorTypeToLLM: Array<{
actionId: string;
actionProvider?: string;
match: (connector: ActionConnector) => boolean;
transform: (connector: ActionConnector) => PlaygroundConnector;
}> = [
{
actionId: string;
actionProvider?: string;
match: (connector: ActionConnector) => boolean;
transform: (connector: ActionConnector) => PlaygroundConnector;
}
> = {
[LLMs.openai_azure]: {
actionId: OPENAI_CONNECTOR_ID,
actionProvider: OpenAiProviderType.AzureAi,
match: (connector) =>
connector.actionTypeId === OPENAI_CONNECTOR_ID &&
(connector as OpenAIConnector).config.apiProvider === OpenAiProviderType.AzureAi,
transform: (connector) => ({
...connector,
title: i18n.translate('xpack.searchPlayground.openAIAzureConnectorTitle', {
defaultMessage: 'OpenAI Azure',
}),
type: LLMs.openai_azure,
}),
},
[LLMs.openai]: {
{
actionId: OPENAI_CONNECTOR_ID,
match: (connector) =>
connector.actionTypeId === OPENAI_CONNECTOR_ID &&
(connector as OpenAIConnector).config.apiProvider === OpenAiProviderType.OpenAi,
transform: (connector) => ({
...connector,
title: i18n.translate('xpack.searchPlayground.openAIConnectorTitle', {
defaultMessage: 'OpenAI',
}),
type: LLMs.openai,
}),
},
};
];
type PlaygroundConnector = ActionConnector & { title: string };
type PlaygroundConnector = ActionConnector & { title: string; type: LLMs };
export const useLoadConnectors = (): UseQueryResult<
Record<LLMs, PlaygroundConnector>,
IHttpFetchError
> => {
export const useLoadConnectors = (): UseQueryResult<PlaygroundConnector[], IHttpFetchError> => {
const {
services: { http, notifications },
} = useKibana();
@ -76,19 +74,15 @@ export const useLoadConnectors = (): UseQueryResult<
async () => {
const queryResult = await loadConnectors({ http });
return Object.entries(mapLLMToActionParam).reduce<Partial<Record<LLMs, PlaygroundConnector>>>(
(result, [llm, { actionId, match, transform }]) => {
const targetConnector = queryResult.find(
(connector) =>
!connector.isMissingSecrets &&
connector.actionTypeId === actionId &&
(match?.(connector) ?? true)
);
return queryResult.reduce<PlaygroundConnector[]>((result, connector) => {
const { transform } = connectorTypeToLLM.find(({ match }) => match(connector)) || {};
return targetConnector ? { ...result, [llm]: transform(targetConnector) } : result;
},
{}
);
if (!connector.isMissingSecrets && !!transform) {
return [...result, transform(connector)];
}
return result;
}, []);
},
{
retry: false,

View file

@ -40,13 +40,14 @@ describe('useManagementLink Hook', () => {
const expectedUrl =
'http://localhost:5601/app/management/insightsAndAlerting/triggersActionsConnectors';
mockGetUrl.mockResolvedValue(expectedUrl);
const { result, waitForNextUpdate } = renderHook(() => useManagementLink());
const connectorId = 'test-connector-id';
const { result, waitForNextUpdate } = renderHook(() => useManagementLink(connectorId));
await waitForNextUpdate();
expect(result.current).toBe(expectedUrl);
expect(mockGetUrl).toHaveBeenCalledWith({
sectionId: 'insightsAndAlerting',
appId: 'triggersActionsConnectors',
appId: 'triggersActionsConnectors/connectors/test-connector-id',
});
});
@ -62,8 +63,8 @@ describe('useManagementLink Hook', () => {
},
},
});
const { result } = renderHook(() => useManagementLink());
const connectorId = 'test-connector-id';
const { result } = renderHook(() => useManagementLink(connectorId));
expect(result.current).toBe('');
});

View file

@ -8,7 +8,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useKibana } from './use_kibana';
export const useManagementLink = () => {
export const useManagementLink = (connectorId: string) => {
const {
services: { share },
} = useKibana();
@ -21,12 +21,12 @@ export const useManagementLink = () => {
const getLink = async () => {
const link = await managementLocator?.getUrl({
sectionId: 'insightsAndAlerting',
appId: 'triggersActionsConnectors',
appId: `triggersActionsConnectors/connectors/${connectorId}`,
});
setManagementLink(link || '');
};
getLink();
}, [managementLocator]);
}, [managementLocator, connectorId]);
return managementLink;
};

View file

@ -104,11 +104,6 @@ export interface AIMessage extends Message {
retrievalDocs: Doc[];
}
export enum SummarizationModelName {
gpt3_5_turbo = 'gpt-3.5-turbo',
gpt_4 = 'gpt-4',
}
export interface ElasticsearchIndex {
count: number; // Elasticsearch _count
has_in_progress_syncs?: boolean; // these default to false if not a connector or crawler
@ -194,7 +189,9 @@ export interface UseChatHelpers {
export interface LLMModel {
name: string;
value?: string;
showConnectorName?: boolean;
connectorId: string;
connectorName: string;
icon: ComponentType;
disabled: boolean;
connectorId?: string;
}

View file

@ -22,6 +22,7 @@ export type Section = 'connectors' | 'rules' | 'alerts' | 'logs';
export const routeToHome = `/`;
export const routeToConnectors = `/connectors`;
export const routeToConnectorEdit = `/connectors/:connectorId`;
export const routeToRules = `/rules`;
export const routeToLogs = `/logs`;
export const legacyRouteToAlerts = `/alerts`;

View file

@ -12,7 +12,7 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiButtonEmpty, EuiPageHeader, EuiPageTemplate } from '@elastic/eui';
import { routeToConnectors, routeToLogs, Section } from '../../../constants';
import { routeToConnectorEdit, routeToConnectors, routeToLogs, Section } from '../../../constants';
import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb';
import { getCurrentDocTitle } from '../../../lib/doc_title';
import { suspendedComponentWithProps } from '../../../lib/suspended_component_with_props';
@ -126,7 +126,7 @@ export const ActionsConnectorsHome: React.FunctionComponent<RouteComponentProps<
<Route exact path={routeToLogs} component={renderLogsList} />
<Route
exact
path={routeToConnectors}
path={[routeToConnectors, routeToConnectorEdit]}
component={suspendedComponentWithProps(ConnectorsList, 'xl')}
/>
</Routes>

View file

@ -29,6 +29,15 @@ const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const actionTypeRegistry = actionTypeRegistryMock.create();
const mocks = coreMock.createSetup();
const { loadAllActions, loadActionTypes } = jest.requireMock('../../../lib/action_connector_api');
const mockGetParams = jest.fn().mockReturnValue({});
const mockGetLocation = jest.fn().mockReturnValue({ search: '' });
const mockGetHistory = jest.fn().mockReturnValue({ push: jest.fn(), createHref: jest.fn() });
jest.mock('react-router-dom', () => ({
useParams: () => mockGetParams(),
useLocation: () => mockGetLocation(),
useHistory: () => mockGetHistory(),
}));
describe('actions_connectors_list', () => {
describe('component empty', () => {

View file

@ -28,6 +28,7 @@ import { omit } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { withTheme, EuiTheme } from '@kbn/kibana-react-plugin/common';
import { getConnectorCompatibility } from '@kbn/actions-plugin/common';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api';
import {
hasDeleteActionsCapability,
@ -54,6 +55,13 @@ import { CreateConnectorFlyout } from '../../action_connector_form/create_connec
import { EditConnectorFlyout } from '../../action_connector_form/edit_connector_flyout';
import { getAlertingSectionBreadcrumb } from '../../../lib/breadcrumb';
import { getCurrentDocTitle } from '../../../lib/doc_title';
import { routeToConnectors } from '../../../constants';
interface EditConnectorProps {
initialConnector?: ActionConnector;
tab?: EditConnectorTabs;
isFix?: boolean;
}
const ConnectorIconTipWithSpacing = withTheme(({ theme }: { theme: EuiTheme }) => {
return (
@ -89,6 +97,9 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
chrome,
docLinks,
} = useKibana().services;
const { connectorId } = useParams<{ connectorId?: string }>();
const history = useHistory();
const location = useLocation();
const canDelete = hasDeleteActionsCapability(capabilities);
const canSave = hasSaveActionsCapability(capabilities);
@ -97,13 +108,9 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
const [pageIndex, setPageIndex] = useState<number>(0);
const [selectedItems, setSelectedItems] = useState<ActionConnectorTableItem[]>([]);
const [isLoadingActionTypes, setIsLoadingActionTypes] = useState<boolean>(false);
const [isLoadingActions, setIsLoadingActions] = useState<boolean>(false);
const [isLoadingActions, setIsLoadingActions] = useState<boolean>(true);
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
const [editConnectorProps, setEditConnectorProps] = useState<{
initialConnector?: ActionConnector;
tab?: EditConnectorTabs;
isFix?: boolean;
}>({});
const [editConnectorProps, setEditConnectorProps] = useState<EditConnectorProps>({});
const [connectorsToDelete, setConnectorsToDelete] = useState<string[]>([]);
useEffect(() => {
loadActions();
@ -164,6 +171,19 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
.sort((a, b) => a.name.localeCompare(b.name))
: [];
useEffect(() => {
if (connectorId && !isLoadingActions) {
const connector = actions.find((action) => action.id === connectorId);
if (connector) {
editItem(connector, EditConnectorTabs.Configuration);
}
const linkToConnectors = history.createHref({ pathname: routeToConnectors });
window.history.replaceState(null, '', linkToConnectors);
}
}, [actions, connectorId, history, isLoadingActions, location]);
function setDeleteConnectorWarning(connectors: string[]) {
const show = connectors.some((c) => {
const action = actions.find((a) => a.id === c);
@ -197,11 +217,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => {
}
}
async function editItem(
actionConnector: ActionConnector,
tab: EditConnectorTabs,
isFix?: boolean
) {
function editItem(actionConnector: ActionConnector, tab: EditConnectorTabs, isFix?: boolean) {
setEditConnectorProps({ initialConnector: actionConnector, tab, isFix: isFix ?? false });
}