diff --git a/public/pages/workflow_detail/components/edit_workflow_metadata_modal.tsx b/public/pages/workflow_detail/components/edit_workflow_metadata_modal.tsx index 6dbba509..3d60780b 100644 --- a/public/pages/workflow_detail/components/edit_workflow_metadata_modal.tsx +++ b/public/pages/workflow_detail/components/edit_workflow_metadata_modal.tsx @@ -85,7 +85,7 @@ export function EditWorkflowMetadataModal( } ) .required('Required') as yup.Schema, - desription: yup + description: yup .string() .min(0) .max(MAX_DESCRIPTION_LENGTH, 'Too long') diff --git a/public/pages/workflow_detail/tools/errors/errors.tsx b/public/pages/workflow_detail/tools/errors/errors.tsx index bb78a355..71d5a561 100644 --- a/public/pages/workflow_detail/tools/errors/errors.tsx +++ b/public/pages/workflow_detail/tools/errors/errors.tsx @@ -4,11 +4,15 @@ */ import React from 'react'; -import { EuiCodeBlock, EuiEmptyPrompt } from '@elastic/eui'; -import { isEmpty } from 'lodash'; +import { + EuiCodeBlock, + EuiEmptyPrompt, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; interface ErrorsProps { - errorMessage: string; + errorMessages: string[]; } /** @@ -18,12 +22,21 @@ interface ErrorsProps { export function Errors(props: ErrorsProps) { return ( <> - {isEmpty(props.errorMessage) ? ( + {props.errorMessages?.length === 0 ? ( No errors} titleSize="s" /> ) : ( - - {props.errorMessage} - + <> + {props.errorMessages.map((errorMessage, idx) => { + return ( + + + + {errorMessage} + + + ); + })} + )} ); diff --git a/public/pages/workflow_detail/tools/query/query.tsx b/public/pages/workflow_detail/tools/query/query.tsx index e3f03d61..60c9c749 100644 --- a/public/pages/workflow_detail/tools/query/query.tsx +++ b/public/pages/workflow_detail/tools/query/query.tsx @@ -29,14 +29,12 @@ import { import { AppState, searchIndex, - setOpenSearchError, setSearchPipelineErrors, useAppDispatch, } from '../../../../store'; import { containsEmptyValues, containsSameValues, - formatSearchPipelineErrors, getDataSourceId, getPlaceholdersFromQuery, getSearchPipelineErrors, @@ -225,15 +223,6 @@ export function Query(props: QueryProps) { errors: searchPipelineErrors, }) ); - if (!isEmpty(searchPipelineErrors)) { - dispatch( - setOpenSearchError({ - error: `Error running search pipeline. ${formatSearchPipelineErrors( - searchPipelineErrors - )}`, - }) - ); - } } else { setSearchPipelineErrors({ errors: {} }); } diff --git a/public/pages/workflow_detail/tools/tools.tsx b/public/pages/workflow_detail/tools/tools.tsx index 683d51ae..ea1e4c51 100644 --- a/public/pages/workflow_detail/tools/tools.tsx +++ b/public/pages/workflow_detail/tools/tools.tsx @@ -44,35 +44,55 @@ const PANEL_TITLE = 'Inspect flows'; * The base Tools component for performing ingest and search, viewing resources, and debugging. */ export function Tools(props: ToolsProps) { - // error message state + // error message states. Error may come from several different sources. const { opensearch, workflows } = useSelector((state: AppState) => state); const opensearchError = opensearch.errorMessage; const workflowsError = workflows.errorMessage; - const [curErrorMessage, setCurErrorMessage] = useState(''); + const { + ingestPipeline: ingestPipelineErrors, + searchPipeline: searchPipelineErrors, + } = useSelector((state: AppState) => state.errors); + const [curErrorMessages, setCurErrorMessages] = useState([]); - // auto-navigate to errors tab if a new error has been set as a result of - // executing OpenSearch or Flow Framework workflow APIs, or from the workflow state - // (note that if provision/deprovision fails, there is no concrete exception returned at the API level - - // it is just set in the workflow's error field when fetching workflow state) + // Propagate any errors coming from opensearch API calls, including ingest/search pipeline verbose calls. useEffect(() => { - setCurErrorMessage(opensearchError); - if (!isEmpty(opensearchError)) { - props.setSelectedTabId(INSPECTOR_TAB_ID.ERRORS); + if ( + !isEmpty(opensearchError) || + !isEmpty(ingestPipelineErrors) || + !isEmpty(searchPipelineErrors) + ) { + if (!isEmpty(opensearchError)) { + setCurErrorMessages([opensearchError]); + } else if (!isEmpty(ingestPipelineErrors)) { + setCurErrorMessages([ + 'Data not ingested. Errors found with the following ingest processor(s):', + ...Object.values(ingestPipelineErrors).map((value) => value.errorMsg), + ]); + } else if (!isEmpty(searchPipelineErrors)) { + setCurErrorMessages([ + 'Errors found with the following search processor(s)', + ...Object.values(searchPipelineErrors).map((value) => value.errorMsg), + ]); + } + } else { + setCurErrorMessages([]); } - }, [opensearchError]); + }, [opensearchError, ingestPipelineErrors, searchPipelineErrors]); + // Propagate any errors coming from the workflow, either runtime from API call, or persisted in the indexed workflow itself. useEffect(() => { - setCurErrorMessage(workflowsError); - if (!isEmpty(workflowsError)) { - props.setSelectedTabId(INSPECTOR_TAB_ID.ERRORS); - } + setCurErrorMessages(!isEmpty(workflowsError) ? [workflowsError] : []); }, [workflowsError]); useEffect(() => { - setCurErrorMessage(props.workflow?.error || ''); - if (!isEmpty(props.workflow?.error)) { + setCurErrorMessages(props.workflow?.error ? [props.workflow.error] : []); + }, [props.workflow?.error]); + + // auto-navigate to errors tab if new errors have been found + useEffect(() => { + if (curErrorMessages.length > 0) { props.setSelectedTabId(INSPECTOR_TAB_ID.ERRORS); } - }, [props.workflow?.error]); + }, [curErrorMessages]); // auto-navigate to ingest tab if a populated value has been set, indicating ingest has been ran useEffect(() => { @@ -136,7 +156,7 @@ export function Tools(props: ToolsProps) { /> )} {props.selectedTabId === INSPECTOR_TAB_ID.ERRORS && ( - + )} {props.selectedTabId === INSPECTOR_TAB_ID.RESOURCES && ( diff --git a/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx b/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx index eae7e97e..4704c5ac 100644 --- a/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx +++ b/public/pages/workflow_detail/workflow_inputs/config_field_list.tsx @@ -126,7 +126,6 @@ export function ConfigFieldList(props: ConfigFieldListProps) { el = ( diff --git a/public/pages/workflow_detail/workflow_inputs/index.ts b/public/pages/workflow_detail/workflow_inputs/index.ts index bf513c09..081d4d34 100644 --- a/public/pages/workflow_detail/workflow_inputs/index.ts +++ b/public/pages/workflow_detail/workflow_inputs/index.ts @@ -4,3 +4,4 @@ */ export * from './workflow_inputs'; +export * from './input_fields'; diff --git a/public/pages/workflow_detail/workflow_inputs/input_fields/model_field.tsx b/public/pages/workflow_detail/workflow_inputs/input_fields/model_field.tsx index 764169cf..1cf101e4 100644 --- a/public/pages/workflow_detail/workflow_inputs/input_fields/model_field.tsx +++ b/public/pages/workflow_detail/workflow_inputs/input_fields/model_field.tsx @@ -21,18 +21,21 @@ import { MODEL_STATE, WorkflowFormValues, ModelFormValue, - IConfigField, ML_CHOOSE_MODEL_LINK, ML_REMOTE_MODEL_LINK, } from '../../../../../common'; import { AppState } from '../../../../store'; interface ModelFieldProps { - field: IConfigField; fieldPath: string; // the full path in string-form to the field (e.g., 'ingest.enrich.processors.text_embedding_processor.inputField') hasModelInterface?: boolean; onModelChange?: (modelId: string) => void; showMissingInterfaceCallout?: boolean; + label?: string; + helpText?: string; + fullWidth?: boolean; + showError?: boolean; + showInvalid?: boolean; } type ModelItem = ModelFormValue & { @@ -118,9 +121,14 @@ export function ModelField(props: ModelFieldProps) { )} {({ field, form }: FieldProps) => { + const isInvalid = + (props.showInvalid ?? true) && + getIn(errors, `${field.name}.id`) && + getIn(touched, `${field.name}.id`); return ( @@ -128,9 +136,13 @@ export function ModelField(props: ModelFieldProps) { } - helpText={'The model ID.'} + helpText={props.helpText || 'The model ID.'} + isInvalid={isInvalid} + error={props.showError && getIn(errors, `${field.name}.id`)} > @@ -165,11 +177,7 @@ export function ModelField(props: ModelFieldProps) { props.onModelChange(option); } }} - isInvalid={ - getIn(errors, field.name) && getIn(touched, field.name) - ? true - : undefined - } + isInvalid={isInvalid} /> ); diff --git a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/ml_processor_inputs.tsx b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/ml_processor_inputs.tsx index b48f41da..c81abe7e 100644 --- a/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/ml_processor_inputs.tsx +++ b/public/pages/workflow_detail/workflow_inputs/processor_inputs/ml_processor_inputs/ml_processor_inputs.tsx @@ -200,7 +200,6 @@ export function MLProcessorInputs(props: MLProcessorInputsProps) { /> ) : ( ([]); + // Accordions do not persist an open/closed state, so we manually persist + const [processorOpenState, setProcessorOpenState] = useState<{ + [processorId: string]: boolean; + }>({}); + function clearProcessorErrors(): void { if (props.context === PROCESSOR_CONTEXT.INGEST) { dispatch(setIngestPipelineErrors({ errors: {} })); @@ -379,31 +385,60 @@ export function ProcessorsList(props: ProcessorsListProps) { `${processorIndex}.errorMsg`, undefined ); + const errorFound = + processorFormError !== undefined || + processorRuntimeError !== undefined; + const isAddedProcessor = + processorAdded && processorIndex === processors.length - 1; + const processorOpen = + processorOpenState[processor.id] ?? isAddedProcessor; + const errorMsg = processorFormError + ? 'Form error(s) detected' + : 'Runtime error(s) detected'; return ( { + setProcessorOpenState({ + ...processorOpenState, + [processor.id]: isOpen, + }); + }} buttonContent={ - + {`${processor.name || 'Processor'}`} - {(processorFormError !== undefined || - processorRuntimeError !== undefined) && ( + {errorFound && !processorOpen && ( - + + + + + + + {errorMsg} + + + )} @@ -421,6 +456,21 @@ export function ProcessorsList(props: ProcessorsListProps) { > + {errorFound && processorOpen && ( + <> + + + {processorFormError || processorRuntimeError} + + + + + )} void; } +const SUCCESS_TOAST_ID = 'success_toast_id'; + /** * The workflow inputs component containing the multi-step flow to create ingest * and search flows for a particular workflow. @@ -290,6 +290,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { props.setIsRunningIngest(false); setLastIngested(Date.now()); getCore().notifications.toasts.add({ + id: SUCCESS_TOAST_ID, iconType: 'check', color: 'success', title: 'Ingest flow updated', @@ -306,7 +307,10 @@ export function WorkflowInputs(props: WorkflowInputsProps) { props.displaySearchPanel()} + onClick={() => { + props.displaySearchPanel(); + getCore().notifications.toasts.remove(SUCCESS_TOAST_ID); + }} > Test flow @@ -603,14 +607,6 @@ export function WorkflowInputs(props: WorkflowInputsProps) { ); if (isEmpty(ingestPipelineErrors)) { bulkIngest(ingestDocsObjs); - } else { - dispatch( - setOpenSearchError({ - error: `Data not ingested. ${formatIngestPipelineErrors( - ingestPipelineErrors - )}`, - }) - ); } }) .catch((error: any) => { @@ -912,6 +908,7 @@ export function WorkflowInputs(props: WorkflowInputsProps) { onClick={async () => { if (await validateAndUpdateSearchResources()) { getCore().notifications.toasts.add({ + id: SUCCESS_TOAST_ID, iconType: 'check', color: 'success', title: 'Search flow updated', @@ -931,9 +928,12 @@ export function WorkflowInputs(props: WorkflowInputsProps) { - props.displaySearchPanel() - } + onClick={() => { + props.displaySearchPanel(); + getCore().notifications.toasts.remove( + SUCCESS_TOAST_ID + ); + }} > Test flow diff --git a/public/pages/workflows/new_workflow/quick_configure_inputs.tsx b/public/pages/workflows/new_workflow/quick_configure_inputs.tsx deleted file mode 100644 index 1c4f521b..00000000 --- a/public/pages/workflows/new_workflow/quick_configure_inputs.tsx +++ /dev/null @@ -1,528 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState, useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { get, isEmpty } from 'lodash'; -import { - EuiCompressedFormRow, - EuiText, - EuiSpacer, - EuiCompressedSuperSelect, - EuiSuperSelectOption, - EuiAccordion, - EuiCompressedFieldText, - EuiCompressedFieldNumber, - EuiCallOut, - EuiLink, -} from '@elastic/eui'; -import { - DEFAULT_IMAGE_FIELD, - DEFAULT_LLM_RESPONSE_FIELD, - DEFAULT_TEXT_FIELD, - DEFAULT_VECTOR_FIELD, - ML_CHOOSE_MODEL_LINK, - ML_REMOTE_MODEL_LINK, - MODEL_STATE, - Model, - ModelInterface, - QuickConfigureFields, - WORKFLOW_TYPE, -} from '../../../../common'; -import { AppState } from '../../../store'; -import { getEmbeddingModelDimensions, parseModelInputs } from '../../../utils'; - -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, connectors } = useSelector((state: AppState) => state.ml); - - // Deployed models state - const [deployedModels, setDeployedModels] = useState([]); - - // Selected LLM interface state. Used for exposing a dropdown - // of available model inputs to select from. - const [selectedLLMInterface, setSelectedLLMInterface] = useState< - ModelInterface | undefined - >(undefined); - - // 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({}); - - // Advanced config accordion state - const [accordionState, setAccordionState] = useState<'open' | 'closed'>( - 'closed' - ); - - // on initial load, and when there are any deployed models found, set - // defaults for the field values for certain workflow types - useEffect(() => { - let defaultFieldValues = {} as QuickConfigureFields; - switch (props.workflowType) { - case WORKFLOW_TYPE.SEMANTIC_SEARCH: - case WORKFLOW_TYPE.HYBRID_SEARCH: { - defaultFieldValues = { - textField: DEFAULT_TEXT_FIELD, - vectorField: DEFAULT_VECTOR_FIELD, - }; - break; - } - case WORKFLOW_TYPE.MULTIMODAL_SEARCH: { - defaultFieldValues = { - textField: DEFAULT_TEXT_FIELD, - vectorField: DEFAULT_VECTOR_FIELD, - imageField: DEFAULT_IMAGE_FIELD, - }; - break; - } - case WORKFLOW_TYPE.RAG: { - defaultFieldValues = { - textField: DEFAULT_TEXT_FIELD, - promptField: '', - llmResponseField: DEFAULT_LLM_RESPONSE_FIELD, - }; - break; - } - case WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG: { - defaultFieldValues = { - textField: DEFAULT_TEXT_FIELD, - vectorField: DEFAULT_VECTOR_FIELD, - promptField: '', - llmResponseField: DEFAULT_LLM_RESPONSE_FIELD, - }; - break; - } - case WORKFLOW_TYPE.CUSTOM: - default: - break; - } - setFieldValues(defaultFieldValues); - }, [deployedModels]); - - // Hook to update the parent field values - useEffect(() => { - props.setFields(fieldValues); - }, [fieldValues]); - - // Try to pre-fill the dimensions based on the chosen embedding model - // If not found, we display a helper callout, and automatically - // open the accordion to guide the user. - const [unknownEmbeddingLength, setUnknownEmbeddingLength] = useState( - false - ); - useEffect(() => { - const selectedModel = deployedModels.find( - (model) => model.id === fieldValues.embeddingModelId - ); - if (selectedModel?.connectorId !== undefined) { - const connector = connectors[selectedModel.connectorId]; - if (connector !== undefined) { - const dimensions = getEmbeddingModelDimensions(connector); - if (dimensions === undefined) { - setUnknownEmbeddingLength(true); - setAccordionState('open'); - } - setUnknownEmbeddingLength(dimensions === undefined); - setFieldValues({ - ...fieldValues, - embeddingLength: getEmbeddingModelDimensions(connector), - }); - } - } - }, [fieldValues.embeddingModelId, deployedModels, connectors]); - - // Set the LLM interface if an LLM is defined - useEffect(() => { - const selectedModel = deployedModels.find( - (model) => model.id === fieldValues.llmId - ); - setSelectedLLMInterface(selectedModel?.interface); - }, [fieldValues.llmId, deployedModels, connectors]); - - // If an LLM interface is defined, set a default prompt field, if applicable. - useEffect(() => { - if ( - (props.workflowType === WORKFLOW_TYPE.RAG || - props.workflowType === WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG) && - selectedLLMInterface !== undefined - ) { - setFieldValues({ - ...fieldValues, - promptField: get(parseModelInputs(selectedLLMInterface), '0.label'), - }); - } - }, [selectedLLMInterface]); - - return ( - <> - {props.workflowType !== WORKFLOW_TYPE.CUSTOM ? ( - // Always include some model selection. For anything other than the vanilla RAG type, - // we will always have a selectable embedding model. - <> - - {unknownEmbeddingLength && ( - <> - - - - )} - - - Learn more - - - } - isInvalid={false} - helpText={ - isEmpty(deployedModels) - ? undefined - : props.workflowType === WORKFLOW_TYPE.RAG - ? 'The large language model to generate user-friendly responses.' - : 'The model to generate embeddings.' - } - > - {isEmpty(deployedModels) ? ( - - You have no models registered in your cluster.{' '} - - Learn more - {' '} - about integrating ML models. - - } - /> - ) : ( - - ({ - value: option.id, - inputDisplay: ( - <> - {option.name} - - ), - dropdownDisplay: ( - <> - {option.name} - - Deployed - - - {option.algorithm} - - - ), - disabled: false, - } as EuiSuperSelectOption) - )} - valueOfSelected={ - props.workflowType === WORKFLOW_TYPE.RAG - ? fieldValues?.llmId - : fieldValues?.embeddingModelId || '' - } - onChange={(option: string) => { - if (props.workflowType === WORKFLOW_TYPE.RAG) { - setFieldValues({ - ...fieldValues, - llmId: option, - }); - } else { - setFieldValues({ - ...fieldValues, - embeddingModelId: option, - }); - } - }} - isInvalid={false} - /> - )} - - - { - // For vector search + RAG, include the LLM model selection as well - props.workflowType === WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG && ( - <> - - - Learn more - - - } - isInvalid={false} - helpText={ - isEmpty(deployedModels) - ? undefined - : 'The large language model to generate user-friendly responses.' - } - > - {isEmpty(deployedModels) ? ( - - You have no models registered in your cluster.{' '} - - Learn more - {' '} - about integrating ML models. - - } - /> - ) : ( - - ({ - value: option.id, - inputDisplay: ( - <> - {option.name} - - ), - dropdownDisplay: ( - <> - {option.name} - - Deployed - - - {option.algorithm} - - - ), - disabled: false, - } as EuiSuperSelectOption) - )} - valueOfSelected={fieldValues?.llmId || ''} - onChange={(option: string) => { - setFieldValues({ - ...fieldValues, - llmId: option, - }); - }} - isInvalid={false} - /> - )} - - - - ) - } - - - accordionState === 'open' - ? setAccordionState('closed') - : setAccordionState('open') - } - > - <> - - - { - setFieldValues({ - ...fieldValues, - textField: e.target.value, - }); - }} - /> - - - {props.workflowType === WORKFLOW_TYPE.MULTIMODAL_SEARCH && ( - <> - - { - setFieldValues({ - ...fieldValues, - imageField: e.target.value, - }); - }} - /> - - - - )} - {(props.workflowType === WORKFLOW_TYPE.SEMANTIC_SEARCH || - props.workflowType === WORKFLOW_TYPE.MULTIMODAL_SEARCH || - props.workflowType === WORKFLOW_TYPE.HYBRID_SEARCH || - props.workflowType === - WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG) && ( - <> - - { - setFieldValues({ - ...fieldValues, - vectorField: e.target.value, - }); - }} - /> - - - - { - setFieldValues({ - ...fieldValues, - embeddingLength: Number(e.target.value), - }); - }} - /> - - - )} - {(props.workflowType === WORKFLOW_TYPE.RAG || - props.workflowType === - WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG) && ( - <> - - - ({ - value: option.label, - inputDisplay: ( - <> - {option.label} - - ), - dropdownDisplay: ( - <> - {option.label} - - {option.type} - - - ), - disabled: false, - } as EuiSuperSelectOption) - )} - valueOfSelected={fieldValues?.promptField || ''} - onChange={(option: string) => { - setFieldValues({ - ...fieldValues, - promptField: option, - }); - }} - isInvalid={false} - /> - - - - { - setFieldValues({ - ...fieldValues, - llmResponseField: e.target.value, - }); - }} - /> - - - )} - - - - ) : undefined} - - ); -} diff --git a/public/pages/workflows/new_workflow/quick_configure_modal.tsx b/public/pages/workflows/new_workflow/quick_configure_modal.tsx index d216b1f7..6f307327 100644 --- a/public/pages/workflows/new_workflow/quick_configure_modal.tsx +++ b/public/pages/workflows/new_workflow/quick_configure_modal.tsx @@ -5,6 +5,8 @@ import React, { useState, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; +import * as yup from 'yup'; +import { Formik, getIn } from 'formik'; import { isEmpty } from 'lodash'; import { useSelector } from 'react-redux'; import { flattie } from 'flattie'; @@ -16,9 +18,12 @@ import { EuiModalBody, EuiModalFooter, EuiSmallButtonEmpty, - EuiCompressedFieldText, - EuiCompressedFormRow, - EuiCompressedTextArea, + EuiFlexItem, + EuiFlexGroup, + EuiCallOut, + EuiSpacer, + EuiText, + EuiLink, } from '@elastic/eui'; import { DEFAULT_PROMPT_RESULTS_FIELD, @@ -49,76 +54,141 @@ import { WORKFLOW_NAME_RESTRICTIONS, MAX_DESCRIPTION_LENGTH, MapFormValue, + MAX_STRING_LENGTH, + Model, + MODEL_STATE, + ML_REMOTE_MODEL_LINK, } from '../../../../common'; -import { APP_PATH } from '../../../utils'; +import { APP_PATH, getInitialValue } from '../../../utils'; import { AppState, createWorkflow, useAppDispatch } from '../../../store'; import { constructUrlWithParams, getDataSourceId, + getEmbeddingModelDimensions, parseModelInputs, parseModelOutputs, } from '../../../utils/utils'; -import { QuickConfigureInputs } from './quick_configure_inputs'; +import { QuickConfigureOptionalFields } from './quick_configure_optional_fields'; +import { ModelField, TextField } from '../../workflow_detail/workflow_inputs'; 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. +// Modal to handle workflow creation. Includes a static field to set the workflow name, +// any required models, 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(); - const { models } = useSelector((state: AppState) => state.ml); + const { models, connectors } = useSelector((state: AppState) => state.ml); const { workflows } = useSelector((state: AppState) => state.workflows); - // model interface states - const [embeddingModelInterface, setEmbeddingModelInterface] = useState< - ModelInterface | undefined - >(undefined); - const [llmInterface, setLLMInterface] = useState( - undefined - ); - - // workflow name state - const [workflowName, setWorkflowName] = useState(''); - const [workflowNameTouched, setWorkflowNameTouched] = useState( - false - ); - const workflowNameExists = Object.values(workflows || {}) - .map((workflow) => workflow.name) - .includes(workflowName); - - // workflow description state - const [workflowDescription, setWorkflowDescription] = useState(''); + // is creating state + const [isCreating, setIsCreating] = useState(false); + // The set of both req'd and optional fields 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 || - workflowNameExists - ); - } + // sub-form values/schema. dependent on the workflow type. + // certain types require different subsets of models. + const [tempErrors, setTempErrors] = useState(false); + const [formValues, setFormValues] = useState<{}>({}); + const [formSchemaObj, setFormSchemaObj] = useState<{}>({}); + useEffect(() => { + const workflowType = props.workflow?.ui_metadata?.type; + // All workflows require a unique name, and optional description. + let tempFormValues = { + name: getInitialValue('string'), + description: getInitialValue('string'), + } as {}; + let tempFormSchemaObj = { + name: yup + .string() + .test('workflowName', WORKFLOW_NAME_RESTRICTIONS, (name) => { + return !( + name === undefined || + name === '' || + name.length > 100 || + WORKFLOW_NAME_REGEXP.test(name) === false + ); + }) + .test( + 'workflowName', + 'This workflow name is already in use. Use a different name', + (name) => { + return !( + Object.values(workflows || {}) + .map((workflow) => workflow.name) + .includes(name || '') && name !== props.workflow?.name + ); + } + ) + .required('Required') as yup.Schema, + description: yup + .string() + .min(0) + .max(MAX_DESCRIPTION_LENGTH, 'Too long') + .optional() as yup.Schema, + } as {}; - // custom sanitization on workflow description - function isInvalidDescription(description: string): boolean { - return description.length > MAX_DESCRIPTION_LENGTH; - } + // If not custom/blank, we will have more req'd form fields for the users to supply + if (workflowType !== WORKFLOW_TYPE.CUSTOM) { + // if a RAG workflow, require an LLM + if ( + workflowType === WORKFLOW_TYPE.RAG || + workflowType === WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG + ) { + tempFormValues = { + ...tempFormValues, + llm: getInitialValue('model'), + }; + tempFormSchemaObj = { + ...tempFormSchemaObj, + llm: yup.object({ + id: yup + .string() + .trim() + .min(1, 'Too short') + .max(MAX_STRING_LENGTH, 'Too long') + .required('Required'), + }), + }; + } + // all workflows besides custom and vanilla RAG require an embedding model + if (workflowType !== WORKFLOW_TYPE.RAG) { + tempFormValues = { + ...tempFormValues, + embeddingModel: getInitialValue('model'), + }; + tempFormSchemaObj = { + ...tempFormSchemaObj, + embeddingModel: yup.object({ + id: yup + .string() + .trim() + .min(1, 'Too short') + .max(MAX_STRING_LENGTH, 'Too long') + .required('Required'), + }), + }; + } + } + setFormValues(tempFormValues); + setFormSchemaObj(tempFormSchemaObj); + }, [props.workflow?.ui_metadata?.type]); - // fetching model interface if available. used to prefill some - // of the input/output maps + // model interface states, used for pre-populating some of the downstream ML processor configs + const [embeddingModelInterface, setEmbeddingModelInterface] = useState< + ModelInterface | undefined + >(undefined); + const [llmInterface, setLLMInterface] = useState( + undefined + ); useEffect(() => { setEmbeddingModelInterface( models[quickConfigureFields?.embeddingModelId || '']?.interface @@ -128,111 +198,236 @@ export function QuickConfigureModal(props: QuickConfigureModalProps) { setLLMInterface(models[quickConfigureFields?.llmId || '']?.interface); }, [models, quickConfigureFields?.llmId]); + // Deployed models state + const [deployedModels, setDeployedModels] = useState([]); + useEffect(() => { + if (models) { + setDeployedModels( + Object.values(models || {}).filter( + (model) => model.state === MODEL_STATE.DEPLOYED + ) + ); + } + }, [models]); + + // Try to pre-fill the dimensions if an embedding model is selected + const [unknownEmbeddingLength, setUnknownEmbeddingLength] = useState( + false + ); + useEffect(() => { + const selectedModel = deployedModels.find( + (model) => model.id === quickConfigureFields?.embeddingModelId + ); + if (selectedModel?.connectorId !== undefined) { + const connector = connectors[selectedModel.connectorId]; + if (connector !== undefined) { + const dimensions = getEmbeddingModelDimensions(connector); + if (dimensions === undefined) { + setUnknownEmbeddingLength(true); + } + setUnknownEmbeddingLength(dimensions === undefined); + setQuickConfigureFields({ + ...quickConfigureFields, + embeddingLength: getEmbeddingModelDimensions(connector), + }); + } + } + }, [quickConfigureFields?.embeddingModelId, deployedModels, connectors]); + return ( - props.onClose()} style={{ width: '40vw' }}> - - -

{`Quick configure`}

-
-
- - - { - setWorkflowNameTouched(true); - setWorkflowName(e.target.value?.trim()); - }} - onBlur={() => setWorkflowNameTouched(true)} - /> - - - { - setWorkflowDescription(e.target.value); - }} - /> - - - - - props.onClose()} - data-testid="quickConfigureCancelButton" - > - Cancel - - { - setIsCreating(true); - let workflowToCreate = { - ...props.workflow, - name: workflowName, - description: workflowDescription, - } as Workflow; - if (!isEmpty(quickConfigureFields)) { - workflowToCreate = injectQuickConfigureFields( - workflowToCreate, - quickConfigureFields, - embeddingModelInterface, - llmInterface - ); - } - 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); - }); - }} - data-testid="quickConfigureCreateButton" - fill={true} - color="primary" - > - Create - - -
+ {}} + validate={(values) => {}} + > + {(formikProps) => { + // update tempErrors if errors detected + useEffect(() => { + setTempErrors(!isEmpty(formikProps.errors)); + }, [formikProps.errors]); + + return ( + props.onClose()} style={{ width: '40vw' }}> + + +

{`Quick configure`}

+
+
+ + + + + + + + + {props.workflow?.ui_metadata?.type !== WORKFLOW_TYPE.CUSTOM && + isEmpty(deployedModels) && ( + + + Preset unavailable. You have no models registered in + your cluster.{' '} + + Learn more + {' '} + about integrating ML models. + + } + /> + + )} + {(props.workflow?.ui_metadata?.type === WORKFLOW_TYPE.RAG || + props.workflow?.ui_metadata?.type === + WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG) && + !isEmpty(deployedModels) && ( + + + setQuickConfigureFields({ + ...quickConfigureFields, + llmId: modelId, + }) + } + /> + + )} + {props.workflow?.ui_metadata?.type !== WORKFLOW_TYPE.CUSTOM && + props.workflow?.ui_metadata?.type !== WORKFLOW_TYPE.RAG && + !isEmpty(deployedModels) && ( + + <> + {unknownEmbeddingLength && ( + <> + + + + )} + + setQuickConfigureFields({ + ...quickConfigureFields, + embeddingModelId: modelId, + }) + } + /> + + + )} + + {props.workflow?.ui_metadata?.type !== WORKFLOW_TYPE.CUSTOM && ( + <> + + + + )} + + + props.onClose()} + data-testid="quickConfigureCancelButton" + > + Cancel + + { + formikProps.submitForm().then(() => { + formikProps.validateForm().then((resp) => { + if (isEmpty(resp)) { + setIsCreating(true); + let workflowToCreate = { + ...props.workflow, + name: getIn(formikProps.values, 'name'), + description: getIn(formikProps.values, 'description'), + } as Workflow; + if (!isEmpty(quickConfigureFields)) { + workflowToCreate = injectQuickConfigureFields( + workflowToCreate, + quickConfigureFields, + embeddingModelInterface, + llmInterface + ); + } + 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); + }); + } + }); + }); + }} + data-testid="quickConfigureCreateButton" + fill={true} + color="primary" + > + Create + + +
+ ); + }} +
); } diff --git a/public/pages/workflows/new_workflow/quick_configure_optional_fields.tsx b/public/pages/workflows/new_workflow/quick_configure_optional_fields.tsx new file mode 100644 index 00000000..54e641c2 --- /dev/null +++ b/public/pages/workflows/new_workflow/quick_configure_optional_fields.tsx @@ -0,0 +1,290 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { get } from 'lodash'; +import { + EuiCompressedFormRow, + EuiText, + EuiSpacer, + EuiCompressedSuperSelect, + EuiSuperSelectOption, + EuiAccordion, + EuiCompressedFieldText, + EuiCompressedFieldNumber, +} from '@elastic/eui'; +import { + DEFAULT_IMAGE_FIELD, + DEFAULT_LLM_RESPONSE_FIELD, + DEFAULT_TEXT_FIELD, + DEFAULT_VECTOR_FIELD, + MODEL_STATE, + Model, + ModelInterface, + QuickConfigureFields, + WORKFLOW_TYPE, +} from '../../../../common'; +import { AppState } from '../../../store'; +import { parseModelInputs } from '../../../utils'; + +interface QuickConfigureOptionalFieldsProps { + workflowType?: WORKFLOW_TYPE; + fields?: QuickConfigureFields; // the set of static/required fields permanently displayed in the modal. Includes all model info. + setFields(fields: QuickConfigureFields): void; +} + +// Dynamic list of optional quick-configuration fields based on the selected use case. Updates/adds +// to the parent QuickConfigureFields for auto-filling preset configurations. +export function QuickConfigureOptionalFields( + props: QuickConfigureOptionalFieldsProps +) { + const { models, connectors } = useSelector((state: AppState) => state.ml); + + // Deployed models state + const [deployedModels, setDeployedModels] = useState([]); + useEffect(() => { + if (models) { + setDeployedModels( + Object.values(models || {}).filter( + (model) => model.state === MODEL_STATE.DEPLOYED + ) + ); + } + }, [models]); + + // Local field values state + const [optionalFieldValues, setOptionalFieldValues] = useState< + QuickConfigureFields + >({}); + + // on initial load, set defaults for the field values for certain workflow types + useEffect(() => { + let defaultFieldValues = {} as QuickConfigureFields; + switch (props.workflowType) { + case WORKFLOW_TYPE.SEMANTIC_SEARCH: + case WORKFLOW_TYPE.HYBRID_SEARCH: { + defaultFieldValues = { + textField: DEFAULT_TEXT_FIELD, + vectorField: DEFAULT_VECTOR_FIELD, + }; + break; + } + case WORKFLOW_TYPE.MULTIMODAL_SEARCH: { + defaultFieldValues = { + textField: DEFAULT_TEXT_FIELD, + vectorField: DEFAULT_VECTOR_FIELD, + imageField: DEFAULT_IMAGE_FIELD, + }; + break; + } + case WORKFLOW_TYPE.RAG: { + defaultFieldValues = { + textField: DEFAULT_TEXT_FIELD, + promptField: '', + llmResponseField: DEFAULT_LLM_RESPONSE_FIELD, + }; + break; + } + case WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG: { + defaultFieldValues = { + textField: DEFAULT_TEXT_FIELD, + vectorField: DEFAULT_VECTOR_FIELD, + promptField: '', + llmResponseField: DEFAULT_LLM_RESPONSE_FIELD, + }; + break; + } + case WORKFLOW_TYPE.CUSTOM: + default: + break; + } + setOptionalFieldValues(defaultFieldValues); + }, []); + + // Selected LLM interface state. Used for exposing a dropdown + // of available model inputs to select from. + const [selectedLLMInterface, setSelectedLLMInterface] = useState< + ModelInterface | undefined + >(undefined); + useEffect(() => { + if (props.fields?.llmId) { + const selectedModel = deployedModels.find( + (model) => model.id === props.fields?.llmId + ); + setSelectedLLMInterface(selectedModel?.interface); + setOptionalFieldValues({ + ...optionalFieldValues, + promptField: get(parseModelInputs(selectedModel?.interface), '0.label'), + }); + } + }, [props.fields?.llmId, deployedModels, connectors]); + + // Override/add any optional fields set here + useEffect(() => { + props.setFields({ ...props.fields, ...optionalFieldValues }); + }, [optionalFieldValues]); + + return ( + + <> + + + { + setOptionalFieldValues({ + ...optionalFieldValues, + textField: e.target.value, + }); + }} + /> + + + {props.workflowType === WORKFLOW_TYPE.MULTIMODAL_SEARCH && ( + <> + + { + setOptionalFieldValues({ + ...optionalFieldValues, + imageField: e.target.value, + }); + }} + /> + + + + )} + {(props.workflowType === WORKFLOW_TYPE.SEMANTIC_SEARCH || + props.workflowType === WORKFLOW_TYPE.MULTIMODAL_SEARCH || + props.workflowType === WORKFLOW_TYPE.HYBRID_SEARCH || + props.workflowType === WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG) && ( + <> + + { + setOptionalFieldValues({ + ...optionalFieldValues, + vectorField: e.target.value, + }); + }} + /> + + + + { + setOptionalFieldValues({ + ...optionalFieldValues, + embeddingLength: Number(e.target.value), + }); + }} + /> + + + )} + {(props.workflowType === WORKFLOW_TYPE.RAG || + props.workflowType === WORKFLOW_TYPE.VECTOR_SEARCH_WITH_RAG) && ( + <> + + + ({ + value: option.label, + inputDisplay: ( + <> + {option.label} + + ), + dropdownDisplay: ( + <> + {option.label} + + {option.type} + + + ), + disabled: false, + } as EuiSuperSelectOption) + )} + valueOfSelected={optionalFieldValues?.promptField || ''} + onChange={(option: string) => { + setOptionalFieldValues({ + ...optionalFieldValues, + promptField: option, + }); + }} + isInvalid={false} + /> + + + + { + setOptionalFieldValues({ + ...optionalFieldValues, + llmResponseField: e.target.value, + }); + }} + /> + + + )} + + + ); +} diff --git a/public/store/reducers/opensearch_reducer.ts b/public/store/reducers/opensearch_reducer.ts index 952a5e27..2983919d 100644 --- a/public/store/reducers/opensearch_reducer.ts +++ b/public/store/reducers/opensearch_reducer.ts @@ -32,7 +32,6 @@ export const INITIAL_OPENSEARCH_STATE = { }; const OPENSEARCH_PREFIX = 'opensearch'; -const SET_OPENSEARCH_ERROR = `${OPENSEARCH_PREFIX}/setError`; const CAT_INDICES_ACTION = `${OPENSEARCH_PREFIX}/catIndices`; const GET_MAPPINGS_ACTION = `${OPENSEARCH_PREFIX}/mappings`; const SEARCH_INDEX_ACTION = `${OPENSEARCH_PREFIX}/search`; @@ -43,13 +42,6 @@ const GET_INGEST_PIPELINE_ACTION = `${OPENSEARCH_PREFIX}/getIngestPipeline`; const GET_SEARCH_PIPELINE_ACTION = `${OPENSEARCH_PREFIX}/getSearchPipeline`; const GET_INDEX_ACTION = `${OPENSEARCH_PREFIX}/getIndex`; -export const setOpenSearchError = createAsyncThunk( - SET_OPENSEARCH_ERROR, - async ({ error }: { error: string }, { rejectWithValue }) => { - return error; - } -); - export const catIndices = createAsyncThunk( CAT_INDICES_ACTION, async ( @@ -329,9 +321,6 @@ const opensearchSlice = createSlice({ state.loading = true; state.errorMessage = ''; }) - .addCase(setOpenSearchError.fulfilled, (state, action) => { - state.errorMessage = action.payload; - }) .addCase(catIndices.fulfilled, (state, action) => { const indicesMap = new Map(); action.payload.forEach((index: Index) => { diff --git a/public/utils/utils.ts b/public/utils/utils.ts index 9ce5392a..39bbcd53 100644 --- a/public/utils/utils.ts +++ b/public/utils/utils.ts @@ -215,7 +215,7 @@ export function getIngestPipelineErrors( if (processorResult.error?.reason !== undefined) { ingestPipelineErrors[idx] = { processorType: processorResult.processor_type, - errorMsg: processorResult.error.reason, + errorMsg: `Type: ${processorResult.processor_type}. Error: ${processorResult.error.reason}`, }; } }); @@ -223,16 +223,6 @@ export function getIngestPipelineErrors( return ingestPipelineErrors; } -export function formatIngestPipelineErrors( - errors: IngestPipelineErrors -): string { - let msg = 'Errors found with the following ingest processor(s):\n\n'; - Object.values(errors || {}).forEach((processorError, idx) => { - msg += `Processor type: ${processorError.processorType}. Error: ${processorError.errorMsg}\n\n`; - }); - return msg; -} - export function getSearchPipelineErrors( searchResponseVerbose: SearchResponseVerbose ): SearchPipelineErrors { @@ -241,23 +231,13 @@ export function getSearchPipelineErrors( if (processorResult?.error !== undefined) { searchPipelineErrors[idx] = { processorType: processorResult.processor_name, - errorMsg: processorResult.error, + errorMsg: `Type: ${processorResult.processor_name}. Error: ${processorResult.error}`, }; } }); return searchPipelineErrors; } -export function formatSearchPipelineErrors( - errors: IngestPipelineErrors -): string { - let msg = 'Errors found with the following search processor(s):\n\n'; - Object.values(errors || {}).forEach((processorError, idx) => { - msg += `Processor type: ${processorError.processorType}. Error: ${processorError.errorMsg}\n\n`; - }); - return msg; -} - // ML inference processors will use standard dot notation or JSONPath depending on the input. // We follow the same logic here to generate consistent results. export function generateTransform(