= ({
align="center"
justify={cicle ? 'center' : 'start'}
gap={4}
- className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:border rb:hover:bg-[#F6F6F6]", {
+ className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:px-2! rb:border rb:hover:bg-[#F6F6F6]", {
'rb:size-7 rb:rounded-[14px] rb:border-[0.5px] rb:border-[#EBEBEB]': cicle,
- 'rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6': !cicle,
+ 'rb:rounded-lg rb:text-[12px] rb:h-6': !cicle,
// Checked state: blue background and border
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[rgba(21,94,239,0.25)] rb:hover:bg-[rgba(21,94,239,0.06)] rb:text-[#155EEF]": checked,
// Unchecked state: gray border and dark text
diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx
index c1f5223c..15dcd496 100644
--- a/web/src/components/Chat/ChatContent.tsx
+++ b/web/src/components/Chat/ChatContent.tsx
@@ -2,13 +2,14 @@
* @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-02-06 21:05:52
+ * @Last Modified time: 2026-03-17 14:11:24
*/
-import { type FC, useRef, useEffect } from 'react'
+import { type FC, useRef, useEffect, useState } from 'react'
import clsx from 'clsx'
import Markdown from '@/components/Markdown'
import type { ChatContentProps } from './types'
-import { Spin } from 'antd'
+import { Spin, Divider, Space } from 'antd'
+import { SoundOutlined } from '@ant-design/icons'
/**
* Chat Content Display Component
@@ -28,7 +29,25 @@ const ChatContent: FC
= ({
// Scroll container reference for controlling auto-scroll to bottom
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
const prevDataLengthRef = useRef(data.length);
- const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom
+ const isScrolledToBottomRef = useRef(true);
+ const audioRef = useRef(null)
+ const [playingIndex, setPlayingIndex] = useState(null)
+
+ const handlePlay = (index: number, audioUrl: string) => {
+ if (playingIndex === index) {
+ audioRef.current?.pause()
+ setPlayingIndex(null)
+ return
+ }
+ if (audioRef.current) {
+ audioRef.current.pause()
+ }
+ const audio = new Audio(audioUrl)
+ audioRef.current = audio
+ audio.play()
+ setPlayingIndex(index)
+ audio.onended = () => setPlayingIndex(null)
+ }
// Track scroll position to determine if user is at bottom
useEffect(() => {
@@ -101,6 +120,19 @@ const ChatContent: FC = ({
{item.subContent && renderRuntime && renderRuntime(item, index)}
{/* Render message content using Markdown component */}
+
+ {item.audioUrl && <>
+
+
+ {playingIndex !== index
+ ? handlePlay(index, item.audioUrl!)} />
+ : handlePlay(index, item.audioUrl!)}
+ />
+ }
+
+ >}
{/* Bottom label (such as timestamp, username, etc.) */}
{labelPosition === 'bottom' &&
diff --git a/web/src/components/Chat/ChatToolbar.tsx b/web/src/components/Chat/ChatToolbar.tsx
new file mode 100644
index 00000000..883ac98a
--- /dev/null
+++ b/web/src/components/Chat/ChatToolbar.tsx
@@ -0,0 +1,204 @@
+/*
+ * @Author: ZhaoYing
+ * @Date: 2026-03-17 14:22:25
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-18 15:55:13
+ */
+// Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration
+import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react'
+import { Flex, Dropdown, Divider, App, Form, type MenuProps } from 'antd'
+import { SettingOutlined } from '@ant-design/icons'
+import { useTranslation } from 'react-i18next'
+import clsx from 'clsx'
+
+import AudioRecorder from '@/components/AudioRecorder'
+import UploadFiles from '@/views/Conversation/components/FileUpload'
+import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
+import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
+import type { UploadFileListModalRef } from '@/views/Conversation/types'
+import type { VariableConfigModalRef } from '@/views/Workflow/types'
+import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
+
+// Exposed methods via ref for parent components to access/set form state
+export interface ChatToolbarRef {
+ getFiles: () => any[]
+ getVariables: () => Variable[]
+ setFiles: (files: any[]) => void
+ setVariables: (variables: Variable[]) => void
+}
+
+// Props for configuring toolbar features, upload settings, and event callbacks
+export interface ChatToolbarProps {
+ features: FeaturesConfigForm
+ extra?: ReactNode
+ uploadAction?: string
+ uploadRequestConfig?: {
+ data?: Record
+ headers?: Record
+ }
+ onFilesChange?: (files: any[]) => void
+ onVariablesChange?: (variables: Variable[]) => void
+ onRecordingComplete?: (file: any) => void;
+ defaultValue?: { memory: boolean }
+}
+
+interface FormValues {
+ files: any[]
+ variables: Variable[];
+ memory?: boolean;
+}
+
+const ChatToolbar = forwardRef(({
+ features,
+ extra,
+ uploadAction,
+ uploadRequestConfig,
+ onFilesChange,
+ onVariablesChange,
+ onRecordingComplete,
+ defaultValue,
+}, ref) => {
+ const { t } = useTranslation()
+ const { message: messageApi } = App.useApp()
+ const uploadFileListModalRef = useRef(null)
+ const variableConfigModalRef = useRef(null)
+ const [form] = Form.useForm()
+ const queryValues = Form.useWatch([], form)
+
+ useEffect(() => {
+ if (!defaultValue) return
+ form.setFieldsValue(defaultValue)
+ }, [defaultValue])
+
+ useImperativeHandle(ref, () => ({
+ getFiles: () => form.getFieldValue('files') || [],
+ getVariables: () => form.getFieldValue('variables') || [],
+ setFiles: (files) => form.setFieldValue('files', files),
+ setVariables: (variables) => {
+ console.log('variables', variables)
+ form.setFieldValue('variables', variables)
+ },
+ }))
+
+ const { file_upload } = features || {}
+
+ // Append newly uploaded file to the file list when upload is complete
+ const fileChange = (file?: any) => {
+ if (file?.status !== 'done') return
+ const files = [...(queryValues?.files || []), file]
+ form.setFieldValue('files', files)
+ onFilesChange?.(files)
+ }
+
+ // Append recorded audio file to the file list and notify parent
+ const handleRecordingComplete = (file: any) => {
+ const files = [...(queryValues?.files || []), file]
+ form.setFieldValue('files', files)
+ onFilesChange?.(files)
+ onRecordingComplete?.(file)
+ }
+
+ // Merge a batch of files (e.g. from remote URL modal) into the file list
+ const addFileList = (list?: any[]) => {
+ if (!list?.length) return
+ const files = [...(queryValues?.files || []), ...list]
+ form.setFieldValue('files', files)
+ onFilesChange?.(files)
+ }
+
+ // Persist variable values from the config modal and notify parent
+ const handleVariablesSave = (values: Variable[]) => {
+ form.setFieldValue('variables', values)
+ onVariablesChange?.(values)
+ }
+
+ // True when any required variable is missing a value, used to highlight the config button
+ const isNeedVariableConfig = queryValues?.variables?.some(
+ vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === '')
+ )
+
+ // Build dropdown menu items based on allowed transfer methods
+ const fileMenus: MenuProps['items'] = []
+ const enabledTypes = ['image', 'document', 'video', 'audio'].filter(
+ type => file_upload?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]
+ )
+ if (file_upload?.allowed_transfer_methods?.includes('remote_url') && enabledTypes.length > 0) {
+ fileMenus.push({
+ key: 'url',
+ label: t('memoryConversation.addRemoteFile'),
+ onClick: () => {
+ if ((queryValues?.files?.length || 0) >= file_upload.max_file_count) {
+ messageApi.warning(t('common.fileNumTip', { num: file_upload.max_file_count }))
+ return
+ }
+ uploadFileListModalRef.current?.handleOpen()
+ }
+ })
+ }
+ if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) {
+ fileMenus.push({
+ key: 'upload',
+ label: (
+ = file_upload.max_file_count}
+ />
+ )
+ })
+ }
+
+ return (
+
+ )
+})
+
+export default ChatToolbar
diff --git a/web/src/components/Chat/types.ts b/web/src/components/Chat/types.ts
index e8e00bd9..9fb77ed7 100644
--- a/web/src/components/Chat/types.ts
+++ b/web/src/components/Chat/types.ts
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2025-12-10 16:45:54
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-12 13:57:51
+ * @Last Modified time: 2026-03-17 13:46:24
*/
import { type ReactNode } from 'react'
@@ -24,6 +24,7 @@ export interface ChatItem {
subContent?: Record[];
files?: any[];
error?: string;
+ audioUrl?: string;
}
/**
diff --git a/web/src/components/DocumentPreview/index.tsx b/web/src/components/DocumentPreview/index.tsx
index 8ab67be1..02345d13 100644
--- a/web/src/components/DocumentPreview/index.tsx
+++ b/web/src/components/DocumentPreview/index.tsx
@@ -4,10 +4,10 @@
* @Author: yujiangping
* @Date: 2026-03-16 19:01:12
* @LastEditors: yujiangping
- * @LastEditTime: 2026-03-16 19:17:47
+ * @LastEditTime: 2026-03-17 16:19:45
*/
import { useState, useEffect, useRef, useCallback, type FC } from 'react';
-import { Spin, Alert, Button, Table, InputNumber } from 'antd';
+import { Spin, Alert, Button, Table, InputNumber, Image } from 'antd';
import {
ReloadOutlined,
DownloadOutlined,
@@ -21,12 +21,10 @@ import { cookieUtils } from '@/utils/request';
import mammoth from 'mammoth';
import * as XLSX from 'xlsx';
import * as pdfjsLib from 'pdfjs-dist';
+import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?url';
// 设置 pdf.js worker
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.mjs',
- import.meta.url,
-).toString();
+pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
interface DocumentPreviewProps {
fileUrl: string;
@@ -65,9 +63,12 @@ const DocumentPreview: FC = ({
const [pptCurrentPage, setPptCurrentPage] = useState(1);
const [pptTotalPages, setPptTotalPages] = useState(0);
+ // 图片状态
+ const [imageBlobUrl, setImageBlobUrl] = useState('');
+
// 支持预览的文件类型
const previewableTypes = [
- '.pdf', '.txt', '.md',
+ '.pdf', '.txt', '.md', '.csv',
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp',
'.doc', '.docx', '.xls', '.xlsx',
'.ppt', '.pptx',
@@ -90,7 +91,7 @@ const DocumentPreview: FC = ({
};
const isPdfFile = () => getFileExtension() === '.pdf';
const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension());
- const isExcelFile = () => ['.xls', '.xlsx'].includes(getFileExtension());
+ const isExcelFile = () => ['.xls', '.xlsx', '.csv'].includes(getFileExtension());
const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension());
const isPreviewable = () => previewableTypes.includes(getFileExtension());
@@ -227,6 +228,28 @@ const DocumentPreview: FC = ({
}
}, [fileUrl]);
+ // ========== 图片加载逻辑 ==========
+ const loadImageFile = async () => {
+ setLoading(true);
+ setError(false);
+ setErrorMessage('');
+ try {
+ const arrayBuffer = await fetchFileBuffer(fileUrl);
+ const ext = getFileExtension().replace('.', '');
+ const mimeMap: Record = {
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
+ gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml',
+ };
+ const blob = new Blob([arrayBuffer], { type: mimeMap[ext] || 'image/png' });
+ const url = URL.createObjectURL(blob);
+ setImageBlobUrl(url);
+ setLoading(false);
+ } catch (err: any) {
+ console.error('加载图片文件失败:', err);
+ handleError(err.message || '图片加载失败');
+ }
+ };
+
// ========== 文本/Word/Excel 加载逻辑 ==========
const loadTextFile = async () => {
setLoading(true);
@@ -274,12 +297,42 @@ const DocumentPreview: FC = ({
}
};
+ const isCsvFile = () => getFileExtension() === '.csv';
+
const loadExcelFile = async () => {
setLoading(true);
setError(false);
setErrorMessage('');
try {
const arrayBuffer = await fetchFileBuffer(fileUrl);
+
+ // CSV 文件需要处理编码问题(可能是 GBK/GB2312)
+ if (isCsvFile()) {
+ let csvText: string;
+ // 先尝试 UTF-8 解码
+ const utf8Text = new TextDecoder('utf-8').decode(arrayBuffer);
+ // 检测是否有乱码特征(常见的 GBK 被错误解析为 UTF-8 的替换字符)
+ if (utf8Text.includes('\uFFFD') || /[\x80-\xff]/.test(utf8Text.slice(0, 200))) {
+ // 尝试 GBK 解码
+ try {
+ csvText = new TextDecoder('gbk').decode(arrayBuffer);
+ } catch {
+ csvText = utf8Text;
+ }
+ } else {
+ csvText = utf8Text;
+ }
+ const workbook = XLSX.read(csvText, { type: 'string' });
+ const sheets = workbook.SheetNames.map(sheetName => {
+ const worksheet = workbook.Sheets[sheetName];
+ const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
+ return { sheetName, data };
+ });
+ setExcelData(sheets);
+ setLoading(false);
+ return;
+ }
+
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheets = workbook.SheetNames.map(sheetName => {
const worksheet = workbook.Sheets[sheetName];
@@ -311,7 +364,7 @@ const DocumentPreview: FC = ({
else if (isExcelFile()) loadExcelFile();
else if (isPdfFile()) loadPdfFile();
else if (isPptFile()) loadPptFile();
- else if (isImageFile()) setLoading(false);
+ else if (isImageFile()) loadImageFile();
}, [fileUrl]);
// PDF 翻页/缩放后重新渲染
@@ -412,11 +465,11 @@ const DocumentPreview: FC = ({
{/* 图片预览 */}
{isImageFile() && !error && !loading && (
-

handleError('图片加载失败')}
+ style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
+ onError={() => handleError('图片渲染失败')}
/>
)}
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index e940c2e1..d0b676a1 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -449,6 +449,7 @@ export const en = {
fileSizeTip: 'File size cannot exceed {{size}}MB',
fileAcceptTip: 'Unsupported file type:',
+ fileNumTip: 'File count cannot exceed {{num}}',
nextStep: 'Next Step',
prevStep: 'Previous Step',
exportSuccess: 'Export successful',
@@ -1373,9 +1374,9 @@ export const en = {
dify: 'Dify',
pleaseUploadFile: 'Please upload file',
setting: 'Settings',
- funConfig: 'Features',
- fileUpload: 'File Upload',
- fileUploadDesc: 'The chat input box supports file uploads. Types include images, documents, and other types',
+ features: 'Conversation Features',
+ file_upload: 'File Upload',
+ file_upload_desc: 'The chat input box supports file uploads. Types include images, documents, and other types',
settings: 'File Upload Settings',
uploadType: 'Upload Type',
local: 'Local Upload',
@@ -1392,8 +1393,8 @@ export const en = {
maxCount: 'Max Files',
singleMaxSize: 'Max Size',
unix: 'items',
- textTranfer: 'Text to Speech',
- textTranferDesc: 'Text can be converted to speech',
+ text_to_speech: 'Text to Speech',
+ text_to_speech_desc: 'Text can be converted to speech',
apps: 'My Apps',
sharing: 'Sharing',
@@ -1781,6 +1782,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
fileUrl: 'File URL',
addRemoteFile: 'Add Remote File',
variableConfig: 'Variable Configuration',
+ memoryCancelTipTitle: 'Are you sure you want to disable conversation memory? Conversations will no longer be saved to the memory store.',
+ memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.',
},
login: {
title: 'Red Bear Memory Science',
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index 14dbf6c8..4a52ad65 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -756,9 +756,9 @@ export const zh = {
dify: 'Dify',
pleaseUploadFile: '请上传文件',
setting: '设置',
- funConfig: '功能',
- fileUpload: '文件上传',
- fileUploadDesc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
+ features: '对话功能',
+ file_upload: '文件上传',
+ file_upload_desc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
settings: '文件上传设置',
uploadType: '上传类型',
local: '本地上传',
@@ -775,8 +775,8 @@ export const zh = {
maxCount: '最大文件数',
singleMaxSize: '单文件最大大小',
unix: '个',
- textTranfer: '文字转语音',
- textTranferDesc: '文本可以转换成语言',
+ text_to_speech: '文字转语音',
+ text_to_speech_desc: '文本可以转换成语音',
apps: '我的应用',
sharing: '共享',
@@ -1082,6 +1082,7 @@ export const zh = {
fileSizeTip: '文件大小不能超过 {{size}}MB',
fileAcceptTip: '不支持的文件类型:',
+ fileNumTip: '文件数量不能超过{{num}}个',
nextStep: '下一步',
prevStep: '上一步',
exportSuccess: '导出成功',
@@ -1777,6 +1778,8 @@ export const zh = {
fileUrl: '文件链接',
addRemoteFile: '添加远程文件',
variableConfig: '变量配置',
+ memoryCancelTipTitle: '确定关闭对话记忆功能吗?关闭后对话将不会保存到记忆库中',
+ memoryTipTitle: '确定打开对话记忆功能吗?打开后对话将会保存到记忆库中',
},
login: {
title: '红熊记忆科学',
diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts
index 846af9f7..ba966159 100644
--- a/web/src/utils/stream.ts
+++ b/web/src/utils/stream.ts
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 16:35:43
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-04 18:19:24
+ * @Last Modified time: 2026-03-18 14:32:40
*/
/**
* Server-Sent Events (SSE) Stream Utility Module
@@ -176,17 +176,23 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
case 500:
case 502:
const errorData = await response.json();
- let errorInfo = errorData.error || i18n.t('common.serviceUpgrading')
+ const errorInfo = errorData.error || i18n.t('common.serviceUpgrading');
message.warning(errorInfo);
- throw errorInfo;
+ throw new Error(errorData);
case 400:
const error = await response.json();
- message.warning(error.error);
- throw error.error || 'Bad Request';
+ const error400 = error.error || 'Bad Request';
+ message.warning(error400);
+ throw new Error(error);
+ case 403:
+ const errors = await response.json();
+ message.warning(i18n.t('common.permissionDenied'));
+ throw new Error(errors);
case 504:
const errorJson = await response.json();
- message.warning(errorJson.error || i18n.t('common.serverError'));
- throw errorData.error;
+ const errorMsg = errorJson.error || i18n.t('common.serverError');
+ message.warning(errorMsg);
+ throw new Error(errorJson);
case 401:
if (url?.includes('/public')) {
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));
diff --git a/web/src/views/ApplicationConfig/Agent.tsx b/web/src/views/ApplicationConfig/Agent.tsx
index add3e147..51c94df0 100644
--- a/web/src/views/ApplicationConfig/Agent.tsx
+++ b/web/src/views/ApplicationConfig/Agent.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 16:58:15
+ * @Last Modified time: 2026-03-17 14:24:29
*/
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import clsx from 'clsx'
@@ -24,7 +24,7 @@ import type {
AiPromptModalRef,
Source,
ChatVariableConfigModalRef,
- FunConfigForm
+ FeaturesConfigForm
} from './types'
import type { Variable } from './components/VariableList/types'
import type { KnowledgeConfig } from './components/Knowledge/types'
@@ -42,7 +42,7 @@ import ToolList from './components/ToolList/ToolList'
import SkillList from './components/Skill'
import ChatVariableConfigModal from './components/ChatVariableConfigModal';
import type { Skill } from '@/views/Skills/types'
-import FunConfig from './components/FunConfig'
+import FeaturesConfig from './components/FeaturesConfig'
/**
* Description wrapper component
@@ -129,7 +129,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[],
* Agent configuration component
* Manages single agent configuration including prompts, knowledge, memory, variables, and tools
*/
-const Agent = forwardRef((_props, ref) => {
+const Agent = forwardRef void }>(({ onFeaturesLoad }, ref) => {
const { t } = useTranslation()
const { id } = useParams();
const { message } = App.useApp()
@@ -200,6 +200,7 @@ const Agent = forwardRef((_props, ref) => {
...response,
tools: allTools
})
+ onFeaturesLoad?.(response.features)
}).finally(() => {
setLoading(false)
})
@@ -356,7 +357,7 @@ const Agent = forwardRef((_props, ref) => {
useImperativeHandle(ref, () => ({
handleSave,
- funConfig: values?.funConfig
+ features: values?.features
}))
const aiPromptModalRef = useRef(null)
@@ -411,8 +412,8 @@ const Agent = forwardRef((_props, ref) => {
setChatVariables(values?.variables || [])
}, [values?.variables])
- const handleSaveFunConfig = (value: FunConfigForm) => {
- form.setFieldValue('funConfig', value)
+ const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
+ form.setFieldValue('features', value)
}
console.log('agent', values)
return (
@@ -426,7 +427,7 @@ const Agent = forwardRef((_props, ref) => {
{defaultModel?.name ? : null}
{defaultModel?.name || t('application.chooseModel')}
- {/* */}
+
@@ -435,7 +436,7 @@ const Agent = forwardRef((_props, ref) => {
-
+
@@ -512,7 +513,7 @@ const Agent = forwardRef
((_props, ref) => {
((_props, ref) => {
+const Cluster = forwardRef void }>(({ onFeaturesLoad }, ref) => {
const { t } = useTranslation()
const { message } = App.useApp()
const [form] = Form.useForm()
@@ -130,6 +131,7 @@ const Cluster = forwardRef((_props, ref) => {
} else {
setSubAgents(sub_agents)
}
+ onFeaturesLoad?.(response.features)
})
}
/**
@@ -166,7 +168,7 @@ const Cluster = forwardRef((_props, ref) => {
}
useImperativeHandle(ref, () => ({
handleSave,
- funConfig: data?.funConfig
+ features: data?.features
}))
const modelConfigModalRef = useRef(null)
@@ -185,16 +187,21 @@ const Cluster = forwardRef((_props, ref) => {
model_parameters: values
})
}
+ const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
+ form.setFieldValue('features', value)
+ }
return (
-
+
+
-
+
void}> = ({data, refres
})
}
const handleExport = () => {
- appExport(data.id, data.name)
+ if (!selectedVersion) return
+ appExport(data.id, data.name, {release_version: selectedVersion.id})
}
return (
diff --git a/web/src/views/ApplicationConfig/TestChat/index.tsx b/web/src/views/ApplicationConfig/TestChat/index.tsx
index 891259f5..b7ce167e 100644
--- a/web/src/views/ApplicationConfig/TestChat/index.tsx
+++ b/web/src/views/ApplicationConfig/TestChat/index.tsx
@@ -1,36 +1,25 @@
-import { type FC, useState, useRef, useEffect, useMemo } from 'react'
+import { type FC, useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
-import { App, Flex, Dropdown, type MenuProps, Divider, Form, Space } from 'antd'
-import { SettingOutlined } from '@ant-design/icons'
+import { App } from 'antd'
import clsx from 'clsx'
import dayjs from 'dayjs'
import ChatIcon from '@/assets/images/application/chat.png'
-
-import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
-import { draftRun } from '@/api/application';
+import { draftRun } from '@/api/application'
import Empty from '@/components/Empty'
import Chat from '@/components/Chat'
-import AudioRecorder from '@/components/AudioRecorder'
import RbCard from '@/components/RbCard/Card'
-import UploadFiles from '@/views/Conversation/components/FileUpload'
-import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
-import Runtime from '@/views/Workflow/components/Chat/Runtime';
+import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
+import Runtime from '@/views/Workflow/components/Chat/Runtime'
import { nodeLibrary } from '@/views/Workflow/constant'
-// import ButtonCheckbox from '@/components/ButtonCheckbox';
-
-// import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg'
-// import OnlineIcon from '@/assets/images/conversation/online.svg'
-// import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
-// import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
import type { ChatItem } from '@/components/Chat/types'
-import type { VariableConfigModalRef, WorkflowConfig } from '@/views/Workflow/types'
+import type { WorkflowConfig } from '@/views/Workflow/types'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
-import type { TestChatProps } from './type';
-import type { UploadFileListModalRef } from '@/views/Conversation/types'
+import type { TestChatProps } from './type'
import type { SSEMessage } from '@/utils/stream'
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record
) => {
return {
@@ -65,29 +54,25 @@ interface NodeData {
elapsed_time?: string;
error?: any;
state: Record;
- status?: 'completed' | 'failed'
+ status?: 'completed' | 'failed';
+ audio_url?: string;
}
-interface FormData {
- files: any[];
- variables: Variable[]
-}
const TestChat: FC = ({
application,
config
}) => {
const { t } = useTranslation()
const { message: messageApi } = App.useApp()
- const variableConfigModalRef = useRef(null)
- const uploadFileListModalRef = useRef(null)
+ const toolbarRef = useRef(null)
- const [loading, setLoading] = useState(false) // Send button loading state
- const [chatList, setChatList] = useState([]) // Chat message history
- const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
- const [conversationId, setConversationId] = useState(null) // Current conversation ID
- const [message, setMessage] = useState(undefined) // Current input message
- const [form] = Form.useForm()
- const queryValues = Form.useWatch([], form)
+ const [loading, setLoading] = useState(false)
+ const [chatList, setChatList] = useState([])
+ const [streamLoading, setStreamLoading] = useState(false)
+ const [conversationId, setConversationId] = useState(null)
+ const [message, setMessage] = useState(undefined)
+ const [fileList, setFileList] = useState([])
+ const [features, setFeatures] = useState({} as FeaturesConfigForm)
useEffect(() => {
getVariables()
@@ -96,6 +81,8 @@ const TestChat: FC = ({
const getVariables = () => {
if (!application || !config) return
+ setFeatures(config?.features || {} as FeaturesConfigForm)
+
let initVariables: Variable[] = []
switch (application.type) {
@@ -104,85 +91,35 @@ const TestChat: FC = ({
const startNodes = nodes.filter(vo => vo.type === 'start')
if (startNodes.length) {
const curVariables = startNodes[0].config.variables as Variable[]
-
- curVariables.forEach((vo) => {
- if (typeof vo.default !== 'undefined') {
- vo.value = vo.default
- }
- const lastVo = curVariables.find(item => item.name === vo.name)
- if (lastVo?.value) {
- vo.value = lastVo.value
- }
- })
- initVariables = curVariables
- }
+ curVariables.forEach((vo) => {
+ if (typeof vo.default !== 'undefined') {
+ vo.value = vo.default
+ }
+ const lastVo = curVariables.find(item => item.name === vo.name)
+ if (lastVo?.value) {
+ vo.value = lastVo.value
+ }
+ })
+ initVariables = curVariables
+ }
break
case 'agent':
initVariables = config.variables as Variable[]
break
}
- form.setFieldValue('variables', [...initVariables])
+ toolbarRef.current?.setVariables([...initVariables])
}
- /**
- * Opens the variable configuration modal
- */
- const handleEditVariables = () => {
- variableConfigModalRef.current?.handleOpen(queryValues.variables)
- }
- /**
- * Saves updated variable values from the modal
- */
- const handleSave = (values: Variable[]) => {
- form.setFieldValue('variables', [...values])
- }
- /**
- * Handles file upload from local device
- */
- const fileChange = (file?: any) => {
- form.setFieldValue('files', [...(queryValues.files || []), file])
- }
- const handleRecordingComplete = async (file: any) => {
- form.setFieldValue('files', [...(queryValues.files || []), file])
- }
-
- /**
- * Handles dropdown menu actions for file upload
- */
- const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
- switch(key) {
- case 'define':
- uploadFileListModalRef.current?.handleOpen()
- break
- }
- }
- /**
- * Adds files from remote URL modal
- */
- const addFileList = (list?: any[]) => {
- if (!list || list.length <= 0) return
- form.setFieldValue('files', [...(queryValues.files || []), ...(list || [])])
- }
- /**
- * Updates the entire file list (used when removing files)
- */
- const updateFileList = (list?: any[]) => {
- form.setFieldValue('files', [...list || []])
- }
- const isNeedVariableConfig = useMemo(() => {
- return queryValues?.variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
- }, [queryValues?.variables])
-
const addUserMessage = (message: string, files: any[]) => {
- const newUserMessage: ChatItem = {
+ setChatList(prev => [...prev, {
role: 'user',
content: message,
created_at: Date.now(),
files
- };
- setChatList(prev => [...prev, newUserMessage])
+ }])
}
+
const addAssistantMessage = () => {
const { type } = application || {}
setChatList(prev => [...prev, {
@@ -193,20 +130,22 @@ const TestChat: FC = ({
}])
}
- const updateAssistantMessage = (content: string) => {
+ const updateAssistantMessage = (content: string, audio_url?: string) => {
setChatList(prev => {
- let newList = [...prev]
+ const newList = [...prev]
const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') {
- lastMsg.content += content
+ lastMsg.content += content;
+ lastMsg.audioUrl = audio_url
}
return newList
})
}
+
const updateErrorAssistantMessage = (message_length: number) => {
if (message_length > 0) return
setChatList(prev => {
- let newList = [...prev]
+ const newList = [...prev]
const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = null
@@ -214,34 +153,37 @@ const TestChat: FC = ({
return newList
})
}
- const handleSend = () => {
- if (loading || !application || !message || !message?.trim()) return
- // Validate required variables before sending
- const { variables, files } = queryValues;
+
+ const buildVariableParams = (variables: Variable[]) => {
let isCanSend = true
const params: Record = {}
- if (variables && variables.length > 0) {
+ if (variables?.length > 0) {
const needRequired: string[] = []
variables.forEach(vo => {
- params[vo.name] = vo.value
-
+ params[vo.name] = vo.value ?? vo.defaultValue
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
isCanSend = false
needRequired.push(vo.name)
}
})
-
if (needRequired.length) {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
}
}
- if (!isCanSend) {
- setLoading(false)
- return
- }
+ return { isCanSend, params }
+ }
+
+ const handleSend = () => {
+ if (loading || !application || !message || !message?.trim()) return
+ const files = toolbarRef.current?.getFiles() || []
+ const variables = toolbarRef.current?.getVariables() || []
+ const { isCanSend, params } = buildVariableParams(variables)
+ if (!isCanSend) return
+
addUserMessage(message, files)
setMessage(undefined)
- form.setFieldValue('files', [])
+ toolbarRef.current?.setFiles([])
+ setFileList([])
addAssistantMessage()
setStreamLoading(true)
setLoading(true)
@@ -252,6 +194,7 @@ const TestChat: FC = ({
handleStreamMessage
)
.catch(() => {
+ updateErrorAssistantMessage(0)
setLoading(false)
})
.finally(() => {
@@ -259,105 +202,77 @@ const TestChat: FC = ({
setStreamLoading(false)
})
}
+
const handleStreamMessage = (data: SSEMessage[]) => {
data.map(item => {
- const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
-
+ const { conversation_id, content, message_length, audio_url } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; };
switch (item.event) {
case 'start':
- if (conversation_id && conversationId !== conversation_id) {
- setConversationId(conversation_id);
- }
+ if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
break
case 'message':
updateAssistantMessage(content)
- if (conversation_id && conversationId !== conversation_id) {
- setConversationId(conversation_id);
- }
- break;
+ if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
+ break
case 'end':
+ if (audio_url) {
+ updateAssistantMessage(content, audio_url)
+ }
updateErrorAssistantMessage(message_length)
setStreamLoading(false)
- break;
+ break
}
})
- };
+ }
const handleWorkflowSend = () => {
if (loading || !application || !message || !message?.trim()) return
-
- // Validate required variables before sending
- const { variables, files } = queryValues;
- let isCanSend = true
- const params: Record = {}
- if (variables.length > 0) {
- const needRequired: string[] = []
- variables.forEach(vo => {
- params[vo.name] = vo.value ?? vo.defaultValue
-
- if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
- isCanSend = false
- needRequired.push(vo.name)
- }
- })
-
- if (needRequired.length) {
- messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
- }
- }
- if (!isCanSend) {
- return
- }
+ const files = toolbarRef.current?.getFiles() || []
+ const variables = toolbarRef.current?.getVariables() || []
+ const { isCanSend, params } = buildVariableParams(variables)
+ if (!isCanSend) return
setLoading(true)
addUserMessage(message, files)
addAssistantMessage()
- form.setFieldsValue({
- files: [],
- })
-
+ toolbarRef.current?.setFiles([])
+ setFileList([])
setMessage(undefined)
setStreamLoading(true)
+
draftRun(
application.id,
formatParams(message, conversationId, files, params),
handleWorkflowStreamMessage
)
.catch((error) => {
- console.log('draftRun error', error)
+ const errorInfo = JSON.parse(error.message)
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
if (lastIndex >= 0) {
- newList[lastIndex] = {
- ...newList[lastIndex],
- status: 'failed',
- content: null,
- subContent: error.error
- }
+ newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
}
return newList
})
- }).finally(() => {
+ })
+ .finally(() => {
setLoading(false)
setStreamLoading(false)
})
}
+
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
data.forEach(item => {
const { content, conversation_id } = item.data as NodeData;
-
switch (item.event) {
- // Append streaming text chunks to assistant message
+ // Append streaming text chunks to assistant message
case 'message':
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
if (lastIndex >= 0) {
- newList[lastIndex] = {
- ...newList[lastIndex],
- content: newList[lastIndex].content + content
- }
+ newList[lastIndex] = { ...newList[lastIndex], content: newList[lastIndex].content + content }
}
return newList
})
@@ -388,10 +303,10 @@ const TestChat: FC = ({
}
})
}
+
const addWorkflowNodeStartMessage = (data: NodeData) => {
const { node_id } = data;
const { nodes } = config as WorkflowConfig
-
const node = nodes.find(n => n.id === node_id);
const { name, type } = node || {}
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
@@ -428,6 +343,7 @@ const TestChat: FC = ({
return newList
})
}
+
const updateWorkflowNodeEndMessage = (data: NodeData) => {
const { node_id, input, output, error, elapsed_time, status } = data;
setChatList(prev => {
@@ -456,10 +372,10 @@ const TestChat: FC = ({
return newList
})
}
+
const updateWorkflowCycleMessage = (data: NodeData) => {
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
const { nodes } = config as WorkflowConfig
-
const node = nodes.find(n => n.id === node_id);
const { name, type } = node || {}
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
@@ -500,22 +416,9 @@ const TestChat: FC = ({
return newList
})
}
+
const updateWorkflowEndMessage = (data: NodeData) => {
- const { error, status } = data as {
- content: string;
- conversation_id: string | null;
- cycle_id: string;
- cycle_idx: number;
- node_id: string;
- node_name?: string;
- node_type?: string;
- input?: any;
- output?: any;
- elapsed_time?: string;
- error?: any;
- state: Record;
- status?: 'completed' | 'failed'
- };
+ const { error, status, audio_url } = data;
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
@@ -525,13 +428,13 @@ const TestChat: FC = ({
status,
error,
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
+ audioUrl: audio_url
}
}
return newList
})
}
- console.log('queryValues', queryValues)
return (
= ({
}
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
- 'rb:h-[calc(100%-140px)]': !queryValues?.files?.length,
- 'rb:h-[calc(100%-208px)]': !!queryValues?.files?.length,
+ 'rb:h-[calc(100%-140px)]': !fileList.length,
+ 'rb:h-[calc(100%-208px)]': !!fileList.length,
})}
data={chatList}
streamLoading={streamLoading}
loading={loading}
onChange={setMessage}
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
- fileList={queryValues?.files || []}
- fileChange={updateFileList}
+ fileList={fileList}
+ fileChange={(list) => {
+ setFileList(list || [])
+ toolbarRef.current?.setFiles(list || [])
+ }}
labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
errorDesc={t('application.ReplyException')}
- renderRuntime={application?.type === 'workflow' ? (item, index) => {
- return
- } : undefined}
+ renderRuntime={application?.type === 'workflow' ? (item, index) => : undefined}
>
-
+
-
-
-
-
)
diff --git a/web/src/views/ApplicationConfig/components/AppSharingModal.tsx b/web/src/views/ApplicationConfig/components/AppSharingModal.tsx
index 39b2a77e..8e8775af 100644
--- a/web/src/views/ApplicationConfig/components/AppSharingModal.tsx
+++ b/web/src/views/ApplicationConfig/components/AppSharingModal.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-03-13 17:19:13
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 17:26:57
+ * @Last Modified time: 2026-03-18 16:03:46
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Checkbox, App, Form } from 'antd';
@@ -78,7 +78,7 @@ const AppSharingModal = forwardRef(({
*/
const handleToggle = (id: string, isShared: boolean) => {
if (isShared) return;
- const prev = form.getFieldValue('target_workspace_ids') as string[] ?? [];
+ const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
form.setFieldValue(
'target_workspace_ids',
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
@@ -135,10 +135,16 @@ const AppSharingModal = forwardRef(({
{/* Target space: scrollable list of workspaces with checkbox selection */}
+
+
+
{spaceList.map(space => {
const isShared = sharedIds.includes(space.id);
@@ -146,11 +152,11 @@ const AppSharingModal = forwardRef
(({
handleToggle(space.id, isShared)}>
e.stopPropagation()}
onChange={() => handleToggle(space.id, isShared)}
/>
{space.name}
- {/* Badge shown when the app is already shared with this workspace */}
{isShared && (
{t('application.alreadyShared')}
)}
diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx
index 6e98f705..b8fba5a6 100644
--- a/web/src/views/ApplicationConfig/components/Chat.tsx
+++ b/web/src/views/ApplicationConfig/components/Chat.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:39
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 15:20:32
+ * @Last Modified time: 2026-03-17 15:27:57
*/
/**
* Chat debugging component for application testing
@@ -12,25 +12,25 @@
import { type FC, useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router-dom'
import clsx from 'clsx'
-import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd';
+import { App } from 'antd';
import { SettingOutlined } from '@ant-design/icons'
import ChatIcon from '@/assets/images/application/chat.png'
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
-import type { ChatData, Config } from '../types'
+import type { ChatData, Config, FeaturesConfigForm } from '../types'
import { runCompare, draftRun } from '@/api/application'
import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types'
import { type SSEMessage } from '@/utils/stream'
import ChatInput from '@/components/Chat/ChatInput'
-import UploadFiles from '@/views/Conversation/components/FileUpload'
-import AudioRecorder from '@/components/AudioRecorder'
-import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
-import type { UploadFileListModalRef } from '@/views/Conversation/types'
+import ChatToolbar from '@/components/Chat/ChatToolbar'
+import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import type { Variable } from './VariableList/types'
+
/**
* Component props
*/
@@ -45,10 +45,12 @@ interface ChatProps {
handleSave: (flag?: boolean) => Promise;
/** Source type: multi-agent cluster or single agent */
source?: 'multi_agent' | 'agent';
- chatVariables?: Variable[]; // Add chatVariables prop
+ /** chatVariables prop */
+ chatVariables?: Variable[];
handleEditVariables?: () => void;
}
+
/**
* Chat debugging component
* Allows testing application with different model configurations side-by-side
@@ -58,18 +60,29 @@ const Chat: FC = ({
handleEditVariables
}) => {
const { t } = useTranslation();
+ const { id } = useParams()
const { message: messageApi } = App.useApp()
+ const toolbarRef = useRef(null)
const [loading, setLoading] = useState(false)
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
const [conversationId, setConversationId] = useState(null)
const [compareLoading, setCompareLoading] = useState(false)
const [fileList, setFileList] = useState([])
const [message, setMessage] = useState(undefined)
- const uploadFileListModalRef = useRef(null)
+ const [features, setFeatures] = useState({} as FeaturesConfigForm)
+
+ useEffect(() => {
+ setCompareLoading(false)
+ setLoading(false)
+ }, [chatList.map(item => item.label).join(',')])
+
+ useEffect(() => {
+ if (data?.features) setFeatures(data.features)
+ }, [data?.features])
useEffect(() => {
setIsCluster(source === 'multi_agent')
- setFileList([])
+ toolbarRef.current?.setFiles([])
setMessage(undefined)
}, [source])
@@ -111,8 +124,8 @@ const Chat: FC = ({
}
}
/** Update assistant message with streaming content */
- const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string) => {
- if (!content || !model_config_id) return
+ const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string, audio_url?: string) => {
+ if ((!content && !audio_url) || !model_config_id) return
updateChatList(prev => {
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
if (targetIndex !== -1) {
@@ -123,12 +136,13 @@ const Chat: FC = ({
if (lastMsg && lastMsg.role === 'assistant') {
modelChatList[targetIndex] = {
...modelChatList[targetIndex],
- conversation_id: conversation_id,
+ conversation_id,
list: [
...curChatMsgList.slice(0, curChatMsgList.length - 1),
{
...lastMsg,
- content: lastMsg.content + content
+ content: lastMsg.content + (content || ''),
+ audioUrl: audio_url
}
]
}
@@ -146,8 +160,7 @@ const Chat: FC = ({
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
if (targetIndex > -1) {
const modelChatList = [...prev]
- const curModelChat = modelChatList[targetIndex]
- const curChatMsgList = curModelChat.list || []
+ const curChatMsgList = modelChatList[targetIndex].list || []
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
if (lastMsg.role === 'assistant') {
modelChatList[targetIndex] = {
@@ -169,13 +182,14 @@ const Chat: FC = ({
}
/** Send message for agent comparison mode */
const handleSend = (msg?: string) => {
- if (loading) return
+ if (loading || !id) return
setLoading(true)
setCompareLoading(true)
handleSave(false)
.then(() => {
const message = msg
if (!message?.trim()) return
+ const files = toolbarRef.current?.getFiles() || []
// Validate required variables before sending
let isCanSend = true
const params: Record = {}
@@ -200,8 +214,9 @@ const Chat: FC = ({
return
}
- addUserMessage(message, fileList)
+ addUserMessage(message, files)
setMessage(message)
+ toolbarRef.current?.setFiles([])
setFileList([])
addAssistantMessage()
@@ -209,13 +224,16 @@ const Chat: FC = ({
setCompareLoading(false)
data.map(item => {
- const { model_config_id, conversation_id, content, message_length } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number };
-
+ const { model_config_id, conversation_id, content, message_length, audio_url } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number; audio_url: string };
+
switch (item.event) {
case 'model_message':
- updateAssistantMessage(content, model_config_id, conversation_id)
+ updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
break;
case 'model_end':
+ if (audio_url) {
+ updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
+ }
updateErrorAssistantMessage(message_length, model_config_id)
break;
case 'compare_end':
@@ -226,9 +244,9 @@ const Chat: FC = ({
};
setTimeout(() => {
- runCompare(data.app_id, {
+ runCompare(id, {
message,
- files: fileList.map(file => {
+ files: files.map(file => {
if (file.url) {
return file
} else {
@@ -246,9 +264,9 @@ const Chat: FC = ({
conversation_id: item.conversation_id
})),
variables: params,
- "parallel": true,
- "stream": true,
- "timeout": 60,
+ parallel: true,
+ stream: true,
+ timeout: 60,
}, handleStreamMessage)
.catch(() => {
setLoading(false)
@@ -272,7 +290,7 @@ const Chat: FC = ({
const assistantMessage: ChatItem = {
role: 'assistant',
content: '',
- created_at: Date.now(),
+ created_at: Date.now()
};
updateChatList(prev => prev.map(item => ({
...item,
@@ -284,8 +302,7 @@ const Chat: FC = ({
if (!content) return
updateChatList(prev => {
const modelChatList = [...prev]
- const curModelChat = modelChatList[0]
- const curChatMsgList = curModelChat.list || []
+ const curChatMsgList = modelChatList[0].list || []
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
if (lastMsg.role === 'assistant') {
modelChatList[0] = {
@@ -305,11 +322,9 @@ const Chat: FC = ({
/** Update cluster message when error occurs */
const updateClusterErrorAssistantMessage = (message_length: number) => {
if (message_length > 0) return
-
updateChatList(prev => {
const modelChatList = [...prev]
- const curModelChat = modelChatList[0]
- const curChatMsgList = curModelChat.list || []
+ const curChatMsgList = modelChatList[0].list || []
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
if (lastMsg.role === 'assistant') {
modelChatList[0] = {
@@ -326,17 +341,19 @@ const Chat: FC = ({
return [...modelChatList]
})
}
- /** Send message for cluster mode */
+
const handleClusterSend = (msg?: string) => {
- if (loading) return
+ if (loading || !id) return
setLoading(true)
setCompareLoading(true)
handleSave(false)
.then(() => {
const message = msg
if (!message || message.trim() === '') return
- addUserMessage(message, fileList)
+ const files = toolbarRef.current?.getFiles() || []
+ addUserMessage(message, files)
setMessage(undefined)
+ toolbarRef.current?.setFiles([])
setFileList([])
addClusterAssistantMessage()
@@ -345,7 +362,7 @@ const Chat: FC = ({
data.map(item => {
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
-
+
switch (item.event) {
case 'start':
if (conversation_id && conversationId !== conversation_id) {
@@ -369,13 +386,12 @@ const Chat: FC = ({
};
setTimeout(() => {
- draftRun(
- data.app_id,
+ draftRun(id,
{
message,
conversation_id: conversationId,
stream: true,
- files: fileList.map(file => {
+ files: files.map(file => {
if (file.url) {
return file
} else {
@@ -410,36 +426,6 @@ const Chat: FC = ({
const handleDelete = (index: number) => {
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
}
- const handleMessageChange = (message: string) => {
- setMessage(message)
- }
- const fileChange = (file?: any) => {
- setFileList([...fileList, file])
- }
- const handleRecordingComplete = async (file: any) => {
- setFileList([...fileList, {
- uid: file.file_id,
- response: { data: file },
- thumbUrl: file.url,
- type: file.type
- }])
- }
-
- const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
- switch (key) {
- case 'define':
- uploadFileListModalRef.current?.handleOpen()
- break
- }
- }
- const addFileList = (list?: any[]) => {
- if (!list || list.length <= 0) return
- setFileList([...fileList, ...(list || [])])
- }
- const updateFileList = (list?: any[]) => {
- setFileList([...list || []])
- }
- const isNeedVariableConfig = chatVariables?.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
return (
@@ -458,13 +444,10 @@ const Chat: FC
= ({
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
})}>
{chat.label &&
-
+
{chat.label}
= ({
message={message}
className="rb:relative!"
loading={loading}
- fileChange={updateFileList}
+ fileChange={(list) => {
+ setFileList(list || [])
+ toolbarRef.current?.setFiles(list || [])
+ }}
fileList={fileList}
onSend={isCluster ? handleClusterSend : handleSend}
- onChange={handleMessageChange}
+ onChange={setMessage}
>
-
-
-
- )
- },
- ],
- onClick: handleShowUpload
- }}
- >
+ 0 ? (
-
- {chatVariables && chatVariables.length > 0 && (
- vo.required && !vo.value),
+ 'rb:border-[#DFE4ED]': !chatVariables.some(vo => vo.required && !vo.value),
})}
onClick={handleEditVariables}
>
- {t(`memoryConversation.variableConfig`)}
+ {t('memoryConversation.variableConfig')}
- )}
-
-
-
-
-
-
+ ) : null
+ }
+ />
>
}
-
-
)
}
diff --git a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx
index 755491be..bab3fd74 100644
--- a/web/src/views/ApplicationConfig/components/ConfigHeader.tsx
+++ b/web/src/views/ApplicationConfig/components/ConfigHeader.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:52
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-16 15:58:10
+ * @Last Modified time: 2026-03-18 15:40:53
*/
import { type FC, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -18,10 +18,10 @@ import exportIcon from '@/assets/images/export_hover.svg'
import deleteIcon from '@/assets/images/delete_hover.svg'
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
-import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FunConfigForm } from '../types'
+import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types'
import { deleteApplication, appExport } from '@/api/application'
import CopyModal from './CopyModal'
-import FunConfig from './FunConfig'
+import FeaturesConfig from './FeaturesConfig'
const { Header } = Layout;
@@ -61,6 +61,10 @@ interface ConfigHeaderProps {
workflowRef: React.RefObject
/** App component ref (Agent/Cluster/Workflow) */
appRef?: React.RefObject
+ /** Features config from parent state */
+ features?: FeaturesConfigForm;
+ /** Callback to update features in parent */
+ onFeaturesChange?: (value: FeaturesConfigForm) => void;
}
/**
@@ -71,6 +75,8 @@ const ConfigHeader: FC = ({
application, activeTab, handleChangeTab, refresh,
workflowRef,
appRef,
+ features,
+ onFeaturesChange,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -173,14 +179,10 @@ const ConfigHeader: FC = ({
return items
}, [t, handleClick, application])
- const funConfig = useMemo(() => {
- return (appRef?.current?.funConfig || { file_type: [] }) as FunConfigForm
- }, [appRef])
- const handleSaveFunConfig = useCallback((value: FunConfigForm) => {
- appRef?.current?.handleSaveFunConfig?.(value)
- }, [appRef])
-
- console.log('formatMenuItems', formatMenuItems)
+ const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
+ appRef?.current?.handleSaveFeaturesConfig?.(value)
+ onFeaturesChange?.(value)
+ }, [appRef, onFeaturesChange])
return (
<>
@@ -211,7 +213,7 @@ const ConfigHeader: FC = ({
{application?.type === 'workflow'
?
- {/*
*/}
+
diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx
new file mode 100644
index 00000000..5fcb752d
--- /dev/null
+++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/FeaturesConfigModal.tsx
@@ -0,0 +1,156 @@
+/*
+ * @Author: ZhaoYing
+ * @Date: 2026-02-03 16:27:56
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-18 15:38:14
+ */
+/**
+ * Copy Application Modal
+ * Allows users to duplicate an existing application with a new name
+ */
+
+import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
+import { Form, Button, Flex } from 'antd';
+import { useTranslation } from 'react-i18next';
+import clsx from 'clsx'
+
+import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
+import RbModal from '@/components/RbModal'
+import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
+import FileUploadSettingModal from './FileUploadSettingModal'
+import type { Application } from '@/views/ApplicationManagement/types';
+
+interface FeaturesConfigModalProps {
+ refresh: (value: FeaturesConfigForm) => void;
+ source?: Application['type'];
+}
+
+/**
+ * Modal for copying applications
+ */
+const FeaturesConfigModal = forwardRef
(({
+ refresh,
+ source,
+}, ref) => {
+ const { t } = useTranslation();
+ const [visible, setVisible] = useState(false);
+ const [form] = Form.useForm();
+ const values = Form.useWatch([], form)
+ const fileUploadSettingModalRef = useRef(null)
+
+ /** Close modal and reset form */
+ const handleClose = () => {
+ setVisible(false);
+ form.resetFields();
+ };
+
+ /** Open modal */
+ const handleOpen = (initValue: FeaturesConfigForm) => {
+ setVisible(true);
+ console.log('initValue', initValue)
+ form.setFieldsValue(initValue)
+ };
+ /** Copy application with new name */
+ const handleSave = () => {
+ setVisible(false);
+ refresh(form.getFieldsValue())
+ }
+
+ const handleOpenSettings = () => {
+ fileUploadSettingModalRef.current?.handleOpen(values?.file_upload)
+ }
+
+ const handleSaveSettings = (settings: FeaturesConfigForm['file_upload']) => {
+ form.setFieldValue('file_upload', { ...settings, enabled: values?.file_upload?.enabled ?? false })
+ }
+
+ /** Expose methods to parent component */
+ useImperativeHandle(ref, () => ({
+ handleOpen,
+ handleClose
+ }));
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+});
+
+export default FeaturesConfigModal;
\ No newline at end of file
diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx
new file mode 100644
index 00000000..3579497a
--- /dev/null
+++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/FileUploadSettingModal.tsx
@@ -0,0 +1,180 @@
+/*
+ * @Author: ZhaoYing
+ * @Date: 2026-03-05
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-17 18:10:47
+ */
+import { forwardRef, useImperativeHandle, useState } from 'react';
+import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd';
+import { useTranslation } from 'react-i18next';
+import clsx from 'clsx';
+
+import RbModal from '@/components/RbModal';
+import type { FeaturesConfigForm } from '../../types'
+
+type FileUpload = Omit
+
+interface FileUploadSettingModalRef {
+ handleOpen: (values?: FileUpload) => void;
+ handleClose: () => void;
+}
+
+interface FileUploadSettingModalProps {
+ onSave: (values: FileUpload) => void;
+}
+
+const fileTypeOptions = [
+ {
+ type: 'document',
+ icon: ,
+ formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
+ },
+ {
+ type: 'image',
+ icon: ,
+ formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
+ },
+ {
+ type: 'audio',
+ icon: ,
+ formats: 'MP3, M4A, WAV, AMR, MPGA',
+ },
+ {
+ type: 'video',
+ icon: ,
+ formats: 'MP4, MOV, MPEG, WEBM',
+ },
+];
+
+const defaultValues: FileUpload = {
+ enabled: false,
+ image_enabled: false,
+ image_max_size_mb: 20,
+ image_allowed_extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
+ audio_enabled: false,
+ audio_max_size_mb: 50,
+ audio_allowed_extensions: ['mp3', 'wav', 'm4a', 'ogg', 'flac'],
+ document_enabled: false,
+ document_max_size_mb: 100,
+ document_allowed_extensions: ['pdf', 'docx', 'xlsx', 'txt', 'csv', 'json'],
+ video_enabled: false,
+ video_max_size_mb: 500,
+ video_allowed_extensions: ['mp4', 'mov', 'avi', 'webm'],
+ max_file_count: 5,
+ allowed_transfer_methods: 'both'
+}
+
+const FileUploadSettingModal = forwardRef(({
+ onSave,
+}, ref) => {
+ const { t } = useTranslation();
+ const [visible, setVisible] = useState(false);
+ const [form] = Form.useForm();
+ const values = Form.useWatch([], form)
+
+ const handleClose = () => {
+ setVisible(false);
+ form.resetFields();
+ };
+
+ const handleOpen = (values?: FileUpload) => {
+ setVisible(true);
+ if (values) {
+ const methods = values.allowed_transfer_methods
+ const transferMethod = Array.isArray(methods)
+ ? methods.length === 2 ? 'both' : methods[0]
+ : methods
+ form.setFieldsValue({ ...values, allowed_transfer_methods: transferMethod as any })
+ } else {
+ form.setFieldsValue(defaultValues)
+ }
+ };
+
+ const handleSave = async () => {
+ const vals = await form.validateFields();
+ const methodMap: Record = {
+ local_file: ['local_file'],
+ remote_url: ['remote_url'],
+ both: ['local_file', 'remote_url'],
+ }
+ onSave({ ...vals, allowed_transfer_methods: methodMap[vals.allowed_transfer_methods as unknown as string] ?? [] });
+ handleClose();
+ };
+
+ useImperativeHandle(ref, () => ({
+ handleOpen,
+ handleClose
+ }));
+
+ return (
+
+
+
+ {t('application.local')}
+ URL
+ {t('application.both')}
+
+
+
+ {t('application.maxCount')}
+
+
+
+
+
+
+ {fileTypeOptions.map((option) => {
+ const enabledKey = `${option.type}_enabled` as keyof FileUpload
+ const sizeKey = `${option.type}_max_size_mb` as keyof FileUpload
+ const isEnabled = values?.[enabledKey]
+ return (
+
+
+ {option.icon}
+
+
+
+ {t(`application.${option.type}`)}
+ {option.formats}
+
+
+
+
+
+
+
+ {isEnabled && (
+
+ {t('application.singleMaxSize')}:
+
+
+
+
+
+ )}
+
+ )
+ })}
+
+
+
+
+ );
+});
+
+export default FileUploadSettingModal;
diff --git a/web/src/views/ApplicationConfig/components/FeaturesConfig/index.tsx b/web/src/views/ApplicationConfig/components/FeaturesConfig/index.tsx
new file mode 100644
index 00000000..bb61b7d5
--- /dev/null
+++ b/web/src/views/ApplicationConfig/components/FeaturesConfig/index.tsx
@@ -0,0 +1,54 @@
+/*
+ * @Author: ZhaoYing
+ * @Date: 2026-03-13 17:20:21
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-18 15:38:59
+ */
+import { type FC, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button } from 'antd';
+
+import FeaturesConfigModal from './FeaturesConfigModal'
+import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
+import type { Application } from '@/views/ApplicationManagement/types';
+
+/** Props for the FeaturesConfig component */
+interface FeaturesConfigProps {
+ /** Current feature configuration values */
+ value: FeaturesConfigForm;
+ /** Callback to propagate updated config back to the parent */
+ refresh: (value: FeaturesConfigForm) => void;
+ source?: Application['type'];
+}
+
+const FeaturesConfig: FC = ({
+ value,
+ refresh,
+ source
+}) => {
+ const { t } = useTranslation();
+ // Ref used to imperatively open the config modal
+ const funConfigModalRef = useRef(null)
+
+ /** Open the feature config modal pre-populated with the current values */
+ const handleFeaturesConfig = () => {
+ console.log('handleFeaturesConfig', value)
+ funConfigModalRef.current?.handleOpen(value)
+ }
+
+ return (
+ <>
+ {/* Button that triggers the feature configuration modal */}
+
+
+ {/* Modal for editing feature settings; calls refresh on save */}
+
+ >
+ )
+}
+
+export default FeaturesConfig
diff --git a/web/src/views/ApplicationConfig/components/FunConfig/FileUploadSettingModal.tsx b/web/src/views/ApplicationConfig/components/FunConfig/FileUploadSettingModal.tsx
deleted file mode 100644
index 3d114600..00000000
--- a/web/src/views/ApplicationConfig/components/FunConfig/FileUploadSettingModal.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-/*
- * @Author: ZhaoYing
- * @Date: 2026-03-05
- * @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-11 15:42:13
- */
-import { forwardRef, useImperativeHandle, useState } from 'react';
-import { Form, Radio, InputNumber, Flex, Switch, Row, Col } from 'antd';
-import { useTranslation } from 'react-i18next';
-import clsx from 'clsx';
-
-import RbModal from '@/components/RbModal';
-import type { FunConfigForm } from '../../types'
-
-interface FileUploadSettingModalRef {
- handleOpen: (values?: FileUploadSettings) => void;
- handleClose: () => void;
-}
-
-interface FileUploadSettings extends Omit {}
-
-interface FileUploadSettingModalProps {
- onSave: (values: FileUploadSettings) => void;
-}
-
-const fileTypeOptions = [
- {
- type: 'document',
- icon: ,
- formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
- defaultMaxCount: 1,
- defaultMaxSize: 2
- },
- {
- type: 'image',
- icon: ,
- formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
- defaultMaxCount: 1,
- defaultMaxSize: 2
- },
- {
- type: 'audio',
- icon: ,
- formats: 'MP3, M4A, WAV, AMR, MPGA',
- defaultMaxCount: 1,
- defaultMaxSize: 2
- },
- {
- type: 'video',
- icon: ,
- formats: 'MP4, MOV, MPEG, WEBM',
- defaultMaxCount: 1,
- defaultMaxSize: 2
- },
-];
-
-const FileUploadSettingModal = forwardRef(({
- onSave,
-}, ref) => {
- const { t } = useTranslation();
- const [visible, setVisible] = useState(false);
- const [form] = Form.useForm();
- const values = Form.useWatch([], form)
-
- const handleClose = () => {
- setVisible(false);
- form.resetFields();
- };
-
- const handleOpen = (values?: FileUploadSettings) => {
- setVisible(true);
- // if (values) {
- // form.setFieldsValue(values);
- // }
- };
-
- const handleSave = async () => {
- const values = await form.validateFields();
- onSave(values);
- handleClose();
- };
-
- useImperativeHandle(ref, () => ({
- handleOpen,
- handleClose
- }));
-
-
- return (
-
-
-
- {t('application.local')}
- URL
- {t('application.both')}
-
-
- {t('application.maxCount')}
-
-
-
-
-
-
- {(fields) => (
-
- {fields.map((field, index) => {
- const option = fileTypeOptions[index];
- const isEnabled = values?.fileTypes?.[index]?.enabled;
-
- return (
-
-
-
- {option.icon}
-
-
-
-
- {t(`application.${option.type}`)}
- {option.formats}
-
-
-
-
-
-
-
- {isEnabled && (
-
- {t('application.singleMaxSize')}:
-
-
-
-
- )}
-
-
-
-
- );
- })}
-
- )}
-
-
-
-
- );
-});
-
-export default FileUploadSettingModal;
diff --git a/web/src/views/ApplicationConfig/components/FunConfig/FunConfigModal.tsx b/web/src/views/ApplicationConfig/components/FunConfig/FunConfigModal.tsx
deleted file mode 100644
index affa4e63..00000000
--- a/web/src/views/ApplicationConfig/components/FunConfig/FunConfigModal.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-/*
- * @Author: ZhaoYing
- * @Date: 2026-02-03 16:27:56
- * @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 17:20:30
- */
-/**
- * Copy Application Modal
- * Allows users to duplicate an existing application with a new name
- */
-
-import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
-import { Form, Button, Flex } from 'antd';
-import { useTranslation } from 'react-i18next';
-
-import type { FunConfigModalRef } from '../../types'
-import RbModal from '@/components/RbModal'
-import type { FunConfigForm } from '../../types'
-import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
-import FileUploadSettingModal from './FileUploadSettingModal'
-
-const FormItem = Form.Item;
-
-interface FunConfigModalProps {
- refresh: (value: FunConfigForm) => void;
-}
-
-/**
- * Modal for copying applications
- */
-const FunConfigModal = forwardRef(({
- refresh,
-}, ref) => {
- const { t } = useTranslation();
- const [visible, setVisible] = useState(false);
- const [form] = Form.useForm();
- const [loading, setLoading] = useState(false)
- const values = Form.useWatch([], form)
- const fileUploadSettingModalRef = useRef(null)
-
- /** Close modal and reset form */
- const handleClose = () => {
- setVisible(false);
- form.resetFields();
- setLoading(false)
- };
-
- /** Open modal */
- const handleOpen = (initValue: FunConfigForm) => {
- setVisible(true);
- form.setFieldsValue(initValue)
- };
- /** Copy application with new name */
- const handleSave = () => {
- setVisible(false);
- setLoading(true)
- const values = form.getFieldsValue()
- refresh(values)
- }
-
- const handleOpenSettings = () => {
- fileUploadSettingModalRef.current?.handleOpen(values)
- }
-
- const handleSaveSettings = (settings: any) => {
- form.setFieldsValue(settings)
- }
-
- /** Expose methods to parent component */
- useImperativeHandle(ref, () => ({
- handleOpen,
- handleClose
- }));
- return (
- <>
-
-
-
-
-
- >
- );
-});
-
-export default FunConfigModal;
\ No newline at end of file
diff --git a/web/src/views/ApplicationConfig/components/FunConfig/index.tsx b/web/src/views/ApplicationConfig/components/FunConfig/index.tsx
deleted file mode 100644
index 7242acee..00000000
--- a/web/src/views/ApplicationConfig/components/FunConfig/index.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * @Author: ZhaoYing
- * @Date: 2026-03-13 17:20:21
- * @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 17:20:21
- */
-import { type FC, useRef } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Button } from 'antd';
-
-import FunConfigModal from './FunConfigModal'
-import type { FunConfigModalRef, FunConfigForm } from '../../types'
-
-/** Props for the FunConfig component */
-interface FunConfigProps {
- /** Current feature configuration values */
- value: FunConfigForm;
- /** Callback to propagate updated config back to the parent */
- refresh: (value: FunConfigForm) => void;
-}
-
-const FunConfig: FC = ({
- value,
- refresh
-}) => {
- const { t } = useTranslation();
- // Ref used to imperatively open the config modal
- const funConfigModalRef = useRef(null)
-
- /** Open the feature config modal pre-populated with the current values */
- const handleFunConfig = () => {
- console.log('funConfig', value)
- funConfigModalRef.current?.handleOpen(value)
- }
-
- return (
- <>
- {/* Button that triggers the feature configuration modal */}
-
-
- {/* Modal for editing feature settings; calls refresh on save */}
-
- >
- )
-}
-
-export default FunConfig
diff --git a/web/src/views/ApplicationConfig/components/ToolList/ToolList.tsx b/web/src/views/ApplicationConfig/components/ToolList/ToolList.tsx
index 1a093b6e..5ce84554 100644
--- a/web/src/views/ApplicationConfig/components/ToolList/ToolList.tsx
+++ b/web/src/views/ApplicationConfig/components/ToolList/ToolList.tsx
@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:26:03
- * @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-02-03 16:26:03
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-18 14:01:13
*/
/**
* Tool List Component
@@ -22,6 +22,7 @@ import type {
import Empty from '@/components/Empty'
import ToolModal from './ToolModal'
import { getToolMethods, getToolDetail } from '@/api/tools'
+import Tag from '@/components/Tag'
/**
* Tool list management component
@@ -42,23 +43,25 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
getToolMethods(item.tool_id)
])
+ console.log('toolDetail', toolDetail)
switch ((toolDetail as any).tool_type) {
case 'mcp':
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return {
...item,
+ is_active: (toolDetail as any).is_active,
label: mcpFilterItem?.description,
method_id: mcpFilterItem?.method_id,
value: mcpFilterItem?.name,
description: mcpFilterItem?.description,
parameters: mcpFilterItem?.parameters
}
- break
case 'builtin':
if ((methods as any[]).length > 1) {
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return {
...item,
+ is_active: (toolDetail as any).is_active,
label: builtinFilterItem?.description,
method_id: builtinFilterItem?.method_id,
value: builtinFilterItem?.name,
@@ -68,17 +71,18 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
}
return {
...item,
+ is_active: (toolDetail as any).is_active,
label: (methods as any[])[0]?.description,
method_id: (methods as any[])[0]?.method_id,
value: (methods as any[])[0]?.name,
description: (methods as any[])[0]?.description,
parameters: (methods as any[])[0]?.parameters
}
- break
default:
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
return {
...item,
+ is_active: (toolDetail as any).is_active,
label: customFilterItem?.name,
method_id: customFilterItem?.method_id,
value: customFilterItem?.name,
@@ -103,7 +107,10 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
}
/** Add new tool to list */
const updateTools = (tool: ToolOption) => {
- const list = [...toolList, tool]
+ const list = [...toolList, {
+ ...tool,
+ is_active: true,
+ }]
setToolList(list)
onChange && onChange(list)
}
@@ -127,6 +134,7 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
setToolList([...list])
onChange && onChange(list)
}
+ console.log('toolList', toolList)
return (
renderItem={(item, index) => (
-
- {item.label}
+
+
+ {item.label}
+
+
+ {item.is_active ? t('common.enable') : t('common.deleted')}
+
{
// State
const [application, setApplication] = useState
(null);
const [activeTab, setActiveTab] = useState('arrangement');
+ const [features, setFeatures] = useState(undefined);
useEffect(() => {
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
@@ -114,10 +115,12 @@ const ApplicationConfig: React.FC = () => {
refresh={getApplicationInfo}
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
workflowRef={workflowRef}
+ features={features}
+ onFeaturesChange={setFeatures}
/>
- {activeTab === 'arrangement' && application?.type === 'agent' && }
- {activeTab === 'arrangement' && application?.type === 'multi_agent' && }
- {activeTab === 'arrangement' && application?.type === 'workflow' && }
+ {activeTab === 'arrangement' && application?.type === 'agent' && }
+ {activeTab === 'arrangement' && application?.type === 'multi_agent' && }
+ {activeTab === 'arrangement' && application?.type === 'workflow' && }
{activeTab === 'api' && }
{activeTab === 'release' && }
{activeTab === 'statistics' && }
diff --git a/web/src/views/ApplicationConfig/types.ts b/web/src/views/ApplicationConfig/types.ts
index 859d6fdf..ac5221a7 100644
--- a/web/src/views/ApplicationConfig/types.ts
+++ b/web/src/views/ApplicationConfig/types.ts
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 17:01:04
+ * @Last Modified time: 2026-03-16 17:42:12
*/
import type { KnowledgeConfig } from './components/Knowledge/types'
import type { Variable } from './components/VariableList/types'
@@ -78,7 +78,7 @@ export interface Config extends MultiAgentConfig {
updated_at: number;
skills?: SkillConfigForm | null;
- funConfig?: FunConfigForm;
+ features?: FeaturesConfigForm;
}
/**
@@ -129,8 +129,8 @@ export interface AgentRef {
* @param flag - Whether to show success message
*/
handleSave: (flag?: boolean) => Promise;
- funConfig: Config['funConfig'];
- handleSaveFunConfig?: (value: FunConfigForm) => void;
+ features: Config['features'];
+ handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}
/**
@@ -142,8 +142,8 @@ export interface ClusterRef {
* @param flag - Whether to show success message
*/
handleSave: (flag?: boolean) => Promise;
- funConfig: Config['funConfig'];
- handleSaveFunConfig?: (value: FunConfigForm) => void;
+ features: Config['features'];
+ handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}
/**
@@ -162,8 +162,8 @@ export interface WorkflowRef {
/** Add variable */
addVariable: () => void;
config: WorkflowConfig | null;
- funConfig: WorkflowConfig['funConfig'];
- handleSaveFunConfig?: (value: FunConfigForm) => void;
+ features: WorkflowConfig['features'];
+ handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
}
/**
@@ -416,17 +416,55 @@ export interface FileTypeConfig {
maxCount: number;
maxSize: number;
}
-export interface FunConfigForm {
- enabled: boolean;
- fileTypes: FileTypeConfig[]
- uploadType: 'local' | 'url' | 'both';
+interface FileSetttings {
+ image_enabled: boolean;
+ image_max_size_mb: number;
+ image_allowed_extensions: string[];
+ audio_enabled: boolean;
+ audio_max_size_mb: number;
+ audio_allowed_extensions: string[];
+ document_enabled: boolean;
+ document_max_size_mb: number;
+ document_allowed_extensions: string[];
+ video_enabled: boolean;
+ video_max_size_mb: number;
+ video_allowed_extensions: string[];
+ max_file_count: number;
+ allowed_transfer_methods: string[] | string;
+}
+export type FeaturesConfigForm = {
+ file_upload: FileSetttings & {
+ enabled: boolean;
+ settings?: FileSetttings
+ };
+ opening_statement: {
+ enabled: boolean;
+ statement: string | null;
+ suggested_questions: string[];
+ };
+ suggested_questions_after_answer: {
+ enabled: boolean;
+ };
+ text_to_speech: {
+ enabled: boolean;
+ voice: string | null;
+ language: string | null;
+ autoplay: boolean;
+ };
+ citation: {
+ enabled: boolean;
+ };
+ web_search: {
+ enabled: boolean;
+ search_engine: string | null;
+ };
}
/**
* Function config modal ref methods
*/
-export interface FunConfigModalRef {
+export interface FeaturesConfigModalRef {
/** Open function config modal */
- handleOpen: (value: FunConfigForm) => void;
+ handleOpen: (value: FeaturesConfigForm) => void;
}
/**
diff --git a/web/src/views/ApplicationManagement/MySharing.tsx b/web/src/views/ApplicationManagement/MySharing.tsx
index 54e501ca..f6971349 100644
--- a/web/src/views/ApplicationManagement/MySharing.tsx
+++ b/web/src/views/ApplicationManagement/MySharing.tsx
@@ -2,15 +2,16 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-13 17:36:16
+ * @Last Modified time: 2026-03-18 16:15:43
*/
-import React, { useState, useEffect, useMemo } from 'react';
+import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
import clsx from 'clsx';
import type { MySharedOutItem } from './types';
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
+import BodyWrapper from '@/components/Empty/BodyWrapper'
const MySharing: React.FC = () => {
const { t } = useTranslation();
@@ -20,7 +21,8 @@ const MySharing: React.FC = () => {
useEffect(() => { getList() }, [])
const getList = () => {
- mySharedOutList().then(res => setData(res as MySharedOutItem[]))
+ mySharedOutList()
+ .then(res => setData(res as MySharedOutItem[]))
}
/** Group items by target_workspace_id */
@@ -57,7 +59,8 @@ const MySharing: React.FC = () => {
});
};
- const handleCancelOne = (item: MySharedOutItem) => {
+ const handleCancelOne = (item: MySharedOutItem, e: MouseEvent) => {
+ e.stopPropagation()
modal.confirm({
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
okText: t('common.confirm'),
@@ -71,87 +74,94 @@ const MySharing: React.FC = () => {
}
});
};
+ /** Navigate to application configuration page */
+ const handleEdit = (item: MySharedOutItem) => {
+ let url = `/#/application/config/${item.source_app_id}`
+ window.open(url);
+ }
return (
-
- {grouped.map(({ workspace, items }) => (
-
- {workspace.target_workspace_icon
- ?
- :
- {workspace.target_workspace_name[0]}
-
- }
-
-
{workspace.target_workspace_name}
-
{t('application.appCount', { count: items.length })}
-
-
- ),
- extra: (
-
- ),
- children: (
-
- {items.map(item => (
-
- handleCancelOne(item)}
- />
-
-
- {item.source_app_name[0]}
+
+
+ {grouped.map(({ workspace, items }) => (
+
+ {workspace.target_workspace_icon
+ ?
+ :
+ {workspace.target_workspace_name[0]}
- {item.source_app_name}
-
-
-
- {t('application.type')}
-
- {t(`application.${item.source_app_type}`)}
-
+ }
+
+
{workspace.target_workspace_name}
+
{t('application.appCount', { count: items.length })}
+
+
+ ),
+ extra: (
+
+ ),
+ children: (
+
+ {items.map(item => (
+ handleEdit(item)}>
+ handleCancelOne(item, e)}
+ />
+
+
+ {item.source_app_name[0]}
+
+ {item.source_app_name}
-
- {t('application.version')}
- {item.source_app_version}
+
+
+ {t('application.type')}
+
+ {t(`application.${item.source_app_type}`)}
+
+
+
+ {t('application.version')}
+ {item.source_app_version}
+
+
+ {t('application.permission')}
+
+ {t(`application.${item.permission}`)}
+
+
+
+ {t('application.souceStatus')}
+ {item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}
+
-
- {t('application.permission')}
-
- {t(`application.${item.permission}`)}
-
-
-
- {t('application.souceStatus')}
- {item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}
-
-
-
- ))}
-
- ),
- }]}
- />
- ))}
-
+
+ ))}
+
+ ),
+ }]}
+ />
+ ))}
+
+
);
};
diff --git a/web/src/views/ApplicationManagement/index.tsx b/web/src/views/ApplicationManagement/index.tsx
index 32652bc3..c9f57268 100644
--- a/web/src/views/ApplicationManagement/index.tsx
+++ b/web/src/views/ApplicationManagement/index.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-16 09:56:02
+ * @Last Modified time: 2026-03-18 10:50:33
*/
/**
* Application Management Page
@@ -185,7 +185,7 @@ const ApplicationManagement: React.FC = () => {
ref={scrollListRef}
url={getApplicationListUrl}
- query={{ ...query, shared_only: activeTab === 'sharing' }}
+ query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
renderItem={(item) => (
{
/** Upload API endpoint */
@@ -48,14 +49,14 @@ interface UploadFilesProps extends Omit {
disabled?: boolean;
/** File size limit in MB */
fileSize?: number;
- /** Allowed file types ['doc', 'xls', 'ppt', 'pdf'] */
- fileType?: string[];
/** Auto-upload on file selection, default is true */
isAutoUpload?: boolean;
/** Maximum number of files allowed */
maxCount?: number;
/** Custom file removal callback */
onRemove?: (file: UploadFile) => boolean | void | Promise;
+
+ featureConfig: FeaturesConfigForm['file_upload']
}
const transform_file_type = {
@@ -130,11 +131,11 @@ const UploadFiles = forwardRef(({
onChange,
disabled = false,
fileSize = 5,
- fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
isAutoUpload = true,
maxCount = 1,
onRemove: customOnRemove,
requestConfig,
+ featureConfig,
...props
}, ref) => {
const { t } = useTranslation();
@@ -142,18 +143,37 @@ const UploadFiles = forwardRef(({
const [fileList, setFileList] = useState(propFileList);
const [accept, setAccept] = useState();
+ const fileType = useMemo(() => {
+ let types: string[] = [];
+ ['image', 'document', 'video', 'audio'].forEach(type => {
+ if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
+ types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
+ }
+ })
+
+ return types
+ }, [featureConfig])
+
/**
* Validates file type and size before upload
* @returns Upload.LIST_IGNORE to prevent upload, or true to proceed
*/
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
- // Validate file size
- if (fileSize) {
- const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
- if (!isLtMaxSize) {
- message.error(t('common.fileSizeTip', { size: fileSize }));
- return Upload.LIST_IGNORE;
- }
+ // Determine file category and get max size from featureConfig
+ const mimePrefix = file.type?.split('/')[0]
+ const categoryMap: Record = {
+ image: 'image_max_size_mb',
+ video: 'video_max_size_mb',
+ audio: 'audio_max_size_mb',
+ }
+ const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
+ const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize
+
+ const fileSizeMB = file.size / 1024 / 1024
+ const isLtMaxSize = fileSizeMB < maxSize;
+ if (!isLtMaxSize) {
+ message.error(t('common.fileSizeTip', { size: maxSize }));
+ return Upload.LIST_IGNORE;
}
// Validate file type
if (fileType && fileType.length > 0) {
diff --git a/web/src/views/Conversation/components/UploadFileListModal.tsx b/web/src/views/Conversation/components/UploadFileListModal.tsx
index a43b9dd4..ea0080b7 100644
--- a/web/src/views/Conversation/components/UploadFileListModal.tsx
+++ b/web/src/views/Conversation/components/UploadFileListModal.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:09:47
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-04 17:47:09
+ * @Last Modified time: 2026-03-18 15:50:31
*/
/**
* Upload File List Modal Component
@@ -18,25 +18,28 @@
*
* @component
*/
-import { forwardRef, useImperativeHandle, useState } from 'react';
+import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
import { Form, Input, Select, Button, Flex } from 'antd';
import { useTranslation } from 'react-i18next';
import type { UploadFileListModalRef } from '../types'
import RbModal from '@/components/RbModal'
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
const FormItem = Form.Item;
interface UploadFileListModalProps {
/** Callback to refresh parent component with new file list */
refresh: (fileList?: any[]) => void;
+ featureConfig: FeaturesConfigForm['file_upload']
}
/**
* Modal for adding remote files via URL
*/
const UploadFileListModal = forwardRef(({
- refresh
+ refresh,
+ featureConfig
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
@@ -79,6 +82,20 @@ const UploadFileListModal = forwardRef {
+ const options = [];
+ if (featureConfig?.image_enabled) {
+ options.push({ label: t('memoryConversation.image'), value: 'image' });
+ }
+ if (featureConfig?.audio_enabled) {
+ options.push({ label: t('memoryConversation.audio'), value: 'audio' });
+ }
+ if (featureConfig?.video_enabled) {
+ options.push({ label: t('memoryConversation.video'), value: 'video' });
+ }
+ return options;
+ }, [featureConfig, t])
+
return (
diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx
index 8a67b3ae..e120af49 100644
--- a/web/src/views/Conversation/index.tsx
+++ b/web/src/views/Conversation/index.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:58:03
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-04 12:10:44
+ * @Last Modified time: 2026-03-18 15:35:05
*/
/**
* Conversation Page
@@ -14,13 +14,12 @@ import { type FC, useState, useEffect, useRef } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component';
-import { Flex, Skeleton, Form, Dropdown, type MenuProps, App, Divider } from 'antd'
-import { SettingOutlined } from '@ant-design/icons'
+import { Flex, Skeleton, App } from 'antd'
import clsx from 'clsx'
import dayjs from 'dayjs'
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application'
-import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
+import type { HistoryItem } from './types'
import Empty from '@/components/Empty'
import { formatDateTime } from '@/utils/format';
import { randomString } from '@/utils/common'
@@ -34,20 +33,14 @@ import OnlineIcon from '@/assets/images/conversation/online.svg'
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
import { type SSEMessage } from '@/utils/stream'
-import UploadFiles from './components/FileUpload'
-import AudioRecorder from '@/components/AudioRecorder'
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
-import UploadFileListModal from './components/UploadFileListModal'
-import type { VariableConfigModalRef } from '@/views/Workflow/types'
+import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
-import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal';
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
-/**
- * Conversation component for shared applications
- */
const Conversation: FC = () => {
const { t } = useTranslation()
- const { message: messageApi } = App.useApp()
+ const { message: messageApi, modal } = App.useApp()
const { token } = useParams()
const location = useLocation()
const searchParams = new URLSearchParams(location.search)
@@ -63,35 +56,21 @@ const Conversation: FC = () => {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef(null);
+ const toolbarRef = useRef(null)
const [shareToken, setShareToken] = useState(localStorage.getItem(`shareToken_${token}`))
+ const [fileList, setFileList] = useState([])
+ const [webSearch, setWebSearch] = useState(false)
+ const [isHasMemory, setIsHasMemory] = useState(false)
+ const [memory, setMemory] = useState(true)
+ const [features, setFeatures] = useState({} as FeaturesConfigForm)
- const [form] = Form.useForm()
- const queryValues = Form.useWatch([], form)
-
- const uploadFileListModalRef = useRef(null)
-
- const variableConfigModalRef = useRef(null)
- const [variables, setVariables] = useState([]) // Workflow input variables
-
- /**
- * Opens the variable configuration modal
- */
- const handleEditVariables = () => {
- variableConfigModalRef.current?.handleOpen(variables)
- }
- /**
- * Saves updated variable values from the modal
- */
- const handleSave = (values: Variable[]) => {
- setVariables([...values])
- }
useEffect(() => {
const shareToken = localStorage.getItem(`shareToken_${token}`)
setShareToken(shareToken)
if (shareToken && shareToken !== '') return
getShareToken(token as string, userId || randomString(12, false))
.then(res => {
- const response = res as { access_token: string } || {}
+ const response = res as { access_token: string } || {}
localStorage.setItem(`shareToken_${token}`, response.access_token ?? '')
setShareToken(response.access_token ?? '')
})
@@ -102,12 +81,15 @@ const Conversation: FC = () => {
getHistory()
}
}, [token, shareToken, page, hasMore, historyList])
+
useEffect(() => {
if (shareToken && token) {
getExperienceConfig(token)
.then(res => {
- const response = res as { variables: Variable[] }
- setVariables(response.variables || [])
+ const response = res as { variables: Variable[]; features: FeaturesConfigForm; app_type: string; memory?: boolean; }
+ toolbarRef.current?.setVariables(response.variables || [])
+ setFeatures(response.features)
+ setIsHasMemory((response.app_type === 'workflow' && response.memory) || (response.app_type !== 'workflow'))
})
} else {
setChatList([])
@@ -118,7 +100,7 @@ const Conversation: FC = () => {
const groupHistoryByDate = (items: HistoryItem[]): Record => {
return items.reduce((groups: Record, item) => {
const date = formatDateTime(item.created_at, 'YYYY-MM-DD')
-
+
if (!groups[date]) {
groups[date] = [];
}
@@ -129,9 +111,7 @@ const Conversation: FC = () => {
/** Fetch conversation history with pagination */
const getHistory = (flag: boolean = false) => {
- if (!token || (pageLoading || !hasMore) && !flag) {
- return
- }
+ if (!token || (pageLoading || !hasMore) && !flag) return
setPageLoading(true);
getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 })
.then(res => {
@@ -154,19 +134,14 @@ const Conversation: FC = () => {
setHasMore(response.page.hasnext);
setLoading(false);
})
- .finally(() => {
- setPageLoading(false);
- })
+ .finally(() => setPageLoading(false))
}
/** Switch to different conversation or start new one */
const handleChangeHistory = (id: string | null) => {
- if (id !== conversation_id) {
- setConversationId(id)
- }
- if (!id) {
- setMessage('')
- }
+ if (id !== conversation_id) setConversationId(id)
+ if (!id) setMessage('')
}
+
useEffect(() => {
if (conversation_id) {
getConversationDetail(token as string, conversation_id)
@@ -179,43 +154,38 @@ const Conversation: FC = () => {
}
}, [conversation_id])
- /** Add user message to chat */
const addUserMessage = (message: string = '', files?: any[]) => {
- const newUserMessage: ChatItem = {
+ setChatList(prev => [...prev, {
conversation_id,
role: 'user',
content: message,
created_at: Date.now(),
files
- };
- setChatList(prev => [...prev, newUserMessage])
+ }])
}
- /** Add empty assistant message placeholder */
+
const addAssistantMessage = () => {
- const newAssistantMessage: ChatItem = {
+ setChatList(prev => [...prev, {
created_at: Date.now(),
role: 'assistant',
- content: '',
- }
- setChatList(prev => [...prev, newAssistantMessage])
+ content: ''
+ }])
}
- /** Update assistant message with streaming content */
- const updateAssistantMessage = (content: string = '') => {
- if (!content) return
- if (streamLoading) {
- setStreamLoading(false)
- }
+ const updateAssistantMessage = (content: string = '', audio_url?: string) => {
+ if (!content && !audio_url) return
+ if (streamLoading) setStreamLoading(false)
setChatList(prev => {
const lastList = [...prev]
const lastIndex = lastList.length - 1
const lastMsg = lastList[lastIndex]
if (lastMsg?.role === 'assistant') {
return [
- ...lastList.slice(0, lastList.length - 1),
+ ...lastList.slice(0, lastIndex),
{
...lastMsg,
- content: lastMsg.content + content
+ content: lastMsg.content + content,
+ audioUrl: audio_url
}
]
}
@@ -223,22 +193,17 @@ const Conversation: FC = () => {
})
}
- const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
-
/** Send message and handle streaming response */
const handleSend = () => {
- if (!token || !shareToken) {
- return
- }
- const { files = [], ...rest } = queryValues || {}
- // Validate required variables before sending
+ if (!token || !shareToken) return
+ const files = toolbarRef.current?.getFiles() || []
+ const variables = toolbarRef.current?.getVariables() || []
let isCanSend = true
const params: Record = {}
if (variables.length > 0) {
const needRequired: string[] = []
variables.forEach(vo => {
params[vo.name] = vo.value ?? vo.defaultValue
-
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
isCanSend = false
needRequired.push(vo.name)
@@ -249,33 +214,34 @@ const Conversation: FC = () => {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
}
}
- if (!isCanSend) {
- return
- }
+ if (!isCanSend) return
+
setLoading(true)
setStreamLoading(true)
addUserMessage(message, files)
addAssistantMessage()
+ toolbarRef.current?.setFiles([])
+ setFileList([])
let currentConversationId: string | null = null
const handleStreamMessage = (data: SSEMessage[]) => {
data.forEach((item) => {
- switch(item.event) {
+ const { content, conversation_id: curId, audio_url } = item.data as { content: string; conversation_id: string; audio_url?: string; }
+ switch (item.event) {
case 'start':
case 'node_start':
- const { conversation_id: newId } = item.data as { conversation_id: string }
+ const { conversation_id: newId } = item.data as { conversation_id: string }
currentConversationId = newId
break
case 'message':
- const { content, conversation_id: curId } = item.data as { content: string; conversation_id: string; }
- updateAssistantMessage(content)
-
- if (curId) {
- currentConversationId = curId;
- }
+ updateAssistantMessage(content, audio_url)
+ if (curId) currentConversationId = curId;
break
case 'end':
case 'workflow_end':
+ if (audio_url) {
+ updateAssistantMessage(content, audio_url)
+ }
setLoading(false)
if (currentConversationId && currentConversationId !== conversation_id) {
setConversationId(currentConversationId)
@@ -286,9 +252,9 @@ const Conversation: FC = () => {
})
};
- form.setFieldValue('files', [])
sendConversation({
- ...rest,
+ web_search: webSearch,
+ memory,
message: message || '',
stream: true,
conversation_id: conversation_id || null,
@@ -315,32 +281,18 @@ const Conversation: FC = () => {
})
}
- const fileChange = (file?: any) => {
- form.setFieldValue('files', [...(queryValues.files || []), file])
- }
- const handleRecordingComplete = async (file: any) => {
- form.setFieldValue('files', [...(queryValues.files || []), {
- uid: file.file_id,
- response: { data: file },
- thumbUrl: file.url,
- type: file.type
- }])
- }
-
- const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
- switch(key) {
- case 'define':
- uploadFileListModalRef.current?.handleOpen()
- break
- }
- }
- const addFileList = (fileList?: any[]) => {
- if (!fileList || fileList.length <= 0) return
- form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
- }
- const updateFileList = (fileList?: any[]) => {
- console.log('fileList', fileList)
- form.setFieldValue('files', [...(fileList || [])])
+ const handleChangeMemory = (value: boolean) => {
+ modal.confirm({
+ title: value ? t('memoryConversation.memoryTipTitle') : t('memoryConversation.memoryCancelTipTitle'),
+ okText: t('common.confirm'),
+ cancelText: t('common.cancel'),
+ onOk: () => {
+ setMemory(value)
+ },
+ onCancel: () => {
+ setMemory(!value)
+ }
+ })
}
return (
@@ -349,8 +301,8 @@ const Conversation: FC = () => {
handleChangeHistory(null)}
>
-
{t('memoryConversation.startANewConversation')}
@@ -365,7 +317,6 @@ const Conversation: FC = () => {
next={getHistory}
hasMore={hasMore}
loader={}
- // endMessage={It is all, nothing more 🤐}
scrollableTarget="scrollableDiv"
>
{Object.entries(groupHistoryList).map(([date, items]) => (
@@ -374,8 +325,8 @@ const Conversation: FC = () => {
{items.map(item => (
handleChangeHistory(item.id)}
>
{item.title}
@@ -391,109 +342,62 @@ const Conversation: FC = () => {
-
-
}
- contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
- data={chatList}
- streamLoading={streamLoading}
- loading={loading}
- onChange={setMessage}
- onSend={handleSend}
- labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
- fileList={queryValues?.files || []}
- fileChange={updateFileList}
- >
-
-
-
-
)
}
-export default Conversation
\ No newline at end of file
+export default Conversation
diff --git a/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx b/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx
index 62489f2f..ad7946f0 100644
--- a/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx
+++ b/web/src/views/UserMemoryDetail/components/PerceptualLastInfo.tsx
@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:23
- * @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-02-03 18:32:23
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-17 17:36:49
*/
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -90,7 +90,7 @@ const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text'
})
}
- const handleDownload = () => {
+ const handleDownload = async () => {
if (!data.file_path) return
window.open(data.file_path, '_blank')
}
diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx
index 8bf2c641..043a72d0 100644
--- a/web/src/views/Workflow/components/CanvasToolbar.tsx
+++ b/web/src/views/Workflow/components/CanvasToolbar.tsx
@@ -10,10 +10,6 @@ interface CanvasToolbarProps {
isHandMode: boolean;
setIsHandMode: React.Dispatch
>;
zoomLevel: number;
- canUndo: boolean;
- canRedo: boolean;
- onUndo: () => void;
- onRedo: () => void;
addNotes: () => void;
}
diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx
index eb24a206..37cb215e 100644
--- a/web/src/views/Workflow/components/Chat/Chat.tsx
+++ b/web/src/views/Workflow/components/Chat/Chat.tsx
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:10:56
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-04 18:51:48
+ * @Last Modified time: 2026-03-18 14:34:20
*/
/**
* Workflow Chat Component
@@ -21,50 +21,56 @@
*
* @component
*/
-import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
+import { forwardRef, useImperativeHandle, useState, useRef, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
-import { App, Space, Button, Flex, Dropdown, type MenuProps, Divider } from 'antd'
+import { App } from 'antd'
import ChatIcon from '@/assets/images/application/chat.png'
import RbDrawer from '@/components/RbDrawer';
-import VariableConfigModal from './VariableConfigModal'
import { draftRun } from '@/api/application';
import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types'
import dayjs from 'dayjs'
-import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types'
+import type { ChatRef, GraphRef, WorkflowConfig } from '../../types'
import { type SSEMessage } from '@/utils/stream'
import type { Variable } from '../Properties/VariableList/types'
import ChatInput from '@/components/Chat/ChatInput'
-import UploadFiles from '@/views/Conversation/components/FileUpload'
-import AudioRecorder from '@/components/AudioRecorder'
-import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
-import type { UploadFileListModalRef } from '@/views/Conversation/types'
+import ChatToolbar from '@/components/Chat/ChatToolbar'
+import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import Runtime from './Runtime';
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
-const Chat = forwardRef(({ appId, graphRef }, ref) => {
+const Chat = forwardRef(({ appId, graphRef, data }, ref) => {
const { t } = useTranslation()
const { message: messageApi } = App.useApp()
- const variableConfigModalRef = useRef(null)
- // State management
- const [open, setOpen] = useState(false) // Drawer visibility
- const [loading, setLoading] = useState(false) // Send button loading state
- const [chatList, setChatList] = useState([]) // Chat message history
- const [variables, setVariables] = useState([]) // Workflow input variables
- const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
- const [conversationId, setConversationId] = useState(null) // Current conversation ID
- const [fileList, setFileList] = useState([]) // Uploaded files
- const [message, setMessage] = useState(undefined) // Current input message
- const uploadFileListModalRef = useRef(null)
+ const toolbarRef = useRef(null)
+ const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => {
+ (toolbarRef as React.MutableRefObject).current = node
+ }, [])
+ const [open, setOpen] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [chatList, setChatList] = useState([])
+ const [variables, setVariables] = useState([])
+ const [streamLoading, setStreamLoading] = useState(false)
+ const [conversationId, setConversationId] = useState(null)
+ const [fileList, setFileList] = useState([])
+ const [message, setMessage] = useState(undefined)
+ const [features, setFeatures] = useState({} as FeaturesConfigForm)
/**
* Opens the chat drawer and loads workflow variables from the start node
*/
const handleOpen = () => {
setOpen(true)
- getVariables()
+ if (data?.features) setFeatures(data.features)
}
+
+ useEffect(() => {
+ if (open && graphRef.current && toolbarRef.current) {
+ getVariables()
+ }
+ }, [open])
/**
* Extracts variables from the workflow's start node and merges with previous values
*/
@@ -84,7 +90,9 @@ const Chat = forwardRef(({ appId
vo.value = lastVo.value
}
})
- setVariables(curVariables)
+ console.log('curVariables', curVariables)
+ setVariables([...curVariables])
+ toolbarRef.current?.setVariables([...curVariables])
}
}
/**
@@ -96,22 +104,12 @@ const Chat = forwardRef(({ appId
setVariables([])
setConversationId(null)
setMessage(undefined)
+ toolbarRef.current?.setFiles([])
+ toolbarRef.current?.setVariables([])
setFileList([])
setLoading(false)
setStreamLoading(false)
}
- /**
- * Opens the variable configuration modal
- */
- const handleEditVariables = () => {
- variableConfigModalRef.current?.handleOpen(variables)
- }
- /**
- * Saves updated variable values from the modal
- */
- const handleSave = (values: Variable[]) => {
- setVariables([...values])
- }
/**
* Sends a message to execute the workflow
*
@@ -337,14 +335,16 @@ const Chat = forwardRef(({ appId
})
}
+ const files = toolbarRef.current?.getFiles() || []
setMessage(undefined)
+ toolbarRef.current?.setFiles([])
setFileList([])
const data = {
message: message,
variables: params,
stream: true,
conversation_id: conversationId,
- files: fileList.map(file => {
+ files: files.map(file => {
if (file.url) {
return file
} else {
@@ -359,7 +359,7 @@ const Chat = forwardRef(({ appId
setStreamLoading(true)
draftRun(appId, data, handleStreamMessage)
.catch((error) => {
- console.log('draftRun error', error)
+ const errorInfo = JSON.parse(error.message)
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
@@ -368,7 +368,7 @@ const Chat = forwardRef(({ appId
...newList[lastIndex],
status: 'failed',
content: null,
- subContent: error.error
+ subContent: errorInfo.error
}
}
return newList
@@ -379,65 +379,20 @@ const Chat = forwardRef(({ appId
})
}
- /**
- * Updates the current input message
- */
- const handleMessageChange = (message: string) => {
- setMessage(message)
- }
- /**
- * Handles file upload from local device
- */
- const fileChange = (file?: any) => {
- setFileList([...fileList, file])
- }
- const handleRecordingComplete = async (file: any) => {
- setFileList([...fileList, {
- response: { data: file },
- thumbUrl: file.url,
- type: file.type
- }])
- }
-
- /**
- * Handles dropdown menu actions for file upload
- */
- const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
- switch(key) {
- case 'define':
- uploadFileListModalRef.current?.handleOpen()
- break
- }
- }
- /**
- * Adds files from remote URL modal
- */
- const addFileList = (list?: any[]) => {
- if (!list || list.length <= 0) return
- setFileList([...fileList, ...(list || [])])
- }
- /**
- * Updates the entire file list (used when removing files)
- */
const updateFileList = (list?: any[]) => {
setFileList([...list || []])
+ toolbarRef.current?.setFiles([...list || []])
}
- // Expose methods to parent component via ref
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
- console.log('fileList', fileList)
-
return (
{t('workflow.run')}
- {variables.length > 0 &&
-
- }
}
classNames={{
body: 'rb:p-0!'
@@ -466,48 +421,16 @@ const Chat = forwardRef
(({ appId
fileChange={updateFileList}
fileList={fileList}
onSend={handleSend}
- onChange={handleMessageChange}
+ onChange={(msg) => setMessage(msg)}
>
-
-
-
- )
- },
- ],
- onClick: handleShowUpload
- }}
- >
-
-
-
-
-
-
-
-
+
-
-
-
-
)
})
diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx
index ec898bc8..903ccbdc 100644
--- a/web/src/views/Workflow/components/PortClickHandler.tsx
+++ b/web/src/views/Workflow/components/PortClickHandler.tsx
@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28
- * @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-02-09 18:30:28
+ * @Last Modified by: ZhaoYing
+ * @Last Modified time: 2026-03-18 12:06:27
*/
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
@@ -70,7 +70,6 @@ const PortClickHandler: React.FC = ({ graph }) => {
// Get source port group information
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
- console.log('sourcePortGroup', sourcePortGroup, sourcePortInfo)
// If add-node position exists, use it; otherwise calculate new position
let newX, newY;
@@ -148,18 +147,23 @@ const PortClickHandler: React.FC = ({ graph }) => {
if (sourcePortGroup === 'left') {
// Connect from left port to new node's right side
targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right';
+ graph.addEdge({
+ source: { cell: newNode.id, port: targetPort },
+ target: { cell: sourceNode.id, port: sourcePort },
+ ...edgeAttrs
+ // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
+ });
} else {
// Connect from right port to new node's left side
targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
+ graph.addEdge({
+ source: { cell: sourceNode.id, port: sourcePort },
+ target: { cell: newNode.id, port: targetPort },
+ ...edgeAttrs
+ // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
+ });
}
- graph.addEdge({
- source: { cell: sourceNode.id, port: sourcePort },
- target: { cell: newNode.id, port: targetPort },
- ...edgeAttrs
- // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
- });
-
// Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle;
if (cycleId) {
@@ -223,20 +227,27 @@ const PortClickHandler: React.FC = ({ graph }) => {
const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop');
const isChildOfIteration = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'iteration');
+ const sourcePortInfo = sourceNode?.getPorts().find((p: any) => p.id === sourcePort);
+ const sourcePortGroup = sourcePortInfo?.group || sourcePort;
+ const isLeftPort = sourcePortGroup === 'left';
+
let filteredNodes;
if (isChildOfLoop) {
- // Use same filtering as AddNode for child nodes of loop, but allow break
+ // Use same filtering as AddNode for child nodes of loop, but allow break
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
} else if (isChildOfIteration) {
// Filter out loop and iteration nodes for children of iteration nodes, but allow break
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
} else {
// Original filtering for non-loop child nodes
- filteredNodes = category.nodes.filter(nodeType => !['start', 'break', 'cycle-start'].includes(nodeType.type));
filteredNodes = category.nodes.filter(nodeType =>
nodeType.type !== 'start' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break'
);
}
+
+ if (isLeftPort) {
+ filteredNodes = filteredNodes.filter(nodeType => nodeType.type !== 'end');
+ }
if (filteredNodes.length === 0) return null;
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts
index 050c1680..7f0843aa 100644
--- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts
+++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts
@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-17 10:00:10
+ * @Last Modified time: 2026-03-18 16:08:17
*/
import { useRef, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
@@ -12,10 +12,11 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
import { register } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port';
-import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, noteNode, notesConfig } from '../constant';
+import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, notesConfig } from '../constant';
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
import { useUser } from '@/store/user';
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
/**
* Props for useWorkflowGraph hook
@@ -25,6 +26,8 @@ export interface UseWorkflowGraphProps {
containerRef: React.RefObject;
/** Reference to the minimap container element */
miniMapRef: React.RefObject;
+ /** Callback when features config is loaded */
+ onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
}
/**
@@ -67,6 +70,7 @@ export interface UseWorkflowGraphReturn {
setChatVariables: React.Dispatch>;
handleAddNotes: () => void;
+ handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
}
/**
@@ -78,6 +82,7 @@ export interface UseWorkflowGraphReturn {
export const useWorkflowGraph = ({
containerRef,
miniMapRef,
+ onFeaturesLoad,
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
// Hooks
const { id } = useParams();
@@ -115,6 +120,7 @@ export const useWorkflowGraph = ({
})
setChatVariables(initChatVariables)
setConfig({ ...rest, variables: initChatVariables })
+ onFeaturesLoad?.(rest.features)
})
}
@@ -132,7 +138,7 @@ export const useWorkflowGraph = ({
if (nodes.length) {
const nodeList = nodes.map(node => {
const { id, type, name, position, config = {} } = node
- let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
+ let nodeLibraryConfig: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
.flatMap(category => category.nodes)
.find(n => n.type === type)
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
@@ -593,13 +599,6 @@ export const useWorkflowGraph = ({
if (!graphRef.current) return false;
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
if (selectedNodes.length) {
- selectedNodes.forEach(node => {
- const data = node.getData();
- node.setData({
- ...data,
- id: `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
- });
- });
graphRef.current.copy(selectedNodes);
}
return false;
@@ -610,7 +609,14 @@ export const useWorkflowGraph = ({
*/
const parseEvent = () => {
if (!graphRef.current?.isClipboardEmpty()) {
- graphRef.current?.paste({ offset: 32 });
+ const pastedNodes = graphRef.current?.paste({ offset: 32 }) ?? [];
+ pastedNodes.forEach(cell => {
+ if (cell.isNode()) {
+ const data = cell.getData();
+ const newId = `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ cell.setData({ ...data, id: newId });
+ }
+ });
blankClick();
}
return false;
@@ -761,8 +767,23 @@ export const useWorkflowGraph = ({
createEdge() {
return graphRef.current?.createEdge(edgeAttrs);
},
- validateConnection({ sourceCell, targetCell, targetMagnet }) {
+ validateConnection({ sourceCell, targetCell, sourceMagnet, targetMagnet }) {
if (!targetMagnet) return false;
+
+ // Only allow right port → left port connections
+ const getPortGroup = (magnet: Element) => {
+ let el: Element | null = magnet;
+ while (el) {
+ const group = el.getAttribute('port-group');
+ if (group) return group;
+ el = el.parentElement;
+ }
+ return null;
+ };
+ const sourceGroup = sourceMagnet ? getPortGroup(sourceMagnet) : null;
+ const targetGroup = targetMagnet ? getPortGroup(targetMagnet) : null;
+
+ if (sourceGroup === 'left' || targetGroup === 'right') return false;
// Node cannot connect to itself
if (sourceCell?.id === targetCell?.id) return false;
@@ -979,6 +1000,9 @@ export const useWorkflowGraph = ({
}) || [];
const edges = graphRef.current?.getEdges() || []
+
+ console.log('config', config)
+
const params = {
...config,
variables: chatVariables.map(v => {
@@ -1172,6 +1196,9 @@ export const useWorkflowGraph = ({
data: { ...cleanNodeData },
});
}
+ const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
+ setConfig(prev => prev ? { ...prev, features: value } as WorkflowConfig : prev)
+ }
return {
config,
@@ -1191,6 +1218,7 @@ export const useWorkflowGraph = ({
handleSave,
chatVariables,
setChatVariables,
- handleAddNotes
+ handleAddNotes,
+ handleSaveFeaturesConfig
};
};
diff --git a/web/src/views/Workflow/index.tsx b/web/src/views/Workflow/index.tsx
index 085df878..752ae0d3 100644
--- a/web/src/views/Workflow/index.tsx
+++ b/web/src/views/Workflow/index.tsx
@@ -6,13 +6,13 @@ import Properties from './components/Properties';
import CanvasToolbar from './components/CanvasToolbar';
import PortClickHandler from './components/PortClickHandler';
import { useWorkflowGraph } from './hooks/useWorkflowGraph';
-import type { WorkflowRef } from '@/views/ApplicationConfig/types'
+import type { WorkflowRef, FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import Chat from './components/Chat/Chat';
import type { ChatRef, AddChatVariableRef } from './types'
import arrowIcon from '@/assets/images/workflow/arrow.png'
import AddChatVariable from './components/AddChatVariable';
-const Workflow = forwardRef((_props, ref) => {
+const Workflow = forwardRef void }>(({ onFeaturesLoad }, ref) => {
const containerRef = useRef(null);
const miniMapRef = useRef(null);
const addChatVariableRef = useRef(null)
@@ -25,12 +25,8 @@ const Workflow = forwardRef((_props, ref) => {
selectedNode,
setSelectedNode,
zoomLevel,
- canUndo,
- canRedo,
isHandMode,
setIsHandMode,
- onUndo,
- onRedo,
onDrop,
blankClick,
deleteEvent,
@@ -39,8 +35,9 @@ const Workflow = forwardRef((_props, ref) => {
handleSave,
chatVariables,
setChatVariables,
- handleAddNotes
- } = useWorkflowGraph({ containerRef, miniMapRef });
+ handleAddNotes,
+ handleSaveFeaturesConfig
+ } = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
const onDragOver = (event: React.DragEvent) => {
event.preventDefault();
@@ -61,7 +58,8 @@ const Workflow = forwardRef((_props, ref) => {
graphRef,
addVariable,
config,
- funConfig: config?.funConfig
+ features: config?.features,
+ handleSaveFeaturesConfig
}))
return (
@@ -93,10 +91,6 @@ const Workflow = forwardRef((_props, ref) => {
isHandMode={isHandMode}
setIsHandMode={setIsHandMode}
zoomLevel={zoomLevel}
- canUndo={canUndo}
- canRedo={canRedo}
- onUndo={onUndo}
- onRedo={onRedo}
addNotes={handleAddNotes}
/>
@@ -115,6 +109,7 @@ const Workflow = forwardRef((_props, ref) => {
/>
diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts
index a43e6680..9ae198b1 100644
--- a/web/src/views/Workflow/types.ts
+++ b/web/src/views/Workflow/types.ts
@@ -2,7 +2,7 @@
import { Graph } from '@antv/x6';
import type { KnowledgeConfig } from './components/Properties/Knowledge/types'
import type { Variable } from './components/Properties/VariableList/types'
-import type { FunConfigForm } from '@/views/ApplicationConfig/types'
+import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
export interface NodeConfig {
type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string;
placeholder?: string;
@@ -91,7 +91,7 @@ export interface WorkflowConfig {
created_at: number;
updated_at: number;
- funConfig?: FunConfigForm;
+ features?: FeaturesConfigForm;
}
export interface ChatRef {