diff --git a/common/constants.ts b/common/constants.ts index f63577f5..b30be6bd 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -146,8 +146,14 @@ export const DELIMITER_OPTIONAL_FIELDS = ['delimiter']; export const SHARED_OPTIONAL_FIELDS = ['max_chunk_limit', 'description', 'tag']; /** - * QUERIES + * QUERY PRESETS */ +export const VECTOR_FIELD_PATTERN = `{{vector_field}}`; +export const TEXT_FIELD_PATTERN = `{{text_field}}`; +export const QUERY_TEXT_PATTERN = `{{query_text}}`; +export const QUERY_IMAGE_PATTERN = `{{query_image}}`; +export const MODEL_ID_PATTERN = `{{model_id}}`; + export const FETCH_ALL_QUERY = { query: { match_all: {}, @@ -156,13 +162,13 @@ export const FETCH_ALL_QUERY = { }; export const SEMANTIC_SEARCH_QUERY = { _source: { - excludes: [`{{vector_field}}`], + excludes: [VECTOR_FIELD_PATTERN], }, query: { neural: { - [`{{vector_field}}`]: { - query_text: `{{query_text}}`, - model_id: `{{model_id}}`, + [VECTOR_FIELD_PATTERN]: { + query_text: QUERY_TEXT_PATTERN, + model_id: MODEL_ID_PATTERN, k: 100, }, }, @@ -170,14 +176,14 @@ export const SEMANTIC_SEARCH_QUERY = { }; export const MULTIMODAL_SEARCH_QUERY = { _source: { - excludes: [`{{vector_field}}`], + excludes: [VECTOR_FIELD_PATTERN], }, query: { neural: { - [`{{vector_field}}`]: { - query_text: `{{query_text}}`, - query_image: `{{query_image}}`, - model_id: `{{model_id}}`, + [VECTOR_FIELD_PATTERN]: { + query_text: QUERY_TEXT_PATTERN, + query_image: QUERY_IMAGE_PATTERN, + model_id: MODEL_ID_PATTERN, k: 100, }, }, @@ -185,23 +191,23 @@ export const MULTIMODAL_SEARCH_QUERY = { }; export const HYBRID_SEARCH_QUERY = { _source: { - excludes: [`{{vector_field}}`], + excludes: [VECTOR_FIELD_PATTERN], }, query: { hybrid: { queries: [ { match: { - [`{{text_field}}`]: { - query: `{{query_text}}`, + [TEXT_FIELD_PATTERN]: { + query: QUERY_TEXT_PATTERN, }, }, }, { neural: { - [`{{vector_field}}`]: { - query_text: `{{query_text}}`, - model_id: `{{model_id}}`, + [VECTOR_FIELD_PATTERN]: { + query_text: QUERY_TEXT_PATTERN, + model_id: MODEL_ID_PATTERN, k: 5, }, }, diff --git a/common/interfaces.ts b/common/interfaces.ts index 6128c6fe..d8738824 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -461,6 +461,13 @@ export type QueryPreset = { query: string; }; +export type QuickConfigureFields = { + embeddingModelId?: string; + vectorField?: string; + textField?: string; + embeddingLength?: number; +}; + /** ********** OPENSEARCH TYPES/INTERFACES ************ */ diff --git a/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx b/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx index 9d8527ae..3b00ddda 100644 --- a/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx +++ b/public/pages/workflow_detail/workflow_inputs/search_inputs/edit_query_modal.tsx @@ -72,7 +72,7 @@ export function EditQueryModal(props: EditQueryModalProps) { setFieldValue(props.queryFieldPath, preset.query); setPopoverOpen(false); }, - size: 's', + size: 'full', })), }, ]} diff --git a/public/pages/workflows/new_workflow/new_workflow.tsx b/public/pages/workflows/new_workflow/new_workflow.tsx index f3d29762..2ae8208f 100644 --- a/public/pages/workflows/new_workflow/new_workflow.tsx +++ b/public/pages/workflows/new_workflow/new_workflow.tsx @@ -14,9 +14,19 @@ import { } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { UseCase } from './use_case'; -import { Workflow, WorkflowTemplate } from '../../../../common'; -import { AppState, useAppDispatch, getWorkflowPresets } from '../../../store'; +import { + FETCH_ALL_QUERY, + Workflow, + WorkflowTemplate, +} from '../../../../common'; +import { + AppState, + useAppDispatch, + getWorkflowPresets, + searchModels, +} from '../../../store'; import { enrichPresetWorkflowWithUiMetadata } from './utils'; +import { getDataSourceId } from '../../../utils'; interface NewWorkflowProps {} @@ -27,6 +37,7 @@ interface NewWorkflowProps {} */ export function NewWorkflow(props: NewWorkflowProps) { const dispatch = useAppDispatch(); + const dataSourceId = getDataSourceId(); // workflows state const { presetWorkflows, loading } = useSelector( @@ -43,9 +54,13 @@ export function NewWorkflow(props: NewWorkflowProps) { setSearchQuery(query); }, 200); - // initial state + // on initial load: + // 1. fetch the workflow presets persisted on server-side + // 2. fetch the ML models. these may be used in quick-create views when selecting a preset, + // so we optimize by fetching once at the top-level here. useEffect(() => { dispatch(getWorkflowPresets()); + dispatch(searchModels({ apiBody: FETCH_ALL_QUERY, dataSourceId })); }, []); // initial hook to populate all workflows diff --git a/public/pages/workflows/new_workflow/quick_configure_inputs.tsx b/public/pages/workflows/new_workflow/quick_configure_inputs.tsx new file mode 100644 index 00000000..5233ae6f --- /dev/null +++ b/public/pages/workflows/new_workflow/quick_configure_inputs.tsx @@ -0,0 +1,163 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { + EuiCompressedFormRow, + EuiText, + EuiSpacer, + EuiCompressedSuperSelect, + EuiSuperSelectOption, + EuiAccordion, + EuiCompressedFieldText, + EuiCompressedFieldNumber, +} from '@elastic/eui'; +import { + MODEL_STATE, + Model, + QuickConfigureFields, + WORKFLOW_TYPE, +} from '../../../../common'; +import { AppState } from '../../../store'; + +interface QuickConfigureInputsProps { + workflowType?: WORKFLOW_TYPE; + setFields(fields: QuickConfigureFields): void; +} + +// Dynamic component to allow optional input configuration fields for different use cases. +// Hooks back to the parent component with such field values +export function QuickConfigureInputs(props: QuickConfigureInputsProps) { + const models = useSelector((state: AppState) => state.models.models); + + // Deployed models state + const [deployedModels, setDeployedModels] = useState([]); + + // Hook to update available deployed models + useEffect(() => { + if (models) { + setDeployedModels( + Object.values(models).filter( + (model) => model.state === MODEL_STATE.DEPLOYED + ) + ); + } + }, [models]); + + // Local field values state + const [fieldValues, setFieldValues] = useState({}); + + // Hook to update the parent field values + useEffect(() => { + props.setFields(fieldValues); + }, [fieldValues]); + + return ( + <> + {(props.workflowType === WORKFLOW_TYPE.SEMANTIC_SEARCH || + props.workflowType === WORKFLOW_TYPE.MULTIMODAL_SEARCH || + props.workflowType === WORKFLOW_TYPE.HYBRID_SEARCH) && ( + <> + + + + + + ({ + value: option.id, + inputDisplay: ( + <> + {option.name} + + ), + dropdownDisplay: ( + <> + {option.name} + + Deployed + + + {option.algorithm} + + + ), + disabled: false, + } as EuiSuperSelectOption) + )} + valueOfSelected={fieldValues?.embeddingModelId || ''} + onChange={(option: string) => { + setFieldValues({ + ...fieldValues, + embeddingModelId: option, + }); + }} + isInvalid={false} + /> + + + + { + setFieldValues({ + ...fieldValues, + textField: e.target.value, + }); + }} + /> + + + + { + setFieldValues({ + ...fieldValues, + vectorField: e.target.value, + }); + }} + /> + + + + { + setFieldValues({ + ...fieldValues, + embeddingLength: Number(e.target.value), + }); + }} + /> + + + + )} + + ); +} diff --git a/public/pages/workflows/new_workflow/quick_configure_modal.tsx b/public/pages/workflows/new_workflow/quick_configure_modal.tsx new file mode 100644 index 00000000..d145a4b6 --- /dev/null +++ b/public/pages/workflows/new_workflow/quick_configure_modal.tsx @@ -0,0 +1,275 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { + EuiSmallButton, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiSmallButtonEmpty, + EuiCompressedFieldText, + EuiCompressedFormRow, +} from '@elastic/eui'; +import { + MODEL_ID_PATTERN, + MapArrayFormValue, + QuickConfigureFields, + TEXT_FIELD_PATTERN, + VECTOR_FIELD_PATTERN, + WORKFLOW_NAME_REGEXP, + WORKFLOW_TYPE, + Workflow, + WorkflowConfig, + customStringify, +} from '../../../../common'; +import { APP_PATH } from '../../../utils'; +import { processWorkflowName } from './utils'; +import { createWorkflow, useAppDispatch } from '../../../store'; +import { constructUrlWithParams, getDataSourceId } from '../../../utils/utils'; +import { QuickConfigureInputs } from './quick_configure_inputs'; +import { isEmpty } from 'lodash'; + +interface QuickConfigureModalProps { + workflow: Workflow; + onClose(): void; +} + +// Modal to handle workflow creation. Includes a static field to set the workflow name, and +// an optional set of quick-configure fields, that when populated, help pre-populate +// some of the detailed workflow configuration. +export function QuickConfigureModal(props: QuickConfigureModalProps) { + const dispatch = useAppDispatch(); + const dataSourceId = getDataSourceId(); + const history = useHistory(); + + // workflow name state + const [workflowName, setWorkflowName] = useState( + processWorkflowName(props.workflow.name) + ); + + const [quickConfigureFields, setQuickConfigureFields] = useState< + QuickConfigureFields + >({}); + + // is creating state + const [isCreating, setIsCreating] = useState(false); + + // custom sanitization on workflow name + function isInvalidName(name: string): boolean { + return ( + name === '' || + name.length > 100 || + WORKFLOW_NAME_REGEXP.test(name) === false + ); + } + + return ( + props.onClose()} style={{ width: '30vw' }}> + + +

{`Quick configure`}

+
+
+ + + { + setWorkflowName(e.target.value); + }} + /> + + + + + props.onClose()}> + Cancel + + { + setIsCreating(true); + let workflowToCreate = { + ...props.workflow, + name: workflowName, + }; + if (!isEmpty(quickConfigureFields)) { + workflowToCreate = injectQuickConfigureFields( + workflowToCreate, + quickConfigureFields + ); + } + dispatch( + createWorkflow({ + apiBody: workflowToCreate, + dataSourceId, + }) + ) + .unwrap() + .then((result) => { + setIsCreating(false); + const { workflow } = result; + history.replace( + constructUrlWithParams( + APP_PATH.WORKFLOWS, + + workflow.id, + dataSourceId + ) + ); + }) + .catch((err: any) => { + setIsCreating(false); + console.error(err); + }); + }} + fill={true} + color="primary" + > + Create + + +
+ ); +} + +// helper fn to populate UI config values if there are some quick configure fields available +function injectQuickConfigureFields( + workflow: Workflow, + quickConfigureFields: QuickConfigureFields +): Workflow { + if (workflow.ui_metadata?.type) { + switch (workflow.ui_metadata?.type) { + // Semantic search / hybrid search: set defaults in the ingest processor, the index mappings, + // and the preset query + case WORKFLOW_TYPE.SEMANTIC_SEARCH: + case WORKFLOW_TYPE.HYBRID_SEARCH: { + if (!isEmpty(quickConfigureFields) && workflow.ui_metadata?.config) { + workflow.ui_metadata.config = updateIngestProcessorConfig( + workflow.ui_metadata.config, + quickConfigureFields + ); + workflow.ui_metadata.config = updateIndexConfig( + workflow.ui_metadata.config, + quickConfigureFields + ); + workflow.ui_metadata.config = updateSearchRequestConfig( + workflow.ui_metadata.config, + quickConfigureFields + ); + } + } + case WORKFLOW_TYPE.CUSTOM: + case undefined: + default: + break; + } + } + return workflow; +} + +// prefill ML ingest pipeline processor config, if applicable +function updateIngestProcessorConfig( + config: WorkflowConfig, + fields: QuickConfigureFields +): WorkflowConfig { + config.ingest.enrich.processors[0].fields.forEach((field) => { + if (field.id === 'model' && fields.embeddingModelId) { + field.value = { id: fields.embeddingModelId }; + } + if (field.id === 'input_map' && fields.textField) { + field.value = [ + [{ key: '', value: fields.textField }], + ] as MapArrayFormValue; + } + if (field.id === 'output_map' && fields.vectorField) { + field.value = [ + [{ key: fields.vectorField, value: '' }], + ] as MapArrayFormValue; + } + }); + + return config; +} + +// prefill index mappings/settings, if applicable +function updateIndexConfig( + config: WorkflowConfig, + fields: QuickConfigureFields +): WorkflowConfig { + if (fields.textField) { + const existingMappings = JSON.parse( + config.ingest.index.mappings.value as string + ); + config.ingest.index.mappings.value = customStringify({ + ...existingMappings, + properties: { + ...(existingMappings.properties || {}), + [fields.textField]: { + type: 'text', + }, + }, + }); + } + if (fields.vectorField) { + const existingMappings = JSON.parse( + config.ingest.index.mappings.value as string + ); + config.ingest.index.mappings.value = customStringify({ + ...existingMappings, + properties: { + ...(existingMappings.properties || {}), + [fields.vectorField]: { + type: 'knn_vector', + dimension: fields.embeddingLength || '', + }, + }, + }); + } + return config; +} + +// pre-populate placeholders in the query, if applicable +function updateSearchRequestConfig( + config: WorkflowConfig, + fields: QuickConfigureFields +): WorkflowConfig { + if (fields.embeddingModelId) { + config.search.request.value = ((config.search.request.value || + '') as string).replace( + new RegExp(MODEL_ID_PATTERN, 'g'), + fields.embeddingModelId + ); + } + if (fields.textField) { + config.search.request.value = ((config.search.request.value || + '') as string).replace( + new RegExp(TEXT_FIELD_PATTERN, 'g'), + fields.textField + ); + } + if (fields.vectorField) { + config.search.request.value = ((config.search.request.value || + '') as string).replace( + new RegExp(VECTOR_FIELD_PATTERN, 'g'), + fields.vectorField + ); + } + + return config; +} diff --git a/public/pages/workflows/new_workflow/use_case.tsx b/public/pages/workflows/new_workflow/use_case.tsx index 1a175b1a..66c76725 100644 --- a/public/pages/workflows/new_workflow/use_case.tsx +++ b/public/pages/workflows/new_workflow/use_case.tsx @@ -4,7 +4,6 @@ */ import React, { useState } from 'react'; -import { useHistory } from 'react-router-dom'; import { EuiText, EuiFlexGroup, @@ -13,111 +12,25 @@ import { EuiCard, EuiHorizontalRule, EuiSmallButton, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiModalFooter, - EuiSmallButtonEmpty, - EuiCompressedFieldText, - EuiCompressedFormRow, } from '@elastic/eui'; -import { WORKFLOW_NAME_REGEXP, Workflow } from '../../../../common'; -import { APP_PATH } from '../../../utils'; -import { processWorkflowName } from './utils'; -import { createWorkflow, useAppDispatch } from '../../../store'; -import { constructUrlWithParams, getDataSourceId } from '../../../utils/utils'; +import { Workflow } from '../../../../common'; +import { QuickConfigureModal } from './quick_configure_modal'; interface UseCaseProps { workflow: Workflow; } export function UseCase(props: UseCaseProps) { - const dispatch = useAppDispatch(); - const dataSourceId = getDataSourceId(); - const history = useHistory(); - // name modal state const [isNameModalOpen, setIsNameModalOpen] = useState(false); - // workflow name state - const [workflowName, setWorkflowName] = useState( - processWorkflowName(props.workflow.name) - ); - - // custom sanitization on workflow name - function isInvalid(name: string): boolean { - return ( - name === '' || - name.length > 100 || - WORKFLOW_NAME_REGEXP.test(name) === false - ); - } - return ( <> {isNameModalOpen && ( - setIsNameModalOpen(false)}> - - -

{`Set a unique name for your workflow`}

-
-
- - - { - setWorkflowName(e.target.value); - }} - /> - - - - setIsNameModalOpen(false)}> - Cancel - - { - const workflowToCreate = { - ...props.workflow, - name: workflowName, - }; - dispatch( - createWorkflow({ - apiBody: workflowToCreate, - dataSourceId, - }) - ) - .unwrap() - .then((result) => { - const { workflow } = result; - history.replace( - constructUrlWithParams( - APP_PATH.WORKFLOWS, - - workflow.id, - dataSourceId - ) - ); - }) - .catch((err: any) => { - console.error(err); - }); - }} - fill={true} - color="primary" - > - Create - - -
+ setIsNameModalOpen(false)} + /> )}