From 5059d3db186a8661ec61736fdbe5fe72fb26817c Mon Sep 17 00:00:00 2001 From: balibabu Date: Thu, 30 Oct 2025 19:06:44 +0800 Subject: [PATCH] Feat: The query variables of the subsequent operators can reference the structured variables defined in the agent operator. #10866 (#10902) ### What problem does this PR solve? Feat: The query variables of the subsequent operators can reference the structured variables defined in the agent operator. #10866 ### Type of change - [x] New Feature (non-breaking change which adds functionality) --- .../prompt-editor/variable-node.tsx | 2 +- .../prompt-editor/variable-picker-plugin.tsx | 2 +- web/src/locales/en.ts | 4 +- web/src/locales/zh.ts | 2 +- web/src/pages/agent/constant/index.tsx | 5 +- .../prompt-editor/variable-picker-plugin.tsx | 144 +++--------- .../agent/form/components/query-variable.tsx | 8 +- .../components/select-with-secondary-menu.tsx | 206 ++++++++++++++++++ .../structured-output-secondary-menu.tsx | 80 +++++++ .../pages/agent/form/invoke-form/index.tsx | 5 +- .../hooks/use-build-structured-output.ts | 92 ++++++++ .../filter-agent-structured-output.ts} | 0 12 files changed, 423 insertions(+), 127 deletions(-) create mode 100644 web/src/pages/agent/form/components/select-with-secondary-menu.tsx create mode 100644 web/src/pages/agent/form/components/structured-output-secondary-menu.tsx create mode 100644 web/src/pages/agent/hooks/use-build-structured-output.ts rename web/src/pages/agent/{form/components/prompt-editor/utils.ts => utils/filter-agent-structured-output.ts} (100%) diff --git a/web/src/components/prompt-editor/variable-node.tsx b/web/src/components/prompt-editor/variable-node.tsx index e2a8cc29f..6d2e31637 100644 --- a/web/src/components/prompt-editor/variable-node.tsx +++ b/web/src/components/prompt-editor/variable-node.tsx @@ -1,5 +1,5 @@ import i18n from '@/locales/config'; -import { BeginId } from '@/pages/flow/constant'; +import { BeginId } from '@/pages/agent/constant'; import { DecoratorNode, LexicalNode, NodeKey } from 'lexical'; import { ReactNode } from 'react'; const prefix = BeginId + '@'; diff --git a/web/src/components/prompt-editor/variable-picker-plugin.tsx b/web/src/components/prompt-editor/variable-picker-plugin.tsx index 6e92d14f9..a47eb9257 100644 --- a/web/src/components/prompt-editor/variable-picker-plugin.tsx +++ b/web/src/components/prompt-editor/variable-picker-plugin.tsx @@ -114,7 +114,7 @@ export default function VariablePickerMenuPlugin({ minLength: 0, }); - const [queryString, setQueryString] = React.useState(''); + const [queryString, setQueryString] = React.useState(''); const options = useBuildComponentIdSelectOptions(node?.id, node?.parentId); diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index e81f41c4a..0dcc84163 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1623,7 +1623,7 @@ This delimiter is used to split the input text into several text pieces echo of extractorDescription: 'Use an LLM to extract structured insights from document chunks—such as summaries, classifications, etc.', outputFormat: 'Output format', - fileFormats: 'File format', + fileFormats: 'File type', fileFormatOptions: { pdf: 'PDF', spreadsheet: 'Spreadsheet', @@ -1644,7 +1644,7 @@ This delimiter is used to split the input text into several text pieces echo of searchMethodTip: `Defines how the content can be searched — by full-text, embedding, or both. The Indexer will store the content in the corresponding data structures for the selected methods.`, // file: 'File', - parserMethod: 'Parsing method', + parserMethod: 'PDF parser', // systemPrompt: 'System Prompt', systemPromptPlaceholder: 'Enter system prompt for image analysis, if empty the system default value will be used', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index af4dc7a68..c1057433c 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1529,7 +1529,7 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于 extractorDescription: '使用 LLM 从文档块(例如摘要、分类等)中提取结构化见解。', outputFormat: '输出格式', - fileFormats: '文件格式', + fileFormats: '文件类型', fields: '字段', addParser: '增加解析器', hierarchy: '层次结构', diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 0f9de04ed..8ac12185f 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -617,7 +617,10 @@ export const initialAgentValues = { type: 'string', value: '', }, - [AgentStructuredOutputField]: {}, + [AgentStructuredOutputField]: { + type: 'Object Array String Number Boolean', + value: '', + }, }, }; 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 92679be71..4e2fa9008 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 @@ -31,20 +31,15 @@ import * as ReactDOM from 'react-dom'; import { $createVariableNode } from './variable-node'; import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from '@/components/ui/hover-card'; -import { Operator } from '@/constants/agent'; -import { cn } from '@/lib/utils'; -import { AgentStructuredOutputField } from '@/pages/agent/constant'; + useFilterStructuredOutputByValue, + useFindAgentStructuredOutputLabel, + useShowSecondaryMenu, +} from '@/pages/agent/hooks/use-build-structured-output'; import { useBuildQueryVariableOptions } from '@/pages/agent/hooks/use-get-begin-query'; -import useGraphStore from '@/pages/agent/store'; -import { get, isPlainObject } from 'lodash'; import { PromptIdentity } from '../../agent-form/use-build-prompt-options'; +import { StructuredOutputSecondaryMenu } from '../structured-output-secondary-menu'; import { ProgrammaticTag } from './constant'; import './index.css'; -import { filterAgentStructuredOutput } from './utils'; class VariableInnerOption extends MenuOption { label: string; value: string; @@ -82,10 +77,6 @@ class VariableOption extends MenuOption { } } -function getNodeId(value: string) { - return value.split('@').at(0); -} - function VariablePickerMenuItem({ index, option, @@ -97,58 +88,9 @@ function VariablePickerMenuItem({ option: VariableOption | VariableInnerOption, ) => void; }) { - const { getOperatorTypeFromId, getNode, clickedNodeId } = useGraphStore( - (state) => state, - ); + const filterStructuredOutput = useFilterStructuredOutputByValue(); - const showSecondaryMenu = useCallback( - (value: string, outputLabel: string) => { - const nodeId = getNodeId(value); - return ( - getOperatorTypeFromId(nodeId) === Operator.Agent && - outputLabel === AgentStructuredOutputField - ); - }, - [getOperatorTypeFromId], - ); - - const renderAgentStructuredOutput = useCallback( - (values: any, option: VariableInnerOption) => { - if (isPlainObject(values) && 'properties' in values) { - return ( -
    - {Object.entries(values.properties).map(([key, value]) => { - const nextOption = new VariableInnerOption( - option.label + `.${key}`, - option.value + `.${key}`, - option.parentLabel, - option.icon, - ); - - const dataType = get(value, 'type'); - - return ( -
  • -
    selectOptionAndCleanUp(nextOption)} - className="hover:bg-bg-card p-1 text-text-primary rounded-sm flex justify-between" - > - {key} - {dataType} -
    - {dataType === 'object' && - renderAgentStructuredOutput(value, nextOption)} -
  • - ); - })} -
- ); - } - - return
; - }, - [selectOptionAndCleanUp], - ); + const showSecondaryMenu = useShowSecondaryMenu(); return (
  • - -
  • - {x.label} -
  • - - -
    -
    - {x.parentLabel} structured output: -
    - {renderAgentStructuredOutput(filteredStructuredOutput, x)} -
    -
    - + + selectOptionAndCleanUp({ + ...x, + ...y, + } as VariableInnerOption) + } + filteredStructuredOutput={filteredStructuredOutput} + > ); } @@ -239,9 +162,8 @@ export default function VariablePickerMenuPlugin({ baseOptions, }: VariablePickerMenuPluginProps): JSX.Element { const [editor] = useLexicalComposerContext(); - const getOperatorTypeFromId = useGraphStore( - (state) => state.getOperatorTypeFromId, - ); + + const findAgentStructuredOutputLabel = useFindAgentStructuredOutputLabel(); // const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { // minLength: 0, @@ -313,27 +235,17 @@ export default function VariablePickerMenuPlugin({ }, []); // agent structured output - const fields = value.split('@'); - if ( - getOperatorTypeFromId(fields.at(0)) === Operator.Agent && - fields.at(1)?.startsWith(AgentStructuredOutputField) - ) { - // is agent structured output - const agentOption = children.find((x) => value.includes(x.value)); - const jsonSchemaFields = fields - .at(1) - ?.slice(AgentStructuredOutputField.length); - - return { - ...agentOption, - label: (agentOption?.label ?? '') + jsonSchemaFields, - value: value, - }; + const agentStructuredOutput = findAgentStructuredOutputLabel( + value, + children, + ); + if (agentStructuredOutput) { + return agentStructuredOutput; } return children.find((x) => x.value === value); }, - [getOperatorTypeFromId, options], + [findAgentStructuredOutputLabel, options], ); const onSelectOption = useCallback( diff --git a/web/src/pages/agent/form/components/query-variable.tsx b/web/src/pages/agent/form/components/query-variable.tsx index dafcb4cde..327e6ce6b 100644 --- a/web/src/pages/agent/form/components/query-variable.tsx +++ b/web/src/pages/agent/form/components/query-variable.tsx @@ -1,4 +1,3 @@ -import { SelectWithSearch } from '@/components/originui/select-with-search'; import { FormControl, FormField, @@ -12,6 +11,7 @@ import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { VariableType } from '../../constant'; import { useBuildQueryVariableOptions } from '../../hooks/use-get-begin-query'; +import { GroupedSelectWithSecondaryMenu } from './select-with-secondary-menu'; type QueryVariableProps = { name?: string; @@ -52,11 +52,11 @@ export function QueryVariable({ )} - + // allowClear + > diff --git a/web/src/pages/agent/form/components/select-with-secondary-menu.tsx b/web/src/pages/agent/form/components/select-with-secondary-menu.tsx new file mode 100644 index 000000000..f20f3a445 --- /dev/null +++ b/web/src/pages/agent/form/components/select-with-secondary-menu.tsx @@ -0,0 +1,206 @@ +import { Button } from '@/components/ui/button'; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { ChevronDown, X } from 'lucide-react'; +import * as React from 'react'; +import { useCallback } from 'react'; +import { + useFilterStructuredOutputByValue, + useFindAgentStructuredOutputLabel, + useShowSecondaryMenu, +} from '../../hooks/use-build-structured-output'; +import { StructuredOutputSecondaryMenu } from './structured-output-secondary-menu'; + +type Item = { + label: string; + value: string; +}; + +type Option = { + label: string; + value: string; + children?: Item[]; +}; + +type Group = { + label: string | React.ReactNode; + options: Option[]; +}; + +interface GroupedSelectWithSecondaryMenuProps { + options: Group[]; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; +} + +export function GroupedSelectWithSecondaryMenu({ + options, + value, + onChange, + placeholder = 'Select an option...', +}: GroupedSelectWithSecondaryMenuProps) { + const [open, setOpen] = React.useState(false); + + const showSecondaryMenu = useShowSecondaryMenu(); + const filterStructuredOutput = useFilterStructuredOutputByValue(); + const findAgentStructuredOutputLabel = useFindAgentStructuredOutputLabel(); + + // Find the label of the selected item + const flattenedOptions = options.flatMap((g) => g.options); + let selectedLabel = + flattenedOptions + .flatMap((o) => [o, ...(o.children || [])]) + .find((o) => o.value === value)?.label || ''; + + if (!selectedLabel && value) { + selectedLabel = + findAgentStructuredOutputLabel(value, flattenedOptions)?.label ?? ''; + } + + // Handle clear click + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange?.(''); + setOpen(false); + }; + + const handleSecondaryMenuClick = useCallback( + (record: Item) => { + onChange?.(record.value); + setOpen(false); + }, + [onChange], + ); + + return ( + + + + + + + + + + {options.map((group, idx) => ( + + {group.options.map((option) => { + const shouldShowSecondary = showSecondaryMenu( + option.value, + option.label, + ); + + if (shouldShowSecondary) { + const filteredStructuredOutput = filterStructuredOutput( + option.value, + ); + return ( + + ); + } + + return option.children ? ( + + + {}} + className="flex items-center justify-between cursor-default" + > + {option.label} + + › + + + + + {option.children.map((child) => ( +
    { + onChange?.(child.value); + setOpen(false); + }} + > + {child.label} +
    + ))} +
    +
    + ) : ( + { + onChange?.(option.value); + setOpen(false); + }} + className={cn( + value === option.value && + 'bg-accent text-accent-foreground', + )} + > + {option.label} + + ); + })} +
    + ))} +
    +
    +
    +
    + ); +} 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 new file mode 100644 index 000000000..99ce28c5b --- /dev/null +++ b/web/src/pages/agent/form/components/structured-output-secondary-menu.tsx @@ -0,0 +1,80 @@ +import { JSONSchema } from '@/components/jsonjoy-builder'; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '@/components/ui/hover-card'; +import { cn } from '@/lib/utils'; +import { get, isPlainObject } from 'lodash'; +import { PropsWithChildren, ReactNode, useCallback } from 'react'; + +type DataItem = { label: ReactNode; value: string; parentLabel?: ReactNode }; + +type StructuredOutputSecondaryMenuProps = { + data: DataItem; + click(option: { label: ReactNode; value: string }): void; + filteredStructuredOutput: JSONSchema; +} & PropsWithChildren; +export function StructuredOutputSecondaryMenu({ + data, + click, + filteredStructuredOutput, +}: StructuredOutputSecondaryMenuProps) { + const renderAgentStructuredOutput = useCallback( + (values: any, option: { label: ReactNode; value: string }) => { + if (isPlainObject(values) && 'properties' in values) { + return ( +
      + {Object.entries(values.properties).map(([key, value]) => { + const nextOption = { + label: option.label + `.${key}`, + value: option.value + `.${key}`, + }; + + const dataType = get(value, 'type'); + + return ( +
    • +
      click(nextOption)} + className="hover:bg-bg-card p-1 text-text-primary rounded-sm flex justify-between" + > + {key} + {dataType} +
      + {dataType === 'object' && + renderAgentStructuredOutput(value, nextOption)} +
    • + ); + })} +
    + ); + } + + return
    ; + }, + [click], + ); + + return ( + + +
  • + {data.label} +
  • +
    + +
    +
    {data?.parentLabel} structured output:
    + {renderAgentStructuredOutput(filteredStructuredOutput, data)} +
    +
    +
    + ); +} diff --git a/web/src/pages/agent/form/invoke-form/index.tsx b/web/src/pages/agent/form/invoke-form/index.tsx index 594eb1b08..cd92b82d1 100644 --- a/web/src/pages/agent/form/invoke-form/index.tsx +++ b/web/src/pages/agent/form/invoke-form/index.tsx @@ -2,6 +2,7 @@ import { Collapse } from '@/components/collapse'; import { FormContainer } from '@/components/form-container'; import NumberInput from '@/components/originui/number-input'; import { SelectWithSearch } from '@/components/originui/select-with-search'; +import { useIsDarkTheme } from '@/components/theme-provider'; import { Button } from '@/components/ui/button'; import { Form, @@ -86,6 +87,8 @@ function InvokeForm({ node }: INextOperatorForm) { const variables = useWatch({ control: form.control, name: 'variables' }); + const isDarkTheme = useIsDarkTheme(); + useWatchFormChange(node?.id, form); return ( @@ -147,7 +150,7 @@ function InvokeForm({ node }: INextOperatorForm) { diff --git a/web/src/pages/agent/hooks/use-build-structured-output.ts b/web/src/pages/agent/hooks/use-build-structured-output.ts new file mode 100644 index 000000000..33d40f9d3 --- /dev/null +++ b/web/src/pages/agent/hooks/use-build-structured-output.ts @@ -0,0 +1,92 @@ +import { get } from 'lodash'; +import { ReactNode, useCallback } from 'react'; +import { AgentStructuredOutputField, Operator } from '../constant'; +import useGraphStore from '../store'; +import { filterAgentStructuredOutput } from '../utils/filter-agent-structured-output'; + +function getNodeId(value: string) { + return value.split('@').at(0); +} + +export function useShowSecondaryMenu() { + const { getOperatorTypeFromId } = useGraphStore((state) => state); + + const showSecondaryMenu = useCallback( + (value: string, outputLabel: string) => { + const nodeId = getNodeId(value); + return ( + getOperatorTypeFromId(nodeId) === Operator.Agent && + outputLabel === AgentStructuredOutputField + ); + }, + [getOperatorTypeFromId], + ); + + return showSecondaryMenu; +} + +export function useFilterStructuredOutputByValue() { + const { getOperatorTypeFromId, getNode, clickedNodeId } = useGraphStore( + (state) => state, + ); + + const filterStructuredOutput = useCallback( + (value: string) => { + const node = getNode(getNodeId(value)); + const structuredOutput = get( + node, + `data.form.outputs.${AgentStructuredOutputField}`, + ); + + const filteredStructuredOutput = filterAgentStructuredOutput( + structuredOutput, + getOperatorTypeFromId(clickedNodeId), + ); + + return filteredStructuredOutput; + }, + [clickedNodeId, getNode, getOperatorTypeFromId], + ); + + return filterStructuredOutput; +} + +export function useFindAgentStructuredOutputLabel() { + const getOperatorTypeFromId = useGraphStore( + (state) => state.getOperatorTypeFromId, + ); + + const findAgentStructuredOutputLabel = useCallback( + ( + value: string, + options: Array<{ + label: string; + value: string; + parentLabel?: string | ReactNode; + icon?: ReactNode; + }>, + ) => { + // agent structured output + const fields = value.split('@'); + if ( + getOperatorTypeFromId(fields.at(0)) === Operator.Agent && + fields.at(1)?.startsWith(AgentStructuredOutputField) + ) { + // is agent structured output + const agentOption = options.find((x) => value.includes(x.value)); + const jsonSchemaFields = fields + .at(1) + ?.slice(AgentStructuredOutputField.length); + + return { + ...agentOption, + label: (agentOption?.label ?? '') + jsonSchemaFields, + value: value, + }; + } + }, + [getOperatorTypeFromId], + ); + + return findAgentStructuredOutputLabel; +} diff --git a/web/src/pages/agent/form/components/prompt-editor/utils.ts b/web/src/pages/agent/utils/filter-agent-structured-output.ts similarity index 100% rename from web/src/pages/agent/form/components/prompt-editor/utils.ts rename to web/src/pages/agent/utils/filter-agent-structured-output.ts