Merge branch 'develop' into feature/node_run

This commit is contained in:
yingzhao
2026-04-20 15:54:19 +08:00
committed by GitHub
179 changed files with 6329 additions and 1706 deletions

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-30 13:59:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-08 11:05:34
* @Last Modified time: 2026-04-13 15:26:33
*/
import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
import { Form, Input, Select, InputNumber, Button, Row, Col, Flex } from 'antd';
@@ -136,7 +136,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
form.validateFields().then((values) => {
const defaultValue = Array.isArray(values.defaultValue)
? values.defaultValue.filter((v: any) => v !== undefined && v !== null && v !== '')
: values.type.includes('object')
: values.type.includes('object') && values.defaultValue
? JSON.parse(values.defaultValue)
: values.defaultValue;
refresh({ ...values, defaultValue }, editIndex);
@@ -345,15 +345,16 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
<Form.Item
name="defaultValue"
label={t('workflow.config.parameter-extractor.default')}
rules={[
(type === 'object' || type === 'array[object]') ? {
rules={(type === 'object' || type === 'array[object]')
? [{
validator: (_, value) => {
if (!value) return Promise.resolve();
try { JSON.parse(value); return Promise.resolve(); }
catch { return Promise.reject(t('workflow.invalidJSON')); }
}
} : {}
]}
}]
: undefined
}
>
{type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-24 17:57:08
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 14:05:50
* @Last Modified time: 2026-04-14 16:33:33
*/
/*
* Runtime Component
@@ -161,8 +161,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
children: (
<Flex gap={8} vertical>
{/* Display error message for failed nodes */}
{item.error &&
{vo.content?.error && vo.content?.error !== '' &&
<RbAlert color="orange" className="rb:pb-0!">
<Flex vertical className="rb:w-full!">
<Flex align="center" justify="space-between">
@@ -219,11 +218,11 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
</div>
}
/** Copy value to clipboard and show success message */
const handleCopy = (value: string) => {
copy(value)
message.success(t('common.copySuccess'))
}
/** Copy value to clipboard and show success message */
const handleCopy = (value: string) => {
copy(value)
message.success(t('common.copySuccess'))
}
return (
<div
@@ -269,7 +268,7 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
</div>
)
: <div className="rb:mb-4">
{item.error &&
{item.error && item.error !== '' &&
<RbAlert color="orange" className="rb:pb-0! rb:mb-2!"><Markdown content={item.error} /></RbAlert>
}
{renderChild(item.subContent)}

View File

@@ -6,6 +6,7 @@ import { Node } from '@antv/x6';
import type { WorkflowRef } from '@/views/ApplicationConfig/types'
import { nodeLibrary } from '../../constant'
import { isSubExprSet } from '../../utils'
import { getToolMethods } from '@/api/tools'
import RbDrawer from '@/components/RbDrawer'
import { useWorkflowStore } from '@/store/workflow'
@@ -44,14 +45,13 @@ const specialValidators: Record<string, (val: any) => boolean> = {
// if-else.cases: every case must have at least one expression, and every expression must be fully set
'if-else.cases': (val: any[]) => {
if (!Array.isArray(val) || !val.length) return true
return val.some(c => {
if (!c?.expressions?.length) return true
return c.expressions.some((expr: any) => {
if (!expr?.left) return true
if (['not_empty', 'empty'].includes(expr.operator)) return false
return !(!!expr.left && (!!expr.right || typeof expr.right === 'boolean' || typeof expr.right === 'number'))
})
})
const isExprSet = (expr: any) => {
if (expr?.sub_variable_condition?.conditions?.length > 0) return expr.sub_variable_condition?.conditions.every(isSubExprSet)
if (!expr.left) return false
if (['not_empty', 'empty'].includes(expr.operator)) return true
return !!expr.left && (!!expr.right || typeof expr.right === 'boolean' || typeof expr.right === 'number')
}
return val.some(c => !c?.expressions?.length || c.expressions.some((expr: any) => !isExprSet(expr)))
},
// question-classifier.categories: every category must have a value
'question-classifier.categories': (val: any[]) => !Array.isArray(val) || !val.some(c => c?.class_name && String(c.class_name).trim()),
@@ -79,7 +79,6 @@ const specialValidators: Record<string, (val: any) => boolean> = {
}
function isEmpty(val: any): boolean {
console.log('validateNode isEmpty', val, val === undefined || val === null || val === '')
if (val === undefined || val === null || val === '') return true
if (Array.isArray(val)) return val.length === 0
return false
@@ -98,7 +97,6 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
const specialKey = `${type}.${field}`
const specialValidator = specialValidators[specialKey]
const isInvalid = specialValidator ? specialValidator(val) : isEmpty(val)
console.log('validateNode', val, specialKey, specialValidator, isEmpty(val))
if (isInvalid) errors.push({ key: specialKey, message: '' })
})
@@ -114,68 +112,13 @@ function validateNode(type: string, config: Record<string, any>): CheckError[] {
return errors
}
export async function runCheckOnGraph(
graph: import('@antv/x6').Graph,
t: (key: string) => string
): Promise<NodeCheckResult[]> {
const nodes = graph.getNodes()
const edges = graph.getEdges()
const targetIds = new Set<string>()
const childTargetIds = new Set<string>()
edges.forEach(e => {
targetIds.add(e.getTargetCellId())
const srcData = graph.getCellById(e.getSourceCellId())?.getData()
const tgtData = graph.getCellById(e.getTargetCellId())?.getData()
if (srcData?.cycle && tgtData?.cycle && srcData.cycle === tgtData.cycle) {
childTargetIds.add(e.getTargetCellId())
}
})
const checked: NodeCheckResult[] = []
for (const node of nodes) {
const data = node.getData()
if (!data || ['add-node', 'notes', 'cycle-start', 'break'].includes(data.type)) continue
const errors: CheckError[] = []
const isChildNode = !!data.cycle
const hasIncoming = isChildNode ? childTargetIds.has(node.id) : !['start', 'cycle-start'].includes(data.type) ? targetIds.has(node.id) : true
if (!hasIncoming) errors.push({ key: 'notConnected', message: t('workflow.notConnected') })
const configErrors = validateNode(data.type, data.config ?? {})
configErrors.forEach(e => {
errors.push({ key: e.key, message: `${t(`workflow.checkListErrors.${e.key}`)} ${t('workflow.cannotBeEmpty')}`.trim() })
})
if (data.type === 'tool') {
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
if (toolId) {
try {
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
const operation = toolParameters?.operation
const method = operation ? methods.find(m => m.name === operation) : methods[0]
if (method) {
method.parameters
.filter(p => p.required && (toolParameters[p.name] === undefined || toolParameters[p.name] === null || toolParameters[p.name] === ''))
.forEach(p => errors.push({ key: 'tool.tool_parameters', message: `${p.name} ${t('workflow.cannotBeEmpty')}` }))
}
} catch { /* ignore */ }
}
}
if (errors.length) {
checked.push({ id: node.id, name: data.name || t(`workflow.${data.type}`), type: data.type, icon: nodeIconMap[data.type] ?? '', errors })
}
}
return checked
}
const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { setCheckResults, getCheckResults } = useWorkflowStore()
const results = getCheckResults(appId)
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const toolMethodsCacheRef = useRef<Record<string, Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>>>({})
const runCheck = useCallback(async () => {
const graph = workflowRef.current?.graphRef?.current
@@ -222,9 +165,13 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
if (data.type === 'tool') {
const toolId = data.config?.tool_id?.defaultValue ?? data.config?.tool_id
const toolParameters = data.config?.tool_parameters?.defaultValue ?? data.config?.tool_parameters ?? {}
if (toolId) {
if (typeof toolId === 'string') {
try {
const methods = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
if (!toolMethodsCacheRef.current[toolId]) {
toolMethodsCacheRef.current[toolId] = await getToolMethods(toolId) as Array<{ name: string; parameters: Array<{ name: string; required: boolean }> }>
}
const methods = toolMethodsCacheRef.current[toolId]
const operation = toolParameters?.operation
const method = operation ? methods.find(m => m.name === operation) : methods[0]
if (method) {
@@ -251,21 +198,27 @@ const CheckList: FC<CheckListProps> = ({ workflowRef, appId }) => {
return checked
}, [workflowRef.current?.graphRef?.current, t])
const scheduleCheckRef = useRef<() => void>()
const scheduleCheck = useCallback(() => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(async () => {
setCheckResults(appId, await runCheck())
}, 500)
}, 300)
}, [runCheck])
scheduleCheckRef.current = scheduleCheck
useEffect(() => {
const graph = workflowRef.current?.graphRef?.current
console.log('graph')
if (!graph) return
const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed']
events.forEach(e => graph.on(e, scheduleCheck))
scheduleCheck()
const handler = () => scheduleCheckRef.current?.()
const events = ['node:added', 'node:removed', 'node:change:data', 'edge:added', 'edge:removed', 'edge:connected', 'edge:changed']
events.forEach(e => graph.on(e, handler))
scheduleCheckRef.current?.()
return () => {
events.forEach(e => graph.off(e, scheduleCheck))
events.forEach(e => graph.off(e, handler))
clearTimeout(timerRef.current)
}
}, [workflowRef.current?.graphRef?.current])

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-04-02 15:15:36
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 14:48:00
* @Last Modified time: 2026-04-16 11:34:41
*/
import { type FC, useEffect, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -81,7 +81,7 @@ export interface Jinja2EditorProps {
value?: string;
onChange?: (value: string) => void;
options?: Suggestion[];
variant?: 'outlined' | 'borderless';
variant?: 'outlined' | 'borderless' | 'filled';
height?: number;
size?: 'default' | 'small';
className?: string;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 16:29:36
* @Last Modified time: 2026-04-16 12:04:37
*/
import { type FC, useState, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
@@ -25,7 +25,7 @@ export interface LexicalEditorProps {
value?: string;
onChange?: (value: string) => void;
options?: Suggestion[];
variant?: 'outlined' | 'borderless';
variant?: 'outlined' | 'borderless' | 'filled';
height?: number;
fontSize?: number;
lineHeight?: number;
@@ -60,6 +60,7 @@ const Editor: FC<LexicalEditorProps> =({
}) => {
console.log('Editor value', value)
const [_count, setCount] = useState(0);
const [focused, setFocused] = useState(false);
if (language === 'jinja2') {
return (
@@ -90,7 +91,7 @@ const Editor: FC<LexicalEditorProps> =({
// Calculate minimum height based on type and size
const minheight = useMemo(() => {
if (type === 'input') {
return `${height ? height : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 26 : 30}px`
return `${height ? height : size === 'small' && ['borderless', 'filled'].includes(variant) ? 18 : size === 'small' ? 26 : 30}px`
}
return `${height ? height : size === 'small' ? 60 : 120}px`
}, [type, size, height, variant])
@@ -103,7 +104,7 @@ const Editor: FC<LexicalEditorProps> =({
// Calculate line height based on size prop
const lineHeight = useMemo(() => {
return `${height ? height - 10 : size === 'small' && variant === 'borderless' ? 18 : size === 'small' ? 16 : 20}px`
}, [size])
}, [size, height, variant])
// Calculate placeholder minimum height
const placeHolderMinheight = useMemo(() => {
@@ -112,20 +113,24 @@ const Editor: FC<LexicalEditorProps> =({
return (
<LexicalComposer initialConfig={initialConfig}>
<div style={{ position: 'relative' }} className={className}>
<div style={{ position: 'relative', borderRadius: '8px', background: variant === 'filled' ? '#F6F6F6': 'transparent' }} className={className}>
<RichTextPlugin
contentEditable={
<ContentEditable
style={{
minHeight: minheight,
padding: height ? '4px 6px' : variant === 'borderless' ? '0' : '6px 8px',
border: variant === 'borderless' ? 'none' : '1px solid #EBEBEB',
padding: height ? '4px 6px' : variant === 'outlined' ? '6px 8px': '0',
border: type === 'input' && focused
? '1px solid #171719'
: variant === 'outlined' ? '1px solid #EBEBEB' : 'none',
borderRadius: '8px',
outline: 'none',
resize: 'none',
fontSize: fontSize,
lineHeight: lineHeight,
}}
onFocus={() => type === 'input' && setFocused(true)}
onBlur={() => type === 'input' && setFocused(false)}
/>
}
placeholder={
@@ -133,12 +138,13 @@ const Editor: FC<LexicalEditorProps> =({
style={{
minHeight: placeHolderMinheight,
position: 'absolute',
top: variant === 'borderless' ? '2px' : '6px',
left: variant === 'borderless' ? '0' : '11px',
color: '#A8A9AA',
top: variant === 'outlined' ? '6px' : type === 'input' ? '6px' : '2px',
left: variant === 'outlined' ? '11px' : type === 'input' ? '8px' : '0',
color: 'rgba(23,23,25,0.25)',
fontSize: fontSize,
lineHeight: placeHolderMinheight,
pointerEvents: 'none',
borderRadius: '8px',
}}
>
{placeholder}

View File

@@ -48,17 +48,13 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
return (
<span
onClick={handleClick}
className={clsx('rb:border rb:rounded-md rb:bg-white rb:text-[10px] rb:inline-flex rb:items-center rb:py-0 rb:px-1.5 rb:mx-0.5 rb:cursor-pointer', {
'rb:border-[#171719]': isSelected,
'rb:border-[#DFE4ED]': !isSelected
})}
className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:mx-px rb:cursor-pointer"
contentEditable={false}
>
{data.isContext ? (
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span>
) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? (
<span className={`rb:size-4 rb:mr-1 rb:bg-cover rb:inline-block rb:flex-shrink-0 ${data.nodeData?.icon}`} />
) : <span className="rb:inline-block rb:h-4"></span>}
{!data.isContext && data.group !== 'CONVERSATION' && !data.value.includes('conv')
? <div className={`rb:size-3 rb:mr-1 rb:bg-cover ${data.nodeData?.icon}`} />
: null
}
{!data.isContext && data.group !== 'CONVERSATION' && (
<>
{!data.value.includes('conv') && <>
@@ -73,7 +69,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
)}
</>
)}
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1 rb:text-[#171719]">{data.label}</span>
<span className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{data.label}</span>
</span>
);
};

View File

@@ -2,12 +2,14 @@
* @Author: ZhaoYing
* @Date: 2025-12-23 16:22:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 16:51:04
* @Last Modified time: 2026-04-13 14:00:07
*/
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { createPortal } from 'react-dom';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
import { Space, Flex } from 'antd';
import clsx from 'clsx';
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { NodeProperties } from '../../../types'
@@ -34,61 +36,62 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [childPanelTop, setChildPanelTop] = useState(0);
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
const [childActiveIndex, setChildActiveIndex] = useState(-1);
const popupRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
// Adjust popup position after render based on actual height
// Adjust popup position after render based on actual size
useLayoutEffect(() => {
if (!popupRef.current || !showSuggestions) return;
const { top, anchorBottom } = popupPosition;
const { top, left, anchorBottom } = popupPosition;
const popupHeight = popupRef.current.offsetHeight;
const popupWidth = popupRef.current.offsetWidth;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const MARGIN = 10;
let finalTop: number;
if (top - popupHeight - MARGIN >= 0) {
// Enough space above: show above cursor
finalTop = top - popupHeight - MARGIN;
} else {
// Not enough space above: show below cursor
finalTop = anchorBottom + MARGIN;
if (finalTop + popupHeight > viewportHeight - MARGIN) {
finalTop = viewportHeight - popupHeight - MARGIN;
}
}
if (finalTop !== top) {
setPopupPosition(prev => ({ ...prev, top: finalTop }));
let finalLeft = left;
if (finalLeft + popupWidth > viewportWidth - MARGIN) {
finalLeft = viewportWidth - popupWidth - MARGIN;
}
if (finalLeft < MARGIN) finalLeft = MARGIN;
if (finalTop !== top || finalLeft !== left) {
setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft }));
}
}, [showSuggestions, popupPosition.anchorBottom]);
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
const CHILD_PANEL_HEIGHT = 280;
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => {
const relativeTop = elRect.top - popupRect.top;
const absoluteBottom = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT;
const overflow = absoluteBottom - (window.innerHeight - 10);
return overflow > 0 ? relativeTop - overflow : relativeTop;
const calcChildPanelPos = (key: string) => {
const el = itemRefs.current.get(key);
if (!el || !popupRef.current) return;
const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height);
const top = Math.max(10, popupRect.bottom - actualChildHeight);
setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 });
};
const scrollSelectedIntoView = () => {
if (!popupRef.current) return;
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
if (!selectedElement) return;
const container = popupRef.current;
const element = selectedElement as HTMLElement;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom;
} else if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top;
}
const resetState = () => {
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelPos({ top: 0, right: 0 });
setActivePanel('main');
setChildActiveIndex(-1);
};
// Listen to editor updates and show suggestions when '/' is typed
@@ -104,11 +107,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
// Get the text content of the current node
const nodeText = anchorNode.getTextContent();
// Check if we have a '/' at the current position or after line break
const textBeforeCursor = nodeText.substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/') ||
(textBeforeCursor === '/' && anchorOffset === 1);
@@ -117,10 +116,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
if (!shouldShow) {
setSelectedIndex(0);
setExpandedParent(null);
setChildPanelTop(0);
setChildPanelPos({ top: 0, right: 0 });
setActivePanel('main');
setChildActiveIndex(-1);
}
// Calculate popup position to keep it within viewport bounds
if (shouldShow) {
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
@@ -148,9 +148,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND,
() => {
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
resetState();
return true;
},
COMMAND_PRIORITY_HIGH
@@ -160,9 +158,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
// Insert selected suggestion into editor
const insertMention = (suggestion: Suggestion) => {
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
resetState();
};
// Group suggestions by node ID
@@ -176,13 +172,28 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
return groups;
}, {});
// Flat list for keyboard navigation
const flatOptions = Object.values(groupedSuggestions).flat().flatMap(option => {
if (option.key === expandedParent?.key && option.children?.length) {
return [option, ...option.children];
// Flat list of main-panel items for keyboard navigation
const flatOptions = Object.values(groupedSuggestions).flat();
// Sync child panel position when keyboard navigates to a parent with children
useEffect(() => {
if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return;
const s = flatOptions[selectedIndex];
if (s.children?.length) {
calcChildPanelPos(s.key);
setExpandedParent(s);
} else {
setExpandedParent(null);
}
return [option];
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// Scroll child active item into view
useEffect(() => {
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
const child = expandedParent.children[childActiveIndex];
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
}, [childActiveIndex, expandedParent]);
// Handle Enter key to select suggestion
useEffect(() => {
@@ -191,7 +202,15 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (showSuggestions && flatOptions.length > 0) {
if (!showSuggestions) return false;
if (activePanel === 'child' && expandedParent?.children?.length) {
const child = expandedParent.children[childActiveIndex];
if (child && !child.disabled) {
event?.preventDefault();
insertMention(child);
return true;
}
} else if (flatOptions.length > 0) {
const selectedOption = flatOptions[selectedIndex];
if (selectedOption && !selectedOption.disabled) {
event?.preventDefault();
@@ -203,57 +222,56 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
},
COMMAND_PRIORITY_HIGH
);
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor]);
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor, activePanel, childActiveIndex, expandedParent]);
// Handle keyboard navigation (Arrow Up/Down, Escape)
// Handle keyboard navigation (Arrow Up/Down/Left/Right, Escape)
useEffect(() => {
if (!showSuggestions) return;
// Navigate down through suggestions, skip disabled items
const unregisterArrowDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
if (showSuggestions && flatOptions.length > 0) {
event?.preventDefault();
if (!showSuggestions) return false;
event?.preventDefault();
if (activePanel === 'child' && expandedParent?.children) {
setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1));
} else {
setSelectedIndex(prev => {
let nextIndex = prev + 1;
while (nextIndex < flatOptions.length && flatOptions[nextIndex].disabled) {
nextIndex++;
}
const newIndex = nextIndex >= flatOptions.length ? prev : nextIndex;
setTimeout(() => scrollSelectedIntoView(), 0);
let next = prev + 1;
// skip items that are disabled AND have no children
while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++;
const newIndex = next >= flatOptions.length ? prev : next;
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
return newIndex;
});
return true;
}
return false;
return true;
},
COMMAND_PRIORITY_HIGH
);
// Navigate up through suggestions, skip disabled items
const unregisterArrowUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
if (showSuggestions && flatOptions.length > 0) {
event?.preventDefault();
if (!showSuggestions) return false;
event?.preventDefault();
if (activePanel === 'child' && expandedParent?.children) {
setChildActiveIndex(i => Math.max(i - 1, 0));
} else {
setSelectedIndex(prev => {
let prevIndex = prev - 1;
while (prevIndex >= 0 && flatOptions[prevIndex].disabled) {
prevIndex--;
}
const newIndex = prevIndex < 0 ? prev : prevIndex;
setTimeout(() => scrollSelectedIntoView(), 0);
let prevIdx = prev - 1;
// skip items that are disabled AND have no children
while (prevIdx >= 0 && flatOptions[prevIdx].disabled && !flatOptions[prevIdx].children?.length) prevIdx--;
const newIndex = prevIdx < 0 ? prev : prevIdx;
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
return newIndex;
});
return true;
}
return false;
return true;
},
COMMAND_PRIORITY_HIGH
);
// Close suggestions on Escape key
const unregisterEscape = editor.registerCommand(
KEY_ESCAPE_COMMAND,
(event) => {
@@ -272,130 +290,156 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
unregisterArrowUp();
unregisterEscape();
};
}, [showSuggestions, selectedIndex, flatOptions, editor]);
}, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]);
// Handle ArrowLeft/Right for panel switching via native keydown (lexical doesn't expose these commands)
useEffect(() => {
if (!showSuggestions) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
const current = flatOptions[selectedIndex];
if (activePanel === 'main' && current?.children?.length) {
e.preventDefault();
setActivePanel('child');
setChildActiveIndex(0);
}
} else if (e.key === 'ArrowRight') {
if (activePanel === 'child') {
e.preventDefault();
setActivePanel('main');
setChildActiveIndex(-1);
}
}
};
document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true);
}, [showSuggestions, activePanel, selectedIndex, flatOptions]);
if (!showSuggestions) return null;
if (Object.entries(groupedSuggestions).length === 0) return null;
if (Object.entries(groupedSuggestions).length === 0) {
return null
}
return (
<div
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{
top: popupPosition.top,
left: popupPosition.left,
}}
>
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
const nodeIcon = nodeOptions[0]?.nodeData?.icon;
return (
<div key={nodeId}>
{nodeName !== 'undefined' && <Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
{nodeIcon && <div className={`rb:size-3 rb:bg-cover ${nodeIcon}`} />}
{nodeName}
</Flex>}
{nodeOptions.map((option) => {
const globalIndex = flatOptions.indexOf(option);
const isExpanded = expandedParent?.key === option.key;
const hasChildren = !!option.children?.length;
return (
<Flex
key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => {
if (option.disabled) return;
insertMention(option);
}}
onMouseEnter={() => {
setSelectedIndex(globalIndex);
if (hasChildren) {
const el = itemRefs.current.get(option.key);
if (el && popupRef.current) {
const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
setChildPanelTop(calcChildPanelTop(elRect, popupRect));
<>
<div
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
style={{
top: popupPosition.top,
left: popupPosition.left,
}}
>
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
return (
<div key={nodeId} className="rb:text-[12px]">
{nodeName !== 'undefined' &&
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
{nodeName}
</div>
}
<Flex vertical gap={2}>
{nodeOptions.map((option) => {
const globalIndex = flatOptions.indexOf(option);
const isExpanded = expandedParent?.key === option.key;
const hasChildren = !!option.children?.length;
const isActive = activePanel === 'main' && selectedIndex === globalIndex;
return (
<Flex
key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': isActive || isExpanded,
'rb:cursor-not-allowed rb:opacity-65': option.disabled,
'rb:cursor-pointer': !option.disabled,
})}
align="center"
justify="space-between"
onClick={() => {
if (option.disabled && !hasChildren) return;
if (!option.disabled) insertMention(option);
if (hasChildren) {
calcChildPanelPos(option.key);
setExpandedParent(option);
}
setExpandedParent(option);
} else {
setExpandedParent(null);
}}
onMouseEnter={() => {
setSelectedIndex(globalIndex);
setActivePanel('main');
setChildActiveIndex(-1);
if (hasChildren) {
calcChildPanelPos(option.key);
setExpandedParent(option);
} else {
setExpandedParent(null);
}
}}
>
{option.label &&
<div className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
</div>
}
}}
>
{option.label && <Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span>
<span>{option.label}</span>
</Space>}
<Space size={4}>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
</Space>
</Flex>
);
})}
</div>
);
})}
</Flex>
<Space size={2}>
{option.dataType && <span>{option.dataType}</span>}
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
</Space>
</Flex>
);
})}
</Flex>
</div>
);
})}
</Flex>
</div>
</div>
{/* Child variables panel - floats to the left */}
{expandedParent?.children?.length && (
{/* Child variables panel - fixed positioned via portal to avoid clipping */}
{expandedParent?.children?.length && createPortal(
<div
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{
top: childPanelTop,
right: 'calc(100% + 8px)',
transform: 'translateY(-8px)',
}}
onMouseEnter={() => setExpandedParent(expandedParent)}
onMouseDown={(e) => e.preventDefault()}
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
style={{ top: childPanelPos.top, right: childPanelPos.right }}
onMouseEnter={() => setActivePanel('child')}
onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }}
>
{/* Header */}
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]">
<Flex justify="space-between" align="center">
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
<Flex justify="space-between" align="center" gap={8}>
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span>
</Flex>
</div>
{expandedParent.children.map((child) => {
const childIndex = flatOptions.indexOf(child);
{expandedParent.children.map((child, ci) => {
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
return (
<Flex
key={child.key}
data-selected={selectedIndex === childIndex}
className="rb:px-3! rb:py-2!"
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': isChildActive,
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled,
})}
align="center"
justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)}
onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }}
>
<span>{child.label}</span>
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
<span className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
</span>
{child.dataType && <span>{child.dataType}</span>}
</Flex>
);
})}
</div>
</div>,
document.body
)}
</div>
</>
);
}
export default AutocompletePlugin
export default AutocompletePlugin

View File

@@ -5,6 +5,7 @@
* @Last Modified time: 2026-04-07 14:50:14
*/
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
import { createPortal } from 'react-dom';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$getSelection, $isRangeSelection, $isTextNode,
@@ -12,6 +13,7 @@ import {
KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND,
} from 'lexical';
import { Space, Flex } from 'antd';
import clsx from 'clsx';
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
import type { Suggestion } from './AutocompletePlugin';
@@ -22,17 +24,22 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
const [selectedIndex, setSelectedIndex] = useState(0);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [childPanelTop, setChildPanelTop] = useState(0);
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
const [childActiveIndex, setChildActiveIndex] = useState(-1);
const popupRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
const CHILD_PANEL_HEIGHT = 280;
useLayoutEffect(() => {
if (!popupRef.current || !showSuggestions) return;
const { top, anchorBottom } = popupPosition;
const { top, left, anchorBottom } = popupPosition;
const popupHeight = popupRef.current.offsetHeight;
const popupWidth = popupRef.current.offsetWidth;
const MARGIN = 10;
let finalTop: number;
if (top - popupHeight - MARGIN >= 0) {
finalTop = top - popupHeight - MARGIN;
@@ -41,51 +48,57 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
if (finalTop + popupHeight > window.innerHeight - MARGIN)
finalTop = window.innerHeight - popupHeight - MARGIN;
}
if (finalTop !== top) setPopupPosition(prev => ({ ...prev, top: finalTop }));
let finalLeft = left;
if (finalLeft + popupWidth > window.innerWidth - MARGIN)
finalLeft = window.innerWidth - popupWidth - MARGIN;
if (finalLeft < MARGIN) finalLeft = MARGIN;
if (finalTop !== top || finalLeft !== left)
setPopupPosition(prev => ({ ...prev, top: finalTop, left: finalLeft }));
}, [showSuggestions, popupPosition.anchorBottom]);
const calcChildPanelTop = (elRect: DOMRect, popupRect: DOMRect) => {
const relativeTop = elRect.top - popupRect.top;
const overflow = popupRect.top + relativeTop + CHILD_PANEL_HEIGHT - (window.innerHeight - 10);
return overflow > 0 ? relativeTop - overflow : relativeTop;
const calcChildPanelPos = (key: string) => {
const el = itemRefs.current.get(key);
if (!el || !popupRef.current) return;
const elRect = el.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, popupRect.height);
const top = Math.max(10, popupRect.bottom - actualChildHeight);
setChildPanelPos({ top, right: window.innerWidth - elRect.left + 8 });
};
const scrollSelectedIntoView = () => {
if (!popupRef.current) return;
const selectedElement = popupRef.current.querySelector('[data-selected="true"]');
if (!selectedElement) return;
const container = popupRef.current;
const element = selectedElement as HTMLElement;
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
if (elementRect.bottom > containerRect.bottom) {
container.scrollTop += elementRect.bottom - containerRect.bottom;
} else if (elementRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - elementRect.top;
}
const resetState = () => {
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelPos({ top: 0, right: 0 });
setActivePanel('main');
setChildActiveIndex(-1);
};
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
if (!selection || !$isRangeSelection(selection)) {
setShowSuggestions(false);
return;
}
if (!selection || !$isRangeSelection(selection)) { setShowSuggestions(false); return; }
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const textBeforeCursor = anchorNode.getTextContent().substring(0, anchorOffset);
const shouldShow = textBeforeCursor.endsWith('/');
setShowSuggestions(shouldShow);
if (!shouldShow) { setSelectedIndex(0); setExpandedParent(null); setChildPanelTop(0); return; }
if (!shouldShow) {
setSelectedIndex(0);
setExpandedParent(null);
setChildPanelPos({ top: 0, right: 0 });
setActivePanel('main');
setChildActiveIndex(-1);
return;
}
const domSelection = window.getSelection();
if (domSelection && domSelection.rangeCount > 0) {
const rect = domSelection.getRangeAt(0).getBoundingClientRect();
const popupWidth = 280;
let left = rect.left;
if (left + popupWidth > window.innerWidth) left = window.innerWidth - popupWidth - 10;
if (left + 280 > window.innerWidth) left = window.innerWidth - 280 - 10;
if (left < 10) left = 10;
setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
}
@@ -96,7 +109,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
useEffect(() => {
return editor.registerCommand(
CLOSE_AUTOCOMPLETE_COMMAND,
() => { setShowSuggestions(false); setExpandedParent(null); setChildPanelTop(0); return true; },
() => { resetState(); return true; },
COMMAND_PRIORITY_HIGH,
);
}, [editor]);
@@ -119,9 +132,7 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
}
});
document.dispatchEvent(new CustomEvent('jinja2-variable-inserted', { detail: { value: suggestion.value } }));
setShowSuggestions(false);
setExpandedParent(null);
setChildPanelTop(0);
resetState();
};
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, s) => {
@@ -131,152 +142,227 @@ const Jinja2AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) =>
return groups;
}, {});
const allOptions = Object.values(groupedSuggestions).flat().flatMap(o =>
o.key === expandedParent?.key && o.children?.length ? [o, ...o.children] : [o]
);
// Flat list of main-panel items for keyboard navigation
const flatOptions = Object.values(groupedSuggestions).flat();
// Sync child panel position when keyboard navigates to a parent with children
useEffect(() => {
if (selectedIndex < 0 || selectedIndex >= flatOptions.length) return;
const s = flatOptions[selectedIndex];
if (s.children?.length) {
calcChildPanelPos(s.key);
setExpandedParent(s);
} else {
setExpandedParent(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
// Scroll child active item into view
useEffect(() => {
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
const child = expandedParent.children[childActiveIndex];
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
}, [childActiveIndex, expandedParent]);
useEffect(() => {
if (!showSuggestions) return;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
const opt = allOptions[selectedIndex];
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
if (!showSuggestions) return false;
if (activePanel === 'child' && expandedParent?.children?.length) {
const child = expandedParent.children[childActiveIndex];
if (child && !child.disabled) { event?.preventDefault(); insertMention(child); return true; }
} else if (flatOptions.length > 0) {
const opt = flatOptions[selectedIndex];
if (opt && !opt.disabled) { event?.preventDefault(); insertMention(opt); return true; }
}
return false;
},
COMMAND_PRIORITY_HIGH,
);
}, [showSuggestions, selectedIndex, allOptions]);
}, [showSuggestions, selectedIndex, flatOptions, activePanel, childActiveIndex, expandedParent]);
useEffect(() => {
if (!showSuggestions) return;
const down = editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (e) => {
if (!showSuggestions) return false;
e?.preventDefault();
setSelectedIndex(prev => {
let next = prev + 1;
while (next < allOptions.length && allOptions[next].disabled) next++;
setTimeout(scrollSelectedIntoView, 0);
return next >= allOptions.length ? prev : next;
});
if (activePanel === 'child' && expandedParent?.children) {
setChildActiveIndex(i => Math.min(i + 1, expandedParent.children!.length - 1));
} else {
setSelectedIndex(prev => {
let next = prev + 1;
while (next < flatOptions.length && flatOptions[next].disabled && !flatOptions[next].children?.length) next++;
const newIndex = next >= flatOptions.length ? prev : next;
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
return newIndex;
});
}
return true;
}, COMMAND_PRIORITY_HIGH);
const up = editor.registerCommand(KEY_ARROW_UP_COMMAND, (e) => {
if (!showSuggestions) return false;
e?.preventDefault();
setSelectedIndex(prev => {
let p = prev - 1;
while (p >= 0 && allOptions[p].disabled) p--;
setTimeout(scrollSelectedIntoView, 0);
return p < 0 ? prev : p;
});
if (activePanel === 'child' && expandedParent?.children) {
setChildActiveIndex(i => Math.max(i - 1, 0));
} else {
setSelectedIndex(prev => {
let p = prev - 1;
while (p >= 0 && flatOptions[p].disabled && !flatOptions[p].children?.length) p--;
const newIndex = p < 0 ? prev : p;
setTimeout(() => itemRefs.current.get(flatOptions[newIndex]?.key)?.scrollIntoView({ block: 'nearest' }), 0);
return newIndex;
});
}
return true;
}, COMMAND_PRIORITY_HIGH);
const esc = editor.registerCommand(KEY_ESCAPE_COMMAND, (e) => {
e?.preventDefault(); setShowSuggestions(false); return true;
}, COMMAND_PRIORITY_HIGH);
return () => { down(); up(); esc(); };
}, [showSuggestions, selectedIndex, allOptions, editor]);
}, [showSuggestions, selectedIndex, flatOptions, editor, activePanel, childActiveIndex, expandedParent]);
// ArrowLeft/Right for panel switching
useEffect(() => {
if (!showSuggestions) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
const current = flatOptions[selectedIndex];
if (activePanel === 'main' && current?.children?.length) {
e.preventDefault();
setActivePanel('child');
setChildActiveIndex(0);
}
} else if (e.key === 'ArrowRight') {
if (activePanel === 'child') {
e.preventDefault();
setActivePanel('main');
setChildActiveIndex(-1);
}
}
};
document.addEventListener('keydown', handler, true);
return () => document.removeEventListener('keydown', handler, true);
}, [showSuggestions, activePanel, selectedIndex, flatOptions]);
if (!showSuggestions || Object.keys(groupedSuggestions).length === 0) return null;
return (
<div
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{ top: popupPosition.top, left: popupPosition.left }}
>
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => (
<div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:text-[12px] rb:py-1.25! rb:font-medium rb:text-[#5B6167]">
{nodeOptions[0]?.nodeData?.icon && <div className={`rb:size-3 rb:bg-cover ${nodeOptions[0].nodeData.icon}`} />}
{nodeOptions[0]?.nodeData?.name || nodeId}
</Flex>
{nodeOptions.map((option) => {
const globalIndex = allOptions.indexOf(option);
const hasChildren = !!option.children?.length;
const isExpanded = expandedParent?.key === option.key;
<>
<div
ref={popupRef}
data-autocomplete-popup="true"
onMouseDown={(e) => e.preventDefault()}
className="rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
style={{ top: popupPosition.top, left: popupPosition.left }}
>
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
<Flex vertical gap={12}>
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
return (
<Flex
key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
data-selected={selectedIndex === globalIndex}
className="rb:pl-6! rb:pr-3! rb:py-2!"
align="center"
justify="space-between"
style={{
cursor: option.disabled ? 'not-allowed' : 'pointer',
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
opacity: option.disabled ? 0.5 : 1,
}}
onClick={() => { if (option.disabled || hasChildren) return; insertMention(option); }}
onMouseEnter={() => {
setSelectedIndex(globalIndex);
if (hasChildren) {
const el = itemRefs.current.get(option.key);
if (el && popupRef.current) {
setChildPanelTop(calcChildPanelTop(el.getBoundingClientRect(), popupRef.current.getBoundingClientRect()));
}
setExpandedParent(option);
} else {
setExpandedParent(null);
}
}}
>
<Space size={4}>
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : '{x}'}</span>
<span>{option.label}</span>
</Space>
<Space size={4}>
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
</Space>
</Flex>
<div key={nodeId} className="rb:text-[12px]">
{nodeName !== 'undefined' &&
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
{nodeName}
</div>
}
<Flex vertical gap={2}>
{nodeOptions.map((option) => {
const globalIndex = flatOptions.indexOf(option);
const hasChildren = !!option.children?.length;
const isExpanded = expandedParent?.key === option.key;
const isActive = activePanel === 'main' && selectedIndex === globalIndex;
return (
<Flex
key={option.key}
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', {
'rb:bg-[#F6F6F6]': isActive || isExpanded,
'rb:cursor-not-allowed rb:opacity-65': option.disabled && !hasChildren,
'rb:cursor-pointer': !option.disabled || hasChildren,
})}
align="center"
justify="space-between"
onClick={() => {
if (option.disabled && !hasChildren) return;
if (!option.disabled) insertMention(option);
if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); }
}}
onMouseEnter={() => {
setSelectedIndex(globalIndex);
setActivePanel('main');
setChildActiveIndex(-1);
if (hasChildren) { calcChildPanelPos(option.key); setExpandedParent(option); }
else setExpandedParent(null);
}}
>
{option.label &&
<div className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {option.label}
</div>
}
<Space size={2}>
{option.dataType && <span>{option.dataType}</span>}
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
</Space>
</Flex>
);
})}
</Flex>
</div>
);
})}
</div>
))}
</Flex>
</Flex>
</div>
</div>
{expandedParent?.children?.length && (
{expandedParent?.children?.length && createPortal(
<div
className="rb:absolute rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
style={{ top: childPanelTop, right: 'calc(100% + 8px)', transform: 'translateY(-8px)' }}
onMouseEnter={() => setExpandedParent(expandedParent)}
onMouseDown={(e) => e.preventDefault()}
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
style={{ top: childPanelPos.top, right: childPanelPos.right }}
onMouseEnter={() => setActivePanel('child')}
onMouseLeave={() => { setActivePanel('main'); setChildActiveIndex(-1); }}
>
<div className="rb:px-3 rb:py-2 rb:text-[12px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0]">
<Flex justify="space-between" align="center">
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
<Flex justify="space-between" align="center" gap={8}>
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span>
</Flex>
</div>
{expandedParent.children.map((child) => {
const childIndex = allOptions.indexOf(child);
{expandedParent.children.map((child, ci) => {
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
return (
<Flex
key={child.key}
data-selected={selectedIndex === childIndex}
className="rb:px-3! rb:py-2!"
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
className={clsx('rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]', {
'rb:bg-[#F6F6F6]': isChildActive,
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled,
})}
align="center"
justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
background: selectedIndex === childIndex ? '#f0f8ff' : 'white',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && insertMention(child)}
onMouseEnter={() => setSelectedIndex(childIndex)}
onMouseEnter={() => { setActivePanel('child'); setChildActiveIndex(ci); }}
>
<span>{child.label}</span>
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
<span className="rb:font-medium">
<span className="rb:text-[#155EEF]">{`{x}`}</span> {child.label}
</span>
{child.dataType && <span>{child.dataType}</span>}
</Flex>
);
})}
</div>
</div>,
document.body
)}
</div>
</>
);
};

View File

@@ -7,12 +7,17 @@ import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-desi
import NodeTools from './NodeTools'
import { useVariableList } from '../Properties/hooks/useVariableList'
import { isSubExprSet } from '../../utils'
import { fileSubFieldOperators } from '../Properties/CaseList'
const caculateIsSet = (item: any, type: string) => {
switch (type) {
case 'categories':
return typeof item?.class_name === 'string' && item?.class_name !== ''
case 'cases': {
if (item?.sub_variable_condition !== undefined) {
return !!item.left && !!item.operator
}
if (!item.left) return false
if (['not_empty', 'empty'].includes(item.operator)) return true
return !!item.left && (!!item.right || typeof item.right === 'boolean' || typeof item.right === 'number')
@@ -26,12 +31,24 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
const variableList = useVariableList(node ?? null, graphRef, data.chatVariables ?? [])
const getLocaleField = (field: string, filedType: string) => {
const key = filedType === 'boolean' ? `workflow.config.if-else..boolean.${field}` : filedType === 'number' ? `workflow.config.if-else.num.${field}` : `workflow.config.if-else.${field}`
const key = filedType === 'boolean'
? `workflow.config.if-else..boolean.${field}`
: filedType === 'number'
? `workflow.config.if-else.num.${field}`
: `workflow.config.if-else.${field}`
const value = t(key)
return value !== key ? value : t(`workflow.config.if-else.num.${field}`)
};
const getSubLocaleField = (field: string, fieldKey: string) => {
const operators = fileSubFieldOperators[fieldKey] ?? fileSubFieldOperators.default
const match = operators?.find(op => op.value === field)
return match?.label ? t(match.label as string) : field
}
const labelRender = (value: string) => {
const filterOption = variableList.find(vo => `{{${vo.value}}}` === value)
?? variableList.flatMap(vo => vo.children ?? []).find(child => `{{${child.value}}}` === value)
?? variableList.flatMap(vo => vo.children ?? []).flatMap((child: any) => child.children ?? []).find((grandchild: any) => `{{${grandchild.value}}}` === value)
if (filterOption) {
return (
@@ -91,7 +108,8 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
{item.expressions.map((expression: any, eIndex: number) => (
<div key={eIndex} className="rb:relative">
{item.expressions.length > 1 && eIndex > 0 && <div className="rb:absolute rb:-top-2 rb:right-2 rb:text-[10px] rb:text-[#155EEF] rb:font-medium rb:leading-3.5 rb:text-right rb:pr-0.5">{item.logical_operator?.toLocaleUpperCase()}</div>}
<Flex align="center" className="rb:bg-[#F0F3F8] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:rounded-md rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
<Flex vertical gap={2} className="rb:bg-[#F0F3F8] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:rounded-md rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
<Flex align="center">
{caculateIsSet(expression, 'cases')
? <>
{labelRender(expression.left)}
@@ -100,6 +118,33 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
</>
: t(`workflow.config.${data.type}.unset`)
}
</Flex>
{expression.sub_variable_condition?.conditions?.length > 0 && expression.sub_variable_condition?.conditions.every(isSubExprSet)
? <div className="rb-border-l rb:ml-2 rb:mt-1.5">
{expression.sub_variable_condition?.conditions.map((sub: any, sIndex: number) => (
<div key={sIndex} className="rb:relative">
{expression.sub_variable_condition?.conditions.length > 1 && sIndex > 0 && <div className="rb:absolute rb:-top-2 rb:right-2 rb:text-[10px] rb:text-[#155EEF] rb:font-medium rb:leading-3.5 rb:text-right rb:pr-0.5">{expression.sub_variable_condition?.logical_operator?.toLocaleUpperCase()}</div>}
<Flex align="center" className=" rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
<span className="rb:text-[#155EEF]">{sub.key}</span>
<span className="rb:mx-1">{getSubLocaleField(sub.operator, sub.key)}</span>
<span className="rb:break-all rb:line-clamp-1">
{sub.key === 'type'
? t(`application.${sub.value}`)
:!['not_empty', 'empty'].includes(sub.operator)
? <span>{typeof sub.value === 'boolean' ? String(sub.value).charAt(0).toUpperCase() + String(sub.value).slice(1) : sub.value}</span>
: null
}
</span>
</Flex>
</div>
))}
</div>
: expression.sub_variable_condition?.conditions?.length > 0
? <Flex align="center" className="rb:mt-1! rb:pl-2! rb:rounded-md rb:py-1! rb:px-1.5! rb:text-[10px] rb:text-[#5B6167] rb:font-medium rb:leading-3.5">
{t(`workflow.config.${data.type}.unset`)}
</Flex>
: null
}
</Flex>
</div>
))}

View File

@@ -30,6 +30,25 @@ const operationsObj = {
],
}
const filterByDataType = (options: Suggestion[], dataType: string): Suggestion[] =>
options.reduce<Suggestion[]>((acc, vo) => {
if (vo.children?.length) {
const children = vo.children.reduce<Suggestion[]>((cacc, child) => {
if (child.children?.length) {
const grandchildren = child.children.filter(gc => gc.dataType === dataType);
if (grandchildren.length) cacc.push({ ...child, children: grandchildren });
} else if (child.dataType === dataType) {
cacc.push(child);
}
return cacc;
}, []);
if (children.length) acc.push({ ...vo, children });
} else if (vo.dataType === dataType) {
acc.push(vo);
}
return acc;
}, []);
const AssignmentList: FC<AssignmentListProps> = ({
parentName,
options = [],
@@ -59,7 +78,9 @@ const AssignmentList: FC<AssignmentListProps> = ({
<Flex gap={10} vertical>
{fields.map(({ key, name, ...restField }) => {
const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']);
const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector);
const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector)
?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === variableSelector)
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === variableSelector);
const dataType = selectedOption?.dataType;
const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default;
@@ -119,7 +140,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
{dataType === 'number' && operation === 'cover'
? <VariableSelect
placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
options={dataType ? filterByDataType(options, dataType) : options}
size={size}
className="rb:flex-1!"
variant="filled"
@@ -150,7 +171,7 @@ const AssignmentList: FC<AssignmentListProps> = ({
</>
: <VariableSelect
placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
options={dataType ? filterByDataType(options, dataType) : options}
size={size}
className="rb:flex-1!"
variant="filled"

View File

@@ -2,9 +2,9 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:24:53
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-25 15:23:45
* @Last Modified time: 2026-04-17 20:47:49
*/
import { useMemo, type FC } from 'react'
import { useEffect, useMemo, type FC } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Space, Divider, InputNumber, type SelectProps, Flex, Row, Col } from 'antd'
@@ -15,11 +15,37 @@ import Editor from '../../Editor'
import { edgeAttrs, nodeWidth } from '../../../constant'
import RbButton from '@/components/RbButton';
import RadioGroupBtn from '../RadioGroupBtn'
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../../../utils';
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY, isSubExprSet } from '../../../utils';
import { typeOptions } from '../ListOperator/FilterConditions'
interface SubCondition {
key: string;
operator: string;
value?: string | number;
input_type?: string;
}
interface SubVariableCondition {
logical_operator: 'and' | 'or';
conditions: SubCondition[];
}
interface Expression {
left?: string;
operator?: string;
right?: string | number;
input_type?: string;
sub_variable_condition?: SubVariableCondition;
}
interface CaseItem {
logical_operator: 'and' | 'or';
expressions: Expression[];
}
interface CaseListProps {
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>;
onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>) => void;
value?: CaseItem[];
onChange?: (value: CaseItem[]) => void;
options: Suggestion[];
name: string;
selectedNode?: any;
@@ -60,13 +86,12 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
{ value: 'empty', label: 'workflow.config.if-else.file.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.file.not_empty' },
],
// TODO包含、不包含、全都是
'array[file]': [
{ value: 'contains', label: 'workflow.config.if-else.contains' },
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
{ value: 'eq', label: 'workflow.config.if-else.file.eq' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
// { value: 'eq', label: 'workflow.config.if-else.eq' },
// { value: 'contains', label: 'workflow.config.if-else.contains' },
// { value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
],
'array': [
{ value: 'contains', label: 'workflow.config.if-else.contains' },
@@ -80,6 +105,218 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
]
}
const fileSubFieldOptions = [
{ value: 'type', label: 'type' },
{ value: 'size', label: 'size' },
{ value: 'name', label: 'name' },
{ value: 'url', label: 'url' },
{ value: 'extension', label: 'extension' },
{ value: 'mime_type', label: 'mime_type' },
]
export const fileSubFieldOperators: { [key: string]: SelectProps['options'] } = {
type: [
{ value: 'eq', label: 'workflow.config.list-operator.type.eq' },
{ value: 'ne', label: 'workflow.config.list-operator.type.ne' },
],
size: [
{ value: 'lt', label: 'workflow.config.if-else.num.lt' },
{ value: 'le', label: 'workflow.config.if-else.num.le' },
{ value: 'gt', label: 'workflow.config.if-else.num.gt' },
{ value: 'ge', label: 'workflow.config.if-else.num.ge' },
],
default: [
{ value: 'contains', label: 'workflow.config.if-else.contains' },
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
{ value: 'startwith', label: 'workflow.config.if-else.startwith' },
{ value: 'endwith', label: 'workflow.config.if-else.endwith' },
{ value: 'eq', label: 'workflow.config.if-else.eq' },
{ value: 'ne', label: 'workflow.config.if-else.ne' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
}
interface ArrayFileSubConditionsProps {
conditionFieldName: number;
caseIndex: number;
conditionIndex: number;
name: string;
filterNumberOptions: Suggestion[];
options: Suggestion[];
updateNodeLayout: (cases: any[]) => void;
updateNodePorts: (caseCount: number, removedCaseIndex?: number) => void;
}
const ArrayFileSubConditions: FC<ArrayFileSubConditionsProps> = ({ conditionFieldName, caseIndex, conditionIndex, name, filterNumberOptions, options, updateNodeLayout, updateNodePorts }) => {
const { t } = useTranslation();
const form = Form.useFormInstance();
const subValues = Form.useWatch([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions'], form)
const handleChangeSubLogicalOperator = () => {
const current = form.getFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'logical_operator']) || 'and';
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'logical_operator'], current === 'and' ? 'or' : 'and');
};
const handleInputTypeChange = (caseIndex: number, conditionIndex: number, subIndex: number) => {
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex, 'value'], undefined);
};
useEffect(() => {
console.log('subValues', subValues)
if (!subValues) return
const cases = form.getFieldValue(name) || [];
setTimeout(() => {
updateNodeLayout(cases);
const allSet = (subValues ?? []).every(isSubExprSet);
console.log('allSet', allSet)
updateNodePorts(cases.length);
}, 100);
}, [subValues])
return (
<div className="rb:bg-white rb:rounded-lg rb:p-1 rb:w-62">
<Form.List name={[conditionFieldName, 'sub_variable_condition', 'conditions']}>
{(subFields, { add: addSub, remove: removeSub }) => {
const subLogicalOperator = form.getFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'logical_operator']) || 'and';
return (
<>
<div className={clsx("rb:relative", {
'rb:ml-11': subFields.length > 1,
})}>
{subFields.length > 1 && (
<div className="rb:absolute rb:-left-8 rb:top-4 rb:bottom-4 rb:w-6 rb:h-[calc(100%-32px)]">
<div className="rb:absolute rb:w-2 rb:h-[calc(50%-20px)] rb:left-5 rb:top-0 rb:z-10 rb:border-l rb:border-t rb:border-[#EBEBEB] rb:rounded-tl-[10px] rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:-right-1.25 rb:top-[calc(50%-10px)]">
<Space size={2} className="rb:cursor-pointer rb:text-[#155EEF] rb:text-[10px] rb:leading-4.5 rb:font-medium rb-border rb:py-px! rb:px-1! rb:rounded-sm" onClick={handleChangeSubLogicalOperator}>
{subLogicalOperator}
<div className="rb:size-2.5 rb:bg-cover rb:bg-[url('@/assets/images/workflow/refresh_active.svg')]"></div>
</Space>
</div>
<div className="rb:absolute rb:w-2 rb:h-[calc(50%-20px)] rb:left-5 rb:bottom-0 rb:z-10 rb:border-l rb:border-b rb:border-[#EBEBEB] rb:rounded-bl-[10px] rb:border-r-0"></div>
</div>
)}
{subFields.map((subField, subIndex) => {
const subExpr = form.getFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex]) || {};
const subOperator = subExpr.operator;
const subLeft = subExpr.key;
const subOperatorList = subLeft === 'type' ? fileSubFieldOperators.type : subLeft === 'size' ? fileSubFieldOperators.size : fileSubFieldOperators.default;
const hideSubRight = subOperator === 'empty' || subOperator === 'not_empty';
const subInputType = subExpr.input_type
return (
<Flex key={subField.key} gap={4} align="start" className="rb:mb-1.5!">
<div className={clsx("rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg rb:border rb:border-[#EBEBEB]", {
'rb:w-43.5': subFields.length > 1,
'rb:w-54.5': subFields.length === 1
})}>
<Row className={clsx('rb:p-1!', { 'rb-border-b': !hideSubRight })}>
<Col flex="100px">
<Form.Item name={[subField.name, 'key']} noStyle>
<Select
options={fileSubFieldOptions}
size="small"
popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')}
variant="borderless"
className="rb:w-full!"
onChange={(value: string) => {
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'sub_variable_condition', 'conditions', subIndex], {
key: value,
input_type: value === 'size' ? 'constant' : undefined,
value: undefined,
operator: value === 'size' ? 'ge' : 'eq',
});
}}
/>
</Form.Item>
</Col>
<Col flex="1">
<Form.Item name={[subField.name, 'operator']} noStyle>
<Select
options={(subOperatorList ?? []).map(vo => ({ ...vo, label: t(String(vo?.label || '')) }))}
size="small"
popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')}
variant="borderless"
className="rb:w-full!"
/>
</Form.Item>
</Col>
</Row>
{!hideSubRight && (
<div>
{subLeft === 'size'
? <Flex align="center">
<Form.Item name={[subField.name, 'input_type']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
options={[{ value: 'variable', label: 'Variable' }, { value: 'constant', label: 'Constant' }]}
popupMatchSelectWidth={false}
variant="borderless"
size="small"
onChange={() => { handleInputTypeChange(caseIndex, conditionIndex, subIndex); }}
className="rb:w-20!"
/>
</Form.Item>
<Divider type="vertical" className="rb:mx-0!" />
<Form.Item name={[subField.name, 'value']} noStyle>
{subInputType === 'variable'
? <VariableSelect
placeholder={t('common.pleaseSelect')}
options={filterNumberOptions}
allowClear={true}
variant="borderless"
size="small"
className="rb:flex-1!"
/>
: <InputNumber
placeholder={t('common.pleaseEnter')}
variant="borderless"
className="rb:w-full!"
suffix="Byte"
size="small"
onChange={(value) => { form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], value); }}
/>
}
</Form.Item>
</Flex>
: <Form.Item name={[subField.name, 'value']} noStyle>
{subLeft === 'type'
? <Select
placeholder={t('common.pleaseSelect')}
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) }))}
variant="borderless"
className="rb:w-full!"
/>
: <Editor options={options} size="small" type="input" variant='borderless' height={28} className="rb:w-full!" />
}
</Form.Item>
}
</div>
)}
</div>
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => { removeSub(subField.name); }}
/>
</Flex>
);
})}
</div>
<Button
onClick={() => { addSub({ key: undefined, operator: undefined, value: undefined, input_type: undefined }); }}
className="rb:py-0! rb:px-1! rb:h-4.5! rb:rounded-sm! rb:text-[12px]!"
size="small"
>
+ {t('workflow.config.if-else.addSubVariable')}
</Button>
</>
);
}}
</Form.List>
</div>
);
};
const CaseList: FC<CaseListProps> = ({
options,
name,
@@ -251,7 +488,7 @@ const CaseList: FC<CaseListProps> = ({
left: newValue,
operator: undefined,
right: undefined,
input_type: undefined
input_type: 'constant'
});
};
@@ -303,7 +540,7 @@ const CaseList: FC<CaseListProps> = ({
const logicalOperator = form.getFieldValue(name)?.[caseIndex]?.logical_operator || 'and'
return (
<Row className="rb:text-[12px] rb:mb-4!">
<Col flex="48px">
<Col flex="44px">
<div className="rb:font-medium rb:leading-4.5">{caseIndex === 0 ? 'IF' : 'ELIF'}</div>
{caseFields.length > 1 && <div className="rb:text-[10px] rb:text-[#5B6167] rb:leading-2.5"> {`CASE ${caseIndex + 1}`}</div>}
</Col>
@@ -314,9 +551,9 @@ const CaseList: FC<CaseListProps> = ({
<div className="rb:absolute rb:w-3 rb:h-[calc(50%-20px)] rb:left-5 rb:top-0 rb:z-10 rb:border-l rb:border-t rb:border-[#EBEBEB] rb:rounded-tl-[10px] rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:-right-1.25 rb:top-[calc(50%-10px)]">
<Form.Item name={[caseField.name, 'logical_operator']} noStyle >
<Space size={2} className="rb:cursor-pointer rb:text-[#155EEF] rb:leading-4.5 rb:font-medium rb-border rb:py-px! rb:px-1! rb:rounded-sm" onClick={() => handleChangeLogicalOperator(caseIndex)}>
<Space size={2} className="rb:cursor-pointer rb:text-[#155EEF] rb:text-[10px] rb:leading-4.5 rb:font-medium rb-border rb:py-px! rb:px-1! rb:rounded-sm" onClick={() => handleChangeLogicalOperator(caseIndex)}>
{logicalOperator}
<div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/workflow/refresh_active.svg')]"></div>
<div className="rb:size-2.5 rb:bg-cover rb:bg-[url('@/assets/images/workflow/refresh_active.svg')]"></div>
</Space>
</Form.Item>
</div>
@@ -329,9 +566,11 @@ const CaseList: FC<CaseListProps> = ({
const currentExpression = currentCase.expressions?.[conditionIndex] || {};
const currentOperator = currentExpression.operator;
const leftFieldValue = currentExpression.left;
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue)
?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue)
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue);
const leftFieldType = leftFieldOption?.dataType;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]' || leftFieldType === 'array[file]';
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || leftFieldType === 'file' || leftFieldType === 'array[object]';
const operatorList = leftFieldType && operatorsObj[leftFieldType]
? operatorsObj[leftFieldType]
: leftFieldType && leftFieldType?.includes('array')
@@ -341,7 +580,7 @@ const CaseList: FC<CaseListProps> = ({
return (
<Flex key={conditionField.key} gap={4} align="start" className="rb:mb-2!">
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
<Row className={clsx("rb:p-1!", {
<Row className={clsx("rb:px-1!", {
'rb-border-b': !hideRightField
})}>
<Col flex="144px">
@@ -375,22 +614,40 @@ const CaseList: FC<CaseListProps> = ({
</Row>
{!hideRightField && (
<div className="rb:py-1 rb:px-1.5">
<div
className={clsx({
"rb:py-1 rb:px-1.5": ['boolean', 'array[boolean]', 'array[file]'].includes(leftFieldType as string)
})}
>
{leftFieldType === 'array[file]'
? <>TODO</>
? <>
<Form.Item name={[conditionField.name, 'sub_variable_condition', 'logical_operator']} initialValue="and" noStyle>
<span />
</Form.Item>
<ArrayFileSubConditions
conditionFieldName={conditionField.name}
caseIndex={caseIndex}
conditionIndex={conditionIndex}
name={name}
options={options}
filterNumberOptions={filterNumberOptions}
updateNodeLayout={updateNodeLayout}
updateNodePorts={updateNodePorts}
/>
</>
: leftFieldType === 'number'
? <Flex align="center">
<Form.Item name={[conditionField.name, 'input_type']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
options={[{ value: 'variable', label: 'Variable' }, { value: 'Constant', label: 'constant' }]}
options={[{ value: 'variable', label: 'Variable' }, { value: 'constant', label: 'Constant' }]}
popupMatchSelectWidth={false}
variant="borderless"
onChange={() => handleInputTypeChange(caseIndex, conditionIndex)}
className="rb:w-20!"
/>
</Form.Item>
<Divider type="vertical" />
<Divider type="vertical" className="rb:mx-0!" />
<Form.Item name={[conditionField.name, 'right']} noStyle>
{inputType === 'variable'
? <VariableSelect
@@ -413,7 +670,7 @@ const CaseList: FC<CaseListProps> = ({
<Form.Item name={[conditionField.name, 'right']} noStyle>
{['boolean', 'array[boolean]'].includes(leftFieldType as string)
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: <Editor options={options} size="small" type="input" />
: <Editor options={options} size="small" type="input" variant='borderless' height={28} />
}
</Form.Item>
)

View File

@@ -94,7 +94,7 @@ const CodeExecution: FC<CodeExecutionProps> = ({ options }) => {
{ label: 'JAVASCRIPT', value: 'javascript' }
]}
popupMatchSelectWidth={false}
className={`rb:font-medium! rb:w-25! rb:h-4! rb:p-0! ${styles.editor}`}
className={`rb:font-medium! rb:w-25! rb:h-4! rb:py-0! rb:px-2! ${styles.editor}`}
onChange={handleChangeLanguage}
variant="borderless"
/>

View File

@@ -155,7 +155,9 @@ const ConditionList: FC<CaseListProps> = ({
const currentExpression = expressions[index] || {};
const currentOperator = currentExpression.operator;
const leftFieldValue = currentExpression.left;
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue)
?? options.flatMap(o => o.children ?? []).find(child => `{{${child.value}}}` === leftFieldValue)
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === leftFieldValue);
const leftFieldType = leftFieldOption?.dataType;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty' || ['array[object]', 'object'].includes(leftFieldType as string);
const operatorList = leftFieldType && ['array[object]', 'object'].includes(leftFieldType)
@@ -176,7 +178,7 @@ const ConditionList: FC<CaseListProps> = ({
className="rb:mb-2!"
>
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
<Row className={clsx("rb:p-1!", {
<Row className={clsx("rb:px-1!", {
'rb-border-b': !hideRightField
})}>
<Col flex="1">
@@ -216,7 +218,7 @@ const ConditionList: FC<CaseListProps> = ({
</Row>
{!hideRightField && (
<div className="rb:py-1 rb:px-1.5">
<div className={leftFieldType === 'boolean' ? "rb:py-1 rb:px-1.5" : ''}>
{leftFieldType === 'number'
? (
<Flex align="center">

View File

@@ -155,7 +155,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
? <CodeMirrorEditor
language="json"
placeholder={object_placeholder}
variant="outlined"
variant="filled"
size="small"
/>
: (

View File

@@ -62,14 +62,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
*/
useEffect(() => {
if (!isCanAdd && value[0]) {
const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0]);
const firstVariable = options.find(opt => `{{${opt.value}}}` === value[0])
?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === value[0])
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === value[0]);
if (firstVariable) {
form.setFieldValue(['group_type', 'output'], firstVariable.dataType);
}
} else if (isCanAdd) {
value.forEach((item: any, index: number) => {
if (item?.value?.[0]) {
const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0]);
const firstVariable = options.find(opt => `{{${opt.value}}}` === item.value[0])
?? options.flatMap(o => o.children ?? []).find(c => `{{${c.value}}}` === item.value[0])
?? options.flatMap(o => o.children ?? []).flatMap((c: any) => c.children ?? []).find((gc: any) => `{{${gc.value}}}` === item.value[0]);
if (firstVariable) {
form.setFieldValue(['group_type', index], firstVariable.dataType);
}

View File

@@ -85,9 +85,9 @@ const EditableTable: FC<EditableTableProps> = ({
return [
{
title: t('workflow.config.name'),
dataIndex: 'name',
dataIndex: 'key',
render: (_: any, __: TableRow, index: number) => (
<Form.Item name={[index, 'name']} className={formClassName}>
<Form.Item name={[index, 'key']} className={formClassName}>
<Editor
options={namefilterOptions}
type="input"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:35:43
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-02 17:17:06
* @Last Modified time: 2026-04-14 17:36:53
*/
import { type FC, useMemo, useRef, useState } from "react";
import { useTranslation } from 'react-i18next'
@@ -35,9 +35,8 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
form.setFieldsValue({ auth })
}
const handleChangeBodyContentType = (e: any) => {
const value = e.target.value || e.target.value
form.setFieldValue(['body', 'data'], ['form-data', 'x-www-form-urlencoded'].includes(value) ? [{}] : undefined)
const handleChangeBodyContentType = () => {
form.setFieldValue(['body', 'data'], undefined)
}
// Handle error handling method change and update node ports accordingly

View File

@@ -56,7 +56,7 @@ const operatorsObj: { [key: string]: SelectProps['options'] } = {
]
}
const typeOptions = ['image', 'document', 'video', 'audio']
export const typeOptions = ['image', 'document', 'video', 'audio']
const FilterConditions: FC<FilterConditionsProps> = ({
options,
@@ -101,24 +101,20 @@ const FilterConditions: FC<FilterConditionsProps> = ({
align="start"
className="rb:mb-2!"
>
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
<div className="rb:flex-1">
{variableType === 'array[file]' &&
<Row className="rb:p-1! rb-border-b">
<Col span={24}>
<Form.Item name={[field.name, 'key']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
options={fileSubVariable}
fieldNames={{ value: 'filed', label: 'label' }}
onChange={(value) => handleKeyFieldChange(index, value)}
variant="borderless"
className="rb:w-full! rb:h-7!"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name={[field.name, 'key']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
options={fileSubVariable}
fieldNames={{ value: 'filed', label: 'label' }}
onChange={(value) => handleKeyFieldChange(index, value)}
className="rb:w-full! select rb:mb-1!"
variant="borderless"
/>
</Form.Item>
}
<Row>
<Row gutter={8}>
<Col flex={hideValueField ? '1' : "96px"}>
<Form.Item name={[field.name, 'comparison_operator']} noStyle>
<Select
@@ -129,28 +125,27 @@ const FilterConditions: FC<FilterConditionsProps> = ({
size="small"
popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')}
className="rb:w-full! select"
variant="borderless"
className="rb:w-full! rb:h-7!"
/>
</Form.Item>
</Col>
{!hideValueField && (
<Col flex="1">
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!">
<Form.Item name={[field.name, 'value']} noStyle>
{innerType === 'boolean'
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} type="inner" />
: keyFieldValue === 'type'
? <Select
placeholder={t('common.pleaseSelect')}
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) } ))}
variant="borderless"
className="rb:w-full!"
variant="filled"
/>
: <Editor
variant="borderless"
variant="filled"
type="input"
size="small"
height={24}
height={28}
options={keyFieldType ? options.flatMap(vo => {
if (vo.dataType === keyFieldType) return [vo];
const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType);
@@ -167,7 +162,7 @@ const FilterConditions: FC<FilterConditionsProps> = ({
</Row>
</div>
<div
className="rb:size-4 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
className="rb:size-4 rb:mt-1.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/workflow/deleteBg.svg')] rb:hover:bg-[url('@/assets/images/workflow/deleteBg_hover.svg')]"
onClick={() => remove(field.name)}
></div>
</Flex>

View File

@@ -58,7 +58,7 @@ const MappingList: FC<MappingListProps> = ({ label, name, options, extra, valueK
placeholder={t('common.pleaseSelect')}
options={options}
size="small"
className="rb:w-51!"
className="rb:flex-1!"
/>
</Form.Item>
<div

View File

@@ -1,16 +1,38 @@
import { type FC } from "react";
/*
* @Author: ZhaoYing
* @Date: 2026-03-07 14:55:04
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-17 10:05:32
*/
import { type FC, useEffect, useState } from "react";
import { useTranslation } from 'react-i18next'
import { Form } from 'antd'
import { Form, Switch } from 'antd'
import RbSlider from '@/components/RbSlider'
import RbCard from '@/components/RbCard/Card'
import ModelSelect from '@/components/ModelSelect'
import type { Model } from '@/views/ModelManagement/types';
const ModelConfig: FC = () => {
const { t } = useTranslation()
const form = Form.useFormInstance()
const model_id = Form.useWatch(['model_id'], form)
console.log('ModelConfig', model_id)
const [selectedModel, setSelectedModel] = useState<Model | null>(null)
const [options, setOptions] = useState<Model[]>([])
const updateOptions = (options: Model[]) => {
setOptions(options)
}
useEffect(() => {
if (model_id && options) {
const model = options.find(item => item.id === model_id)
setSelectedModel(model || null)
form.setFieldValue('json_output', false)
} else {
setSelectedModel(null)
}
}, [model_id, options])
return (
<>
@@ -25,6 +47,7 @@ const ModelConfig: FC = () => {
params={{ type: 'llm,chat' }}
className="rb:w-full!"
size="small"
updateOptions={updateOptions}
/>
</Form.Item>
{model_id && (
@@ -52,7 +75,7 @@ const ModelConfig: FC = () => {
<Form.Item
name="max_tokens"
label={t('workflow.config.llm.max_tokens')}
className="rb:mb-0!"
className="rb:mb-1.5!"
>
<RbSlider
min={256}
@@ -63,6 +86,16 @@ const ModelConfig: FC = () => {
className="rb:-mt-2!"
/>
</Form.Item>
<Form.Item
name="json_output"
valuePropName="checked"
label={t('workflow.config.llm.json_output')}
layout="horizontal"
className="rb:mb-0!"
hidden={!(selectedModel?.capability?.includes('json_output'))}
>
<Switch />
</Form.Item>
</RbCard>
)}
</>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:40:13
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-08 10:48:21
* @Last Modified time: 2026-04-16 13:57:30
*/
import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
import { createPortal } from 'react-dom'
@@ -40,15 +40,34 @@ const VariableSelect: FC<VariableSelectProps> = ({
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
const [expandedParentKey, setExpandedParentKey] = useState<string | null>(null);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [activePanel, setActivePanel] = useState<'main' | 'child'>('main');
const [childActiveIndex, setChildActiveIndex] = useState<number>(-1);
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 });
const [childPanelPos, setChildPanelPos] = useState({ top: 0, right: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
const childItemRefs = useRef<Map<string, HTMLElement>>(new Map());
const activeKeyRef = useRef<string | null>(null);
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
const calcChildPos = (key: string) => {
const el = itemRefs.current.get(key);
if (!el) return;
const rect = el.getBoundingClientRect();
const dropdownEl = dropdownRef.current;
if (!dropdownEl) return;
const dropdownRect = dropdownEl.getBoundingClientRect();
const dropdownBottom = dropdownRect.bottom;
const actualChildHeight = Math.min(CHILD_PANEL_HEIGHT, dropdownRect.height);
// Bottom-align child panel with main panel
const top = Math.max(10, dropdownBottom - actualChildHeight);
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
};
// Calculate dropdown position (runs synchronously after DOM paint to avoid flicker)
useLayoutEffect(() => {
if (!open || !containerRef.current) return;
@@ -69,7 +88,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
? triggerRect.bottom + MARGIN
: Math.max(MARGIN, triggerRect.top - dropdownHeight - MARGIN);
setDropdownPos({ top, left, width });
}, [open, search, Array.isArray(value) ? value.length : 0]);
// Re-calculate child panel position if expanded
if (expandedParentKey) calcChildPos(expandedParentKey);
}, [open, search, Array.isArray(value) ? value.length : 0, options.length, expandedParentKey]);
const filteredOptions = filterBooleanType
? options.filter(o => o.dataType !== 'boolean')
@@ -84,6 +105,10 @@ const VariableSelect: FC<VariableSelectProps> = ({
? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value))
: undefined;
const expandedParent = expandedParentKey
? filteredOptions.find(o => o.key === expandedParentKey) ?? null
: null;
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, Suggestion[]>, s) => {
const nodeId = s.nodeData.id as string;
if (!groups[nodeId]) groups[nodeId] = [];
@@ -103,6 +128,12 @@ const VariableSelect: FC<VariableSelectProps> = ({
}, {})
: groupedSuggestions;
useEffect(() => {
if (!expandedParentKey) return;
calcChildPos(expandedParentKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dropdownPos, expandedParentKey]);
useEffect(() => {
if (!open) return;
const updatePos = () => {
@@ -139,7 +170,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
) {
setOpen(false);
setSearch('');
setExpandedParent(null);
setExpandedParentKey(null);
setChildPanelPos({ top: 0, right: 0 });
}
};
@@ -147,6 +178,87 @@ const VariableSelect: FC<VariableSelectProps> = ({
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Flat list of all visible selectable items (main panel only, no children expanded inline)
const flatItems = Object.values(filteredGroups).flat();
useEffect(() => {
setActiveIndex(-1);
setActivePanel('main');
setChildActiveIndex(-1);
}, [open, search]);
useEffect(() => {
if (activeIndex < 0 || activeIndex >= flatItems.length) {
setExpandedParentKey(null);
return;
}
const s = flatItems[activeIndex];
activeKeyRef.current = s.key;
itemRefs.current.get(s.key)?.scrollIntoView({ block: 'nearest' });
if (s.children?.length) {
calcChildPos(s.key);
setExpandedParentKey(s.key);
} else {
setExpandedParentKey(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeIndex]);
useEffect(() => {
if (!expandedParent?.children?.length || childActiveIndex < 0) return;
const child = expandedParent.children[childActiveIndex];
if (child) childItemRefs.current.get(child.key)?.scrollIntoView({ block: 'nearest' });
}, [childActiveIndex, expandedParent]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
const children = expandedParent?.children ?? [];
if (activePanel === 'child') {
if (e.key === 'ArrowDown') {
e.preventDefault();
setChildActiveIndex(i => Math.min(i + 1, children.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setChildActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
setActivePanel('main');
setChildActiveIndex(-1);
} else if (e.key === 'Enter' && childActiveIndex >= 0 && childActiveIndex < children.length) {
e.preventDefault();
const child = children[childActiveIndex];
if (!child.disabled) handleSelect(child);
} else if (e.key === 'Escape') {
setOpen(false);
}
} else {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
if (expandedParent?.children?.length) {
setActivePanel('child');
setChildActiveIndex(0);
}
} else if (e.key === 'Enter' && activeIndex >= 0 && activeIndex < flatItems.length) {
e.preventDefault();
const s = flatItems[activeIndex];
if (!s.disabled) handleSelect(s);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, activeIndex, activePanel, childActiveIndex, flatItems, expandedParent]);
const handleSelect = (suggestion: Suggestion) => {
if (multiple) {
const key = `{{${suggestion.value}}}`;
@@ -159,7 +271,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
onChange?.(`{{${suggestion.value}}}`, suggestion);
setOpen(false);
setSearch('');
setExpandedParent(null);
setExpandedParentKey(null);
}
};
@@ -167,19 +279,6 @@ const VariableSelect: FC<VariableSelectProps> = ({
e.stopPropagation();
onChange?.(multiple ? [] : '', multiple ? [] : undefined);
};
const updateChildPos = (key: string) => {
const el = itemRefs.current.get(key);
if (el) {
const rect = el.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.top - 10;
const top = spaceBelow >= CHILD_PANEL_HEIGHT
? rect.top
: Math.max(10, window.innerHeight - CHILD_PANEL_HEIGHT - 10);
setChildPanelPos({ top, right: window.innerWidth - rect.left + 8 });
}
};
const sep = <span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>;
const isConversation = (parentOfSelected ?? selectedSuggestion)?.group === 'CONVERSATION' ||
(selectedSuggestion ? filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === value)) : false);
@@ -190,20 +289,30 @@ const VariableSelect: FC<VariableSelectProps> = ({
{/* Trigger */}
<div
className={clsx(
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:px-2 rb:transition-colors',
variant === 'filled' && 'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none',
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white',
variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]',
variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent',
multiple && size === 'small' ? 'rb:min-h-7 rb:py-0.75' : multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-7 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]',
!multiple && (size === 'small' ? 'rb:text-[12px]' : 'rb:text-[12px]'),
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-lg rb:px-2 rb:transition-colors', {
'rb:bg-[#F6F6F6] rb:border-none rb:shadow-none': variant === 'filled',
'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff] rb:bg-white': variant === 'outlined',
'rb:border-[#171719]!': variant === 'outlined' && open,
'rb:border-none rb:shadow-none rb:bg-transparent': variant === 'borderless',
'rb:text-[12px]': size === 'small',
'rb:text-[14px]': size !== 'small',
},
multiple && size === 'small'
? 'rb:min-h-7 rb:py-0.75'
: multiple
? 'rb:min-h-8 rb:py-1'
: size === 'small'
? 'rb:h-7 rb:text-[10px]'
: size === 'large'
? 'rb:h-10'
: 'rb:h-8 rb:text-[12px]',
className
)}
onClick={() => setOpen(o => !o)}
>
{multiple ? (
selectedValues.length > 0 ? (
<span className="rb:flex rb:flex-wrap rb:gap-1 rb:flex-1 rb:min-w-0">
<Flex wrap gap={4} className="rb:flex-1! rb:min-w-0">
{selectedValues.map(v => {
const s = suggestionMap.get(v);
if (!s) return null;
@@ -214,11 +323,11 @@ const VariableSelect: FC<VariableSelectProps> = ({
return (
<span
key={v}
className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full"
className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:cursor-pointer"
>
{!isConv && nd?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nd.icon}`} />}
{!isConv && nd?.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />}
{!isConv && nd?.name && <span className="rb:text-[#5B6167]">{nd.name}{sep}</span>}
<span className="rb:text-[#171719]">
<span>
{parent ? <>{parent.label}{sep}{s.label}</> : s.label}
</span>
<span
@@ -228,23 +337,25 @@ const VariableSelect: FC<VariableSelectProps> = ({
</span>
);
})}
</span>
</Flex>
) : (
<span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span>
<span className="rb:text-[rgba(23,23,25,0.25)] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:flex-1">{placeholder}</span>
)
) : selectedSuggestion ? (
<div className="rb:flex rb:flex-1 rb:min-w-0 rb:max-w-full">
<span className="rb:inline-flex rb:items-center rb:gap-0.5 rb:bg-[#f0f8ff] rb:rounded rb:px-1 rb:py-0.5 rb:text-[11px] rb:max-w-full rb:overflow-hidden">
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />}
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167] rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{sep}</span>}
<span className="rb:text-[#171719] rb:shrink rb:min-w-0 rb:truncate">
<span
className="rb-border rb:rounded-md rb:bg-white rb:text-[10px] rb:text-[#212332] rb:h-5! rb:inline-flex rb:items-center rb:p-1 rb:cursor-pointer"
>
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:bg-cover rb:mr-1 ${nodeData.icon}`} />}
{!isConversation && nodeData?.name && <span className="rb:shrink rb:min-w-0 rb:truncate rb:max-w-[40%]">{nodeData.name}</span>}
{!isConversation && nodeData?.name && <span>{sep}</span>}
<span className="rb:shrink rb:min-w-0 rb:truncate">
{parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label}
</span>
</span>
</div>
) : (
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span>
<span className="rb:text-[rgba(23,23,25,0.25)] rb:flex-1">{placeholder}</span>
)}
<Space size={4} className="rb:shrink-0 rb:ml-1">
{allowClear && (
@@ -266,18 +377,19 @@ const VariableSelect: FC<VariableSelectProps> = ({
{open && createPortal(
<div
ref={dropdownRef}
className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-lg rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)] rb:p-1"
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
style={{ top: dropdownPos.top, left: dropdownPos.left, minWidth: dropdownPos.width }}
>
<div className="rb:min-w-70 rb:max-h-60 rb:overflow-y-auto rb:py-1">
{Object.entries(filteredGroups).map(([nodeId, suggestions]) => {
<div className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto">
{Object.entries(filteredGroups).map(([nodeId, suggestions], index) => {
const nd = suggestions[0].nodeData;
return (
<div key={nodeId}>
<Flex align="center" gap={4} className="rb:px-3! rb:py-1.25! rb:text-[12px] rb:text-[#5B6167]">
{nd.icon && <div className={`rb:size-4 rb:bg-cover ${nd.icon}`} />}
<div key={nodeId} className={clsx("rb:text-[12px]", {
'rb:mt-3': index !== 0
})}>
<div className="rb:px-2 rb:leading-4.25 rb:mb-1.25 rb:font-medium rb:text-[#5B6167]">
{nd.name}
</Flex>
</div>
{suggestions.map(s => {
const isSelected = multiple
? selectedValues.includes(`{{${s.value}}}`)
@@ -288,11 +400,9 @@ const VariableSelect: FC<VariableSelectProps> = ({
<Flex
key={s.key}
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
className={clsx("rb:pl-6! rb:pr-3! rb:py-1.25! rb:rounded-lg!", {
'rb:bg-[#e6f4ff]': isSelected || isExpanded,
'rb:bg-white rb:hover:bg-[#F6F6F6]!': !(isSelected || isExpanded),
'rb:opacity-60': s.disabled,
'rb:cursor-not-allowed': s.disabled,
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': isSelected || isExpanded || flatItems.indexOf(s) === activeIndex,
'rb:cursor-not-allowed rb:opacity-65': s.disabled,
'rb:cursor-pointer': !s.disabled,
})}
align="center"
@@ -300,30 +410,29 @@ const VariableSelect: FC<VariableSelectProps> = ({
onClick={() => {
if (s.disabled) return;
if (hasChildren) {
updateChildPos(s.key);
setExpandedParent(prev => prev?.key === s.key ? null : s);
calcChildPos(s.key);
setExpandedParentKey(prev => prev === s.key ? null : s.key);
}
handleSelect(s);
}}
onMouseEnter={() => {
if (hasChildren) {
updateChildPos(s.key);
setExpandedParent(s);
calcChildPos(s.key);
setExpandedParentKey(s.key);
} else {
setExpandedParent(null);
setExpandedParentKey(null);
}
}}
>
<Space size={4}>
<div className="rb:font-medium">
{multiple && (
<Checkbox checked={isSelected} />
<Checkbox checked={isSelected} className="rb:mr-2!" />
)}
<span className="rb:text-[#155EEF]">{`{x}`}</span>
<span>{s.label}</span>
</Space>
<Space size={4} className="rb:text-[#5B6167] rb:text-[12px]">
{s.dataType && <span>{s.dataType}</span>}
<span className="rb:text-[#155EEF]">{`{x}`}</span> {s.label}
</div>
<Space size={2}>
{s.dataType && <span>{s.dataType}</span>}
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
</Space>
</Flex>
@@ -334,7 +443,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
})}
{Object.keys(filteredGroups).length === 0 && (
<div className="rb:px-3 rb:py-4 rb:text-center rb:text-[#bfbfbf] rb:text-[14px]">
{t('workflow.variableSelect.empty', '暂无变量')}
{t('workflow.variableSelect.empty')}
</div>
)}
</div>
@@ -346,51 +455,43 @@ const VariableSelect: FC<VariableSelectProps> = ({
{open && expandedParent?.children?.length && createPortal(
<div
id="variable-select-child-panel"
className="rb:fixed rb:z-9999 rb:bg-white rb:rounded-xl rb:py-1 rb:min-w-60 rb:max-h-60 rb:overflow-y-auto rb:text-[14px] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
className="rb:min-w-70 rb:max-h-57.5 rb:overflow-y-auto rb:text-[12px] rb:fixed rb:z-1000 rb:bg-white rb:rounded-lg rb:border-[0.5px] rb:border-[#EBEBEB] rb:shadow-[0px_2px_6px_0px_rgba(0,0,0,0.1)] rb:py-3 rb:px-2"
style={{ top: childPanelPos.top, right: childPanelPos.right }}
onMouseEnter={() => setExpandedParent(expandedParent)}
onMouseEnter={() => setExpandedParentKey(expandedParentKey)}
>
<div
className="rb:px-3 rb:py-2 rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:border-b rb:border-[#F0F0F0] rb:cursor-pointer rb:hover:bg-[#f0f8ff]"
onClick={() => !expandedParent.disabled && handleSelect(expandedParent)}
>
<div className="rb:pb-2 rb:mb-1 rb:font-medium rb:text-[#5B6167] rb-border-b">
<Flex justify="space-between" align="center" gap={8}>
<Flex align="center" gap={6}>
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
</Flex>
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
<span>{expandedParent.dataType}</span>
</Flex>
</div>
{expandedParent.children.map(child => {
{expandedParent.children.map((child, ci) => {
const isSelected = multiple
? selectedValues.includes(`{{${child.value}}}`)
: `{{${child.value}}}` === value;
const hasGrandChildren = !!child.children?.length;
const isChildActive = activePanel === 'child' && ci === childActiveIndex;
return (
<Flex
key={child.key}
className={clsx("rb:px-3! rb:py-2! rb:hover:bg-[#f0f8ff]!", {
'rb:bg-[#f0f8ff]': isSelected,
'rb:white': !isSelected
ref={(el) => { if (el) childItemRefs.current.set(child.key, el); }}
className={clsx("rb:px-2! rb:py-0.75! rb:rounded-sm rb:leading-4.5 rb:text-[#5B6167] rb:hover:bg-[#F6F6F6]", {
'rb:bg-[#F6F6F6]': isSelected || isChildActive,
'rb:cursor-not-allowed rb:opacity-65': child.disabled,
'rb:cursor-pointer': !child.disabled,
})}
align="center"
justify="space-between"
style={{
cursor: child.disabled ? 'not-allowed' : 'pointer',
opacity: child.disabled ? 0.5 : 1,
}}
onClick={() => !child.disabled && handleSelect(child)}
>
<Flex align="center" gap={6}>
<Flex align="center" gap={8}>
{multiple && (
<Checkbox checked={isSelected} />
)}
<span>{child.label}</span>
</Flex>
<Flex align="center" gap={4}>
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
{hasGrandChildren && <span className="rb:text-[#5B6167] rb:ml-1"></span>}
<span className="rb:font-medium">{child.label}</span>
</Flex>
<Space size={2}>
{child.dataType && <span>{child.dataType}</span>}
</Space>
</Flex>
);
})}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-01-19 17:00:26
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-08 10:12:27
* @Last Modified time: 2026-04-13 10:44:17
*/
/**
* useVariableList Hook
@@ -414,7 +414,7 @@ export const useVariableList = (
const pd = parentLoop.getData();
const pid = pd.id;
if (pd.type === 'loop') {
(pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'String', `${pid}.${cv.name}`, pd));
(pd.cycle_vars || []).forEach((cv: any) => addVariable(list, keys, `${pid}_cycle_${cv.name}`, cv.name, cv.type || 'string', `${pid}.${cv.name}`, pd));
} else if (pd.type === 'iteration' && pd.config.input.defaultValue) {
let itemType = 'object';
const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue);

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-10 17:24:19
* @Last Modified time: 2026-04-13 10:44:19
*/
import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx'
@@ -266,7 +266,7 @@ const Properties: FC<PropertiesProps> = ({
key,
label: cycleVar.name,
type: 'variable',
dataType: cycleVar.type || 'String',
dataType: cycleVar.type || 'string',
value: `${parentNodeId}.${cycleVar.name}`,
nodeData: parentData,
});
@@ -643,7 +643,7 @@ const Properties: FC<PropertiesProps> = ({
key: contextKey,
label: 'context',
type: 'variable',
dataType: 'String',
dataType: 'string',
value: `context`,
nodeData: selectedNode.getData(),
isContext: true,
@@ -791,7 +791,7 @@ const Properties: FC<PropertiesProps> = ({
key: `${selectedNode.id}_cycle_${cycleVar.name}`,
label: cycleVar.name,
type: 'variable',
dataType: cycleVar.type || 'String',
dataType: cycleVar.type || 'string',
value: `${selectedNode.getData().id}.${cycleVar.name}`,
nodeData: selectedNode.getData(),
}));

View File

@@ -23,6 +23,11 @@
}
.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless) {
height: 28px;
border: 1px solid #F6F6F6;
border-radius: 8px;
}
.properties :global(.select.ant-select-single.ant-select-sm.ant-select-borderless.ant-select-focused) {
border: 1px solid #171719;
}
.properties :global(.ant-table-wrapper .ant-table-thead>tr>th),
.properties :global(.ant-table-wrapper .ant-table-thead>tr>td),
@@ -157,4 +162,7 @@
padding-inline-start: 0px;
border-radius: 4px;
margin-block: 0px;
}
.properties :global(.ant-input-number-affix-wrapper) {
font-size: 12px;
}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 19:56:56
* @Last Modified time: 2026-04-16 17:52:30
*/
import LoopNode from './components/Nodes/LoopNode';
import NormalNode from './components/Nodes/NormalNode';
@@ -101,6 +101,10 @@ export const nodeLibrary: NodeLibrary[] = [
step: 1,
defaultValue: 2000
},
json_output: {
type: 'define',
defaultValue: false
},
context: {
type: 'variableList',
placeholder: 'workflow.config.llm.contextPlaceholder'

View File

@@ -114,6 +114,7 @@ export const useWorkflowGraph = ({
graphRef.current.getNodes().forEach(node => {
const data = node.getData()
if (data?.type === 'if-else' || data?.type === 'question-classifier') {
console.log('chatVariables', chatVariables)
node.setData({ ...data, chatVariables }, { silent: true })
}
})
@@ -206,7 +207,7 @@ export const useWorkflowGraph = ({
? Object.entries(group_variables as Record<string, any>).map(([key, value]) => ({ key, value }))
: group_variables
} else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value }))
nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([key, value]) => ({ key, value }))
} else if (type === 'code' && key === 'code' && config[key] && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
try {
nodeLibraryConfig.config[key].defaultValue = decodeURIComponent(atob(config[key] as string))
@@ -1025,24 +1026,39 @@ export const useWorkflowGraph = ({
graphRef.current.on('node:removed', blankClick)
// When edge connected, bring connected nodes' ports to front
graphRef.current.on('edge:connected', ({ isNew }) => {
// Bring edge to front first, then bring child nodes above edges
// Parent (loop/iteration) nodes stay behind to avoid covering edges
// Reset any port hover state left from dragging
graphRef.current.on('edge:connected', ({ isNew, edge }) => {
if (isNew) {
graphRef.current?.getNodes().forEach(node => {
if (!node.getData()?.cycle) node.toFront();
});
graphRef.current?.getEdges().forEach(edge => {
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
edge.toFront();
}
});
graphRef.current?.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront();
});
const sourceCellId = edge.getSourceCellId()
const targetCellId = edge.getTargetCellId()
const sourceCell = graphRef.current?.getCellById(sourceCellId);
const targetCell = graphRef.current?.getCellById(targetCellId);
sourceCell?.toFront();
targetCell?.toFront()
if (['loop', 'iteration'].includes(sourceCell?.getData()?.type)) {
graphRef.current?.getEdges().forEach(edge => {
const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
if (edgeSourceCell?.getData()?.cycle === sourceCellId || edgeTargetCell?.getData()?.cycle === sourceCellId) {
edge.toFront();
}
});
graphRef.current?.getNodes().forEach(node => {
if (node.getData()?.cycle === sourceCellId) node.toFront();
});
}
if (['loop', 'iteration'].includes(targetCell?.getData()?.type)) {
graphRef.current?.getEdges().forEach(edge => {
const edgeSourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const edgeTargetCell = graphRef.current?.getCellById(edge.getTargetCellId());
if (edgeSourceCell?.getData()?.cycle === targetCellId || edgeTargetCell?.getData()?.cycle === targetCellId) {
edge.toFront();
}
});
graphRef.current?.getNodes().forEach(node => {
if (node.getData()?.cycle === targetCellId) node.toFront();
});
}
}
});
@@ -1187,9 +1203,6 @@ export const useWorkflowGraph = ({
}) || [];
const edges = graphRef.current?.getEdges() || []
console.log('config', config)
const params = {
...config,
features: featuresRef.current,
@@ -1246,9 +1259,17 @@ export const useWorkflowGraph = ({
itemConfig[key] = {}
if (value.length > 0) {
value.forEach((vo: any) => {
itemConfig[key][vo.name] = vo.value
itemConfig[key][vo.key] = vo.value
})
}
} else if (data.type === 'http-request' && key === 'body' && data.config[key] && 'defaultValue' in data.config[key]) {
const value = data.config[key].defaultValue
itemConfig[key] = value
if (value.content_type === 'json' && value.data && value.data !== '') {
itemConfig[key].data = value.data.replace(/\u00a0/g, ' ')
} else {
itemConfig[key].data = value.data
}
} else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') {
itemConfig[key] = data.config[key].defaultValue
} else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) {

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-24 15:07:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-24 15:07:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-17 20:40:47
*/
import { portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from './constant'
@@ -22,11 +22,31 @@ import { portItemArgsY, conditionNodePortItemArgsY, conditionNodeHeight } from '
* @param cases - Array of case objects, each containing an `expressions` array.
* @returns The total pixel height for the condition node.
*/
export const isSubExprSet = (sub: any) => {
if (!sub?.key) return false;
if (['not_empty', 'empty'].includes(sub?.operator)) return true;
return !!sub.value || typeof sub.value === 'boolean' || typeof sub.value === 'number';
};
const getEffectiveExprCount = (expr: any): number => {
const subs = expr?.sub_variable_condition?.conditions;
if (subs?.length && subs.every(isSubExprSet)) return 1 + subs.length;
if (subs?.length > 0) {
return 2
}
return 1;
};
export const calcConditionNodeTotalHeight = (cases: any[]) => {
// Total number of expressions across all cases
const exprCount = cases.reduce((acc: number, c: any) => acc + (c?.expressions?.length || 0), 0);
// Sum of expression counts only for cases that have more than one expression
const hasMultiExprCount = cases.reduce((acc: number, c: any) => acc + (c?.expressions?.length > 1 ? c?.expressions?.length : 0), 0);
// Total number of effective expression rows (sub_variable_condition expand height when all set)
const exprCount = cases.reduce((acc: number, c: any) =>
acc + (c?.expressions?.reduce((s: number, e: any) => s + getEffectiveExprCount(e), 0) || 0), 0);
// Sum of effective expression counts only for cases that have more than one expression
const hasMultiExprCount = cases.reduce((acc: number, c: any) => {
if (!c?.expressions?.length || c.expressions.length <= 1) return acc;
const effectiveCount = c.expressions.reduce((s: number, e: any) => s + getEffectiveExprCount(e), 0);
return acc + effectiveCount;
}, 0);
return conditionNodeHeight + (cases.length - 1) * 26 + exprCount * 20 + hasMultiExprCount * 3;
};
@@ -68,17 +88,44 @@ export const getConditionNodeCasePortY = (cases: any[], caseIndex: number) => {
let singleExprCount = 0;
let multiExprCount = 0;
let extraExprs = 0;
let portItemArgsYNum = 0;
for (let i = 0; i < caseIndex; i++) {
const notHasSub = cases[i]?.expressions?.filter((e: any) => !e?.sub_variable_condition?.conditions || e?.sub_variable_condition?.conditions.length <1).length
const n = cases[i]?.expressions?.length || 0;
y += portItemArgsY * (n + 1);
if (n === 1) singleExprCount++;
else if (n >= 2) {
let casePortItemArgsYNum = n + 1;
// Add extra y for expressions with all sub_variable_condition set
cases[i]?.expressions?.forEach((expr: any) => {
const subs = expr?.sub_variable_condition?.conditions;
if (subs?.length && subs.every(isSubExprSet)) {
casePortItemArgsYNum += subs.length;
} else if (subs?.length) {
casePortItemArgsYNum += 1
}
});
portItemArgsYNum += casePortItemArgsYNum;
if (n === 1 && !cases[i]?.expressions?.some((e: any) => e?.sub_variable_condition?.conditions?.length > 0)) {
singleExprCount++
} else if (n >= 2 || cases[i]?.expressions?.some((e: any) => e?.sub_variable_condition?.conditions?.length > 0)) {
multiExprCount++;
if (n > 2) extraExprs += n - 2;
cases[i]?.expressions?.forEach((e: any) => {
const subs = e?.sub_variable_condition?.conditions;
if (subs?.length && subs.every(isSubExprSet) && subs.length > 1) {
extraExprs += subs.length + 2;
}
});
console.log('extraExprs notHasSub', notHasSub)
if (notHasSub > 3) {
extraExprs += n - 2 + notHasSub/4;
} else {
extraExprs += n - 2 + notHasSub/4
}
}
}
console.log('singleExprCount', singleExprCount, 'multiExprCount', multiExprCount, 'extraExprs', extraExprs)
y += portItemArgsY * portItemArgsYNum
// Correction for single-expression cases (slightly shorter rendered height)
if (singleExprCount > 0) y -= singleExprCount * 7 + 2;
// Correction for multi-expression cases (compact logical operator row)