Merge pull request #789 from SuanmoSuanyangTechnology/feature/ui_upgrade_zy
Feature/UI upgrade zy
This commit is contained in:
19
web/src/assets/images/workflow/list-operator.svg
Normal file
19
web/src/assets/images/workflow/list-operator.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<title>编组 13</title>
|
||||||
|
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="应用管理-工作流-配置-开始" transform="translate(-696, -719)">
|
||||||
|
<g id="编组-13" transform="translate(696, 719)">
|
||||||
|
<rect id="矩形" fill="#155EEF" x="0" y="0" width="24" height="24" rx="8"></rect>
|
||||||
|
<g id="编组-37" transform="translate(6, 6)" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2">
|
||||||
|
<rect id="矩形" x="0" y="0" width="5" height="5" rx="1"></rect>
|
||||||
|
<rect id="矩形备份-6" x="0" y="7" width="5" height="5" rx="1"></rect>
|
||||||
|
<line x1="8.00267756" y1="1" x2="12" y2="1" id="路径-15"></line>
|
||||||
|
<line x1="8.00267756" y1="4" x2="12" y2="4" id="路径-15备份"></line>
|
||||||
|
<line x1="8.00267756" y1="8" x2="12" y2="8" id="路径-15"></line>
|
||||||
|
<line x1="8.00267756" y1="11" x2="12" y2="11" id="路径-15备份"></line>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -241,7 +241,7 @@ const Menu: FC<{
|
|||||||
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
|
const findMatchingKey = (menuList: MenuItem[], parentPaths: string[] = []): { key: string | null; } => {
|
||||||
for (const menu of menuList) {
|
for (const menu of menuList) {
|
||||||
if (menu.path) {
|
if (menu.path) {
|
||||||
const menuPath = menu.path[0] !== '/' ? '/' + menu.path : menu.path;
|
const menuPath = menu.path?.[0] !== '/' ? '/' + menu.path : menu.path;
|
||||||
|
|
||||||
/** Exact match or path prefix match (ensure complete path segment match) */
|
/** Exact match or path prefix match (ensure complete path segment match) */
|
||||||
const isExactMatch = menuPath === currentPath;
|
const isExactMatch = menuPath === currentPath;
|
||||||
|
|||||||
@@ -2179,6 +2179,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
unknown: 'Unknown Node',
|
unknown: 'Unknown Node',
|
||||||
notes: 'Sticky Note',
|
notes: 'Sticky Note',
|
||||||
'document-extractor': 'Document Extractor',
|
'document-extractor': 'Document Extractor',
|
||||||
|
'list-operator': 'List Operator',
|
||||||
|
|
||||||
clickToConfigure: 'Click to configure node parameters',
|
clickToConfigure: 'Click to configure node parameters',
|
||||||
nodeProperties: 'Node Properties',
|
nodeProperties: 'Node Properties',
|
||||||
@@ -2252,6 +2253,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
'string': 'String',
|
'string': 'String',
|
||||||
'number': 'Number',
|
'number': 'Number',
|
||||||
'boolean': 'Boolean',
|
'boolean': 'Boolean',
|
||||||
|
'file': 'File',
|
||||||
|
'array[file]': 'Array[File]',
|
||||||
'array[string]': 'Array[String]',
|
'array[string]': 'Array[String]',
|
||||||
'array[number]': 'Array[Number]',
|
'array[number]': 'Array[Number]',
|
||||||
'array[boolean]': 'Array[Boolean]',
|
'array[boolean]': 'Array[Boolean]',
|
||||||
@@ -2380,6 +2383,20 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
'document-extractor': {
|
'document-extractor': {
|
||||||
file_selector: 'Input Variable',
|
file_selector: 'Input Variable',
|
||||||
},
|
},
|
||||||
|
'list-operator': {
|
||||||
|
variable: 'Input Variable',
|
||||||
|
filter_by: 'Filter Conditions',
|
||||||
|
addCondition: 'Add Filter Condition',
|
||||||
|
order_by: 'Sort',
|
||||||
|
asc: 'asc',
|
||||||
|
desc: 'desc',
|
||||||
|
extract_by: 'Extract Nth Item',
|
||||||
|
limit: 'Take First N Items',
|
||||||
|
type: {
|
||||||
|
eq: 'In',
|
||||||
|
ne: 'Not In',
|
||||||
|
}
|
||||||
|
},
|
||||||
name: 'Key',
|
name: 'Key',
|
||||||
type: 'Type',
|
type: 'Type',
|
||||||
value: 'Value',
|
value: 'Value',
|
||||||
|
|||||||
@@ -2175,6 +2175,7 @@ export const zh = {
|
|||||||
unknown: '未知节点',
|
unknown: '未知节点',
|
||||||
notes: '便签',
|
notes: '便签',
|
||||||
'document-extractor': '文档提取器',
|
'document-extractor': '文档提取器',
|
||||||
|
'list-operator': '列表操作',
|
||||||
|
|
||||||
clickToConfigure: '点击配置节点参数',
|
clickToConfigure: '点击配置节点参数',
|
||||||
nodeProperties: '节点属性',
|
nodeProperties: '节点属性',
|
||||||
@@ -2248,6 +2249,8 @@ export const zh = {
|
|||||||
'string': 'String',
|
'string': 'String',
|
||||||
'number': 'Number',
|
'number': 'Number',
|
||||||
'boolean': 'Boolean',
|
'boolean': 'Boolean',
|
||||||
|
'file': 'File',
|
||||||
|
'array[file]': 'Array[File]',
|
||||||
'array[string]': 'Array[String]',
|
'array[string]': 'Array[String]',
|
||||||
'array[number]': 'Array[Number]',
|
'array[number]': 'Array[Number]',
|
||||||
'array[boolean]': 'Array[Boolean]',
|
'array[boolean]': 'Array[Boolean]',
|
||||||
@@ -2379,6 +2382,20 @@ export const zh = {
|
|||||||
'document-extractor': {
|
'document-extractor': {
|
||||||
file_selector: '输入变量',
|
file_selector: '输入变量',
|
||||||
},
|
},
|
||||||
|
'list-operator': {
|
||||||
|
variable: '输入变量',
|
||||||
|
filter_by: '过滤条件',
|
||||||
|
addCondition: '添加过滤条件',
|
||||||
|
order_by: '排序',
|
||||||
|
asc: 'asc',
|
||||||
|
desc: 'desc',
|
||||||
|
extract_by: '取第 N 项',
|
||||||
|
limit: '取前 N 项',
|
||||||
|
type: {
|
||||||
|
eq: '在',
|
||||||
|
ne: '不在',
|
||||||
|
}
|
||||||
|
},
|
||||||
name: '键',
|
name: '键',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
value: '值',
|
value: '值',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-03-13 17:27:52
|
* @Date: 2026-03-13 17:27:52
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-31 16:04:15
|
* @Last Modified time: 2026-04-02 17:58:07
|
||||||
*/
|
*/
|
||||||
import { type FC, useState, useRef, useEffect } from 'react'
|
import { type FC, useState, useRef, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -63,6 +63,12 @@ interface NodeData {
|
|||||||
state: Record<string, any>;
|
state: Record<string, any>;
|
||||||
status?: 'completed' | 'failed';
|
status?: 'completed' | 'failed';
|
||||||
audio_url?: string;
|
audio_url?: string;
|
||||||
|
citations?: {
|
||||||
|
document_id: string;
|
||||||
|
file_name: string;
|
||||||
|
knowledge_id: string;
|
||||||
|
score: string;
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const TestChat: FC<TestChatProps> = ({
|
const TestChat: FC<TestChatProps> = ({
|
||||||
@@ -111,8 +117,7 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let initVariables: Variable[] = []
|
let initVariables: Variable[] = []
|
||||||
|
|
||||||
switch (application.type) {
|
switch (application.type) {
|
||||||
@@ -162,7 +167,7 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAssistantMessage = (content: string, audio_url?: string, audio_status?: string, citations?: any[]) => {
|
const updateAssistantMessage = (content: string, audio_url?: string, audio_status?: string, citations?: NodeData['citations']) => {
|
||||||
setChatList(prev => {
|
setChatList(prev => {
|
||||||
const newList = [...prev]
|
const newList = [...prev]
|
||||||
const lastMsg = newList[newList.length - 1]
|
const lastMsg = newList[newList.length - 1]
|
||||||
@@ -281,12 +286,7 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
data.map(item => {
|
data.map(item => {
|
||||||
const { conversation_id, content, message_length, audio_url, citations } = item.data as {
|
const { conversation_id, content, message_length, audio_url, citations } = item.data as {
|
||||||
conversation_id: string, content: string, message_length: number; audio_url?: string;
|
conversation_id: string, content: string, message_length: number; audio_url?: string;
|
||||||
citations?: {
|
citations?: NodeData['citations']
|
||||||
document_id: string;
|
|
||||||
file_name: string;
|
|
||||||
knowledge_id: string;
|
|
||||||
score: string;
|
|
||||||
}[]
|
|
||||||
};
|
};
|
||||||
switch (item.event) {
|
switch (item.event) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@@ -344,15 +344,15 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWorkflowSend = () => {
|
const handleWorkflowSend = (msg?: string) => {
|
||||||
if (loading || !application || !message || !message?.trim()) return
|
if (loading || !application || !((message && message?.trim() !== '') || (msg && msg?.trim() !== ''))) return
|
||||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||||
const variables = toolbarRef.current?.getVariables() || []
|
const variables = toolbarRef.current?.getVariables() || []
|
||||||
const { isCanSend, params } = buildVariableParams(variables)
|
const { isCanSend, params } = buildVariableParams(variables)
|
||||||
if (!isCanSend) return
|
if (!isCanSend) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
addUserMessage(message, files)
|
addUserMessage((msg || message) as string, files)
|
||||||
addAssistantMessage()
|
addAssistantMessage()
|
||||||
toolbarRef.current?.setFiles([])
|
toolbarRef.current?.setFiles([])
|
||||||
setFileList([])
|
setFileList([])
|
||||||
@@ -361,7 +361,7 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
|
|
||||||
draftRun(
|
draftRun(
|
||||||
application.id,
|
application.id,
|
||||||
formatParams(message, conversationId, files, params),
|
formatParams((msg || message) as string, conversationId, files, params),
|
||||||
handleWorkflowStreamMessage
|
handleWorkflowStreamMessage
|
||||||
)
|
)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -383,7 +383,7 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
|
|
||||||
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
|
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const { content, conversation_id } = item.data as NodeData;
|
const { content, conversation_id, citations } = item.data as NodeData;
|
||||||
switch (item.event) {
|
switch (item.event) {
|
||||||
// Append streaming text chunks to assistant message
|
// Append streaming text chunks to assistant message
|
||||||
case 'message':
|
case 'message':
|
||||||
@@ -412,6 +412,9 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
// Mark workflow as complete
|
// Mark workflow as complete
|
||||||
case 'workflow_end':
|
case 'workflow_end':
|
||||||
updateWorkflowEndMessage(item.data as NodeData)
|
updateWorkflowEndMessage(item.data as NodeData)
|
||||||
|
if (citations && citations.length > 0) {
|
||||||
|
updateWorkflowEndMessage(item.data as NodeData, citations)
|
||||||
|
}
|
||||||
setStreamLoading(false)
|
setStreamLoading(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
break
|
break
|
||||||
@@ -536,7 +539,7 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateWorkflowEndMessage = (data: NodeData) => {
|
const updateWorkflowEndMessage = (data: NodeData, citations?: NodeData['citations']) => {
|
||||||
const { error, status } = data;
|
const { error, status } = data;
|
||||||
setChatList(prev => {
|
setChatList(prev => {
|
||||||
const newList = [...prev]
|
const newList = [...prev]
|
||||||
@@ -547,6 +550,10 @@ const TestChat: FC<TestChatProps> = ({
|
|||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||||
|
meta_data: {
|
||||||
|
...newList[lastIndex].meta_data || {},
|
||||||
|
citations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newList
|
return newList
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 16:27:56
|
* @Date: 2026-02-03 16:27:56
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-27 17:32:10
|
* @Last Modified time: 2026-04-02 17:49:51
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Copy Application Modal
|
* Copy Application Modal
|
||||||
@@ -116,24 +116,24 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
>
|
>
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
|
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||||
|
<SwitchFormItem
|
||||||
|
title={t('application.opening_statement')}
|
||||||
|
name={['opening_statement', "enabled"]}
|
||||||
|
desc={values?.opening_statement?.enabled ? undefined : t('application.opening_statement_desc')}
|
||||||
|
/>
|
||||||
|
{values?.opening_statement?.enabled && (() => {
|
||||||
|
const statement = values.opening_statement?.statement
|
||||||
|
return statement && statement.trim() !== '' ? <>
|
||||||
|
<div className="rb:bg-white rb:rounded-lg rb:py-1 rb:px-3 rb:mb-1">
|
||||||
|
{statement}
|
||||||
|
</div>
|
||||||
|
<Button block onClick={handleOpenStatementSettings}>{t('application.editOpeningStatement')}</Button>
|
||||||
|
</> : <Button block onClick={handleOpenStatementSettings}>{t('application.editOpeningStatement')}</Button>
|
||||||
|
})()}
|
||||||
|
<Form.Item name="opening_statement" hidden />
|
||||||
|
</div>
|
||||||
{source !== 'workflow' && <>
|
{source !== 'workflow' && <>
|
||||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
|
||||||
<SwitchFormItem
|
|
||||||
title={t('application.opening_statement')}
|
|
||||||
name={['opening_statement', "enabled"]}
|
|
||||||
desc={values?.opening_statement?.enabled ? undefined : t('application.opening_statement_desc')}
|
|
||||||
/>
|
|
||||||
{values?.opening_statement?.enabled && (() => {
|
|
||||||
const statement = values.opening_statement?.statement
|
|
||||||
return statement && statement.trim() !== '' ? <>
|
|
||||||
<div className="rb:bg-white rb:rounded-lg rb:py-1 rb:px-3 rb:mb-1">
|
|
||||||
{statement}
|
|
||||||
</div>
|
|
||||||
<Button block onClick={handleOpenStatementSettings}>{t('application.editOpeningStatement')}</Button>
|
|
||||||
</> : <Button block onClick={handleOpenStatementSettings}>{t('application.editOpeningStatement')}</Button>
|
|
||||||
})()}
|
|
||||||
<Form.Item name="opening_statement" hidden />
|
|
||||||
</div>
|
|
||||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||||
<SwitchFormItem
|
<SwitchFormItem
|
||||||
title={t(`memoryConversation.web_search`)}
|
title={t(`memoryConversation.web_search`)}
|
||||||
@@ -148,14 +148,14 @@ const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigMod
|
|||||||
desc={t('application.text_to_speech_desc')}
|
desc={t('application.text_to_speech_desc')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
|
||||||
<SwitchFormItem
|
|
||||||
title={t(`application.citation`)}
|
|
||||||
name={['citation', "enabled"]}
|
|
||||||
desc={t('application.citation_desc')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>}
|
</>}
|
||||||
|
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||||
|
<SwitchFormItem
|
||||||
|
title={t(`application.citation`)}
|
||||||
|
name={['citation', "enabled"]}
|
||||||
|
desc={t('application.citation_desc')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||||
<SwitchFormItem
|
<SwitchFormItem
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface FileUploadSettingModalProps {
|
|||||||
capability?: Capability[];
|
capability?: Capability[];
|
||||||
source?: Application['type']
|
source?: Application['type']
|
||||||
}
|
}
|
||||||
const documentType = {
|
export const documentType = {
|
||||||
type: 'document',
|
type: 'document',
|
||||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
||||||
formats: [
|
formats: [
|
||||||
@@ -41,7 +41,7 @@ const documentType = {
|
|||||||
"md",
|
"md",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const imageType = {
|
export const imageType = {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
||||||
formats: [
|
formats: [
|
||||||
@@ -50,7 +50,7 @@ const imageType = {
|
|||||||
"jpeg"
|
"jpeg"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const audioType = {
|
export const audioType = {
|
||||||
type: 'audio',
|
type: 'audio',
|
||||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
||||||
formats: [
|
formats: [
|
||||||
@@ -59,7 +59,7 @@ const audioType = {
|
|||||||
"m4a",
|
"m4a",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
const videoType = {
|
export const videoType = {
|
||||||
type: 'video',
|
type: 'video',
|
||||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
||||||
formats: [
|
formats: [
|
||||||
@@ -68,7 +68,7 @@ const videoType = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValues: FileUpload = {
|
export const defaultValues: FileUpload = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
image_enabled: false,
|
image_enabled: false,
|
||||||
image_max_size_mb: 20,
|
image_max_size_mb: 20,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const MySharing: React.FC = () => {
|
|||||||
label: (
|
label: (
|
||||||
<Flex align="center" gap={12}>
|
<Flex align="center" gap={12}>
|
||||||
{workspace.target_workspace_icon
|
{workspace.target_workspace_icon
|
||||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
? <img src={workspace.target_workspace_icon} alt={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||||
{workspace.target_workspace_name[0]}
|
{workspace.target_workspace_name[0]}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-06 21:09:42
|
* @Date: 2026-02-06 21:09:42
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-27 18:23:04
|
* @Last Modified time: 2026-04-02 18:29:48
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* File Upload Component
|
* File Upload Component
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* @component
|
* @component
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
|
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||||
import { Upload, Progress, App, Flex } from 'antd';
|
import { Upload, Progress, App, Flex, Button } from 'antd';
|
||||||
import type { UploadProps, UploadFile } from 'antd';
|
import type { UploadProps, UploadFile } from 'antd';
|
||||||
import type { UploadProps as RcUploadProps, RcFile, UploadFileStatus } from 'antd/es/upload/interface';
|
import type { UploadProps as RcUploadProps, RcFile, UploadFileStatus } from 'antd/es/upload/interface';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -56,7 +56,9 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
|||||||
/** Custom file removal callback */
|
/** Custom file removal callback */
|
||||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||||
|
|
||||||
featureConfig: FeaturesConfigForm['file_upload']
|
featureConfig: FeaturesConfigForm['file_upload'];
|
||||||
|
textType?: 'button' | 'text';
|
||||||
|
block?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transform_file_type: Record<string, string> = {
|
export const transform_file_type: Record<string, string> = {
|
||||||
@@ -149,6 +151,8 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
|||||||
onRemove: customOnRemove,
|
onRemove: customOnRemove,
|
||||||
requestConfig,
|
requestConfig,
|
||||||
featureConfig,
|
featureConfig,
|
||||||
|
textType = 'text',
|
||||||
|
block,
|
||||||
...props
|
...props
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -159,11 +163,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
|||||||
const fileType = useMemo(() => {
|
const fileType = useMemo(() => {
|
||||||
let types: string[] = [];
|
let types: string[] = [];
|
||||||
['image', 'document', 'video', 'audio'].forEach(type => {
|
['image', 'document', 'video', 'audio'].forEach(type => {
|
||||||
if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
|
if (featureConfig?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
|
||||||
types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
|
const exts = featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[];
|
||||||
|
if (Array.isArray(exts)) types = types.concat(exts.filter(Boolean));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return types
|
return types
|
||||||
}, [featureConfig])
|
}, [featureConfig])
|
||||||
|
|
||||||
@@ -205,6 +209,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
|||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('onChange', isAutoUpload)
|
||||||
|
|
||||||
if (!isAutoUpload) {
|
if (!isAutoUpload) {
|
||||||
const newFileList = [...fileList, file as UploadFile];
|
const newFileList = [...fileList, file as UploadFile];
|
||||||
@@ -238,11 +243,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
|||||||
request.uploadFile(action, formData, requestConfig)
|
request.uploadFile(action, formData, requestConfig)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
onSuccess?.({ data: res });
|
onSuccess?.({ data: res });
|
||||||
|
onChange?.({ ...fileVo, status: 'done', response: { data: res } })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
onError?.(error as Error);
|
onError?.(error as Error);
|
||||||
fileVo.status = 'error'
|
onChange?.({ ...fileVo, status: 'error' })
|
||||||
onChange?.(fileVo)
|
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -327,7 +332,10 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
|||||||
<Upload
|
<Upload
|
||||||
{...uploadProps}
|
{...uploadProps}
|
||||||
>
|
>
|
||||||
<div>{t('memoryConversation.uploadFile')}</div>
|
{textType === 'text'
|
||||||
|
? <div>{t('memoryConversation.uploadFile')}</div>
|
||||||
|
: <Button disabled={disabled} block={block}>{t('memoryConversation.uploadFile')}</Button>
|
||||||
|
}
|
||||||
</Upload>
|
</Upload>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,107 +2,203 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2025-12-30 13:59:36
|
* @Date: 2025-12-30 13:59:36
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-02-28 16:19:26
|
* @Last Modified time: 2026-04-02 19:01:12
|
||||||
*/
|
*/
|
||||||
/**
|
import { forwardRef, useImperativeHandle, useState, useRef, useMemo } from 'react';
|
||||||
* ChatVariableModal Component
|
import { Form, Input, Select, InputNumber, Button, Row, Col, Flex, Spin } from 'antd';
|
||||||
*
|
import clsx from 'clsx';
|
||||||
* This component provides a modal for adding or editing chat variables in workflows.
|
|
||||||
* It supports various variable types and provides appropriate input fields based on the selected type.
|
|
||||||
*/
|
|
||||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
|
||||||
import { Form, Input, Select, InputNumber } from 'antd';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { ChatVariableModalRef } from './types'
|
import type { ChatVariableModalRef } from './types'
|
||||||
import type { ChatVariable } from '../../types';
|
import type { ChatVariable } from '../../types';
|
||||||
import RbModal from '@/components/RbModal'
|
import RbModal from '@/components/RbModal'
|
||||||
|
import { defaultValues as defaultFileUploadValues } from '@/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal'
|
||||||
|
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||||
|
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||||
|
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||||
|
import { getFileInfoByUrl } from '@/api/fileStorage'
|
||||||
|
import { transform_file_type } from '@/views/Conversation/components/FileUpload'
|
||||||
|
|
||||||
const FormItem = Form.Item;
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for ChatVariableModal component
|
|
||||||
*/
|
|
||||||
interface ChatVariableModalProps {
|
interface ChatVariableModalProps {
|
||||||
/**
|
|
||||||
* Callback function to refresh variable list
|
|
||||||
* @param {ChatVariable} value - The variable data
|
|
||||||
* @param {number} [editIndex] - Optional index for editing existing variable
|
|
||||||
*/
|
|
||||||
refresh: (value: ChatVariable, editIndex?: number) => void;
|
refresh: (value: ChatVariable, editIndex?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported variable types
|
|
||||||
*/
|
|
||||||
const types = [
|
const types = [
|
||||||
'string', // String type
|
'string',
|
||||||
'number', // Number type
|
'number',
|
||||||
'boolean', // Boolean type
|
'boolean',
|
||||||
'object', // Object type
|
'object',
|
||||||
'array[string]', // Array of strings
|
'file',
|
||||||
'array[number]', // Array of numbers
|
'array[file]',
|
||||||
'array[boolean]', // Array of booleans
|
'array[string]',
|
||||||
'array[object]', // Array of objects
|
'array[number]',
|
||||||
|
'array[boolean]',
|
||||||
|
'array[object]',
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
|
||||||
* ChatVariableModal component
|
|
||||||
*/
|
|
||||||
const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProps>(({
|
const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProps>(({
|
||||||
refresh
|
refresh
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null);
|
||||||
|
|
||||||
// State management
|
const [visible, setVisible] = useState(false);
|
||||||
const [visible, setVisible] = useState(false); // Modal visibility
|
const [form] = Form.useForm<ChatVariable>();
|
||||||
const [form] = Form.useForm<ChatVariable>(); // Form instance
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false); // Loading state
|
const [fileList, setFileList] = useState<any[]>([]);
|
||||||
const [editIndex, setEditIndex] = useState<number | undefined>(undefined); // Index of variable being edited
|
const [editIndex, setEditIndex] = useState<number | undefined>(undefined);
|
||||||
const type = Form.useWatch('type', form); // Current selected type
|
|
||||||
|
const type = Form.useWatch('type', form);
|
||||||
|
const allowed_transfer_methods = Form.useWatch('allowed_transfer_methods', form);
|
||||||
|
const image_enabled = Form.useWatch('image_enabled', form);
|
||||||
|
const audio_enabled = Form.useWatch('audio_enabled', form);
|
||||||
|
const document_enabled = Form.useWatch('document_enabled', form);
|
||||||
|
const video_enabled = Form.useWatch('video_enabled', form);
|
||||||
|
const image_max_size_mb = Form.useWatch('image_max_size_mb', form);
|
||||||
|
const audio_max_size_mb = Form.useWatch('audio_max_size_mb', form);
|
||||||
|
const document_max_size_mb = Form.useWatch('document_max_size_mb', form);
|
||||||
|
const video_max_size_mb = Form.useWatch('video_max_size_mb', form);
|
||||||
|
const image_allowed_extensions = Form.useWatch('image_allowed_extensions', form);
|
||||||
|
const audio_allowed_extensions = Form.useWatch('audio_allowed_extensions', form);
|
||||||
|
const document_allowed_extensions = Form.useWatch('document_allowed_extensions', form);
|
||||||
|
const video_allowed_extensions = Form.useWatch('video_allowed_extensions', form);
|
||||||
|
const max_file_count = Form.useWatch('max_file_count', form);
|
||||||
|
|
||||||
|
const hasEnabledFileType = !!(image_enabled || audio_enabled || document_enabled || video_enabled);
|
||||||
|
|
||||||
|
const featureConfig = useMemo(() => ({
|
||||||
|
enabled: hasEnabledFileType,
|
||||||
|
allowed_transfer_methods,
|
||||||
|
max_file_count,
|
||||||
|
image_enabled, image_max_size_mb, image_allowed_extensions,
|
||||||
|
audio_enabled, audio_max_size_mb, audio_allowed_extensions,
|
||||||
|
document_enabled, document_max_size_mb, document_allowed_extensions,
|
||||||
|
video_enabled, video_max_size_mb, video_allowed_extensions,
|
||||||
|
}), [
|
||||||
|
hasEnabledFileType, allowed_transfer_methods, max_file_count,
|
||||||
|
image_enabled, image_max_size_mb, image_allowed_extensions,
|
||||||
|
audio_enabled, audio_max_size_mb, audio_allowed_extensions,
|
||||||
|
document_enabled, document_max_size_mb, document_allowed_extensions,
|
||||||
|
video_enabled, video_max_size_mb, video_allowed_extensions,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle modal close
|
|
||||||
*/
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
setFileList([]);
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setEditIndex(undefined);
|
setEditIndex(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle modal open
|
|
||||||
*/
|
|
||||||
const handleOpen = (variable?: ChatVariable, index?: number) => {
|
const handleOpen = (variable?: ChatVariable, index?: number) => {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
if (variable) {
|
if (variable) {
|
||||||
// Exclude 'default' property and set form values
|
|
||||||
const { default: _, ...rest } = variable;
|
const { default: _, ...rest } = variable;
|
||||||
form.setFieldsValue({ ...rest });
|
form.setFieldsValue({ ...rest });
|
||||||
setEditIndex(index);
|
setEditIndex(index);
|
||||||
|
if (variable.type === 'file' || variable.type === 'array[file]') {
|
||||||
|
const defaultVal = variable.defaultValue;
|
||||||
|
if (defaultVal) {
|
||||||
|
const list = Array.isArray(defaultVal) ? defaultVal : [defaultVal];
|
||||||
|
setFileList(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Reset form for new variable
|
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setEditIndex(undefined);
|
setEditIndex(undefined);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle save/submit action
|
|
||||||
*/
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
form.validateFields().then((values) => {
|
form.validateFields().then((values) => {
|
||||||
// Create variable with 'default' property mapped from 'defaultValue'
|
|
||||||
refresh({ ...values, default: values.defaultValue }, editIndex);
|
refresh({ ...values, default: values.defaultValue }, editIndex);
|
||||||
handleClose();
|
handleClose();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose handleOpen method to parent component via ref
|
useImperativeHandle(ref, () => ({ handleOpen }));
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
handleOpen
|
const setFormFileValue = (updated: any[]) => {
|
||||||
}));
|
const isSingle = form.getFieldValue('type') === 'file';
|
||||||
|
form.setFieldValue('defaultValue', isSingle ? (updated[0] ?? null) : updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileChange = (file?: any) => {
|
||||||
|
const fileObj = file ? {
|
||||||
|
...file,
|
||||||
|
type: file.type,
|
||||||
|
transfer_method: "local_file",
|
||||||
|
upload_file_id: file.response?.data?.file_id,
|
||||||
|
} : undefined
|
||||||
|
if (form.getFieldValue('type') === 'file') {
|
||||||
|
const updated = [fileObj];
|
||||||
|
setFileList(updated);
|
||||||
|
setTimeout(() => setFormFileValue(updated), 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileList(prev => {
|
||||||
|
const index = prev.findIndex((item: any) => item.uid === fileObj.uid);
|
||||||
|
const updated = index > -1
|
||||||
|
? prev.map((item, i) => i === index ? fileObj : item)
|
||||||
|
: [...prev, fileObj];
|
||||||
|
setTimeout(() => setFormFileValue(updated), 0);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFileList = (list?: any[]) => {
|
||||||
|
if (!list?.length) return;
|
||||||
|
const uploadingList = list.map(f => ({ ...f, status: 'uploading' }));
|
||||||
|
setFileList(prev => {
|
||||||
|
const isSingle = form.getFieldValue('type') === 'file';
|
||||||
|
const updated = isSingle ? [uploadingList[0]] : [...prev, ...uploadingList];
|
||||||
|
setTimeout(() => setFormFileValue(updated), 0);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
const isSingle = form.getFieldValue('type') === 'file';
|
||||||
|
(isSingle ? [uploadingList[0]] : uploadingList).forEach(file => {
|
||||||
|
getFileInfoByUrl(file.url)
|
||||||
|
.then((res) => {
|
||||||
|
const { file_name, file_size, content_type } = res as { file_name: string; file_size: number; content_type: string };
|
||||||
|
setFileList(prev => {
|
||||||
|
const updated = prev.map(f =>
|
||||||
|
f.uid === file.uid
|
||||||
|
? { ...f, status: 'done', name: file_name, size: file_size, type: transform_file_type[content_type] || content_type }
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
setFormFileValue(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFileList(prev => {
|
||||||
|
const updated = prev.map(f => f.uid === file.uid ? { ...f, status: 'error' } : f);
|
||||||
|
setFormFileValue(updated);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const previewFileList = useMemo(() => {
|
||||||
|
return fileList.map(file => ({
|
||||||
|
...file,
|
||||||
|
url: file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
|
||||||
|
}));
|
||||||
|
}, [fileList]);
|
||||||
|
|
||||||
|
const handleDelete = (file: any) => {
|
||||||
|
const updated = fileList.filter(item =>
|
||||||
|
item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl
|
||||||
|
: item.url && file.url ? item.url !== file.url
|
||||||
|
: item.uid !== file.uid
|
||||||
|
);
|
||||||
|
setFileList(updated);
|
||||||
|
setFormFileValue(updated);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RbModal
|
<RbModal
|
||||||
@@ -118,7 +214,6 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
|
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
|
||||||
>
|
>
|
||||||
{/* Variable name field */}
|
|
||||||
<FormItem
|
<FormItem
|
||||||
name="name"
|
name="name"
|
||||||
label={t('workflow.config.parameter-extractor.name')}
|
label={t('workflow.config.parameter-extractor.name')}
|
||||||
@@ -129,8 +224,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
|
|||||||
>
|
>
|
||||||
<Input placeholder={t('common.enter')} />
|
<Input placeholder={t('common.enter')} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
{/* Variable type field */}
|
|
||||||
<FormItem
|
<FormItem
|
||||||
name="type"
|
name="type"
|
||||||
label={t('workflow.config.parameter-extractor.type')}
|
label={t('workflow.config.parameter-extractor.type')}
|
||||||
@@ -138,42 +232,114 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
|
|||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
onChange={() => form.setFieldValue('defaultValue', undefined)}
|
onChange={(value) => {
|
||||||
|
form.setFieldValue('defaultValue', undefined);
|
||||||
|
setFileList([]);
|
||||||
|
if (value === 'file' || value === 'array[file]') form.setFieldsValue(defaultFileUploadValues as any);
|
||||||
|
}}
|
||||||
options={types.map(key => ({
|
options={types.map(key => ({
|
||||||
value: key,
|
value: key,
|
||||||
label: t(`workflow.config.parameter-extractor.${key}`),
|
label: t(`workflow.config.parameter-extractor.${key}`),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
{/* Default value field - dynamic based on type */}
|
{type === 'file' || type === 'array[file]' ? (
|
||||||
<Form.Item
|
<>
|
||||||
name="defaultValue"
|
<UploadFileListModal
|
||||||
label={t('workflow.config.parameter-extractor.default')}
|
ref={uploadFileListModalRef}
|
||||||
>
|
featureConfig={featureConfig}
|
||||||
{type === 'number'
|
refresh={addFileList}
|
||||||
? <InputNumber
|
|
||||||
placeholder={t('common.enter')}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
onChange={(value) => form.setFieldValue('defaultValue', value)}
|
|
||||||
/>
|
/>
|
||||||
: type === 'boolean'
|
<Form.Item name="defaultValue" hidden noStyle />
|
||||||
? <Select
|
<Form.Item label={t('workflow.config.parameter-extractor.default')}>
|
||||||
placeholder={t('common.pleaseSelect')}
|
|
||||||
options={[
|
<Row gutter={8}>
|
||||||
{ value: true, label: 'true' },
|
<Col span={12}>
|
||||||
{ value: false, label: 'false' }
|
<UploadFiles
|
||||||
]}
|
featureConfig={featureConfig}
|
||||||
/>
|
onChange={fileChange}
|
||||||
: <Input placeholder={t('common.enter')} />
|
block={true}
|
||||||
}
|
textType="button"
|
||||||
</Form.Item>
|
disabled={type === 'file' && fileList.length > 0}
|
||||||
|
/>
|
||||||
{/* Variable description field */}
|
</Col>
|
||||||
<FormItem
|
<Col span={12}>
|
||||||
name="description"
|
<Button block
|
||||||
label={t('workflow.config.parameter-extractor.desc')}
|
disabled={type === 'file' && fileList.length > 0}
|
||||||
>
|
onClick={() => uploadFileListModalRef.current?.handleOpen()}>
|
||||||
|
{t('memoryConversation.addRemoteFile')}
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{previewFileList.length > 0 && (
|
||||||
|
<Flex gap={8} wrap className="rb:mt-2!">
|
||||||
|
{previewFileList.map((file) => (
|
||||||
|
<Spin key={`${file.url || file.uid}_${file.status}`} spinning={file.status === 'uploading'}>
|
||||||
|
{file.type?.includes('image') ? (
|
||||||
|
<div className={clsx('rb:inline-block rb:group rb:relative rb:rounded-lg rb:border', {
|
||||||
|
'rb:border-[#FF5D34]': file.status === 'error',
|
||||||
|
'rb:border-[#F6F6F6]': file.status !== 'error',
|
||||||
|
})}>
|
||||||
|
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover" />
|
||||||
|
<div
|
||||||
|
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||||
|
onClick={() => handleDelete(file)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap={10}
|
||||||
|
className={clsx('rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb:bg-[#F6F6F6] rb:py-2! rb:px-2.5! rb:border', {
|
||||||
|
'rb:border-[#FF5D34]': file.status === 'error',
|
||||||
|
'rb:border-[#F6F6F6]': file.status !== 'error',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={clsx(
|
||||||
|
"rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]",
|
||||||
|
file.type?.includes('pdf') ? "rb:bg-[url('@/assets/images/file/pdf.svg')]" :
|
||||||
|
(file.type?.includes('excel') || file.type?.includes('spreadsheetml')) ? "rb:bg-[url('@/assets/images/file/excel.svg')]" :
|
||||||
|
file.type?.includes('csv') ? "rb:bg-[url('@/assets/images/file/csv.svg')]" :
|
||||||
|
file.type?.includes('json') ? "rb:bg-[url('@/assets/images/file/json.svg')]" :
|
||||||
|
file.type?.includes('ppt') ? "rb:bg-[url('@/assets/images/file/ppt.svg')]" :
|
||||||
|
file.type?.includes('text') ? "rb:bg-[url('@/assets/images/file/txt.svg')]" :
|
||||||
|
file.type?.includes('markdown') ? "rb:bg-[url('@/assets/images/file/md.svg')]" :
|
||||||
|
(file.type?.includes('doc') || file.type?.includes('word')) ? "rb:bg-[url('@/assets/images/file/word.svg')]" : null
|
||||||
|
)} />
|
||||||
|
<div className="rb:flex-1 rb:w-32.5">
|
||||||
|
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||||
|
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
|
||||||
|
{file.type?.split('/').pop()} · {file.size}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')]"
|
||||||
|
onClick={() => handleDelete(file)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="defaultValue" label={t('workflow.config.parameter-extractor.default')}>
|
||||||
|
{type === 'number'
|
||||||
|
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
|
||||||
|
: type === 'boolean'
|
||||||
|
? <Select
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={[{ value: true, label: 'true' }, { value: false, label: 'false' }]}
|
||||||
|
/>
|
||||||
|
: <Input placeholder={t('common.enter')} />
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormItem name="description" label={t('workflow.config.parameter-extractor.desc')}>
|
||||||
<Input.TextArea placeholder={t('common.enter')} />
|
<Input.TextArea placeholder={t('common.enter')} />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -181,4 +347,4 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ChatVariableModal;
|
export default ChatVariableModal;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
|
|||||||
title={t('workflow.addvariable')}
|
title={t('workflow.addvariable')}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
|
width={480}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-06 21:10:56
|
* @Date: 2026-02-06 21:10:56
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-27 17:30:47
|
* @Last Modified time: 2026-04-02 18:01:09
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Workflow Chat Component
|
* Workflow Chat Component
|
||||||
@@ -66,6 +66,17 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
*/
|
*/
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
|
|
||||||
|
if (features?.opening_statement?.statement && features?.opening_statement?.statement.trim() !== '') {
|
||||||
|
setChatList(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
created_at: Date.now(),
|
||||||
|
content: features?.opening_statement?.statement,
|
||||||
|
meta_data: {
|
||||||
|
suggested_questions: features?.opening_statement?.suggested_questions || []
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,23 +160,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
const message = msg
|
const message = msg
|
||||||
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status))
|
||||||
setChatList(prev => [...prev, {
|
|
||||||
role: 'user',
|
|
||||||
content: message,
|
|
||||||
created_at: Date.now(),
|
|
||||||
meta_data: {
|
|
||||||
files
|
|
||||||
},
|
|
||||||
}])
|
|
||||||
setChatList(prev => [...prev, {
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
created_at: Date.now(),
|
|
||||||
subContent: [],
|
|
||||||
}])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles SSE stream messages from workflow execution
|
* Handles SSE stream messages from workflow execution
|
||||||
@@ -179,7 +175,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
*/
|
*/
|
||||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const { content, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = item.data as {
|
const { content, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status, citations } = item.data as {
|
||||||
content: string;
|
content: string;
|
||||||
conversation_id: string | null;
|
conversation_id: string | null;
|
||||||
cycle_id: string;
|
cycle_id: string;
|
||||||
@@ -192,7 +188,13 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
elapsed_time?: string;
|
elapsed_time?: string;
|
||||||
error?: any;
|
error?: any;
|
||||||
state: Record<string, any>;
|
state: Record<string, any>;
|
||||||
status?: 'completed' | 'failed'
|
status?: 'completed' | 'failed',
|
||||||
|
citations?: {
|
||||||
|
document_id: string;
|
||||||
|
file_name: string;
|
||||||
|
knowledge_id: string;
|
||||||
|
score: string;
|
||||||
|
}[]
|
||||||
};
|
};
|
||||||
|
|
||||||
const node = graphRef.current?.getNodes().find(n => n.id === node_id);
|
const node = graphRef.current?.getNodes().find(n => n.id === node_id);
|
||||||
@@ -327,6 +329,10 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||||
|
meta_data: {
|
||||||
|
...newList[lastIndex].meta_data || {},
|
||||||
|
citations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newList
|
return newList
|
||||||
@@ -362,6 +368,24 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
setChatList(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
created_at: Date.now(),
|
||||||
|
meta_data: {
|
||||||
|
files
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
created_at: Date.now(),
|
||||||
|
subContent: [],
|
||||||
|
}
|
||||||
|
])
|
||||||
|
setLoading(true)
|
||||||
setStreamLoading(true)
|
setStreamLoading(true)
|
||||||
draftRun(appId, data, handleStreamMessage)
|
draftRun(appId, data, handleStreamMessage)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -418,6 +442,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
|
|||||||
renderRuntime={(item, index) => {
|
renderRuntime={(item, index) => {
|
||||||
return <Runtime item={item} index={index} />
|
return <Runtime item={item} index={index} />
|
||||||
}}
|
}}
|
||||||
|
onSend={handleSend}
|
||||||
/>
|
/>
|
||||||
<Flex align="center" gap={10} className="rb:relative rb:m-4! rb:mb-1!">
|
<Flex align="center" gap={10} className="rb:relative rb:m-4! rb:mb-1!">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|||||||
@@ -77,15 +77,16 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lexical editor configuration
|
// Lexical editor configuration — must be stable (never recreated)
|
||||||
const initialConfig = {
|
const initialConfig = useMemo(() => ({
|
||||||
namespace: 'AutocompleteEditor',
|
namespace: 'AutocompleteEditor',
|
||||||
theme,
|
theme,
|
||||||
nodes: [VariableNode],
|
nodes: [VariableNode],
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
},
|
},
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}), []);
|
||||||
|
|
||||||
// Calculate minimum height based on type and size
|
// Calculate minimum height based on type and size
|
||||||
const minheight = useMemo(() => {
|
const minheight = useMemo(() => {
|
||||||
@@ -133,7 +134,7 @@ const Editor: FC<LexicalEditorProps> =({
|
|||||||
style={{
|
style={{
|
||||||
minHeight: placeHolderMinheight,
|
minHeight: placeHolderMinheight,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: variant === 'borderless' ? '0' : '6px',
|
top: variant === 'borderless' ? '2px' : '6px',
|
||||||
left: variant === 'borderless' ? '0' : '11px',
|
left: variant === 'borderless' ? '0' : '11px',
|
||||||
color: '#A8A9AA',
|
color: '#A8A9AA',
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
|
|||||||
setSelected(!isSelected);
|
setSelected(!isSelected);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('data', data)
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -44,13 +45,21 @@ const VariableComponent: React.FC<{ nodeKey: NodeKey; data: Suggestion }> = ({
|
|||||||
>
|
>
|
||||||
{data.isContext ? (
|
{data.isContext ? (
|
||||||
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span>
|
<span style={{ fontSize: '12px', marginRight: '4px' }}>📄</span>
|
||||||
) : data.group !== 'CONVERSATION' ? (
|
) : data.group !== 'CONVERSATION' && !data.value.includes('conv') ? (
|
||||||
<div className={`rb:size-4 rb:mr-1 rb:bg-cover ${data.nodeData?.icon}`} />
|
<span className={`rb:size-4 rb:mr-1 rb:bg-cover rb:inline-block rb:flex-shrink-0 ${data.nodeData?.icon}`} />
|
||||||
) : null}
|
) : <span className="rb:inline-block rb:h-4"></span>}
|
||||||
{!data.isContext && data.group !== 'CONVERSATION' && (
|
{!data.isContext && data.group !== 'CONVERSATION' && (
|
||||||
<>
|
<>
|
||||||
<span className="rb:wrap-break-word rb:line-clamp-1">{data.nodeData?.name}</span>
|
{!data.value.includes('conv') && <>
|
||||||
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
|
<span className="rb:wrap-break-word rb:line-clamp-1">{data.nodeData?.name}</span>
|
||||||
|
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
|
||||||
|
</>}
|
||||||
|
{data.parentLabel && (
|
||||||
|
<>
|
||||||
|
<span className="rb:wrap-break-word rb:line-clamp-1">{data.parentLabel}</span>
|
||||||
|
<span style={{ color: '#DFE4ED', margin: '0 2px' }}>/</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<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 rb:text-[#171719]">{data.label}</span>
|
||||||
@@ -62,7 +71,7 @@ export class VariableNode extends DecoratorNode<React.JSX.Element> {
|
|||||||
__data: Suggestion;
|
__data: Suggestion;
|
||||||
|
|
||||||
static getType(): string {
|
static getType(): string {
|
||||||
return 'tag';
|
return 'variable';
|
||||||
}
|
}
|
||||||
|
|
||||||
static clone(node: VariableNode): VariableNode {
|
static clone(node: VariableNode): VariableNode {
|
||||||
@@ -100,7 +109,7 @@ export class VariableNode extends DecoratorNode<React.JSX.Element> {
|
|||||||
exportJSON(): SerializedVariableNode {
|
exportJSON(): SerializedVariableNode {
|
||||||
return {
|
return {
|
||||||
data: this.__data,
|
data: this.__data,
|
||||||
type: 'tag',
|
type: 'variable',
|
||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-04-02 17:12:41
|
* @Last Modified time: 2026-04-02 17:12:41
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState, useRef, type FC } from 'react';
|
import { useEffect, useLayoutEffect, useState, useRef, type FC } from 'react';
|
||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
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 { $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 { Space, Flex } from 'antd';
|
||||||
@@ -23,6 +23,8 @@ export interface Suggestion {
|
|||||||
nodeData: NodeProperties;
|
nodeData: NodeProperties;
|
||||||
isContext?: boolean; // Flag for context variable
|
isContext?: boolean; // Flag for context variable
|
||||||
disabled?: boolean; // Flag for disabled state
|
disabled?: boolean; // Flag for disabled state
|
||||||
|
children?: Suggestion[]; // Sub-variables (e.g. file fields)
|
||||||
|
parentLabel?: string; // Parent variable label (for child display)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete plugin for variable suggestions triggered by '/' character
|
// Autocomplete plugin for variable suggestions triggered by '/' character
|
||||||
@@ -30,8 +32,45 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
|
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, anchorBottom: 0 });
|
||||||
|
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||||
|
const [childPanelTop, setChildPanelTop] = useState(0);
|
||||||
const popupRef = useRef<HTMLDivElement>(null);
|
const popupRef = useRef<HTMLDivElement>(null);
|
||||||
|
const itemRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||||
|
|
||||||
|
// Adjust popup position after render based on actual height
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!popupRef.current || !showSuggestions) return;
|
||||||
|
const { top, anchorBottom } = popupPosition;
|
||||||
|
const popupHeight = popupRef.current.offsetHeight;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
}, [showSuggestions, popupPosition.anchorBottom]);
|
||||||
|
|
||||||
|
const CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
|
||||||
|
|
||||||
|
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 scrollSelectedIntoView = () => {
|
const scrollSelectedIntoView = () => {
|
||||||
if (!popupRef.current) return;
|
if (!popupRef.current) return;
|
||||||
@@ -77,6 +116,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
setShowSuggestions(shouldShow);
|
setShowSuggestions(shouldShow);
|
||||||
if (!shouldShow) {
|
if (!shouldShow) {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
|
setExpandedParent(null);
|
||||||
|
setChildPanelTop(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate popup position to keep it within viewport bounds
|
// Calculate popup position to keep it within viewport bounds
|
||||||
@@ -87,28 +128,15 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
const rect = range.getBoundingClientRect();
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
const popupWidth = 280;
|
const popupWidth = 280;
|
||||||
const popupHeight = 200;
|
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
let left = rect.left;
|
let left = rect.left;
|
||||||
let top = rect.top - 10;
|
|
||||||
|
|
||||||
if (left + popupWidth > viewportWidth) {
|
if (left + popupWidth > viewportWidth) {
|
||||||
left = viewportWidth - popupWidth - 10;
|
left = viewportWidth - popupWidth - 10;
|
||||||
}
|
}
|
||||||
if (left < 10) {
|
if (left < 10) left = 10;
|
||||||
left = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top - popupHeight < 10) {
|
setPopupPosition({ top: rect.top, left, anchorBottom: rect.bottom });
|
||||||
top = rect.bottom + 10;
|
|
||||||
if (top + popupHeight > viewportHeight) {
|
|
||||||
top = viewportHeight - popupHeight - 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPopupPosition({ top, left });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -121,6 +149,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
CLOSE_AUTOCOMPLETE_COMMAND,
|
CLOSE_AUTOCOMPLETE_COMMAND,
|
||||||
() => {
|
() => {
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setExpandedParent(null);
|
||||||
|
setChildPanelTop(0);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
COMMAND_PRIORITY_HIGH
|
COMMAND_PRIORITY_HIGH
|
||||||
@@ -131,6 +161,8 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
const insertMention = (suggestion: Suggestion) => {
|
const insertMention = (suggestion: Suggestion) => {
|
||||||
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
setExpandedParent(null);
|
||||||
|
setChildPanelTop(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group suggestions by node ID
|
// Group suggestions by node ID
|
||||||
@@ -144,17 +176,23 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
return groups;
|
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];
|
||||||
|
}
|
||||||
|
return [option];
|
||||||
|
});
|
||||||
|
|
||||||
// Handle Enter key to select suggestion
|
// Handle Enter key to select suggestion
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSuggestions) return;
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
const allOptions = Object.values(groupedSuggestions).flat();
|
|
||||||
|
|
||||||
return editor.registerCommand(
|
return editor.registerCommand(
|
||||||
KEY_ENTER_COMMAND,
|
KEY_ENTER_COMMAND,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (showSuggestions && allOptions.length > 0) {
|
if (showSuggestions && flatOptions.length > 0) {
|
||||||
const selectedOption = allOptions[selectedIndex];
|
const selectedOption = flatOptions[selectedIndex];
|
||||||
if (selectedOption && !selectedOption.disabled) {
|
if (selectedOption && !selectedOption.disabled) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
insertMention(selectedOption);
|
insertMention(selectedOption);
|
||||||
@@ -165,26 +203,24 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
},
|
},
|
||||||
COMMAND_PRIORITY_HIGH
|
COMMAND_PRIORITY_HIGH
|
||||||
);
|
);
|
||||||
}, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]);
|
}, [showSuggestions, selectedIndex, flatOptions, insertMention, editor]);
|
||||||
|
|
||||||
// Handle keyboard navigation (Arrow Up/Down, Escape)
|
// Handle keyboard navigation (Arrow Up/Down, Escape)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showSuggestions) return;
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
const allOptions = Object.values(groupedSuggestions).flat();
|
|
||||||
|
|
||||||
// Navigate down through suggestions, skip disabled items
|
// Navigate down through suggestions, skip disabled items
|
||||||
const unregisterArrowDown = editor.registerCommand(
|
const unregisterArrowDown = editor.registerCommand(
|
||||||
KEY_ARROW_DOWN_COMMAND,
|
KEY_ARROW_DOWN_COMMAND,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (showSuggestions && allOptions.length > 0) {
|
if (showSuggestions && flatOptions.length > 0) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
setSelectedIndex(prev => {
|
setSelectedIndex(prev => {
|
||||||
let nextIndex = prev + 1;
|
let nextIndex = prev + 1;
|
||||||
while (nextIndex < allOptions.length && allOptions[nextIndex].disabled) {
|
while (nextIndex < flatOptions.length && flatOptions[nextIndex].disabled) {
|
||||||
nextIndex++;
|
nextIndex++;
|
||||||
}
|
}
|
||||||
const newIndex = nextIndex >= allOptions.length ? prev : nextIndex;
|
const newIndex = nextIndex >= flatOptions.length ? prev : nextIndex;
|
||||||
setTimeout(() => scrollSelectedIntoView(), 0);
|
setTimeout(() => scrollSelectedIntoView(), 0);
|
||||||
return newIndex;
|
return newIndex;
|
||||||
});
|
});
|
||||||
@@ -199,11 +235,11 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
const unregisterArrowUp = editor.registerCommand(
|
const unregisterArrowUp = editor.registerCommand(
|
||||||
KEY_ARROW_UP_COMMAND,
|
KEY_ARROW_UP_COMMAND,
|
||||||
(event) => {
|
(event) => {
|
||||||
if (showSuggestions && allOptions.length > 0) {
|
if (showSuggestions && flatOptions.length > 0) {
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
setSelectedIndex(prev => {
|
setSelectedIndex(prev => {
|
||||||
let prevIndex = prev - 1;
|
let prevIndex = prev - 1;
|
||||||
while (prevIndex >= 0 && allOptions[prevIndex].disabled) {
|
while (prevIndex >= 0 && flatOptions[prevIndex].disabled) {
|
||||||
prevIndex--;
|
prevIndex--;
|
||||||
}
|
}
|
||||||
const newIndex = prevIndex < 0 ? prev : prevIndex;
|
const newIndex = prevIndex < 0 ? prev : prevIndex;
|
||||||
@@ -236,7 +272,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
unregisterArrowUp();
|
unregisterArrowUp();
|
||||||
unregisterEscape();
|
unregisterEscape();
|
||||||
};
|
};
|
||||||
}, [showSuggestions, selectedIndex, groupedSuggestions, editor]);
|
}, [showSuggestions, selectedIndex, flatOptions, editor]);
|
||||||
|
|
||||||
if (!showSuggestions) return null;
|
if (!showSuggestions) return null;
|
||||||
|
|
||||||
@@ -248,12 +284,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
data-autocomplete-popup="true"
|
data-autocomplete-popup="true"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
className="rb:fixed rb:z-1000 rb:py-1 rb:bg-white rb:rounded-xl rb:min-w-70 rb:max-h-50 rb:overflow-y-auto rb:transform-[translateY(-100%)] rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
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={{
|
style={{
|
||||||
top: popupPosition.top,
|
top: popupPosition.top,
|
||||||
left: popupPosition.left,
|
left: popupPosition.left,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="rb:py-1 rb:min-w-70 rb:max-h-50 rb:overflow-y-auto">
|
||||||
<Flex vertical gap={12}>
|
<Flex vertical gap={12}>
|
||||||
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
|
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions]) => {
|
||||||
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||||
@@ -265,31 +302,49 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
{nodeName}
|
{nodeName}
|
||||||
</Flex>
|
</Flex>
|
||||||
{nodeOptions.map((option) => {
|
{nodeOptions.map((option) => {
|
||||||
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
|
const globalIndex = flatOptions.indexOf(option);
|
||||||
|
const isExpanded = expandedParent?.key === option.key;
|
||||||
|
const hasChildren = !!option.children?.length;
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
key={option.key}
|
key={option.key}
|
||||||
|
ref={(el) => { if (el) itemRefs.current.set(option.key, el); }}
|
||||||
data-selected={selectedIndex === globalIndex}
|
data-selected={selectedIndex === globalIndex}
|
||||||
className="rb:pl-6! rb:pr-3! rb:py-2! "
|
className="rb:pl-6! rb:pr-3! rb:py-2!"
|
||||||
align="center"
|
align="center"
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
style={{
|
style={{
|
||||||
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
cursor: option.disabled ? 'not-allowed' : 'pointer',
|
||||||
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
|
background: (selectedIndex === globalIndex || isExpanded) ? '#f0f8ff' : 'white',
|
||||||
opacity: option.disabled ? 0.5 : 1,
|
opacity: option.disabled ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
onClick={() => !option.disabled && insertMention(option)}
|
onClick={() => {
|
||||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
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));
|
||||||
|
}
|
||||||
|
setExpandedParent(option);
|
||||||
|
} else {
|
||||||
|
setExpandedParent(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span>
|
<span className="rb:text-[#155EEF]">{option.isContext ? '📄' : `{x}`}</span>
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
</Space>
|
</Space>
|
||||||
{option.dataType && (
|
<Space size={4}>
|
||||||
<span className="rb:text-[#5B6167]">
|
{option.dataType && <span className="rb:text-[#5B6167]">{option.dataType}</span>}
|
||||||
{option.dataType}
|
{hasChildren && <span className="rb:text-[#5B6167] rb:ml-1">›</span>}
|
||||||
</span>
|
</Space>
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -297,6 +352,49 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
</div>
|
||||||
|
{/* Child variables panel - floats to the left */}
|
||||||
|
{expandedParent?.children?.length && (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
{/* 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">
|
||||||
|
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||||
|
<span>{expandedParent.dataType}</span>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
{expandedParent.children.map((child) => {
|
||||||
|
const childIndex = flatOptions.indexOf(child);
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
key={child.key}
|
||||||
|
data-selected={selectedIndex === childIndex}
|
||||||
|
className="rb:px-3! rb:py-2!"
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<span>{child.label}</span>
|
||||||
|
{child.dataType && <span className="rb:text-[#5B6167]">{child.dataType}</span>}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,20 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
|||||||
|
|
||||||
if (conversationMatch) {
|
if (conversationMatch) {
|
||||||
const [_, variableName] = conversationMatch;
|
const [_, variableName] = conversationMatch;
|
||||||
const conversationSuggestion = optionsRef.current.find(s =>
|
const fullValue = `conv.${variableName}`;
|
||||||
|
// First try direct match on top-level label
|
||||||
|
let conversationSuggestion = optionsRef.current.find(s =>
|
||||||
s.group === 'CONVERSATION' && s.label === variableName
|
s.group === 'CONVERSATION' && s.label === variableName
|
||||||
);
|
);
|
||||||
|
// Then search children by value (e.g. conv.api_key.url)
|
||||||
|
if (!conversationSuggestion) {
|
||||||
|
for (const s of optionsRef.current) {
|
||||||
|
if (s.group === 'CONVERSATION' && s.children) {
|
||||||
|
const child = s.children.find(c => c.value === fullValue);
|
||||||
|
if (child) { conversationSuggestion = child; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (conversationSuggestion) {
|
if (conversationSuggestion) {
|
||||||
paragraph.append($createVariableNode(conversationSuggestion));
|
paragraph.append($createVariableNode(conversationSuggestion));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
|||||||
<VariableSelect
|
<VariableSelect
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={options.filter(vo => vo.nodeData.type === 'loop' || vo.value.includes('conv.') || (vo.nodeData.type === 'iteration' && (vo.label === 'item' || vo.label === 'index')))}
|
options={options.filter(vo => vo.nodeData.type === 'loop' || vo.value.includes('conv.') || (vo.nodeData.type === 'iteration' && (vo.label === 'item' || vo.label === 'index')))}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
form.setFieldValue([parentName, name, 'operation'], undefined);
|
form.setFieldValue([parentName, name, 'operation'], undefined);
|
||||||
form.setFieldValue([parentName, name, 'value'], undefined);
|
form.setFieldValue([parentName, name, 'value'], undefined);
|
||||||
@@ -121,7 +120,6 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
|||||||
? <VariableSelect
|
? <VariableSelect
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
size={size}
|
size={size}
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
className="select"
|
className="select"
|
||||||
@@ -153,7 +151,6 @@ const AssignmentList: FC<AssignmentListProps> = ({
|
|||||||
: <VariableSelect
|
: <VariableSelect
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
size={size}
|
size={size}
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
className="select"
|
className="select"
|
||||||
|
|||||||
@@ -281,7 +281,6 @@ const CaseList: FC<CaseListProps> = ({
|
|||||||
options={options}
|
options={options}
|
||||||
size="small"
|
size="small"
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val)}
|
onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val)}
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
className="rb:w-36!"
|
className="rb:w-36!"
|
||||||
@@ -326,7 +325,6 @@ const CaseList: FC<CaseListProps> = ({
|
|||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={options.filter(vo => vo.dataType === 'number')}
|
options={options.filter(vo => vo.dataType === 'number')}
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ const ConditionList: FC<CaseListProps> = ({
|
|||||||
)}
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
onChange={(val) => handleLeftFieldChange(index, val)}
|
onChange={(val) => handleLeftFieldChange(index, val)}
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
@@ -201,7 +200,6 @@ const ConditionList: FC<CaseListProps> = ({
|
|||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={options.filter(vo => vo.dataType === 'number')}
|
options={options.filter(vo => vo.dataType === 'number')}
|
||||||
allowClear={false}
|
allowClear={false}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 15:17:39
|
* @Date: 2026-02-03 15:17:39
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-03 10:54:15
|
* @Last Modified time: 2026-04-03 18:39:07
|
||||||
*/
|
*/
|
||||||
import { useEffect, type FC } from 'react'
|
import { useEffect, type FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -87,9 +87,18 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
|||||||
let filteredOptions = options;
|
let filteredOptions = options;
|
||||||
if (value.length > 0) {
|
if (value.length > 0) {
|
||||||
const firstVariableValue = value[0];
|
const firstVariableValue = value[0];
|
||||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
const allSuggestions = options.flatMap(opt => opt.children ? [opt, ...opt.children] : [opt]);
|
||||||
|
const firstVariable = allSuggestions.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
||||||
if (firstVariable) {
|
if (firstVariable) {
|
||||||
filteredOptions = options.filter(opt => opt.dataType === firstVariable.dataType);
|
filteredOptions = options.flatMap(opt => {
|
||||||
|
if (opt.dataType === 'file' && opt.children?.length) {
|
||||||
|
return [{ ...opt, children: opt.children.map(c => ({ ...c, disabled: c.dataType !== firstVariable.dataType })) }];
|
||||||
|
}
|
||||||
|
if (opt.dataType === firstVariable.dataType && !opt.children?.length) return [opt];
|
||||||
|
const filteredChildren = opt.children?.filter(c => c.dataType === firstVariable.dataType);
|
||||||
|
if (filteredChildren?.length) return [{ ...opt, children: filteredChildren }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +115,7 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
|||||||
<VariableSelect
|
<VariableSelect
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={filteredOptions}
|
options={filteredOptions}
|
||||||
mode="multiple"
|
multiple={true}
|
||||||
size={size}
|
size={size}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -168,15 +177,27 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
|
|||||||
const currentGroupValue = value[name]?.value || [];
|
const currentGroupValue = value[name]?.value || [];
|
||||||
if (currentGroupValue.length > 0) {
|
if (currentGroupValue.length > 0) {
|
||||||
const firstVariableValue = currentGroupValue[0];
|
const firstVariableValue = currentGroupValue[0];
|
||||||
const firstVariable = options.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
const allSuggestions = options.flatMap(opt => opt.children ? [opt, ...opt.children] : [opt]);
|
||||||
|
const firstVariable = allSuggestions.find(opt => `{{${opt.value}}}` === firstVariableValue);
|
||||||
|
|
||||||
if (firstVariable) {
|
if (firstVariable) {
|
||||||
return options.filter(opt => opt.dataType === firstVariable.dataType);
|
return options.flatMap(vo => {
|
||||||
|
if (vo.dataType === 'file' && vo.children?.length) {
|
||||||
|
return [{ ...vo, children: vo.children.map(c => ({ ...c, disabled: c.dataType !== firstVariable.dataType })) }];
|
||||||
|
}
|
||||||
|
if (vo.dataType === firstVariable.dataType && (!vo.children || vo.children.length < 1)) return [vo];
|
||||||
|
const filteredChildren = vo.children?.filter(sub => sub.dataType === firstVariable.dataType);
|
||||||
|
if (filteredChildren?.length) return [{ ...vo, children: filteredChildren }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
mode="multiple"
|
|
||||||
|
multiple={true}
|
||||||
size={size}
|
size={size}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Form, Button, Select, type SelectProps, Flex, Row, Col } from 'antd'
|
||||||
|
|
||||||
|
import type { Suggestion } from '../../../Editor/plugin/AutocompletePlugin'
|
||||||
|
import RadioGroupBtn from '../../RadioGroupBtn'
|
||||||
|
import { fileSubVariable } from '../../hooks/useVariableList'
|
||||||
|
import Editor from '../../../Editor'
|
||||||
|
|
||||||
|
interface Case {
|
||||||
|
filter_by: Array<{
|
||||||
|
key: string;
|
||||||
|
comparison_operator: string;
|
||||||
|
value: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterConditionsProps {
|
||||||
|
value?: Case;
|
||||||
|
onChange?: (value: Case) => void;
|
||||||
|
options: Suggestion[];
|
||||||
|
parentName: string;
|
||||||
|
variableType?: string;
|
||||||
|
}
|
||||||
|
const operatorsObj: { [key: string]: SelectProps['options'] } = {
|
||||||
|
default: [
|
||||||
|
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||||
|
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||||
|
{ 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' },
|
||||||
|
],
|
||||||
|
number: [
|
||||||
|
{ value: 'eq', label: 'workflow.config.if-else.num.eq' },
|
||||||
|
{ value: 'ne', label: 'workflow.config.if-else.num.ne' },
|
||||||
|
{ 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' },
|
||||||
|
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||||
|
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||||
|
],
|
||||||
|
boolean: [
|
||||||
|
{ value: 'eq', label: 'workflow.config.if-else.boolean.eq' },
|
||||||
|
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
|
||||||
|
{ value: 'empty', label: 'workflow.config.if-else.empty' },
|
||||||
|
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
|
||||||
|
],
|
||||||
|
type: [
|
||||||
|
{ value: 'eq', label: 'workflow.config.list-operator.type.eq' },
|
||||||
|
{ value: 'ne', label: 'workflow.config.list-operator.type.ne' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = ['image', 'document', 'video', 'audio']
|
||||||
|
|
||||||
|
const FilterConditions: FC<FilterConditionsProps> = ({
|
||||||
|
options,
|
||||||
|
parentName,
|
||||||
|
variableType,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
|
||||||
|
const handleKeyFieldChange = (index: number, newValue: string) => {
|
||||||
|
form.setFieldValue(['filter_by', index], {
|
||||||
|
key: newValue,
|
||||||
|
comparison_operator: undefined,
|
||||||
|
value: undefined,
|
||||||
|
value_type: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.List name={[parentName, 'conditions']}>
|
||||||
|
{(fields, { add, remove }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="rb:relative"
|
||||||
|
>
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const filter_by = form.getFieldValue(['filter_by']) || [];
|
||||||
|
const currentCondition = filter_by[index] || {};
|
||||||
|
const currentOperator = currentCondition.comparison_operator;
|
||||||
|
const hideValueField = currentOperator === 'empty' || currentOperator === 'not_empty';
|
||||||
|
const keyFieldValue = currentCondition.key;
|
||||||
|
const keyFieldOption = fileSubVariable.find(option => option.filed === keyFieldValue);
|
||||||
|
const keyFieldType = keyFieldOption?.dataType;
|
||||||
|
const operatorList = operatorsObj[keyFieldValue === 'type' ? 'type' : keyFieldType || 'default'] || operatorsObj.default || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
key={field.key}
|
||||||
|
gap={4}
|
||||||
|
align="start"
|
||||||
|
className="rb:mb-2!"
|
||||||
|
>
|
||||||
|
<div className="rb:flex-1 rb:bg-[#F6F6F6] rb:rounded-lg">
|
||||||
|
{variableType === 'array[file]' &&
|
||||||
|
<Row className={clsx("rb:p-1!", {
|
||||||
|
'rb-border-b': !hideValueField
|
||||||
|
})}>
|
||||||
|
<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!"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
<Row>
|
||||||
|
<Col flex="96px">
|
||||||
|
<Form.Item name={[field.name, 'comparison_operator']} noStyle>
|
||||||
|
<Select
|
||||||
|
options={operatorList.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>
|
||||||
|
{!hideValueField && (
|
||||||
|
<Col flex="1">
|
||||||
|
<Form.Item name={[field.name, 'value']} className="rb:pt-0.5! rb:mb-0! rb:pl-2!">
|
||||||
|
{variableType?.includes('boolean')
|
||||||
|
? <RadioGroupBtn options={[{ value: true, label: 'True' }, { value: false, label: 'False' }]} />
|
||||||
|
: keyFieldValue === 'type'
|
||||||
|
? <Select
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={typeOptions.map(vo => ({ value: vo, label: t(`application.${vo}`) } ))}
|
||||||
|
variant="borderless"
|
||||||
|
className="rb:w-full!"
|
||||||
|
/>
|
||||||
|
: <Editor
|
||||||
|
variant="borderless"
|
||||||
|
type="input"
|
||||||
|
size="small"
|
||||||
|
height={24}
|
||||||
|
options={keyFieldType ? options.flatMap(vo => {
|
||||||
|
if (vo.dataType === keyFieldType) return [vo];
|
||||||
|
const filteredChildren = vo.children?.filter(sub => sub.dataType === keyFieldType);
|
||||||
|
if (filteredChildren?.length) return [{ ...vo, children: filteredChildren }];
|
||||||
|
return [];
|
||||||
|
}) : options
|
||||||
|
}
|
||||||
|
placeholder={t('common.pleaseEnter')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</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')]"
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
></div>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="middle"
|
||||||
|
block
|
||||||
|
onClick={() => add({})}
|
||||||
|
className="rb:text-[12px]!"
|
||||||
|
>
|
||||||
|
+ {t('workflow.config.list-operator.addCondition')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Form.List>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterConditions
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { type FC } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Form, Switch, Select, Row, Col, Divider, InputNumber } from 'antd'
|
||||||
|
import { Node } from '@antv/x6'
|
||||||
|
|
||||||
|
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
|
||||||
|
import VariableSelect from '../VariableSelect'
|
||||||
|
import { fileSubVariable } from '../hooks/useVariableList'
|
||||||
|
import FilterConditions from './FilterConditions'
|
||||||
|
import RadioGroupBtn from '../RadioGroupBtn'
|
||||||
|
import RbSlider from '@/components/RbSlider'
|
||||||
|
|
||||||
|
|
||||||
|
interface ListOperatorProps {
|
||||||
|
options: Suggestion[]
|
||||||
|
selectedNode: Node
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListOperator: FC<ListOperatorProps> = ({ options }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const form = Form.useFormInstance()
|
||||||
|
const values = Form.useWatch([], form) || {}
|
||||||
|
const variableOption = options.find(option => `{{${option.value}}}` === values?.variable)
|
||||||
|
const variableType = variableOption?.dataType
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name="variable" label={t('workflow.config.list-operator.variable')} required>
|
||||||
|
<VariableSelect
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={options.filter(vo => vo.dataType.includes('array') && vo.dataType !== 'array[object]')}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Form.Item layout="horizontal" name={['filter_by', 'enabled']} label={t('workflow.config.list-operator.filter_by')} className="rb:mb-0!">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
{values?.filter_by?.enabled &&
|
||||||
|
<FilterConditions
|
||||||
|
variableType={variableType}
|
||||||
|
parentName="filter_by"
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Form.Item layout="horizontal" name={['order_by', 'enabled']} label={t('workflow.config.list-operator.order_by')} className="rb:mb-0!">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
{values?.order_by?.enabled &&
|
||||||
|
<Row gutter={8}>
|
||||||
|
{/* 仅 array[file]有效 */}
|
||||||
|
{variableType === 'array[file]' &&
|
||||||
|
<Col flex="200px">
|
||||||
|
<Form.Item name={['order_by', 'key']} className="rb:mb-0!">
|
||||||
|
<Select
|
||||||
|
options={fileSubVariable}
|
||||||
|
fieldNames={{ value: 'filed', label: 'label' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
}
|
||||||
|
<Col flex="1">
|
||||||
|
<Form.Item name={['order_by', 'value']} className="rb:mb-0!">
|
||||||
|
<RadioGroupBtn
|
||||||
|
options={['asc', 'desc'].map(key => ({ label: t(`workflow.config.list-operator.${key}`), value: key }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Form.Item layout="horizontal" name={['extract_by', "enabled"]} label={t('workflow.config.list-operator.extract_by')} className="rb:mb-0!">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
{values?.extract_by?.enabled &&
|
||||||
|
<Form.Item name={['extract_by', "serial"]} className="rb:mb-0!">
|
||||||
|
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
|
||||||
|
</Form.Item>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Form.Item layout="horizontal" name={['limit', "enabled"]} label={t('workflow.config.list-operator.limit')} className="rb:mb-2!">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
{values?.limit?.enabled &&
|
||||||
|
<Form.Item name={['limit', "size"]} className="rb:mb-0!">
|
||||||
|
<RbSlider
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
isInput={true}
|
||||||
|
size="small"
|
||||||
|
className="rb:-mt-2!"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListOperator
|
||||||
@@ -57,7 +57,6 @@ const MappingList: FC<MappingListProps> = ({ label, name, options, extra, valueK
|
|||||||
<VariableSelect
|
<VariableSelect
|
||||||
placeholder={t('common.pleaseSelect')}
|
placeholder={t('common.pleaseSelect')}
|
||||||
options={options}
|
options={options}
|
||||||
popupMatchSelectWidth={false}
|
|
||||||
size="small"
|
size="small"
|
||||||
className="rb:w-51!"
|
className="rb:w-51!"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,36 +2,29 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 15:40:13
|
* @Date: 2026-02-03 15:40:13
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-03-25 16:54:44
|
* @Last Modified time: 2026-04-03 18:51:17
|
||||||
*/
|
*/
|
||||||
import { type FC } from 'react'
|
import { useState, useRef, useEffect, useLayoutEffect, type FC } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Select, type SelectProps, Flex, Space } from 'antd'
|
import { Flex, Space, Checkbox } from 'antd'
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
|
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
|
||||||
type LabelRender = SelectProps['labelRender'];
|
|
||||||
|
|
||||||
/**
|
interface VariableSelectProps {
|
||||||
* Props for VariableSelect component
|
|
||||||
*/
|
|
||||||
interface VariableSelectProps extends SelectProps {
|
|
||||||
/** Available variable options */
|
|
||||||
options: Suggestion[];
|
options: Suggestion[];
|
||||||
/** Current selected value */
|
value?: string | string[];
|
||||||
value?: string;
|
|
||||||
/** Whether to show clear button */
|
|
||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
/** Filter out boolean type variables */
|
|
||||||
filterBooleanType?: boolean;
|
filterBooleanType?: boolean;
|
||||||
/** Size of the select component */
|
multiple?: boolean;
|
||||||
size?: 'small' | 'middle' | 'large'
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
placeholder?: string;
|
||||||
|
variant?: 'outlined' | 'borderless';
|
||||||
|
className?: string;
|
||||||
|
onChange?: (value: string | string[], option: Suggestion | Suggestion[] | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* VariableSelect component
|
|
||||||
* Custom select component for workflow variables with grouped options and custom rendering
|
|
||||||
* @param props - Component props
|
|
||||||
*/
|
|
||||||
const VariableSelect: FC<VariableSelectProps> = ({
|
const VariableSelect: FC<VariableSelectProps> = ({
|
||||||
placeholder,
|
placeholder,
|
||||||
options,
|
options,
|
||||||
@@ -40,109 +33,378 @@ const VariableSelect: FC<VariableSelectProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
size = 'middle',
|
size = 'middle',
|
||||||
filterBooleanType = false,
|
filterBooleanType = false,
|
||||||
mode,
|
multiple = false,
|
||||||
...resetPorps
|
variant = 'outlined',
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [expandedParent, setExpandedParent] = useState<Suggestion | null>(null);
|
||||||
|
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 CHILD_PANEL_HEIGHT = 280; // max-h-60 (240) + header (~40)
|
||||||
* Handle value change and pass selected option to parent
|
|
||||||
* @param value - Selected value
|
|
||||||
*/
|
|
||||||
const handleChange: SelectProps['onChange'] = (value: string) => {
|
|
||||||
const filterItem = options.find(option => `{{${option.value}}}` === value)
|
|
||||||
onChange?.(value, filterItem);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Custom label renderer for selected value
|
|
||||||
* Displays node icon, name and variable label
|
|
||||||
* @param props - Label render props
|
|
||||||
*/
|
|
||||||
const labelRender: LabelRender = (props) => {
|
|
||||||
const { value } = props
|
|
||||||
const filterOption = filteredOptions.find(vo => `{{${vo.value}}}` === value)
|
|
||||||
|
|
||||||
if (filterOption) {
|
// Calculate dropdown position when opening
|
||||||
return (
|
useEffect(() => {
|
||||||
<span
|
if (!open || !containerRef.current) return;
|
||||||
className={clsx("rb:max-w-full rb:wrap-break-word rb:line-clamp-1 rb:rounded-md rb:bg-white rb:text-[12px] rb:inline-flex rb:items-center rb:px-1.5 rb:cursor-pointer", {
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
'rb:leading-5.5!': size !== 'small',
|
setDropdownPos({ top: rect.bottom + 8, left: rect.left, width: rect.width });
|
||||||
'rb:leading-4! rb:text-[10px]!': size === 'small',
|
}, [open]);
|
||||||
'rb-border': mode !== "multiple"
|
|
||||||
})}
|
// Adjust dropdown vertical position after render
|
||||||
contentEditable={false}
|
useLayoutEffect(() => {
|
||||||
>
|
if (!open || !dropdownRef.current || !containerRef.current) return;
|
||||||
{filterOption.nodeData?.icon && filterOption.nodeData?.name && (
|
const triggerRect = containerRef.current.getBoundingClientRect();
|
||||||
<>
|
const dropdownHeight = dropdownRef.current.offsetHeight;
|
||||||
<div className={`rb:size-3 rb:mr-1 rb:bg-cover ${filterOption.nodeData.icon}`} />
|
const dropdownWidth = dropdownRef.current.offsetWidth;
|
||||||
{filterOption.nodeData.name}
|
const viewportHeight = window.innerHeight;
|
||||||
<span className="rb:text-[#DFE4ED] rb:mx-0.5">/</span>
|
const MARGIN = 8;
|
||||||
</>
|
|
||||||
)}
|
// Horizontal: left-align to trigger, clamp to viewport
|
||||||
<span className="rb:text-[#171719]">{filterOption.label}</span>
|
const left = Math.min(triggerRect.left, window.innerWidth - dropdownWidth - 10);
|
||||||
</span>
|
|
||||||
)
|
const spaceBelow = viewportHeight - triggerRect.bottom - MARGIN;
|
||||||
|
const spaceAbove = triggerRect.top - MARGIN;
|
||||||
|
|
||||||
|
let finalTop: number;
|
||||||
|
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||||
|
finalTop = triggerRect.bottom + MARGIN;
|
||||||
|
} else {
|
||||||
|
finalTop = triggerRect.top - dropdownHeight - MARGIN;
|
||||||
|
if (finalTop < MARGIN) finalTop = MARGIN;
|
||||||
}
|
}
|
||||||
return null
|
setDropdownPos(prev => ({ ...prev, top: finalTop, left }));
|
||||||
}
|
}, [open, search, Array.isArray(value) ? value.length : 0]);
|
||||||
// Filter options based on boolean type if needed
|
|
||||||
const filteredOptions = filterBooleanType
|
const filteredOptions = filterBooleanType
|
||||||
? options.filter(option => option.dataType !== 'boolean')
|
? options.filter(o => o.dataType !== 'boolean')
|
||||||
: options;
|
: options;
|
||||||
|
|
||||||
/**
|
const allSuggestions = filteredOptions.flatMap(o => o.children ? [o, ...o.children] : [o]);
|
||||||
* Group suggestions by node ID
|
const suggestionMap = new Map(allSuggestions.map(s => [`{{${s.value}}}`, s]));
|
||||||
*/
|
|
||||||
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, any[]>, suggestion) => {
|
const selectedValues = multiple ? (Array.isArray(value) ? value : []) : [];
|
||||||
const { nodeData } = suggestion
|
const selectedSuggestion = !multiple && value ? suggestionMap.get(value as string) : undefined;
|
||||||
const nodeId = nodeData.id as string;
|
const parentOfSelected = !multiple && value
|
||||||
if (!groups[nodeId]) {
|
? filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === value))
|
||||||
groups[nodeId] = [];
|
: undefined;
|
||||||
}
|
|
||||||
groups[nodeId].push(suggestion);
|
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, Suggestion[]>, s) => {
|
||||||
|
const nodeId = s.nodeData.id as string;
|
||||||
|
if (!groups[nodeId]) groups[nodeId] = [];
|
||||||
|
groups[nodeId].push(s);
|
||||||
return groups;
|
return groups;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
/**
|
const filteredGroups = search
|
||||||
* Format grouped options for Select component
|
? Object.entries(groupedSuggestions).reduce((acc: Record<string, Suggestion[]>, [nodeId, suggestions]) => {
|
||||||
*/
|
const matched = suggestions.filter(s =>
|
||||||
const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({
|
s.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
label: <Flex align="center" gap={4}>
|
s.value.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
{suggestions[0].nodeData.icon && <div className={`rb:size-3 ${suggestions[0].nodeData.icon}`} />}
|
s.children?.some(c => c.label.toLowerCase().includes(search.toLowerCase()))
|
||||||
{suggestions[0].nodeData.name}
|
);
|
||||||
</Flex>,
|
if (matched.length) acc[nodeId] = matched;
|
||||||
options: suggestions.map(s => ({
|
return acc;
|
||||||
label: <Flex align="center" justify="space-between" gap={4}>
|
}, {})
|
||||||
<Space size={8}>
|
: groupedSuggestions;
|
||||||
<span className="rb:text-[#155EEF]">{`{x}`}</span>
|
|
||||||
{s.label}
|
useEffect(() => {
|
||||||
</Space>
|
if (!open) return;
|
||||||
<span className="rb:text-[#5B6167]">{s.dataType}</span>
|
const updatePos = () => {
|
||||||
</Flex>,
|
if (!containerRef.current || !dropdownRef.current) return;
|
||||||
value: `{{${s.value}}}`
|
const triggerRect = containerRef.current.getBoundingClientRect();
|
||||||
}))
|
const dropdownHeight = dropdownRef.current.offsetHeight;
|
||||||
}));
|
const dropdownWidth = dropdownRef.current.offsetWidth;
|
||||||
|
const MARGIN = 8;
|
||||||
|
const left = Math.min(triggerRect.left, window.innerWidth - dropdownWidth - 10);
|
||||||
|
const spaceBelow = window.innerHeight - triggerRect.bottom - MARGIN;
|
||||||
|
const spaceAbove = triggerRect.top - MARGIN;
|
||||||
|
let top: number;
|
||||||
|
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {
|
||||||
|
top = triggerRect.bottom + MARGIN;
|
||||||
|
} else {
|
||||||
|
top = triggerRect.top - dropdownHeight - MARGIN;
|
||||||
|
if (top < MARGIN) top = MARGIN;
|
||||||
|
}
|
||||||
|
setDropdownPos(prev => ({ ...prev, top, left }));
|
||||||
|
};
|
||||||
|
document.addEventListener('scroll', updatePos, true);
|
||||||
|
return () => document.removeEventListener('scroll', updatePos, true);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
const childPanel = document.getElementById('variable-select-child-panel');
|
||||||
|
if (
|
||||||
|
!containerRef.current?.contains(target) &&
|
||||||
|
!dropdownRef.current?.contains(target) &&
|
||||||
|
!childPanel?.contains(target)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
setExpandedParent(null);
|
||||||
|
setChildPanelPos({ top: 0, right: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSelect = (suggestion: Suggestion) => {
|
||||||
|
if (multiple) {
|
||||||
|
const key = `{{${suggestion.value}}}`;
|
||||||
|
const next = selectedValues.includes(key)
|
||||||
|
? selectedValues.filter(v => v !== key)
|
||||||
|
: [...selectedValues, key];
|
||||||
|
const nextOptions = next.map(v => suggestionMap.get(v)).filter(Boolean) as Suggestion[];
|
||||||
|
onChange?.(next, nextOptions);
|
||||||
|
} else {
|
||||||
|
onChange?.(`{{${suggestion.value}}}`, suggestion);
|
||||||
|
setOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
setExpandedParent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange?.(multiple ? [] : '', multiple ? [] : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChildPos = (key: string) => {
|
||||||
|
const el = itemRefs.current.get(key);
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const absoluteBottom = rect.top + CHILD_PANEL_HEIGHT;
|
||||||
|
const overflow = absoluteBottom - (window.innerHeight - 10);
|
||||||
|
const top = overflow > 0 ? rect.top - overflow : rect.top;
|
||||||
|
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);
|
||||||
|
const nodeData = (parentOfSelected ?? selectedSuggestion)?.nodeData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<div ref={containerRef} className="rb:relative rb:w-full">
|
||||||
{...resetPorps}
|
{/* Trigger */}
|
||||||
mode={mode}
|
<div
|
||||||
size={size}
|
className={clsx(
|
||||||
placeholder={placeholder}
|
'rb:w-full rb:flex rb:items-center rb:justify-between rb:cursor-pointer rb:rounded-md rb:bg-white rb:px-2 rb:transition-colors',
|
||||||
value={value}
|
variant === 'outlined' && 'rb:border rb:border-[#d9d9d9] hover:rb:border-[#4096ff]',
|
||||||
style={{ width: '100%' }}
|
variant === 'outlined' && open && 'rb:border-[#4096ff] rb:shadow-[0_0_0_2px_rgba(5,145,255,0.1)]',
|
||||||
options={groupedOptions}
|
variant === 'borderless' && 'rb:border-none rb:shadow-none rb:bg-transparent',
|
||||||
labelRender={labelRender}
|
multiple ? 'rb:min-h-8 rb:py-1' : size === 'small' ? 'rb:h-6 rb:text-[10px]' : size === 'large' ? 'rb:h-10' : 'rb:h-8 rb:text-[12px]',
|
||||||
onChange={handleChange}
|
!multiple && (size === 'small' ? 'rb:text-[10px]' : 'rb:text-[12px]'),
|
||||||
showSearch
|
className
|
||||||
allowClear={allowClear}
|
)}
|
||||||
optionFilterProp="value"
|
onClick={() => setOpen(o => !o)}
|
||||||
filterOption={(input, option) => {
|
>
|
||||||
if (input === '/') return true;
|
{multiple ? (
|
||||||
const value = 'value' in option! ? option.value as string : '';
|
selectedValues.length > 0 ? (
|
||||||
return value.toLowerCase().includes(input.toLowerCase());
|
<span className="rb:flex rb:flex-wrap rb:gap-1 rb:flex-1 rb:min-w-0">
|
||||||
}}
|
{selectedValues.map(v => {
|
||||||
/>
|
const s = suggestionMap.get(v);
|
||||||
)
|
if (!s) return null;
|
||||||
}
|
const parent = filteredOptions.find(o => o.children?.some(c => `{{${c.value}}}` === v));
|
||||||
|
const nd = s.nodeData;
|
||||||
|
const isConv = (parent ?? s)?.group === 'CONVERSATION' ||
|
||||||
|
filteredOptions.some(o => o.group === 'CONVERSATION' && o.children?.some(c => `{{${c.value}}}` === v));
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{!isConv && nd?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nd.icon}`} />}
|
||||||
|
{!isConv && nd?.name && <span className="rb:text-[#5B6167]">{nd.name}{sep}</span>}
|
||||||
|
<span className="rb:text-[#171719]">
|
||||||
|
{parent ? <>{parent.label}{sep}{s.label}</> : s.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rb:cursor-pointer rb:text-[#bfbfbf] hover:rb:text-[#999] rb:leading-none rb:ml-0.5"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleSelect(s); }}
|
||||||
|
>✕</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rb:text-[#bfbfbf] rb:flex-1 rb:text-[12px]">{placeholder}</span>
|
||||||
|
)
|
||||||
|
) : selectedSuggestion ? (
|
||||||
|
<span className="rb:flex rb:flex-1 rb:min-w-0">
|
||||||
|
<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">
|
||||||
|
{!isConversation && nodeData?.icon && <div className={`rb:size-3 rb:shrink-0 rb:bg-cover ${nodeData.icon}`} />}
|
||||||
|
{!isConversation && nodeData?.name && <span className="rb:text-[#5B6167]">{nodeData.name}{sep}</span>}
|
||||||
|
<span className="rb:text-[#171719]">
|
||||||
|
{parentOfSelected ? <>{parentOfSelected.label}{sep}{selectedSuggestion.label}</> : selectedSuggestion.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="rb:text-[#bfbfbf] rb:flex-1">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
<Space size={4} className="rb:shrink-0 rb:ml-1">
|
||||||
|
{allowClear && (
|
||||||
|
<span
|
||||||
|
className={clsx('rb:text-[#bfbfbf] rb:text-[10px] hover:rb:text-[#999] rb:leading-none rb:transition-opacity',
|
||||||
|
(multiple ? selectedValues.length > 0 : !!selectedSuggestion) ? 'rb:opacity-100 rb:cursor-pointer' : 'rb:opacity-0 rb:pointer-events-none'
|
||||||
|
)}
|
||||||
|
onClick={handleClear}
|
||||||
|
>✕</span>
|
||||||
|
)}
|
||||||
|
<div className={clsx("rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')]", {
|
||||||
|
'rb:rotate-0': open,
|
||||||
|
'rb:rotate-180': !open,
|
||||||
|
})}></div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown via portal */}
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="rb:fixed rb:z-9999 rb:bg-white rb:text-[14px] rb:rounded-xl rb:shadow-[0px_2px_12px_0px_rgba(23,23,25,0.12)]"
|
||||||
|
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]) => {
|
||||||
|
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:font-medium rb:text-[#5B6167]">
|
||||||
|
{nd.icon && <div className={`rb:size-3 rb:bg-cover ${nd.icon}`} />}
|
||||||
|
{nd.name}
|
||||||
|
</Flex>
|
||||||
|
{suggestions.map(s => {
|
||||||
|
const isSelected = multiple
|
||||||
|
? selectedValues.includes(`{{${s.value}}}`)
|
||||||
|
: `{{${s.value}}}` === value;
|
||||||
|
const isExpanded = expandedParent?.key === s.key;
|
||||||
|
const hasChildren = !!s.children?.length;
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
key={s.key}
|
||||||
|
ref={(el) => { if (el) itemRefs.current.set(s.key, el); }}
|
||||||
|
className="rb:mx-3! rb:pl-3! rb:pr-3! rb:py-1.5! rb:rounded-lg!"
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
style={{
|
||||||
|
cursor: s.disabled ? 'not-allowed' : 'pointer',
|
||||||
|
background: isSelected || isExpanded ? '#f0f8ff' : 'white',
|
||||||
|
opacity: s.disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (s.disabled) return;
|
||||||
|
if (hasChildren) {
|
||||||
|
updateChildPos(s.key);
|
||||||
|
setExpandedParent(prev => prev?.key === s.key ? null : s);
|
||||||
|
}
|
||||||
|
handleSelect(s);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hasChildren) {
|
||||||
|
updateChildPos(s.key);
|
||||||
|
setExpandedParent(s);
|
||||||
|
} else {
|
||||||
|
setExpandedParent(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={4}>
|
||||||
|
{multiple && (
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
)}
|
||||||
|
<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>}
|
||||||
|
|
||||||
|
{hasChildren && <div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/arrow_up.svg')] rb:rotate-90"></div>}
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{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', '暂无变量')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Child panel via portal — escapes overflow clipping */}
|
||||||
|
{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)]"
|
||||||
|
style={{ top: childPanelPos.top, right: childPanelPos.right }}
|
||||||
|
onMouseEnter={() => setExpandedParent(expandedParent)}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center" gap={8}>
|
||||||
|
<Flex align="center" gap={6}>
|
||||||
|
<span>{expandedParent.nodeData.name}.{expandedParent.label}</span>
|
||||||
|
</Flex>
|
||||||
|
<span>{expandedParent.dataType}</span>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
{expandedParent.children.map(child => {
|
||||||
|
const isSelected = multiple
|
||||||
|
? selectedValues.includes(`{{${child.value}}}`)
|
||||||
|
: `{{${child.value}}}` === value;
|
||||||
|
const hasGrandChildren = !!child.children?.length;
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
key={child.key}
|
||||||
|
className={clsx("rb:px-3! rb:py-2! rb:hover:bg-[#f0f8ff]!", {
|
||||||
|
'rb:bg-[#f0f8ff]': isSelected,
|
||||||
|
'rb:white': !isSelected
|
||||||
|
})}
|
||||||
|
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}>
|
||||||
|
{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>}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default VariableSelect
|
export default VariableSelect
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-01-19 17:00:26
|
* @Date: 2026-01-19 17:00:26
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-02-28 16:24:31
|
* @Last Modified time: 2026-04-02 16:58:40
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* useVariableList Hook
|
* useVariableList Hook
|
||||||
@@ -19,6 +19,16 @@ import { Graph, Node } from '@antv/x6';
|
|||||||
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin';
|
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin';
|
||||||
import type { ChatVariable } from '../../../types';
|
import type { ChatVariable } from '../../../types';
|
||||||
|
|
||||||
|
export const fileSubVariable = [
|
||||||
|
{ label: 'type', dataType: 'string', filed: 'type' },
|
||||||
|
{ label: 'size', dataType: 'number', filed: 'size' },
|
||||||
|
{ label: 'name', dataType: 'string', filed: 'name' },
|
||||||
|
{ label: 'url', dataType: 'string', filed: 'url' },
|
||||||
|
{ label: 'extension', dataType: 'string', filed: 'extension' },
|
||||||
|
{ label: 'mime_type', dataType: 'string', filed: 'mime_type' },
|
||||||
|
{ label: 'related_id', dataType: 'string', filed: 'related_id' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node variable definitions
|
* Node variable definitions
|
||||||
*
|
*
|
||||||
@@ -45,7 +55,12 @@ const NODE_VARIABLES = {
|
|||||||
],
|
],
|
||||||
'document-extractor': [
|
'document-extractor': [
|
||||||
{ label: 'text', dataType: 'string', field: 'text' },
|
{ label: 'text', dataType: 'string', field: 'text' },
|
||||||
]
|
],
|
||||||
|
'list-operator': [
|
||||||
|
{ label: 'result', dataType: 'array[string]', field: 'result' },
|
||||||
|
{ label: 'first_record', dataType: 'string', field: 'first_record' },
|
||||||
|
{ label: 'last_record', dataType: 'string', field: 'last_record' },
|
||||||
|
] // dataType will be overridden dynamically
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +75,17 @@ const NODE_VARIABLES = {
|
|||||||
* @param {any} nodeData - Node data associated with the variable
|
* @param {any} nodeData - Node data associated with the variable
|
||||||
* @param {Partial<Suggestion>} [extra] - Additional suggestion properties
|
* @param {Partial<Suggestion>} [extra] - Additional suggestion properties
|
||||||
*/
|
*/
|
||||||
|
const buildFileChildren = (key: string, value: string, nodeData: any, parentLabel: string): Suggestion[] =>
|
||||||
|
fileSubVariable.map(sub => ({
|
||||||
|
key: `${key}_${sub.filed}`,
|
||||||
|
label: sub.label,
|
||||||
|
type: 'variable',
|
||||||
|
dataType: sub.dataType,
|
||||||
|
value: `${value}.${sub.filed}`,
|
||||||
|
nodeData,
|
||||||
|
parentLabel,
|
||||||
|
}));
|
||||||
|
|
||||||
const addVariable = (
|
const addVariable = (
|
||||||
list: Suggestion[],
|
list: Suggestion[],
|
||||||
keys: Set<string>,
|
keys: Set<string>,
|
||||||
@@ -72,7 +98,10 @@ const addVariable = (
|
|||||||
) => {
|
) => {
|
||||||
if (!keys.has(key)) {
|
if (!keys.has(key)) {
|
||||||
keys.add(key);
|
keys.add(key);
|
||||||
list.push({ key, label, type: 'variable', dataType, value, nodeData, ...extra });
|
const children = dataType === 'file'
|
||||||
|
? buildFileChildren(key, value, nodeData, label)
|
||||||
|
: undefined;
|
||||||
|
list.push({ key, label, type: 'variable', dataType, value, nodeData, children, ...extra });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,9 +123,26 @@ const processNodeVariables = (
|
|||||||
|
|
||||||
// Add node-specific variables
|
// Add node-specific variables
|
||||||
if (type in NODE_VARIABLES) {
|
if (type in NODE_VARIABLES) {
|
||||||
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
if (type === 'list-operator') {
|
||||||
addVariable(variableList, addedKeys, `${dataNodeId}_${label}`, label, dataType, `${dataNodeId}.${field}`, nodeData);
|
// Determine output type from the first variable in config
|
||||||
});
|
const variableValue = config?.variable;
|
||||||
|
let itemType = 'string';
|
||||||
|
if (variableValue) {
|
||||||
|
const refVar = variableList.find(v => `{{${v.value}}}` === variableValue);
|
||||||
|
if (refVar?.dataType.startsWith('array[')) {
|
||||||
|
itemType = refVar.dataType.replace(/^array\[(.+)\]$/, '$1');
|
||||||
|
} else if (refVar) {
|
||||||
|
itemType = refVar.dataType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_result`, 'result', `array[${itemType}]`, `${dataNodeId}.result`, nodeData);
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_first_record`, 'first_record', itemType, `${dataNodeId}.first_record`, nodeData);
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_last_record`, 'last_record', itemType, `${dataNodeId}.last_record`, nodeData);
|
||||||
|
} else {
|
||||||
|
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
||||||
|
addVariable(variableList, addedKeys, `${dataNodeId}_${label}`, label, dataType, `${dataNodeId}.${field}`, nodeData);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process special node types
|
// Process special node types
|
||||||
@@ -181,7 +227,8 @@ const hasOutputNodeTypes = [
|
|||||||
'http-request',
|
'http-request',
|
||||||
'tool',
|
'tool',
|
||||||
'jinja-render',
|
'jinja-render',
|
||||||
'document-extractor'
|
'document-extractor',
|
||||||
|
'list-operator'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,10 +238,10 @@ const hasOutputNodeTypes = [
|
|||||||
* @param {any} values - Additional values to merge with node config
|
* @param {any} values - Additional values to merge with node config
|
||||||
* @returns {Suggestion[]} List of node variables
|
* @returns {Suggestion[]} List of node variables
|
||||||
*/
|
*/
|
||||||
export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[] => {
|
export const getCurrentNodeVariables = (nodeData: any, values: any, upstreamVariables: Suggestion[] = []): Suggestion[] => {
|
||||||
if (!nodeData || !hasOutputNodeTypes.includes(nodeData.type)) return [];
|
if (!nodeData || !hasOutputNodeTypes.includes(nodeData.type)) return [];
|
||||||
const list: Suggestion[] = [];
|
const list: Suggestion[] = [...upstreamVariables];
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>(upstreamVariables.map(v => v.key));
|
||||||
const dataNodeId = nodeData.id;
|
const dataNodeId = nodeData.id;
|
||||||
|
|
||||||
processNodeVariables({
|
processNodeVariables({
|
||||||
@@ -206,7 +253,8 @@ export const getCurrentNodeVariables = (nodeData: any, values: any): Suggestion[
|
|||||||
}, dataNodeId, list, keys);
|
}, dataNodeId, list, keys);
|
||||||
|
|
||||||
// Special case: var-aggregator without group enabled returns no variables
|
// Special case: var-aggregator without group enabled returns no variables
|
||||||
return nodeData.type === 'var-aggregator' && !nodeData.config.group.defaultValue ? [] : list;
|
const result = list.filter(v => v.nodeData?.id === dataNodeId);
|
||||||
|
return nodeData.type === 'var-aggregator' && !nodeData.config.group.defaultValue ? [] : result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,52 +311,21 @@ export const getChildNodeVariables = (
|
|||||||
// Add node-specific variables
|
// Add node-specific variables
|
||||||
if (type in NODE_VARIABLES) {
|
if (type in NODE_VARIABLES) {
|
||||||
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
NODE_VARIABLES[type as keyof typeof NODE_VARIABLES].forEach(({ label, dataType, field }) => {
|
||||||
const varKey = `${nodeId}_${label}`;
|
addVariable(list, keys, `${nodeId}_${label}`, label, dataType, `${nodeId}.${field}`, nodeData);
|
||||||
if (!keys.has(varKey)) {
|
|
||||||
keys.add(varKey);
|
|
||||||
list.push({
|
|
||||||
key: varKey,
|
|
||||||
label,
|
|
||||||
type: 'variable',
|
|
||||||
dataType,
|
|
||||||
value: `${nodeId}.${field}`,
|
|
||||||
nodeData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add parameter-extractor variables
|
// Add parameter-extractor variables
|
||||||
if (type === 'parameter-extractor') {
|
if (type === 'parameter-extractor') {
|
||||||
(nodeData.config?.params?.defaultValue || []).forEach((p: any) => {
|
(nodeData.config?.params?.defaultValue || []).forEach((p: any) => {
|
||||||
if (p?.name && !keys.has(`${nodeId}_${p.name}`)) {
|
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
|
||||||
keys.add(`${nodeId}_${p.name}`);
|
|
||||||
list.push({
|
|
||||||
key: `${nodeId}_${p.name}`,
|
|
||||||
label: p.name,
|
|
||||||
type: 'variable',
|
|
||||||
dataType: p.type || 'string',
|
|
||||||
value: `${nodeId}.${p.name}`,
|
|
||||||
nodeData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add code node variables
|
// Add code node variables
|
||||||
if (type === 'code') {
|
if (type === 'code') {
|
||||||
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
|
(nodeData.config?.output_variables?.defaultValue || []).forEach((p: any) => {
|
||||||
if (p?.name && !keys.has(`${nodeId}_${p.name}`)) {
|
if (p?.name) addVariable(list, keys, `${nodeId}_${p.name}`, p.name, p.type || 'string', `${nodeId}.${p.name}`, nodeData);
|
||||||
keys.add(`${nodeId}_${p.name}`);
|
|
||||||
list.push({
|
|
||||||
key: `${nodeId}_${p.name}`,
|
|
||||||
label: p.name,
|
|
||||||
type: 'variable',
|
|
||||||
dataType: p.type || 'string',
|
|
||||||
value: `${nodeId}.${p.name}`,
|
|
||||||
nodeData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { nodeLibrary } from '../../constant';
|
|||||||
import RbCard from '@/components/RbCard/Card';
|
import RbCard from '@/components/RbCard/Card';
|
||||||
import ModelConfig from './ModelConfig'
|
import ModelConfig from './ModelConfig'
|
||||||
import ModelSelect from '@/components/ModelSelect'
|
import ModelSelect from '@/components/ModelSelect'
|
||||||
|
import ListOperator from './ListOperator'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for Properties component
|
* Props for Properties component
|
||||||
@@ -362,7 +363,7 @@ const Properties: FC<PropertiesProps> = ({
|
|||||||
*/
|
*/
|
||||||
const currentNodeVariables = useMemo(() => {
|
const currentNodeVariables = useMemo(() => {
|
||||||
if (!selectedNode) return []
|
if (!selectedNode) return []
|
||||||
return getCurrentNodeVariables(selectedNode?.getData(), values)
|
return getCurrentNodeVariables(selectedNode?.getData(), values, variableList)
|
||||||
}, [selectedNode?.getData(), values])
|
}, [selectedNode?.getData(), values])
|
||||||
|
|
||||||
const [outputCollapsed, setOutputCollapsed] = useState(true)
|
const [outputCollapsed, setOutputCollapsed] = useState(true)
|
||||||
@@ -466,7 +467,12 @@ const Properties: FC<PropertiesProps> = ({
|
|||||||
<Form.Item name="id" label="ID">
|
<Form.Item name="id" label="ID">
|
||||||
<Input disabled />
|
<Input disabled />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{selectedNode?.data?.type === 'unknown'
|
{selectedNode?.data?.type === 'list-operator'
|
||||||
|
? <ListOperator
|
||||||
|
options={variableList}
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
/>
|
||||||
|
: selectedNode?.data?.type === 'unknown'
|
||||||
? <>
|
? <>
|
||||||
<Form.Item name="replaceNode" label={t('workflow.config.unknown.replaceNodeType')}>
|
<Form.Item name="replaceNode" label={t('workflow.config.unknown.replaceNodeType')}>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -313,8 +313,7 @@ export const nodeLibrary: NodeLibrary[] = [
|
|||||||
},
|
},
|
||||||
{ type: "cycle-start", icon: 'rb:bg-[url("@/assets/images/workflow/start.svg")]'},
|
{ type: "cycle-start", icon: 'rb:bg-[url("@/assets/images/workflow/start.svg")]'},
|
||||||
{ type: "break", icon: 'rb:bg-[url("@/assets/images/workflow/break.svg")]'},
|
{ type: "break", icon: 'rb:bg-[url("@/assets/images/workflow/break.svg")]'},
|
||||||
{
|
{ type: "var-aggregator", icon: 'rb:bg-[url("@/assets/images/workflow/aggregator.svg")]',
|
||||||
type: "var-aggregator", icon: 'rb:bg-[url("@/assets/images/workflow/aggregator.svg")]',
|
|
||||||
config: {
|
config: {
|
||||||
group: {
|
group: {
|
||||||
type: 'switch',
|
type: 'switch',
|
||||||
@@ -462,6 +461,42 @@ export const nodeLibrary: NodeLibrary[] = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{ type: "list-operator", icon: 'rb:bg-[url("@/assets/images/workflow/list-operator.svg")]',
|
||||||
|
config: {
|
||||||
|
variable: {
|
||||||
|
type: 'variableList',
|
||||||
|
},
|
||||||
|
filter_by: {
|
||||||
|
type: 'define',
|
||||||
|
defaultValue: {
|
||||||
|
enabled: false,
|
||||||
|
conditions: [{}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
order_by: {
|
||||||
|
type: 'define',
|
||||||
|
defaultValue: {
|
||||||
|
"enabled": false,
|
||||||
|
"key": "",
|
||||||
|
"value": "asc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'define',
|
||||||
|
defaultValue: {
|
||||||
|
"enabled": false,
|
||||||
|
"size": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extract_by: {
|
||||||
|
type: 'define',
|
||||||
|
defaultValue: {
|
||||||
|
"enabled": false,
|
||||||
|
"serial": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export const useWorkflowGraph = ({
|
|||||||
const { id, type, name, position, config = {} } = node
|
const { id, type, name, position, config = {} } = node
|
||||||
let nodeLibraryConfig: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
let nodeLibraryConfig: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
||||||
.flatMap(category => category.nodes)
|
.flatMap(category => category.nodes)
|
||||||
.find(n => n.type === type) as NodeProperties
|
.find(n => n.type === type) as NodeProperties || unknownNode
|
||||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ ...nodeLibraryConfig, config: nodeLibraryConfig.config || {} }))
|
nodeLibraryConfig = JSON.parse(JSON.stringify({ ...nodeLibraryConfig, config: nodeLibraryConfig.config || {} }))
|
||||||
|
|
||||||
if (nodeLibraryConfig?.config) {
|
if (nodeLibraryConfig?.config) {
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // 支持通过IP地址访问
|
host: '0.0.0.0', // 支持通过IP地址访问
|
||||||
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
// 主要API代理,支持 /api 和 /api/* 格式
|
// 主要API代理,支持 /api 和 /api/* 格式
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://0.0.0.0:5173', // 后端服务地址
|
// target: 'http://192.168.110.83:8000', // wxy
|
||||||
|
// target: 'http://192.168.110.86:8000', // lxy
|
||||||
|
// target: 'http://192.168.110.2:8000', // xjn
|
||||||
|
// target: 'http://192.168.110.72:8000', // llq
|
||||||
|
// target: 'http://192.168.110.39:8000', // myh
|
||||||
|
target: 'https://devmemorybear.redbearai.com/', // 开发后端服务地址
|
||||||
|
// target: 'https://devcopymemorybear.redbearai.com/', // 开发sass后端服务地址
|
||||||
|
// target: 'https://testmemorybear.redbearai.com/', // 测试后端服务地址
|
||||||
|
// target: 'https://memorybear.redbearai.com/', // 预发服务地址
|
||||||
|
// target: 'https://cloud.memorybear.ai/', // AMAZON 生产地址
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|
||||||
// 匹配所有以/api开头的请求,包括/api/token
|
// 匹配所有以/api开头的请求,包括/api/token
|
||||||
|
|||||||
Reference in New Issue
Block a user