feat(web): share chat & app chat support files
This commit is contained in:
@@ -140,6 +140,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
const values = Form.useWatch<Config>([], form)
|
||||
const [isSave, setIsSave] = useState(false)
|
||||
const initialized = useRef(false)
|
||||
|
||||
console.log('chatList', chatList)
|
||||
|
||||
// Initialization flag
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,13 +10,12 @@
|
||||
* Provides real-time streaming responses and conversation history
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useState } from 'react';
|
||||
import { type FC, useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
import { Input, Form } from 'antd'
|
||||
import { Flex, Dropdown, type MenuProps } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
import type { ChatData, Config } from '../types'
|
||||
import { runCompare, draftRun } from '@/api/application'
|
||||
@@ -24,6 +23,11 @@ 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'
|
||||
|
||||
/**
|
||||
* Component props
|
||||
@@ -47,22 +51,25 @@ interface ChatProps {
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<{ message: string }>()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [compareLoading, setCompareLoading] = useState(false)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setIsCluster(source === 'multi_agent')
|
||||
}, [source])
|
||||
|
||||
/** Add user message to all chat lists */
|
||||
const addUserMessage = (message: string) => {
|
||||
const addUserMessage = (message: string, files: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -151,17 +158,18 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
})
|
||||
}
|
||||
/** Send message for agent comparison mode */
|
||||
const handleSend = () => {
|
||||
const handleSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = form.getFieldValue('message')
|
||||
const message = msg
|
||||
if (!message?.trim()) return
|
||||
|
||||
addUserMessage(message)
|
||||
form.setFieldsValue({ message: undefined })
|
||||
addUserMessage(message, fileList)
|
||||
setMessage(message)
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
@@ -187,6 +195,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
setTimeout(() => {
|
||||
runCompare(data.app_id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
models: chatList.map(item => ({
|
||||
model_config_id: item.model_config_id,
|
||||
label: item.label,
|
||||
@@ -267,16 +286,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
})
|
||||
}
|
||||
/** Send message for cluster mode */
|
||||
const handleClusterSend = () => {
|
||||
const handleClusterSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = form.getFieldValue('message')
|
||||
const message = msg
|
||||
if (!message || message.trim() === '') return
|
||||
addUserMessage(message)
|
||||
form.setFieldsValue({ message: undefined })
|
||||
addUserMessage(message, fileList)
|
||||
setMessage(undefined)
|
||||
setFileList([])
|
||||
addClusterAssistantMessage()
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
@@ -313,7 +333,18 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
handleStreamMessage
|
||||
)
|
||||
@@ -330,9 +361,36 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const handleDelete = (index: number) => {
|
||||
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
|
||||
}
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const [update, setUpdate] = useState(false)
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
setUpdate(prev => !prev)
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
|
||||
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 || []])
|
||||
}
|
||||
|
||||
console.log('chatList', chatList, fileList)
|
||||
return (
|
||||
<div className="rb:relative rb:h-[calc(100vh-110px)]">
|
||||
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
|
||||
{chatList.length === 0
|
||||
? <Empty
|
||||
url={DebuggingEmpty}
|
||||
@@ -342,12 +400,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
className="rb:h-full"
|
||||
/>
|
||||
: <>
|
||||
<div className={clsx(`rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full`, {
|
||||
'rb:h-[calc(100vh-236px)]': !isCluster,
|
||||
'rb:h-[calc(100%-76px)]': isCluster,
|
||||
})}>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<div key={index} className={clsx('rb:h-full rb:flex rb:flex-col', {
|
||||
<div key={index} className={clsx('rb:flex rb:flex-col', {
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
@@ -370,8 +425,8 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mx-[16px] rb:pt-[24px]': true,
|
||||
'rb:h-[calc(100vh-186px)]': isCluster,
|
||||
'rb:h-[calc(100vh-286px)]': !isCluster,
|
||||
'rb:h-[calc(100vh-258px)]': isCluster,
|
||||
'rb:h-[calc(100vh-356px)]': !isCluster,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-[400px]!': chatList.length === 1,
|
||||
@@ -386,26 +441,58 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:flex rb:items-center rb:gap-2.5 rb:p-4">
|
||||
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
|
||||
<Form.Item name="message" className="rb:mb-0!">
|
||||
<Input
|
||||
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
|
||||
placeholder={t('application.chatPlaceholder')}
|
||||
onPressEnter={isCluster ? handleClusterSend : handleSend}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<img src={ChatSendIcon} className={clsx("rb:w-11 rb:h-11 rb:cursor-pointer", {
|
||||
'rb:opacity-50': loading,
|
||||
})} onClick={isCluster ? handleClusterSend : handleSend} />
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/link.svg')] rb:hover:bg-[url('src/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Select, Steps, Flex, Alert, Row, Col, Statistic, Input, Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadWorkflowModalData, UploadWorkflowModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import UploadFiles from '@/components/Upload/UploadFiles'
|
||||
import { fileUploadUrl } from '@/api/fileStorage'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
|
||||
interface UploadWorkflowModalProps {
|
||||
refresh: () => void;
|
||||
}
|
||||
const steps = [
|
||||
'upload',
|
||||
'complex',
|
||||
'node',
|
||||
'configCheck',
|
||||
'sureInfo',
|
||||
'completed'
|
||||
]
|
||||
const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<UploadWorkflowModalData>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [current, setCurrent] = useState<number>(5);
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
form.resetFields();
|
||||
setVisible(true);
|
||||
};
|
||||
// 封装保存方法,添加提交逻辑
|
||||
const handleSave = () => {
|
||||
switch(current) {
|
||||
case 0:
|
||||
setCurrent(1)
|
||||
break;
|
||||
case 1:
|
||||
setCurrent(2)
|
||||
break;
|
||||
case 2:
|
||||
setCurrent(3)
|
||||
break;
|
||||
case 3:
|
||||
setCurrent(4)
|
||||
break;
|
||||
case 4:
|
||||
setCurrent(5)
|
||||
break;
|
||||
case 5:
|
||||
break;
|
||||
default:
|
||||
setCurrent(prev => prev + 1)
|
||||
break;
|
||||
}
|
||||
// form
|
||||
// .validateFields()
|
||||
// .then(() => {
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.log('err', err)
|
||||
// });
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
const handleLastStep = () => {
|
||||
setCurrent(prev => prev - 1)
|
||||
}
|
||||
const handleJump = (type: string) => {
|
||||
switch(type) {
|
||||
case 'detail':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const getFooter = useMemo(() => {
|
||||
switch(current) {
|
||||
case 0:
|
||||
return [
|
||||
<Button key="back" onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('application.nextStep')}
|
||||
</Button>
|
||||
]
|
||||
case 5:
|
||||
return [
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]
|
||||
default:
|
||||
return [
|
||||
<Button onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button key="back" onClick={handleLastStep}>
|
||||
{t('application.lastStep')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('application.nextStep')}
|
||||
</Button>
|
||||
]
|
||||
}
|
||||
}, [current])
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.importWorkflow')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('application.nextStep')}
|
||||
onOk={handleSave}
|
||||
footer={getFooter}
|
||||
width={1000}
|
||||
>
|
||||
<div className='rb:p-3 rb:bg-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:mb-3'>
|
||||
<Steps
|
||||
labelPlacement="vertical"
|
||||
size="small"
|
||||
current={current}
|
||||
items={steps.map(key => ({ title: t(`application.${key}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{current === 0 &&
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item name="provider" label={t('application.workflowProvider')}>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={[
|
||||
{ label: 'Dify', value: 'dify' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="file" valuePropName="fileList" noStyle>
|
||||
<UploadFiles
|
||||
action={fileUploadUrl}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
multiple={true}
|
||||
maxCount={1}
|
||||
fileType={['yml', 'yaml', 'zip', 'json']}
|
||||
onChange={(fileList) => {
|
||||
console.log('文件列表变化:', fileList);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
|
||||
{current === 1 &&
|
||||
<Flex vertical gap={12} className="rb:w-[70%]! rb:mx-auto!">
|
||||
{['fileType', 'parse', 'nodes', 'variable'].map(key => (
|
||||
<Alert key={key} message={key} type="success" showIcon />
|
||||
))}
|
||||
|
||||
<Row gutter={12}>
|
||||
{['complex', 'nodes', 'task'].map(key => (
|
||||
<Col key={key} span={8}>
|
||||
<Statistic title={key} value={0} className="rb:text-center rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:py-3!" />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Flex>
|
||||
}
|
||||
|
||||
{/* 节点映射 */}
|
||||
{current === 2 &&
|
||||
<Flex vertical gap={12} className="rb:w-[70%]! rb:mx-auto!">
|
||||
<RbCard>
|
||||
<Flex justify="space-around">
|
||||
<div> Left Node</div>
|
||||
→
|
||||
<div>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
className="rb:w-50"
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
</RbCard>
|
||||
</Flex>
|
||||
}
|
||||
{current === 3 &&
|
||||
<Flex vertical gap={12} className="rb:w-[70%]! rb:mx-auto!">
|
||||
|
||||
</Flex>
|
||||
}
|
||||
{current === 4 &&
|
||||
<Form form={form} layout="horizontal" className="rb:w-[70%]! rb:mx-auto!">
|
||||
<div className="rb:text-[#5B6167] rb:font-medium">{t('application.baseInfo')}</div>
|
||||
<Form.Item name="name" label={t('application.workflowName')} rules={[{ required: true }]}>
|
||||
<Input placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="source" label={t('application.source')}>
|
||||
source
|
||||
</Form.Item>
|
||||
<Form.Item name="fileName" label={t('application.fileName')}>
|
||||
fileName
|
||||
</Form.Item>
|
||||
<Form.Item name="fileSize" label={t('application.fileSize')}>
|
||||
fileSize
|
||||
</Form.Item>
|
||||
<Form.Item name="desciption" label={t('application.desciption')}>
|
||||
<Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<div className="rb:text-[#5B6167] rb:font-medium">{t('application.importStatistic')}</div>
|
||||
<Row gutter={12}>
|
||||
{['complex', 'nodes', 'task'].map(key => (
|
||||
<Col key={key} span={8}>
|
||||
<Statistic title={key} value={0} className="rb:text-center rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:py-3!" />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Form>
|
||||
}
|
||||
{current === 5 &&
|
||||
<Flex justify="center" vertical gap={12} className="rb:w-[70%]! rb:mx-auto! rb:text-center">
|
||||
<div>导入成功</div>
|
||||
<div>您的工作流已成功导入,可以在应用管理中查看和管理</div>
|
||||
</Flex>
|
||||
}
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadWorkflowModal;
|
||||
@@ -12,18 +12,19 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Row, Col, App, Select } from 'antd';
|
||||
import { Button, Row, Col, App, Select, Space } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import type { Application, ApplicationModalRef, Query } from './types';
|
||||
import ApplicationModal, { types } from './components/ApplicationModal';
|
||||
import type { Application, ApplicationModalRef, Query, UploadWorkflowModalRef } from './types';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getApplicationListUrl, deleteApplication } from '@/api/application'
|
||||
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import UploadWorkflowModal from './components/UploadWorkflowModal'
|
||||
|
||||
/**
|
||||
* Application management main component
|
||||
@@ -35,6 +36,7 @@ const ApplicationManagement: React.FC = () => {
|
||||
const [query, setQuery] = useState<Query>({} as Query);
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const scrollListRef = useRef<PageScrollListRef>(null)
|
||||
const uploadWorkflowModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Convert URLSearchParams to a plain object for easier access
|
||||
@@ -80,6 +82,10 @@ const ApplicationManagement: React.FC = () => {
|
||||
const handleChangeType = (value?: string) => {
|
||||
setQuery(prev => ({...prev, type: value}))
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
uploadWorkflowModalRef.current?.handleOpen()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16} className="rb:mb-4">
|
||||
@@ -104,9 +110,14 @@ const ApplicationManagement: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} className="rb:text-right">
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
<Space size={12}>
|
||||
<Button onClick={handleImport}>
|
||||
{t('application.importWorkflow')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -156,8 +167,13 @@ const ApplicationManagement: React.FC = () => {
|
||||
ref={applicationModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
<UploadWorkflowModal
|
||||
ref={uploadWorkflowModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationManagement;
|
||||
export default ApplicationManagement
|
||||
@@ -173,3 +173,10 @@ export interface ApiExtensionModalRef {
|
||||
/** Open API extension modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
|
||||
|
||||
export interface UploadWorkflowModalData {
|
||||
}
|
||||
export interface UploadWorkflowModalRef {
|
||||
handleOpen: () => void;
|
||||
}
|
||||
251
web/src/views/Conversation/components/FileUpload.tsx
Normal file
251
web/src/views/Conversation/components/FileUpload.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:09:42
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
*
|
||||
* A reusable file upload component based on Ant Design Upload.
|
||||
* Supports single/multiple file uploads, drag-and-drop, file validation, and preview.
|
||||
*
|
||||
* Features:
|
||||
* - File type validation (images, documents, etc.)
|
||||
* - File size validation
|
||||
* - Auto-upload or manual upload modes
|
||||
* - Progress tracking
|
||||
* - Custom upload actions and headers
|
||||
* - File list management
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Upload, Progress, App } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { fileUploadUrl } from '@/api/fileStorage'
|
||||
|
||||
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Upload API endpoint */
|
||||
action?: string;
|
||||
/** Enable multiple file selection */
|
||||
multiple?: boolean;
|
||||
/** List of uploaded files */
|
||||
fileList?: UploadFile[];
|
||||
/** Callback when file list changes */
|
||||
onChange?: (fileList: UploadFile | UploadFile[]) => void;
|
||||
customRequest?: RcUploadProps['customRequest'];
|
||||
/** Custom upload request configuration */
|
||||
requestConfig?: {
|
||||
data?: Record<string, string | number | boolean>;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
/** Disable upload */
|
||||
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<boolean | void>;
|
||||
/** Trigger to reset file list */
|
||||
update?: boolean;
|
||||
}
|
||||
// Mapping of file extensions to MIME types
|
||||
const ALL_FILE_TYPE: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
// txt: 'text/plain',
|
||||
pdf: 'application/pdf',
|
||||
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
csv: 'text/csv',
|
||||
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
// md: 'text/markdown',
|
||||
// htm: 'text/html',
|
||||
// html: 'text/html',
|
||||
// json: 'application/json',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
}
|
||||
export interface UploadFilesRef {
|
||||
/** Current file list */
|
||||
fileList: UploadFile[];
|
||||
/** Clear all uploaded files */
|
||||
clearFiles: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common upload component based on Ant Design Upload
|
||||
* Supports single/multiple file uploads, drag-and-drop, file validation, and preview
|
||||
*/
|
||||
const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
action = fileUploadUrl,
|
||||
multiple = false,
|
||||
fileList: propFileList = [],
|
||||
onChange,
|
||||
disabled = false,
|
||||
fileSize = 5,
|
||||
fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
|
||||
isAutoUpload = true,
|
||||
maxCount = 1,
|
||||
onRemove: customOnRemove,
|
||||
update,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp()
|
||||
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
|
||||
const [accept, setAccept] = useState<string | undefined>();
|
||||
|
||||
// Reset file list when update prop changes
|
||||
useEffect(() => {
|
||||
setFileList([])
|
||||
}, [update])
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
// Validate file type
|
||||
if (fileType && fileType.length > 0) {
|
||||
// Get file extension
|
||||
const fileName = file.name.toLowerCase();
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
|
||||
// Check if extension is in allowed types list
|
||||
const isValidExtension = fileType.some(type => type.toLowerCase() === fileExtension);
|
||||
|
||||
// Also check MIME type if available (as fallback validation)
|
||||
const isValidMimeType = file.type && accept ? accept.includes(file.type) : true;
|
||||
|
||||
if (!isValidExtension && !isValidMimeType) {
|
||||
message.error(`${t('common.fileAcceptTip')} ${fileExtension || file.type}`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAutoUpload) {
|
||||
const newFileList = [...fileList, file as UploadFile];
|
||||
setFileList(newFileList);
|
||||
onChange?.(newFileList);
|
||||
return Upload.LIST_IGNORE; // Prevent auto-upload
|
||||
}
|
||||
|
||||
return isAutoUpload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles upload state changes
|
||||
*/
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => {
|
||||
console.log('event', event)
|
||||
setFileList(newFileList);
|
||||
if (onChange) {
|
||||
onChange(maxCount === 1 ? newFileList[0] : newFileList);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all uploaded files
|
||||
*/
|
||||
const clearFiles = () => {
|
||||
setFileList([]);
|
||||
if (onChange) {
|
||||
onChange([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build accept string from file types (includes both MIME types and extensions)
|
||||
useEffect(() => {
|
||||
if (fileType && fileType.length > 0) {
|
||||
// Include both MIME types and file extensions
|
||||
const acceptArray: string[] = [];
|
||||
fileType.forEach((type: string) => {
|
||||
const lowerType = type.toLowerCase();
|
||||
// Add MIME type (if exists)
|
||||
const mimeType = ALL_FILE_TYPE[lowerType];
|
||||
if (mimeType) {
|
||||
acceptArray.push(mimeType);
|
||||
}
|
||||
// Add file extension (.md, .html, etc.)
|
||||
acceptArray.push(`.${lowerType}`);
|
||||
});
|
||||
setAccept(acceptArray.join(','));
|
||||
} else {
|
||||
setAccept(undefined);
|
||||
}
|
||||
}, [fileType])
|
||||
|
||||
// Generate upload component configuration
|
||||
const uploadProps: UploadProps = {
|
||||
action,
|
||||
multiple: multiple && maxCount > 1,
|
||||
fileList,
|
||||
beforeUpload,
|
||||
headers: {
|
||||
authorization: `Bearer ${cookieUtils.get('authToken')}`,
|
||||
},
|
||||
onChange: handleChange,
|
||||
accept,
|
||||
disabled,
|
||||
showUploadList: false,
|
||||
itemRender: (_, file, __, actions) => {
|
||||
return (
|
||||
<div key={file.uid} className="rb:relative rb:w-full rb:pt-2 rb:px-2.5 rb-pb-[10px] rb:border rb:border-[#EBEBEB] rb:rounded rb:p-2 rb:mt-2 rb:bg-white">
|
||||
<div className="rb:text-[12px] rb:flex rb:items-center rb:justify-between rb:mb-0.5">
|
||||
{file.name}
|
||||
<span className="rb:text-[#5B6167] rb:cursor-pointer" onClick={() => actions?.remove()}>Cancel</span>
|
||||
</div>
|
||||
<Progress percent={file.percent || 0} strokeColor={file.status === 'error' ? '#FF5D34' : '#155EEF'} size="small" showInfo={false} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: 'rb:-mb-1.5!',
|
||||
...props,
|
||||
};
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
fileList,
|
||||
clearFiles
|
||||
}));
|
||||
|
||||
return (
|
||||
<Upload
|
||||
{...uploadProps}
|
||||
>
|
||||
{t('memoryConversation.uploadFile')}
|
||||
</Upload>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadFiles;
|
||||
135
web/src/views/Conversation/components/UploadFileListModal.tsx
Normal file
135
web/src/views/Conversation/components/UploadFileListModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:09:47
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
*
|
||||
* A modal dialog for adding remote files via URL.
|
||||
* Allows users to specify file type and URL for files hosted externally.
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic form fields for multiple file URLs
|
||||
* - File type selection (currently supports images)
|
||||
* - Form validation
|
||||
* - Add/remove file entries
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Select, Button, Space } from 'antd';
|
||||
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadFileListModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface UploadFileListModalProps {
|
||||
/** Callback to refresh parent component with new file list */
|
||||
refresh: (fileList?: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding remote files via URL
|
||||
*/
|
||||
const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/**
|
||||
* Closes the modal and resets loading state
|
||||
*/
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the modal and resets form fields
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
setVisible(true);
|
||||
form.resetFields();
|
||||
};
|
||||
/**
|
||||
* Validates and saves the file list
|
||||
* Transforms form values into file objects with transfer_method: 'remote_url'
|
||||
*/
|
||||
const handleSave = () => {
|
||||
form.validateFields().then((values) => {
|
||||
const fileList = values.files?.map((file: any) => ({
|
||||
...file,
|
||||
uid: Math.random().toString(36).substr(2, 9),
|
||||
transfer_method: 'remote_url'
|
||||
})) || [];
|
||||
refresh(fileList)
|
||||
handleClose()
|
||||
})
|
||||
}
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('memoryConversation.addRemoteFile')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.save')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.List name="files">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{/* Render each file entry with type selector and URL input */}
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex' }} align="baseline">
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
initialValue="image"
|
||||
>
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
options={[
|
||||
{ label: t('memoryConversation.image'), value: 'image' }
|
||||
]}
|
||||
className="rb:w-30"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'url']}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
>
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5" />
|
||||
</FormItem>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} style={{ marginTop: 30 }} />
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadFileListModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:58:35
|
||||
* @Last Modified time: 2026-02-06 21:11:23
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -14,12 +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 } from 'antd'
|
||||
import { Flex, Skeleton, Form, Dropdown, type MenuProps } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken } from '@/api/application'
|
||||
import type { HistoryItem, QueryParams } from './types'
|
||||
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import { randomString } from '@/utils/common'
|
||||
@@ -33,6 +33,10 @@ 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 { shareFileUploadUrl } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
|
||||
/**
|
||||
* Conversation component for shared applications
|
||||
@@ -58,6 +62,8 @@ const Conversation: FC = () => {
|
||||
|
||||
const [form] = Form.useForm<QueryParams>()
|
||||
const queryValues = Form.useWatch<QueryParams>([], form)
|
||||
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
useEffect(() => {
|
||||
const shareToken = localStorage.getItem(`shareToken_${token}`)
|
||||
setShareToken(shareToken)
|
||||
@@ -142,12 +148,13 @@ const Conversation: FC = () => {
|
||||
}, [conversation_id])
|
||||
|
||||
/** Add user message to chat */
|
||||
const addUserMessage = (message: string = '') => {
|
||||
const addUserMessage = (message: string = '', files?: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
conversation_id,
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now()
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
}
|
||||
@@ -189,9 +196,10 @@ const Conversation: FC = () => {
|
||||
if (!token || !shareToken) {
|
||||
return
|
||||
}
|
||||
const { files = [], ...rest } = queryValues || {}
|
||||
setLoading(true)
|
||||
setStreamLoading(true)
|
||||
addUserMessage(message)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
|
||||
let currentConversationId: string | null = null
|
||||
@@ -222,18 +230,54 @@ const Conversation: FC = () => {
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
form.setFieldValue('files', [])
|
||||
sendConversation({
|
||||
...queryValues,
|
||||
...rest,
|
||||
message: message || '',
|
||||
stream: true,
|
||||
conversation_id: conversation_id || null,
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
})
|
||||
}, handleStreamMessage, shareToken)
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const [update, setUpdate] = useState(false)
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
setUpdate(prev => !prev)
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
|
||||
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[]) => {
|
||||
form.setFieldValue('files', [...(fileList || [])])
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex className="rb:w-full rb:p-[-16px]!">
|
||||
<div className="rb:w-86.25 rb:h-screen rb:overflow-hidden rb:border-r rb:border-[#EAECEE] rb:p-3">
|
||||
@@ -285,37 +329,75 @@ const Conversation: FC = () => {
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName="rb:h-[calc(100%-152px)] "
|
||||
contentClassName="rb:h-[calc(100%-180px)]"
|
||||
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}
|
||||
>
|
||||
<Form form={form} initialValues={{ memory: false, web_search: false}}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrl}
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/link.svg')] rb:hover:bg-[url('src/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:57:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:57:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:11:19
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Conversation
|
||||
@@ -50,4 +50,9 @@ export interface QueryParams {
|
||||
stream: boolean;
|
||||
/** Current conversation ID */
|
||||
conversation_id?: string | null;
|
||||
files?: any[];
|
||||
}
|
||||
|
||||
export interface UploadFileListModalRef {
|
||||
handleOpen: (fileList?: any[]) => void;
|
||||
}
|
||||
@@ -1,7 +1,30 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:10:56
|
||||
*/
|
||||
/**
|
||||
* Workflow Chat Component
|
||||
*
|
||||
* A drawer-based chat interface for testing and debugging workflow executions.
|
||||
* Provides real-time streaming of workflow node execution status, input/output data,
|
||||
* and error messages. Supports variable configuration and file attachments.
|
||||
*
|
||||
* Key Features:
|
||||
* - Real-time workflow execution monitoring with SSE streaming
|
||||
* - Node-level execution tracking (start, end, error states)
|
||||
* - Variable configuration for workflow inputs
|
||||
* - File upload support (images and documents)
|
||||
* - Collapsible node execution details with input/output inspection
|
||||
* - Error handling and display
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
import { Input, Form, App, Space, Button, Collapse } from 'antd'
|
||||
import { App, Space, Button, Collapse, Flex, Dropdown, type MenuProps } from 'antd'
|
||||
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'
|
||||
import CodeBlock from '@/components/Markdown/CodeBlock'
|
||||
|
||||
@@ -12,30 +35,43 @@ import { draftRun } from '@/api/application';
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import type { Variable } from '../Properties/VariableList/types'
|
||||
import styles from './chat.module.css'
|
||||
import Markdown from '@/components/Markdown'
|
||||
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'
|
||||
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const [form] = Form.useForm<{ message: string }>()
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [variables, setVariables] = useState<Variable[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
// State management
|
||||
const [open, setOpen] = useState(false) // Drawer visibility
|
||||
const [loading, setLoading] = useState(false) // Send button loading state
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
|
||||
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
|
||||
const [fileList, setFileList] = useState<any[]>([]) // Uploaded files
|
||||
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
/**
|
||||
* Opens the chat drawer and loads workflow variables from the start node
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
getVariables()
|
||||
}
|
||||
/**
|
||||
* Extracts variables from the workflow's start node and merges with previous values
|
||||
*/
|
||||
const getVariables = () => {
|
||||
const nodes = graphRef.current?.getNodes()
|
||||
const list = nodes?.map(node => node.getData()) || []
|
||||
@@ -55,20 +91,42 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setVariables(curVariables)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Closes the drawer and resets all state
|
||||
*/
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setChatList([])
|
||||
setVariables([])
|
||||
setConversationId(null)
|
||||
}
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
const handleSend = () => {
|
||||
/**
|
||||
* Sends a message to execute the workflow
|
||||
*
|
||||
* Process:
|
||||
* 1. Validates required variables
|
||||
* 2. Adds user message to chat
|
||||
* 3. Initiates SSE stream for workflow execution
|
||||
* 4. Handles real-time node execution updates
|
||||
* 5. Updates chat with results or errors
|
||||
*
|
||||
* @param msg - Optional message to send (uses state if not provided)
|
||||
*/
|
||||
const handleSend = async (msg?: string) => {
|
||||
if (loading || !appId) return
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
@@ -90,8 +148,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
const message = form.getFieldValue('message')
|
||||
// setLoading(true)
|
||||
const message = msg
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
@@ -104,6 +162,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
subContent: [],
|
||||
}])
|
||||
|
||||
/**
|
||||
* Handles SSE stream messages from workflow execution
|
||||
*
|
||||
* Events:
|
||||
* - message: Streaming text chunks for final output
|
||||
* - node_start: Node execution begins
|
||||
* - node_end: Node execution completes successfully
|
||||
* - node_error: Node execution fails
|
||||
* - workflow_end: Entire workflow completes
|
||||
*/
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { chunk, conversation_id, node_id, input, output, error, elapsed_time, status } = item.data as {
|
||||
@@ -125,6 +193,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
console.log('node', node?.getData())
|
||||
|
||||
switch(item.event) {
|
||||
// Append streaming text chunks to assistant message
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -138,6 +207,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Track node execution start
|
||||
case 'node_start':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -170,6 +240,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Update node with execution results or errors
|
||||
case 'node_end':
|
||||
case 'node_error':
|
||||
setChatList(prev => {
|
||||
@@ -198,6 +269,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Mark workflow as complete
|
||||
case 'workflow_end':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -221,14 +293,27 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
})
|
||||
}
|
||||
|
||||
form.setFieldValue('message', undefined)
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, {
|
||||
setMessage(undefined)
|
||||
setFileList([])
|
||||
const data = {
|
||||
message: message,
|
||||
variables: params,
|
||||
stream: true,
|
||||
conversation_id: conversationId
|
||||
}, handleStreamMessage)
|
||||
conversation_id: conversationId,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, data, handleStreamMessage)
|
||||
.catch((error) => {
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
@@ -243,29 +328,72 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
}
|
||||
return newList
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
// 暴露给父组件的方法
|
||||
|
||||
/**
|
||||
* Updates the current input message
|
||||
*/
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const [update, setUpdate] = useState(false)
|
||||
/**
|
||||
* Handles file upload from local device
|
||||
*/
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
setUpdate(prev => !prev)
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', 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
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
/**
|
||||
* Updates the entire file list (used when removing files)
|
||||
*/
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
/**
|
||||
* Returns CSS class for status-based text color
|
||||
*/
|
||||
const getStatus = (status?: string) => {
|
||||
return status === 'completed' ? 'rb:text-[#369F21]' : status === 'failed' ? 'rb:text-[#FF5D34]' : 'rb:text-[#5B6167]'
|
||||
}
|
||||
|
||||
console.log('chatList', chatList)
|
||||
return (
|
||||
<RbDrawer
|
||||
title={<div className="rb:flex rb:items-center rb:gap-2.5">
|
||||
{t('workflow.run')}
|
||||
{variables.length > 0 && <Space>
|
||||
<Button size="small" onClick={handleEditVariables}>变量</Button>
|
||||
<Button size="small" onClick={handleEditVariables}>{t('application.variable')}</Button>
|
||||
</Space>}
|
||||
</div>}
|
||||
classNames={{
|
||||
@@ -275,7 +403,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
onClose={handleClose}
|
||||
>
|
||||
<ChatContent
|
||||
classNames="rb:mx-[16px] rb:pt-[24px] rb:h-[calc(100%-76px)]"
|
||||
classNames="rb:mx-[16px] rb:pt-[24px] rb:h-[calc(100%-86px)]"
|
||||
contentClassNames="rb:max-w-[400px]!'"
|
||||
empty={<Empty url={ChatIcon} title={t('application.chatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
|
||||
data={chatList}
|
||||
@@ -365,19 +493,47 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="rb:flex rb:items-center rb:gap-2.5 rb:p-4">
|
||||
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
|
||||
<Form.Item name="message" className="rb:mb-0!">
|
||||
<Input
|
||||
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
|
||||
placeholder={t('application.chatPlaceholder')}
|
||||
onPressEnter={handleSend}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<img src={ChatSendIcon} className={clsx("rb:w-11 rb:h-11 rb:cursor-pointer", {
|
||||
'rb:opacity-50': loading,
|
||||
})} onClick={handleSend} />
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={handleSend}
|
||||
onChange={handleMessageChange}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
fileList={[]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('src/assets/images/conversation/link.svg')] rb:hover:bg-[url('src/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
</div>
|
||||
|
||||
<VariableConfigModal
|
||||
@@ -385,6 +541,11 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</RbDrawer>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user