diff --git a/web/src/pages/agent/form/components/prompt-editor/index.tsx b/web/src/pages/agent/form/components/prompt-editor/index.tsx index 45ed906861..92edcf5674 100644 --- a/web/src/pages/agent/form/components/prompt-editor/index.tsx +++ b/web/src/pages/agent/form/components/prompt-editor/index.tsx @@ -24,7 +24,7 @@ import { cn } from '@/lib/utils'; import { JsonSchemaDataType } from '@/pages/agent/constant'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { Variable } from 'lucide-react'; -import { ReactNode, useCallback, useState } from 'react'; +import { forwardRef, ReactNode, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { EnterKeyPlugin } from './enter-key-plugin'; import { PasteHandlerPlugin } from './paste-handler-plugin'; @@ -129,17 +129,20 @@ function PromptContent({ ); } -export function PromptEditor({ - value, - onChange, - onBlur, - placeholder, - showToolbar, - multiLine = true, - extraOptions, - baseOptions, - types, -}: IProps) { +export const PromptEditor = forwardRef(function PromptEditor( + { + value, + onChange, + onBlur, + placeholder, + showToolbar, + multiLine = true, + extraOptions, + baseOptions, + types, + }: IProps, + ref: React.Ref, +) { const { t } = useTranslation(); const initialConfig: InitialConfigType = { namespace: 'PromptEditor', @@ -163,7 +166,7 @@ export function PromptEditor({ ); return ( -
+
@@ -202,4 +205,4 @@ export function PromptEditor({
); -} +}); diff --git a/web/src/pages/agent/form/components/prompt-editor/variable-node.tsx b/web/src/pages/agent/form/components/prompt-editor/variable-node.tsx index 97fedfdd23..5b5790ef36 100644 --- a/web/src/pages/agent/form/components/prompt-editor/variable-node.tsx +++ b/web/src/pages/agent/form/components/prompt-editor/variable-node.tsx @@ -38,7 +38,7 @@ export class VariableNode extends DecoratorNode { createDOM(): HTMLElement { const dom = document.createElement('span'); - dom.className = 'mr-1'; + dom.className = 'variable-node [&+.variable-node]:ml-[.25em]'; return dom; } @@ -53,16 +53,18 @@ export class VariableNode extends DecoratorNode { ); if (this.__parentLabel) { content = ( -
-
{this.__icon}
-
{this.__parentLabel}
-
/
+
+
+ {this.__icon} + {this.__parentLabel} +
+ {content}
); } return ( -
+
{content}
); diff --git a/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx b/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx index 822a77d9b6..5ea6564b1b 100644 --- a/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx +++ b/web/src/pages/agent/form/components/prompt-editor/variable-picker-plugin.tsx @@ -18,36 +18,50 @@ import { $getRoot, $getSelection, $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, TextNode, } from 'lexical'; import React, { + createContext, + ForwardedRef, + forwardRef, + HTMLAttributes, ReactElement, ReactNode, useCallback, + useContext, useEffect, + useMemo, useRef, } from 'react'; import * as ReactDOM from 'react-dom'; import { $createVariableNode } from './variable-node'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; import { JsonSchemaDataType, VariableRegex } from '@/pages/agent/constant'; import { useFindAgentStructuredOutputLabel, + useGetStructuredOutputByValue, useShowSecondaryMenu, } from '@/pages/agent/hooks/use-build-structured-output'; import { useFilterQueryVariableOptionsByTypes } from '@/pages/agent/hooks/use-get-begin-query'; -import { get } from 'lodash'; +import { LucideChevronRight } from 'lucide-react'; import { PromptIdentity } from '../../agent-form/use-build-prompt-options'; import { StructuredOutputSecondaryMenu } from '../structured-output-secondary-menu'; import { ProgrammaticTag } from './constant'; import './index.css'; -class VariableInnerOption extends MenuOption { + +const SelectedValueContext = createContext(''); + +class VariableOption extends MenuOption { label: string; value: string; parentLabel: string | JSX.Element; icon?: ReactNode; type?: string; + options?: VariableOption[]; constructor( label: string, @@ -65,15 +79,15 @@ class VariableInnerOption extends MenuOption { } } -class VariableOption extends MenuOption { +class VariableOptionGroup extends MenuOption { label: ReactElement | string; title: string; - options: VariableInnerOption[]; + options: VariableOption[]; constructor( label: ReactElement | string, title: string, - options: VariableInnerOption[], + options: VariableOption[], ) { super(title); this.label = label; @@ -82,66 +96,237 @@ class VariableOption extends MenuOption { } } -function VariablePickerMenuItem({ - index, - option, - selectOptionAndCleanUp, - types, -}: { - index: number; - option: VariableOption; - types?: JsonSchemaDataType[]; - selectOptionAndCleanUp: ( - option: VariableOption | VariableInnerOption, - ) => void; -}) { - const showSecondaryMenu = useShowSecondaryMenu(); +const VariablePickerOption = forwardRef(function VariablePickerOption( + { + option, + label, + hasSubMenu = false, + className, + onClick, + ...props + }: { + option: VariableOption; + label?: string; + hasSubMenu?: boolean; + className?: string; + onClick?: () => void; + } & HTMLAttributes, + ref: ForwardedRef, +) { + const selectedValue = useContext(SelectedValueContext); + const isSelected = option.value === selectedValue; return (
  • } key={option.key} - tabIndex={-1} - ref={option.setRefElement} + onClick={onClick} + className={cn( + 'px-2 py-1 text-text-primary rounded-sm flex justify-between items-center', + 'hover:bg-bg-card focus-visible:bg-bg-card', + isSelected && 'bg-bg-card', + className, + )} role="option" - id={'typeahead-item-' + index} + aria-label={option.label} + aria-selected={isSelected} > -
    - {option.title} -
      - {option.options.map((x) => { - const shouldShowSecondary = showSecondaryMenu(x.value, x.label); - - if (shouldShowSecondary) { - return ( - - selectOptionAndCleanUp({ - ...x, - ...y, - } as VariableInnerOption) - } - > - ); - } - - return ( -
    • selectOptionAndCleanUp(x)} - className="hover:bg-bg-card p-1 text-text-primary rounded-sm flex justify-between items-center" - > - {x.label} - {get(x, 'type')} -
    • - ); - })} -
    -
    + {label ?? option.label} + {option.type} + {hasSubMenu ? ( + + ) : null}
  • ); +}); + +// TODO: Stage 2 +/* +function VariableStructuredOptions({ + option, + children, + selectOptionAndCleanUp, +}: { + option: VariableOption; + children?: ReactNode; + selectOptionAndCleanUp: (option: VariableOption) => void; +}) { + const selectedValue = useContext(SelectedValueContext); + + const hasSelectedDescendant = useMemo(() => { + const _hasSelectedDescendant = (options: VariableOption[]): boolean => { + let result = false; + + for (const x of options) { + if (x.value === selectedValue) { + return true; + } + + if (x.options?.length) { + result = result || _hasSelectedDescendant(x.options); + } + } + + return result; + }; + + return _hasSelectedDescendant(option?.options ?? []); + }, [option?.options, selectedValue]); + + const renderStructuredOptions = useCallback((options?: VariableOption[], level = 0) => { + if (!options?.length) { + return null; + } + + return ( +
      0 && 'border-l !ml-2', + )} + > + {options.map((o) => { + if (o.options?.length) { + return ( +
      + selectOptionAndCleanUp(o)} + /> + + {renderStructuredOptions(o.options, level + 1)} +
      + ); + } + + return ( + selectOptionAndCleanUp(o)} + /> + ); + })} +
    + ); + }, []); + + return ( + + + selectOptionAndCleanUp(option)} + hasSubMenu + /> + + + + +
    +
    + {t('flow.structuredOutput.structuredOutput')} +
    + + {renderStructuredOptions(option?.options)} +
    +
    +
    +
    + ); +} +*/ + +function VariablePickerOptionGroup({ + title, + options = [], + selectOptionAndCleanUp, + types, +}: { + title?: string; + options?: VariableOption[]; + types?: JsonSchemaDataType[]; + selectOptionAndCleanUp: (option: VariableOption) => void; +}) { + const showSecondaryMenu = useShowSecondaryMenu(); + const selectedValue = useContext(SelectedValueContext); + + return ( +
      +
    • +
      + {title} +
      +
    • + + {options.map((x) => { + const shouldShowSecondary = showSecondaryMenu(x.value, x.label); + const isSelected = x.value === selectedValue; + + if (shouldShowSecondary) { + // TODO: Stage 2 + /* + if ( + !isEmpty(types) + && !hasSpecificTypeChild(x ?? {}, types) + && !types?.some((x) => x === JsonSchemaDataType.Object) + ) { + return null; + } + + return ( + + ); + */ + + return ( + + selectOptionAndCleanUp({ + ...x, + ...y, + } as VariableOption) + } + /> + ); + } + + return ( + selectOptionAndCleanUp(x)} + /> + ); + })} +
    + ); } export type VariablePickerMenuOptionType = { @@ -162,6 +347,7 @@ export type VariablePickerMenuPluginProps = { baseOptions?: VariablePickerMenuOptionType[]; types?: JsonSchemaDataType[]; }; + export default function VariablePickerMenuPlugin({ value, extraOptions, @@ -169,29 +355,29 @@ export default function VariablePickerMenuPlugin({ types, }: VariablePickerMenuPluginProps): JSX.Element { const [editor] = useLexicalComposerContext(); - + // const shouldShowSecondaryMenu = useShowSecondaryMenu(); const findAgentStructuredOutputLabel = useFindAgentStructuredOutputLabel(); - - // const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { - // minLength: 0, - // }); + const filterStructuredOutput = useGetStructuredOutputByValue(); const testTriggerFn = React.useCallback((text: string) => { - const lastChar = text.slice(-1); - if (lastChar === '/') { - console.log('Found trigger character "/"'); + const triggerRegex = /(^|\s|\()([/]((?:[^/\s\()])*))$/; + const match = triggerRegex.exec(text); + + if (match !== null) { + const mayLeadingWhitespace = match[1]; + return { - leadOffset: text.length - 1, - matchingString: '', - replaceableString: '/', + leadOffset: match.index + mayLeadingWhitespace.length, + matchingString: match[3], // This will send to onQueryChange() event handler + replaceableString: match[2], }; } + return null; }, []); const previousValue = useRef(); - - const [queryString, setQueryString] = React.useState(''); + const [queryString, setQueryString] = React.useState(''); let options = useFilterQueryVariableOptionsByTypes({ types }); @@ -199,40 +385,128 @@ export default function VariablePickerMenuPlugin({ options = baseOptions as typeof options; } - const buildNextOptions = useCallback(() => { - let filteredOptions = [...options, ...(extraOptions ?? [])]; - if (queryString) { - const lowerQuery = queryString.toLowerCase(); - filteredOptions = options - .map((x) => ({ - ...x, - options: x.options.filter( - (y) => - y.label.toLowerCase().includes(lowerQuery) || - y.value.toLowerCase().includes(lowerQuery), - ), - })) - .filter((x) => x.options.length > 0); - } - - const finalOptions: VariableOption[] = filteredOptions.map( - (x) => - new VariableOption( - x.label, - x.title, - x.options.map((y) => { - return new VariableInnerOption( - y.label, - y.value, - x.label, - y.icon, - y.type, - ); - }), + const unifiedOptions = useMemo(() => { + const allGroups = Array.from( + [...options, ...(extraOptions ?? [])], + (g) => + new VariableOptionGroup( + g.label, + g.title, + g.options as VariableOption[], ), ); - return finalOptions; - }, [extraOptions, options, queryString]); + + // TODO: Stage 2 + /* + const _treeify = (values: any, option: VariableOption): void | VariableOption[] => { + if (values == null) { + return; + } + + const properties = get(values, 'properties') || get(values, 'items.properties'); + + if (isPlainObject(values) && properties) { + option.options = Object.entries(properties).map(([key, value]) => { + const nextOption = new VariableOption( + `${option.label}.${key}`, + `${option.value}.${key}`, + option.label, + ); + + const { + dataType, + compositeDataType, + } = getStructuredDatatype(value); + + if ( + isEmpty(types) + || types?.some((x) => x === compositeDataType) + || hasSpecificTypeChild(value ?? {}, types) + ) { + + nextOption.type = compositeDataType; + + if ([JsonSchemaDataType.Object, JsonSchemaDataType.Array].some(x => x === dataType)) { + _treeify(value, nextOption)!; + } + } + + return nextOption; + }); + } + }; + */ + + const treeified = allGroups.map((group) => { + group.options = group.options.map((option) => { + const newOption = new VariableOption( + option.label, + option.value, + option.parentLabel, + option.icon, + option.type, + ); + + // TODO: Stage 2 + /* + if (shouldShowSecondaryMenu(newOption.value, newOption.label)) { + const structuredOutput = _treeify(filterStructuredOutput(newOption.value), newOption); + + if (structuredOutput) { + newOption.options = structuredOutput; + } + } + */ + + return newOption; + }); + + return group; + }); + + const filtered = treeified + .map((g) => ({ + ...g, + options: g.options.filter((y) => { + if (!queryString) { + return true; + } + + // TODO: Stage 2 + // Stage 1: Allow filtering by component label, such as: "agent_0.content", "retrieval_0.json", etc. + const parentLabel = + typeof y.parentLabel === 'string' + ? `${y.parentLabel.toLowerCase()}.` + : ''; + const thisLabel = `${parentLabel}${y.label.toLowerCase()}`; + const thisValue = `${parentLabel}${y.value.toLowerCase()}`; + + return ( + thisLabel.includes(queryString) || thisValue.includes(queryString) + ); + }), + })) + .filter((x) => x.options.length); + + const _flat = ( + option: VariableOption, + ): VariableOption | VariableOption[] => { + if (option.options) { + return [option, ...option.options.flatMap((x) => _flat(x))]; + } + + return option; + }; + + const flattened: VariableOption[] = filtered + .flatMap((x) => x?.options ?? []) + .flatMap((x) => _flat(x)); + + return { + treeified: filtered, + flattened, + }; + }, [queryString, options, extraOptions, filterStructuredOutput]); const findItemByValue = useCallback( (value: string) => { @@ -263,7 +537,7 @@ export default function VariablePickerMenuPlugin({ const onSelectOption = useCallback( ( - selectedOption: VariableInnerOption, + selectedOption: VariableOption, nodeToRemove: TextNode | null, closeMenu: () => void, ) => { @@ -278,7 +552,7 @@ export default function VariablePickerMenuPlugin({ nodeToRemove.remove(); } const variableNode = $createVariableNode( - (selectedOption as VariableInnerOption).value, + (selectedOption as VariableOption).value, selectedOption.label as string, selectedOption.parentLabel as string | ReactNode, selectedOption.icon as ReactNode, @@ -390,37 +664,53 @@ export default function VariablePickerMenuPlugin({ }, [editor]); return ( - - onQueryChange={setQueryString} + setQueryString(s?.toLowerCase() ?? '')} onSelectOption={(option, textNodeContainingQuery, closeMenu) => onSelectOption( - option as VariableInnerOption, // Only the second level menu can be selected + option as VariableOption, // Only the second level menu can be selected textNodeContainingQuery, closeMenu, ) } triggerFn={testTriggerFn} - options={buildNextOptions()} - menuRenderFn={(anchorElementRef, { selectOptionAndCleanUp }) => { - const nextOptions = buildNextOptions(); - return anchorElementRef.current && nextOptions.length - ? ReactDOM.createPortal( -
    -
      - {nextOptions.map((option, i: number) => ( - { + if (!anchorElementRef.current || !unifiedOptions.flattened.length) { + return null; + } + + return ReactDOM.createPortal( + +
      + +
      + {unifiedOptions.treeified.map((group) => ( + ))} -
    -
    , - anchorElementRef.current, - ) - : null; +
    + +
    + , + anchorElementRef.current, + ); }} /> ); diff --git a/web/src/pages/agent/form/components/structured-output-secondary-menu.tsx b/web/src/pages/agent/form/components/structured-output-secondary-menu.tsx index 5fcf6ed4e7..53dc04f762 100644 --- a/web/src/pages/agent/form/components/structured-output-secondary-menu.tsx +++ b/web/src/pages/agent/form/components/structured-output-secondary-menu.tsx @@ -3,11 +3,18 @@ import { HoverCardContent, HoverCardTrigger, } from '@/components/ui/hover-card'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { getStructuredDatatype } from '@/utils/canvas-util'; import { get, isEmpty, isPlainObject } from 'lodash'; import { ChevronRight } from 'lucide-react'; -import { PropsWithChildren, ReactNode, useCallback } from 'react'; +import { + ForwardedRef, + forwardRef, + PropsWithChildren, + ReactNode, + useCallback, +} from 'react'; import { useTranslation } from 'react-i18next'; import { JsonSchemaDataType } from '../../constant'; import { useGetStructuredOutputByValue } from '../../hooks/use-build-structured-output'; @@ -16,129 +23,143 @@ import { hasSpecificTypeChild } from '../../utils/filter-agent-structured-output type DataItem = { label: ReactNode; value: string; parentLabel?: ReactNode }; type StructuredOutputSecondaryMenuProps = { + className?: string; data: DataItem; click(option: { label: ReactNode; value: string }): void; types?: JsonSchemaDataType[]; } & PropsWithChildren; -export function StructuredOutputSecondaryMenu({ - data, - click, - types = [], -}: StructuredOutputSecondaryMenuProps) { - const { t } = useTranslation(); - const filterStructuredOutput = useGetStructuredOutputByValue(); - const structuredOutput = filterStructuredOutput(data.value); - - const handleSubMenuClick = useCallback( - (option: { label: ReactNode; value: string }, dataType?: string) => () => { - // The query variable of the iteration operator can only select array type data. - if ( - (!isEmpty(types) && types?.some((x) => x === dataType)) || - isEmpty(types) - ) { - click(option); - } - }, - [click, types], - ); - - const handleMenuClick = useCallback(() => { - if (isEmpty(types) || types?.some((x) => x === JsonSchemaDataType.Object)) { - click(data); - } - }, [click, data, types]); - - const renderAgentStructuredOutput = useCallback( - (values: any, option: { label: ReactNode; value: string }) => { - const properties = - get(values, 'properties') || get(values, 'items.properties'); - - if (isPlainObject(values) && properties) { - return ( -
      - {Object.entries(properties).map(([key, value]) => { - const nextOption = { - label: option.label + `.${key}`, - value: option.value + `.${key}`, - }; - - const { dataType, compositeDataType } = - getStructuredDatatype(value); - - if ( - isEmpty(types) || - (!isEmpty(types) && - (types?.some((x) => x === compositeDataType) || - hasSpecificTypeChild(value ?? {}, types))) - ) { - return ( -
    • -
      - {key} - - {compositeDataType} - -
      - {[JsonSchemaDataType.Object, JsonSchemaDataType.Array].some( - (x) => x === dataType, - ) && renderAgentStructuredOutput(value, nextOption)} -
    • - ); - } - - return null; - })} -
    - ); - } - - return
    ; - }, - [handleSubMenuClick, types], - ); - - if ( - !isEmpty(types) && - !hasSpecificTypeChild(structuredOutput, types) && - !types.some((x) => x === JsonSchemaDataType.Object) +export const StructuredOutputSecondaryMenu = forwardRef( + function StructuredOutputSecondaryMenu( + { className, data, click, types = [] }: StructuredOutputSecondaryMenuProps, + ref: ForwardedRef, ) { - return null; - } + const { t } = useTranslation(); + const filterStructuredOutput = useGetStructuredOutputByValue(); + const structuredOutput = filterStructuredOutput(data.value); - return ( - - -
  • + () => { + // The query variable of the iteration operator can only select array type data. + if ( + (!isEmpty(types) && types?.some((x) => x === dataType)) || + isEmpty(types) + ) { + click(option); + } + }, + [click, types], + ); + + const handleMenuClick = useCallback(() => { + if ( + isEmpty(types) || + types?.some((x) => x === JsonSchemaDataType.Object) + ) { + click(data); + } + }, [click, data, types]); + + const renderAgentStructuredOutput = useCallback( + (values: any, option: { label: ReactNode; value: string }) => { + const properties = + get(values, 'properties') || get(values, 'items.properties'); + + if (isPlainObject(values) && properties) { + return ( +
      + {Object.entries(properties).map(([key, value]) => { + const nextOption = { + label: option.label + `.${key}`, + value: option.value + `.${key}`, + }; + + const { dataType, compositeDataType } = + getStructuredDatatype(value); + + if ( + isEmpty(types) || + (!isEmpty(types) && + (types?.some((x) => x === compositeDataType) || + hasSpecificTypeChild(value ?? {}, types))) + ) { + return ( +
    • +
      + {key} + + {compositeDataType} + +
      + {[ + JsonSchemaDataType.Object, + JsonSchemaDataType.Array, + ].some((x) => x === dataType) && + renderAgentStructuredOutput(value, nextOption)} +
    • + ); + } + + return null; + })} +
    + ); + } + + return
    ; + }, + [handleSubMenuClick, types], + ); + + if ( + !isEmpty(types) && + !hasSpecificTypeChild(structuredOutput, types) && + !types.some((x) => x === JsonSchemaDataType.Object) + ) { + return null; + } + + return ( + + +
  • + {data.label} + object + +
  • +
    + + -
    - {data.label} object -
    - - - - -
    -
    - {t('flow.structuredOutput.structuredOutput')} -
    - {renderAgentStructuredOutput(structuredOutput, data)} -
    -
    -
    - ); -} + +
    +
    + {t('flow.structuredOutput.structuredOutput')} +
    + + {renderAgentStructuredOutput(structuredOutput, data)} +
    +
    + + + ); + }, +); diff --git a/web/src/pages/agent/hooks/use-get-begin-query.tsx b/web/src/pages/agent/hooks/use-get-begin-query.tsx index 4c65c631cd..4c4c73f440 100644 --- a/web/src/pages/agent/hooks/use-get-begin-query.tsx +++ b/web/src/pages/agent/hooks/use-get-begin-query.tsx @@ -152,7 +152,15 @@ export function useBuildBeginDynamicVariableOptions() { options: inputs.map((x) => ({ label: x.name, parentLabel: {t('flow.beginInput')}, - icon: , + icon: ( + + ), value: `begin@${x.key}`, type: transferToVariableType(x.type), })), @@ -174,7 +182,15 @@ export function useBuildGlobalWithBeginVariableOptions() { .map(([key, value]) => ({ label: key, value: key, - icon: , + icon: ( + + ), parentLabel: {t('flow.beginInput')}, type: Array.isArray(value) ? `${VariableType.Array}${key === AgentGlobals.SysFiles ? '' : ''}`