mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-03 09:11:59 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>' : ''}`
|
||||
|
||||
Reference in New Issue
Block a user