refactor: ux improvements for variable picker in prompt editor (#13213)

### What problem does this PR solve?

User experience enhancement for variable picker in prompt editor:

- Add case-insensitive string search for variables.
- Add basic keyboard navigation in variable picker:
   - Hit <kbd>UpArrow</kbd> and <kbd>DownArrow</kbd> for navigating.
- Hit <kbd>Tab</kbd> or <kbd>Enter</kbd> for selecting focused item into
editor.
- Fix unexpectedly inserting invalid variable into editor by hitting
<kbd>Tab</kbd>.

_Note: you still need to pick variables inside secondary menu (agent
structured output, etc.) by using your pointing device. May finish these
later._

### Type of change

- [x] Refactoring
This commit is contained in:
Jimmy Ben Klieve
2026-02-25 17:22:48 +08:00
committed by GitHub
parent 394ff16b66
commit 220e611e33
5 changed files with 599 additions and 267 deletions

View File

@@ -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<HTMLDivElement>,
) {
const { t } = useTranslation();
const initialConfig: InitialConfigType = {
namespace: 'PromptEditor',
@@ -163,7 +166,7 @@ export function PromptEditor({
);
return (
<div className="relative">
<div ref={ref} className="relative">
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={
@@ -176,10 +179,10 @@ export function PromptEditor({
placeholder={
<div
className={cn(
'absolute top-1 left-2 text-text-disabled pointer-events-none',
'-z-10 absolute top-1 left-2 text-text-disabled pointer-events-none',
{
'truncate w-[90%]': !multiLine,
'translate-y-10': multiLine,
'translate-y-9': multiLine,
},
)}
>
@@ -202,4 +205,4 @@ export function PromptEditor({
</LexicalComposer>
</div>
);
}
});

View File

@@ -38,7 +38,7 @@ export class VariableNode extends DecoratorNode<ReactNode> {
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<ReactNode> {
);
if (this.__parentLabel) {
content = (
<div className="flex items-center gap-1 text-text-primary ">
<div>{this.__icon}</div>
<div>{this.__parentLabel}</div>
<div className="text-text-disabled mr-1">/</div>
<div className="flex items-center gap-1 text-text-primary">
<div className="contents after:content-['/'] after:text-text-disabled">
{this.__icon}
{this.__parentLabel}
</div>
{content}
</div>
);
}
return (
<div className="bg-accent-primary-5 text-sm inline-flex items-center rounded-md px-2 py-1">
<div className="bg-accent-primary-5 text-sm inline-flex items-center rounded-md px-2 py-1 align-middle">
{content}
</div>
);

View File

@@ -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<string>('');
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<HTMLLIElement>,
ref: ForwardedRef<HTMLElement>,
) {
const selectedValue = useContext(SelectedValueContext);
const isSelected = option.value === selectedValue;
return (
<li
{...props}
ref={ref as ForwardedRef<HTMLLIElement>}
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}
>
<div>
<span className="text text-text-secondary">{option.title}</span>
<ul className="pl-2 py-1">
{option.options.map((x) => {
const shouldShowSecondary = showSecondaryMenu(x.value, x.label);
if (shouldShowSecondary) {
return (
<StructuredOutputSecondaryMenu
key={x.value}
data={x}
types={types}
click={(y) =>
selectOptionAndCleanUp({
...x,
...y,
} as VariableInnerOption)
}
></StructuredOutputSecondaryMenu>
);
}
return (
<li
key={x.value}
onClick={() => selectOptionAndCleanUp(x)}
className="hover:bg-bg-card p-1 text-text-primary rounded-sm flex justify-between items-center"
>
<span className="truncate flex-1 min-w-0">{x.label}</span>
<span className="text-text-secondary">{get(x, 'type')}</span>
</li>
);
})}
</ul>
</div>
<span className="truncate flex-1 min-w-0">{label ?? option.label}</span>
<span className="text-text-secondary text-xs">{option.type}</span>
{hasSubMenu ? (
<LucideChevronRight className="ml-2 size-[1em] text-text-secondary" />
) : null}
</li>
);
});
// 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 (
<ul
className={cn(
level > 0 && 'border-l !ml-2',
)}
>
{options.map((o) => {
if (o.options?.length) {
return (
<div key={o.key}>
<VariablePickerOption
key={o.key}
ref={o.setRefElement}
option={o}
label={o.label.split('.').pop()}
className={o.value === selectedValue ? 'bg-bg-card' : ''}
onClick={() => selectOptionAndCleanUp(o)}
/>
{renderStructuredOptions(o.options, level + 1)}
</div>
);
}
return (
<VariablePickerOption
key={o.key}
ref={o.setRefElement}
option={o}
label={o.label.split('.').pop()}
onClick={() => selectOptionAndCleanUp(o)}
/>
);
})}
</ul>
);
}, []);
return (
<HoverCard
open={hasSelectedDescendant || undefined}
openDelay={100}
closeDelay={100}
>
<HoverCardTrigger asChild>
<VariablePickerOption
key={option?.key}
ref={option?.setRefElement}
option={option}
onClick={() => selectOptionAndCleanUp(option)}
hasSubMenu
/>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className="bg-bg-base min-w-72 border-0.5 border-border rounded-md shadow-lg p-0"
>
<ScrollArea className="p-2">
<section>
<header className="mb-2 text-text-secondary text-xs">
{t('flow.structuredOutput.structuredOutput')}
</header>
{renderStructuredOptions(option?.options)}
</section>
</ScrollArea>
</HoverCardContent>
</HoverCard>
);
}
*/
function VariablePickerOptionGroup({
title,
options = [],
selectOptionAndCleanUp,
types,
}: {
title?: string;
options?: VariableOption[];
types?: JsonSchemaDataType[];
selectOptionAndCleanUp: (option: VariableOption) => void;
}) {
const showSecondaryMenu = useShowSecondaryMenu();
const selectedValue = useContext(SelectedValueContext);
return (
<ul role="group" aria-label={title}>
<li key={title} role="presentation">
<div className="text-xs text-text-secondary cursor-auto py-1">
{title}
</div>
</li>
{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 (
<VariableStructuredOptions
key={`subMenu:${x.key}`}
option={x}
selectOptionAndCleanUp={selectOptionAndCleanUp}
/>
);
*/
return (
<StructuredOutputSecondaryMenu
ref={x.setRefElement}
key={x.value}
data={x}
types={types}
className={isSelected ? 'bg-bg-card' : ''}
click={(y) =>
selectOptionAndCleanUp({
...x,
...y,
} as VariableOption)
}
/>
);
}
return (
<VariablePickerOption
key={x.key}
ref={x.setRefElement}
option={x}
onClick={() => selectOptionAndCleanUp(x)}
/>
);
})}
</ul>
);
}
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<string | undefined>();
const [queryString, setQueryString] = React.useState<string | null>('');
const [queryString, setQueryString] = React.useState<string>('');
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 (
<LexicalTypeaheadMenuPlugin<VariableOption | VariableInnerOption>
onQueryChange={setQueryString}
<LexicalTypeaheadMenuPlugin
// Lift the priority to the highest level to prevent the default Enter key behavior from being triggered
commandPriority={COMMAND_PRIORITY_CRITICAL}
onQueryChange={(s) => 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(
<div className="typeahead-popover w-80 p-2 bg-bg-base">
<ul className="scroll-auto overflow-x-hidden">
{nextOptions.map((option, i: number) => (
<VariablePickerMenuItem
index={i}
key={option.key}
option={option}
options={unifiedOptions.flattened}
menuRenderFn={(
anchorElementRef,
{ selectOptionAndCleanUp, options, selectedIndex },
) => {
if (!anchorElementRef.current || !unifiedOptions.flattened.length) {
return null;
}
return ReactDOM.createPortal(
<SelectedValueContext.Provider
value={
(selectedIndex !== null &&
(options[selectedIndex] as VariableOption)?.value) ||
''
}
>
<div className="typeahead-popover w-80 bg-bg-base border-0.5 border-border">
<ScrollArea className="p-2">
<div className="max-h-64 space-y-2">
{unifiedOptions.treeified.map((group) => (
<VariablePickerOptionGroup
key={group.title}
title={group.title}
options={group.options}
types={types}
selectOptionAndCleanUp={selectOptionAndCleanUp}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null;
</div>
</ScrollArea>
</div>
</SelectedValueContext.Provider>,
anchorElementRef.current,
);
}}
/>
);

View File

@@ -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 (
<ul className="border-l">
{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 (
<li key={key} className="pl-1">
<div
onClick={handleSubMenuClick(
nextOption,
compositeDataType,
)}
className="hover:bg-bg-card p-1 text-text-primary rounded-sm flex justify-between"
>
{key}
<span className="text-text-secondary">
{compositeDataType}
</span>
</div>
{[JsonSchemaDataType.Object, JsonSchemaDataType.Array].some(
(x) => x === dataType,
) && renderAgentStructuredOutput(value, nextOption)}
</li>
);
}
return null;
})}
</ul>
);
}
return <div></div>;
},
[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<HTMLLIElement>,
) {
return null;
}
const { t } = useTranslation();
const filterStructuredOutput = useGetStructuredOutputByValue();
const structuredOutput = filterStructuredOutput(data.value);
return (
<HoverCard key={data.value} openDelay={100} closeDelay={100}>
<HoverCardTrigger asChild>
<li
onClick={handleMenuClick}
className="hover:bg-bg-card py-1 px-2 text-text-primary rounded-sm text-sm flex justify-between items-center gap-2"
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 (
<ul className="border-l">
{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 (
<li key={key} className="pl-1">
<div
onClick={handleSubMenuClick(
nextOption,
compositeDataType,
)}
className="hover:bg-bg-card p-1 text-text-primary rounded-sm flex justify-between"
>
{key}
<span className="text-text-secondary text-xs">
{compositeDataType}
</span>
</div>
{[
JsonSchemaDataType.Object,
JsonSchemaDataType.Array,
].some((x) => x === dataType) &&
renderAgentStructuredOutput(value, nextOption)}
</li>
);
}
return null;
})}
</ul>
);
}
return <div></div>;
},
[handleSubMenuClick, types],
);
if (
!isEmpty(types) &&
!hasSpecificTypeChild(structuredOutput, types) &&
!types.some((x) => x === JsonSchemaDataType.Object)
) {
return null;
}
return (
<HoverCard key={data.value} openDelay={100} closeDelay={100}>
<HoverCardTrigger asChild>
<li
ref={ref}
onClick={handleMenuClick}
className={cn(
'py-1 px-2 text-text-primary rounded-sm text-sm flex items-center gap-2',
'hover:bg-bg-card focus-visible:bg-bg-card',
className,
)}
>
<span>{data.label}</span>
<span className="ml-auto text-xs text-text-secondary">object</span>
<ChevronRight className="size-[1em] text-text-secondary" />
</li>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className="min-w-72 bg-bg-base border-0.5 border-border rounded-md shadow-lg p-0 overflow-hidden"
>
<div className="flex justify-between flex-1">
{data.label} <span className="text-text-secondary">object</span>
</div>
<ChevronRight className="size-3.5 text-text-secondary" />
</li>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className={cn(
'min-w-72 border border-border rounded-md shadow-lg p-0',
)}
>
<section className="p-2">
<div className="p-1">
{t('flow.structuredOutput.structuredOutput')}
</div>
{renderAgentStructuredOutput(structuredOutput, data)}
</section>
</HoverCardContent>
</HoverCard>
);
}
<ScrollArea className="p-2">
<section>
<header className="mb-2 text-text-secondary">
{t('flow.structuredOutput.structuredOutput')}
</header>
{renderAgentStructuredOutput(structuredOutput, data)}
</section>
</ScrollArea>
</HoverCardContent>
</HoverCard>
);
},
);

View File

@@ -152,7 +152,15 @@ export function useBuildBeginDynamicVariableOptions() {
options: inputs.map((x) => ({
label: x.name,
parentLabel: <span>{t('flow.beginInput')}</span>,
icon: <OperatorIcon name={Operator.Begin} className="block" />,
icon: (
<OperatorIcon
name={Operator.Begin}
className="
p-0 mr-1 relative
before:-z-10 before:content-[''] before:absolute before:inset-0
before:-m-[.25em] before:bg-accent-primary before:rounded-sm"
/>
),
value: `begin@${x.key}`,
type: transferToVariableType(x.type),
})),
@@ -174,7 +182,15 @@ export function useBuildGlobalWithBeginVariableOptions() {
.map(([key, value]) => ({
label: key,
value: key,
icon: <OperatorIcon name={Operator.Begin} className="block" />,
icon: (
<OperatorIcon
name={Operator.Begin}
className="
p-0 mr-1 relative
before:-z-10 before:content-[''] before:absolute before:inset-0
before:-m-[.25em] before:bg-accent-primary before:rounded-sm"
/>
),
parentLabel: <span>{t('flow.beginInput')}</span>,
type: Array.isArray(value)
? `${VariableType.Array}${key === AgentGlobals.SysFiles ? '<file>' : ''}`