From 6849c620b8541dd336df5be8f6a9ad64a7b48e09 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Fri, 6 Feb 2026 21:11:51 +0800 Subject: [PATCH] feat(web): share chat & app chat support files --- web/package.json | 2 + web/src/api/fileStorage.ts | 2 + .../conversation/{voice.svg => audio.svg} | 0 .../images/conversation/audio_hover.svg | 22 ++ .../assets/images/conversation/audio_ing.gif | Bin 0 -> 73401 bytes .../assets/images/conversation/audio_ing.svg | 21 ++ web/src/assets/images/conversation/delete.svg | 2 +- .../images/conversation/delete_hover.svg | 16 ++ web/src/assets/images/conversation/excel.svg | 15 + .../images/conversation/excel_disabled.svg | 15 + .../assets/images/conversation/link_hover.svg | 22 ++ web/src/assets/images/conversation/pdf.svg | 18 ++ .../images/conversation/pdf_disabled.svg | 18 ++ web/src/assets/images/conversation/word.svg | 15 + .../images/conversation/word_disabled.svg | 15 + web/src/components/AudioRecorder/index.tsx | 61 ++++ web/src/components/Chat/ChatContent.tsx | 34 +-- web/src/components/Chat/ChatInput.tsx | 135 ++++++--- web/src/components/Chat/index.tsx | 36 ++- web/src/components/Chat/types.ts | 76 ++--- web/src/components/Upload/UploadFiles.tsx | 47 +-- web/src/i18n/en.ts | 6 + web/src/i18n/zh.ts | 13 + web/src/utils/stream.ts | 4 +- web/src/views/ApplicationConfig/Agent.tsx | 2 + .../ApplicationConfig/components/Chat.tsx | 159 ++++++++--- .../components/UploadWorkflowModal.tsx | 267 ++++++++++++++++++ web/src/views/ApplicationManagement/index.tsx | 28 +- web/src/views/ApplicationManagement/types.ts | 7 + .../Conversation/components/FileUpload.tsx | 251 ++++++++++++++++ .../components/UploadFileListModal.tsx | 135 +++++++++ web/src/views/Conversation/index.tsx | 134 +++++++-- web/src/views/Conversation/types.ts | 9 +- .../views/Workflow/components/Chat/Chat.tsx | 235 ++++++++++++--- 34 files changed, 1571 insertions(+), 251 deletions(-) rename web/src/assets/images/conversation/{voice.svg => audio.svg} (100%) create mode 100644 web/src/assets/images/conversation/audio_hover.svg create mode 100644 web/src/assets/images/conversation/audio_ing.gif create mode 100644 web/src/assets/images/conversation/audio_ing.svg create mode 100644 web/src/assets/images/conversation/delete_hover.svg create mode 100644 web/src/assets/images/conversation/excel.svg create mode 100644 web/src/assets/images/conversation/excel_disabled.svg create mode 100644 web/src/assets/images/conversation/link_hover.svg create mode 100644 web/src/assets/images/conversation/pdf.svg create mode 100644 web/src/assets/images/conversation/pdf_disabled.svg create mode 100644 web/src/assets/images/conversation/word.svg create mode 100644 web/src/assets/images/conversation/word_disabled.svg create mode 100644 web/src/components/AudioRecorder/index.tsx create mode 100644 web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx create mode 100644 web/src/views/Conversation/components/FileUpload.tsx create mode 100644 web/src/views/Conversation/components/UploadFileListModal.tsx diff --git a/web/package.json b/web/package.json index 89800fcf..e2d5c898 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.22.0", "react-syntax-highlighter": "^16.1.0", + "recordrtc": "^5.6.2", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", @@ -73,6 +74,7 @@ "@types/react-i18next": "^7.8.3", "@types/react-router-dom": "^5.3.3", "@types/react-syntax-highlighter": "^15.5.13", + "@types/recordrtc": "^5.6.15", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "^10.4.21", "eslint": "^9.36.0", diff --git a/web/src/api/fileStorage.ts b/web/src/api/fileStorage.ts index 86da129c..e5ed9ff8 100644 --- a/web/src/api/fileStorage.ts +++ b/web/src/api/fileStorage.ts @@ -29,3 +29,5 @@ export const deleteFileUrl = (file_id: string) => `/storage/files/${file_id}` export const deleteFile = (fileId: string) => { return request.delete(deleteFileUrl(fileId)) } + +export const shareFileUploadUrl = `${API_PREFIX}/storage/share/files` \ No newline at end of file diff --git a/web/src/assets/images/conversation/voice.svg b/web/src/assets/images/conversation/audio.svg similarity index 100% rename from web/src/assets/images/conversation/voice.svg rename to web/src/assets/images/conversation/audio.svg diff --git a/web/src/assets/images/conversation/audio_hover.svg b/web/src/assets/images/conversation/audio_hover.svg new file mode 100644 index 00000000..759d2bcd --- /dev/null +++ b/web/src/assets/images/conversation/audio_hover.svg @@ -0,0 +1,22 @@ + + + 编组 15 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/audio_ing.gif b/web/src/assets/images/conversation/audio_ing.gif new file mode 100644 index 0000000000000000000000000000000000000000..358f0a9dda1b2a4f760ed9bc0677f2d3d349978f GIT binary patch literal 73401 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 literal 0 HcmV?d00001 diff --git a/web/src/assets/images/conversation/audio_ing.svg b/web/src/assets/images/conversation/audio_ing.svg new file mode 100644 index 00000000..280a1bd9 --- /dev/null +++ b/web/src/assets/images/conversation/audio_ing.svg @@ -0,0 +1,21 @@ + + + 编组 15 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/delete.svg b/web/src/assets/images/conversation/delete.svg index f755f1f8..27f1c15f 100644 --- a/web/src/assets/images/conversation/delete.svg +++ b/web/src/assets/images/conversation/delete.svg @@ -5,7 +5,7 @@ - + diff --git a/web/src/assets/images/conversation/delete_hover.svg b/web/src/assets/images/conversation/delete_hover.svg new file mode 100644 index 00000000..f755f1f8 --- /dev/null +++ b/web/src/assets/images/conversation/delete_hover.svg @@ -0,0 +1,16 @@ + + + 编组 3 + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/excel.svg b/web/src/assets/images/conversation/excel.svg new file mode 100644 index 00000000..31e34041 --- /dev/null +++ b/web/src/assets/images/conversation/excel.svg @@ -0,0 +1,15 @@ + + + 表格 + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/excel_disabled.svg b/web/src/assets/images/conversation/excel_disabled.svg new file mode 100644 index 00000000..3f1031ac --- /dev/null +++ b/web/src/assets/images/conversation/excel_disabled.svg @@ -0,0 +1,15 @@ + + + 表格 + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/link_hover.svg b/web/src/assets/images/conversation/link_hover.svg new file mode 100644 index 00000000..38833e16 --- /dev/null +++ b/web/src/assets/images/conversation/link_hover.svg @@ -0,0 +1,22 @@ + + + 链接 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/pdf.svg b/web/src/assets/images/conversation/pdf.svg new file mode 100644 index 00000000..b78c3cc2 --- /dev/null +++ b/web/src/assets/images/conversation/pdf.svg @@ -0,0 +1,18 @@ + + + PDF + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/pdf_disabled.svg b/web/src/assets/images/conversation/pdf_disabled.svg new file mode 100644 index 00000000..ab091fd0 --- /dev/null +++ b/web/src/assets/images/conversation/pdf_disabled.svg @@ -0,0 +1,18 @@ + + + PDF + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/word.svg b/web/src/assets/images/conversation/word.svg new file mode 100644 index 00000000..682a072b --- /dev/null +++ b/web/src/assets/images/conversation/word.svg @@ -0,0 +1,15 @@ + + + 文档 + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/images/conversation/word_disabled.svg b/web/src/assets/images/conversation/word_disabled.svg new file mode 100644 index 00000000..64e065d6 --- /dev/null +++ b/web/src/assets/images/conversation/word_disabled.svg @@ -0,0 +1,15 @@ + + + 文档 + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/components/AudioRecorder/index.tsx b/web/src/components/AudioRecorder/index.tsx new file mode 100644 index 00000000..f6a030b4 --- /dev/null +++ b/web/src/components/AudioRecorder/index.tsx @@ -0,0 +1,61 @@ +import { type FC, useRef, useState } from 'react' +import RecordRTC from 'recordrtc' + +import { fileUpload } from '@/api/fileStorage' + +interface AudioRecorderProps { + onRecordingComplete?: (file: { file_id: string; file_key: string; }, blob: Blob) => void + className?: string +} + +const AudioRecorder: FC = ({ + onRecordingComplete, + className = '', +}) => { + const [isRecording, setIsRecording] = useState(false) + const recorderRef = useRef(null) + + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) + recorderRef.current = new RecordRTC(stream, { + type: 'audio', + mimeType: 'audio/webm' + }) + recorderRef.current.startRecording() + setIsRecording(true) + } catch (error) { + console.error('Failed to start recording:', error) + } + } + + const stopRecording = () => { + if (recorderRef.current) { + recorderRef.current.stopRecording(() => { + const blob = recorderRef.current!.getBlob() + const formData = new FormData() + formData.append('file', blob, `recording_${Date.now()}.webm`) + fileUpload(formData) + .then(res => { + onRecordingComplete?.(res as { file_id: string; file_key: string; }, blob) + recorderRef.current?.destroy() + recorderRef.current = null + }) + }) + setIsRecording(false) + } + } + + return ( +
+ ) +} + +export default AudioRecorder diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index a5d02b2b..32e6ae23 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:46:17 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-01-12 20:41:27 + * @Last Modified time: 2026-02-06 21:05:52 */ import { type FC, useRef, useEffect } from 'react' import clsx from 'clsx' @@ -11,8 +11,8 @@ import type { ChatContentProps } from './types' import { Spin } from 'antd' /** - * 聊天内容显示组件 - * 负责渲染聊天消息列表,支持不同角色的消息样式和自动滚动 + * Chat Content Display Component + * Responsible for rendering chat message list, supports different role message styles and auto-scrolling */ const ChatContent: FC = ({ classNames, @@ -25,10 +25,10 @@ const ChatContent: FC = ({ errorDesc, renderRuntime }) => { - // 滚动容器引用,用于控制自动滚动到底部 + // Scroll container reference for controlling auto-scroll to bottom const scrollContainerRef = useRef<(HTMLDivElement | null)>(null) - // 当数据变化时,自动滚动到底部显示最新消息 + // Auto-scroll to bottom when data changes to show latest messages useEffect(() => { setTimeout(() => { if (scrollContainerRef.current) { @@ -39,37 +39,37 @@ const ChatContent: FC = ({ return (
{data.length === 0 - ? empty // 显示空状态 + ? empty // Display empty state : data.map((item, index) => (
- {/* 流式加载时且内容为空则不显示 */} + {/* Don't display if streaming and content is empty */} {streamLoading && item.content === '' && !renderRuntime ? : <> - {/* 顶部标签(如时间戳、用户名等) */} + {/* Top label (such as timestamp, username, etc.) */} {labelPosition === 'top' &&
{labelFormat(item)}
} - {/* 消息气泡框 */} + {/* Message bubble */}
{item.subContent && renderRuntime && renderRuntime(item, index)} - {/* 使用Markdown组件渲染消息内容 */} + {/* Render message content using Markdown component */}
- {/* 底部标签(如时间戳、用户名等) */} + {/* Bottom label (such as timestamp, username, etc.) */} {labelPosition === 'bottom' &&
{labelFormat(item)} diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index be9fc48d..665bff65 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -2,9 +2,9 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:46:14 * @Last Modified by: ZhaoYing - * @Last Modified time: 2025-12-20 15:38:40 + * @Last Modified time: 2026-02-06 21:05:09 */ -import { useEffect } from 'react' +import { type FC, useEffect, useMemo } from 'react' import { Flex, Input, Form } from 'antd' import SendIcon from '@/assets/images/conversation/send.svg' import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg' @@ -12,15 +12,24 @@ import LoadingIcon from '@/assets/images/conversation/loading.svg' import type { ChatInputProps } from './types' /** - * 聊天输入框组件 - * 提供消息输入、发送功能,支持键盘快捷键和加载状态显示 + * Chat Input Component + * Provides message input and send functionality, supports keyboard shortcuts and loading state display */ -const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputProps) => { +const ChatInput: FC = ({ + message, + onSend, + loading, + children, + fileList, + fileChange, + className = '', + onChange +}) => { const [form] = Form.useForm() - // 监听表单值变化,用于控制发送按钮状态 - const values = Form.useWatch([], form); + const values = Form.useWatch([], form) + // Monitor form value changes to control send button state - // 当外部message为空时,清空表单 + // Clear form when external message is empty useEffect(() => { if (!message) { form.setFieldsValue({ @@ -29,7 +38,7 @@ const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputPr } }, [form, message]) - // 当加载状态时,清空输入框 + // Clear input when loading useEffect(() => { if (loading) { form.setFieldsValue({ @@ -38,39 +47,93 @@ const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputPr } }, [loading]) + + const handleDelete = (file: any) => { + fileChange?.(fileList?.filter(item => item.uid !== file.uid) || []) + } + // Convert file object to preview URL + const previewFileList = useMemo(() => { + return fileList?.map(file => ({ + ...file, + url: file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : file.thumbUrl) + })) || [] + }, [fileList]) + + const handleSend = () => { + onSend(values.message) + } + return ( -
+
- {/* 消息输入表单 */} + {previewFileList.length > 0 && + {previewFileList.map((file) => { + if (file.type.includes('image')) { + return ( +
+ {file.name} +
handleDelete(file)} + >
+
+ ) + } + return ( +
+ {(file.type.includes('word') || file.type.includes('wordprocessingml.document')) &&
} + {(file.type.includes('pdf')) &&
} + {(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) &&
} +
+
{file.name}
+
{file.type} · {file.size}
+
+
handleDelete(file)} + >
+
+ ) + })} +
} + {/* Message input form */}
- - onChange(e.target.value)} - onKeyDown={(e) => { - // Enter键发送,Shift+Enter换行 - if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) { - e.preventDefault(); - onSend(); - } - }} - /> - + + onChange?.(e.target.value)} + onKeyDown={(e) => { + // Enter to send, Shift+Enter for new line + if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) { + e.preventDefault(); + handleSend(); + } + }} + /> +
- {/* 底部操作区域 */} + {/* Bottom action area */} - {/* 子组件内容(如按钮等) */} - {children} - {/* 发送按钮 - 根据状态显示不同图标 */} - {loading - ? - : !values || !values?.message || values?.message?.trim() === '' - ? - : - } + {/* Child component content (such as buttons) */} +
{children}
+
+ {/* Send button - display different icons based on state */} + {loading + ? + : !values || !values?.message || values?.message?.trim() === '' + ? + : + } +
diff --git a/web/src/components/Chat/index.tsx b/web/src/components/Chat/index.tsx index 7db29bfc..9a60918a 100644 --- a/web/src/components/Chat/index.tsx +++ b/web/src/components/Chat/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2025-12-10 16:46:09 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2025-12-11 13:43:51 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-02-06 21:05:09 */ import { type FC } from 'react' import ChatInput from './ChatInput' @@ -10,35 +10,43 @@ import type { ChatProps } from './types' import ChatContent from './ChatContent' /** - * 聊天组件 - 主要组件,由内容区域和输入框组成 - * 提供完整的聊天界面功能,包括消息显示和输入交互 + * Chat Component - Main component consisting of content area and input box + * Provides complete chat interface functionality, including message display and input interaction */ const Chat: FC = ({ empty, data, - onChange, - onSend, - streamLoading = false, - loading, + onChange, + onSend, + streamLoading = false, + loading, contentClassName = '', children, labelFormat, - errorDesc + errorDesc, + fileList, + fileChange }) => { return (
- {/* 聊天内容显示区域 */} + {/* Chat content display area */} - {/* 聊天输入框区域 */} - + {/* Chat input area */} + {children}
diff --git a/web/src/components/Chat/types.ts b/web/src/components/Chat/types.ts index 264ce39c..96e8e284 100644 --- a/web/src/components/Chat/types.ts +++ b/web/src/components/Chat/types.ts @@ -2,85 +2,95 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:45:54 * @Last Modified by: ZhaoYing - * @Last Modified time: 2025-12-11 13:43:52 + * @Last Modified time: 2026-02-06 21:05:09 */ import { type ReactNode } from 'react' /** - * 聊天消息项接口 + * Chat message item interface */ export interface ChatItem { - /** 消息唯一标识 */ + /** Message unique identifier */ id?: string; - /** 会话ID */ + /** Conversation ID */ conversation_id?: string | null; - /** 消息角色:用户或助手 */ + /** Message role: user or assistant */ role?: 'user' | 'assistant'; - /** 消息内容 */ + /** Message content */ content?: string | null; - /** 创建时间 */ + /** Creation time */ created_at?: number | string; status?: string; - subContent?: Record[] + subContent?: Record[]; + files?: any[]; } /** - * 聊天组件主要属性接口 + * Chat component main props interface */ export interface ChatProps { - /** 空状态显示内容 */ + /** Empty state display content */ empty?: ReactNode; - /** 聊天数据列表 */ + /** Chat data list */ data: ChatItem[]; - /** 输入内容变化回调 */ + /** Input content change callback */ onChange: (message: string) => void; - /** 发送消息回调 */ + /** Send message callback */ onSend: () => void; - /** 流式加载状态 */ + /** Streaming loading state */ streamLoading?: boolean; - /** 加载状态 */ + /** Loading state */ loading: boolean; - /** 内容区域自定义样式类名 */ + /** Content area custom class name */ contentClassName?: string; - /** 子组件内容 */ + /** Child component content */ children?: ReactNode; - /** 标签格式化函数 */ + /** Label format function */ labelFormat: (item: ChatItem) => any; errorDesc?: string; + /** Attachment list */ + fileList?: any[]; + /** Attachment update */ + fileChange?: (fileList: any[]) => void; } /** - * 聊天输入框组件属性接口 + * Chat input component props interface */ export interface ChatInputProps { - /** 当前输入消息 */ + /** Current input message */ message?: string; - /** 输入内容变化回调 */ - onChange: (message: string) => void; - /** 发送消息回调 */ - onSend: () => void; - /** 加载状态 */ + /** Input content change callback */ + onChange?: (message: string) => void; + /** Send message callback */ + onSend: (message?: string) => void; + /** Loading state */ loading: boolean; - /** 子组件内容 */ + /** Child component content */ children?: ReactNode; + /** Attachment list */ + fileList?: any[]; + /** Attachment update */ + fileChange?: (fileList: any[]) => void; + className?: string; } /** - * 聊天内容区域组件属性接口 + * Chat content area component props interface */ export interface ChatContentProps { - /** 自定义样式类名 */ + /** Custom class name */ classNames?: string | Record; contentClassNames?: string | Record; - /** 聊天数据列表 */ + /** Chat data list */ data: ChatItem[]; - /** 流式加载状态 */ + /** Streaming loading state */ streamLoading: boolean; - /** 空状态显示内容 */ + /** Empty state display content */ empty?: ReactNode; - /** 标签位置:顶部或底部 */ + /** Label position: top or bottom */ labelPosition?: 'top' | 'bottom'; - /** 标签格式化函数 */ + /** Label format function */ labelFormat: (item: ChatItem) => any; errorDesc?: string; renderRuntime?: (item: ChatItem, index: number) => ReactNode; diff --git a/web/src/components/Upload/UploadFiles.tsx b/web/src/components/Upload/UploadFiles.tsx index 91b844f5..86864d9a 100644 --- a/web/src/components/Upload/UploadFiles.tsx +++ b/web/src/components/Upload/UploadFiles.tsx @@ -1,12 +1,14 @@ -import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; +import { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react'; import { Upload, Button, Modal, Progress, App } from 'antd'; import { UploadOutlined } from '@ant-design/icons'; import type { UploadProps, UploadFile } from 'antd'; +import type { UploadRequestOption } from 'rc-upload/lib/interface'; // import { request } from '@/utils/request'; import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface'; import CloudUploadOutlined from '@/assets/images/CloudUploadOutlined.png' import { useTranslation } from 'react-i18next'; import { cookieUtils } from '@/utils/request' +import { fileUpload } from '@/api/fileStorage' const { confirm } = Modal; const { Dragger } = Upload; @@ -61,6 +63,8 @@ const ALL_FILE_TYPE: { ttl: 'text/turtle', rdf: 'application/rdf+xml', xml: 'application/rdf+xml', + yaml: 'application/x-yaml', + yml: 'application/x-yaml', } export interface UploadFilesRef { fileList: UploadFile[]; @@ -157,45 +161,6 @@ const UploadFiles = forwardRef(({ return isAutoUpload; }; - - // 自定义上传方法 - /* - const customRequest: RcUploadProps['customRequest'] = ({ file, onSuccess, onError, onProgress }) => { - setLoading(true); - - const formData = new FormData(); - formData.append('file', file as RcFile); - - // 添加额外的请求参数 - const requestData = requestConfig.data; - if (requestData) { - Object.keys(requestData).forEach(key => { - const value = requestData[key]; - formData.append(key, String(value)); - }); - } - - request.post(action, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - ...requestConfig.headers, - }, - ...requestConfig, - }) - .then((response) => { - if (onSuccess) onSuccess(response); - }) - .catch((error) => { - message.error('上传失败,请重试'); - if (onError) onError(error); - // setFileList(fileList.filter((item) => item.uid !== (file as UploadFile).uid)); - }) - .finally(() => { - setLoading(false); - }); - }; - */ - // 处理上传状态变化 const handleChange: UploadProps['onChange'] = ({ fileList: newFileList, event }) => { console.log('event', event) @@ -240,7 +205,7 @@ const UploadFiles = forwardRef(({ fileList, beforeUpload, headers: { - authorization: `Bearer ${cookieUtils.get('authToken')}`, + authorization: `Bearer ${cookieUtils.get('authToken') || ''}`, }, onRemove: handleRemove, onChange: handleChange, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9d706ff6..fed0f2a6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1574,6 +1574,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re memory: 'Memory', memoryConversationAnalysisEmpty: 'No conversation analysis available.', memoryConversationAnalysisEmptySubTitle: 'Conversation analysis will appear here.', + + uploadFile: 'Upload File', + fileType: 'File Type', + image: 'Image', + fileUrl: 'File URL', + addRemoteFile: 'Add Remote File' }, login: { title: 'Red Bear Memory Science', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index a7ef34ac..c0f48169 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -684,6 +684,13 @@ export const zh = { analyTask: '分析任务意图', dynamicMatchSkill: '动态匹配技能', executeTask: '执行任务', + + upload: '上传与解析', + complex: '兼容性分析', + node: '节点映射', + configCheck: '配置校验', + sureInfo: '信息确认', + completed: '完成导入', }, role: { roleManagement: '角色管理', @@ -1648,6 +1655,12 @@ export const zh = { memory: '记忆', memoryConversationAnalysisEmpty: '目前没有可用的对话分析内容', memoryConversationAnalysisEmptySubTitle: '输入您的用户ID后,点击"测试记忆"查看对话记忆', + + uploadFile: '上传文件', + fileType: '文件类型', + image: '图片', + fileUrl: '文件链接', + addRemoteFile: '添加远程文件' }, login: { title: '红熊记忆科学', diff --git a/web/src/utils/stream.ts b/web/src/utils/stream.ts index ba0e4b98..b637e76a 100644 --- a/web/src/utils/stream.ts +++ b/web/src/utils/stream.ts @@ -178,7 +178,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe const errorData = await response.json(); errorData.error || i18n.t('common.serviceUpgrading'); message.warning(errorData.error || i18n.t('common.serviceUpgrading')); - break + return; case 400: const error = await response.json(); message.warning(error.error); @@ -186,7 +186,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe case 504: const errorJson = await response.json(); message.warning(errorJson.error || i18n.t('common.serverError')); - break + return; 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 6feb1548..d2b9ef4e 100644 --- a/web/src/views/ApplicationConfig/Agent.tsx +++ b/web/src/views/ApplicationConfig/Agent.tsx @@ -140,6 +140,8 @@ const Agent = forwardRef((_props, ref) => { const values = Form.useWatch([], form) const [isSave, setIsSave] = useState(false) const initialized = useRef(false) + + console.log('chatList', chatList) // Initialization flag useEffect(() => { diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index 716f3cc0..9090b5c5 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -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 = ({ 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(null) const [compareLoading, setCompareLoading] = useState(false) + const [fileList, setFileList] = useState([]) + const [message, setMessage] = useState(undefined) + const uploadFileListModalRef = useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 ( -
+
{chatList.length === 0 ? = ({ chatList, data, updateChatList, handleSave, sourc className="rb:h-full" /> : <> -
+
{chatList.map((chat, index) => ( -
1, })}> {chat.label && @@ -370,8 +425,8 @@ const Chat: FC = ({ chatList, data, updateChatList, handleSave, sourc = ({ chatList, data, updateChatList, handleSave, sourc labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label} errorDesc={t('application.ReplyException')} /> -
))}
-
-
- - - -
- +
+ + + + + ) + }, + ], + onClick: handleShowUpload + }} + > +
+
+
+ {/* + + + */} +
+
} + +
) } diff --git a/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx new file mode 100644 index 00000000..2f2f56b2 --- /dev/null +++ b/web/src/views/ApplicationManagement/components/UploadWorkflowModal.tsx @@ -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(({ + refresh +}, ref) => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false) + const [current, setCurrent] = useState(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 [ + , + + ] + case 5: + return [ + , + + ] + default: + return [ + , + , + + ] + } + }, [current]) + + return ( + +
+ ({ title: t(`application.${key}`) }))} + /> +
+ {current === 0 && +
+ + +
+ + + + } + {current === 3 && + + + + } + {current === 4 && + +
{t('application.baseInfo')}
+ + + + + source + + + fileName + + + fileSize + + + + + +
{t('application.importStatistic')}
+ + {['complex', 'nodes', 'task'].map(key => ( + + + + ))} + + + } + {current === 5 && + +
导入成功
+
您的工作流已成功导入,可以在应用管理中查看和管理
+
+ } + + ); +}); + +export default UploadWorkflowModal; \ No newline at end of file diff --git a/web/src/views/ApplicationManagement/index.tsx b/web/src/views/ApplicationManagement/index.tsx index d5335679..264b72d3 100644 --- a/web/src/views/ApplicationManagement/index.tsx +++ b/web/src/views/ApplicationManagement/index.tsx @@ -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({} as Query); const applicationModalRef = useRef(null); const scrollListRef = useRef(null) + const uploadWorkflowModalRef = useRef(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 ( <> @@ -104,9 +110,14 @@ const ApplicationManagement: React.FC = () => { /> - + + + + @@ -156,8 +167,13 @@ const ApplicationManagement: React.FC = () => { ref={applicationModalRef} refresh={refresh} /> + + ); }; -export default ApplicationManagement; \ No newline at end of file +export default ApplicationManagement \ No newline at end of file diff --git a/web/src/views/ApplicationManagement/types.ts b/web/src/views/ApplicationManagement/types.ts index 5589206a..ccc4f114 100644 --- a/web/src/views/ApplicationManagement/types.ts +++ b/web/src/views/ApplicationManagement/types.ts @@ -173,3 +173,10 @@ export interface ApiExtensionModalRef { /** Open API extension modal */ handleOpen: () => void; } + + +export interface UploadWorkflowModalData { +} +export interface UploadWorkflowModalRef { + handleOpen: () => void; +} \ No newline at end of file diff --git a/web/src/views/Conversation/components/FileUpload.tsx b/web/src/views/Conversation/components/FileUpload.tsx new file mode 100644 index 00000000..f7620f3b --- /dev/null +++ b/web/src/views/Conversation/components/FileUpload.tsx @@ -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 { + /** 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; + headers?: Record; + }; + /** 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; + /** 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(({ + 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(propFileList); + const [accept, setAccept] = useState(); + + // 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 ( +
+
+ {file.name} + actions?.remove()}>Cancel +
+ +
+ ); + }, + className: 'rb:-mb-1.5!', + ...props, + }; + + // Expose methods to parent component via ref + useImperativeHandle(ref, () => ({ + fileList, + clearFiles + })); + + return ( + + {t('memoryConversation.uploadFile')} + + ); +}); + +export default UploadFiles; \ No newline at end of file diff --git a/web/src/views/Conversation/components/UploadFileListModal.tsx b/web/src/views/Conversation/components/UploadFileListModal.tsx new file mode 100644 index 00000000..a14b0e38 --- /dev/null +++ b/web/src/views/Conversation/components/UploadFileListModal.tsx @@ -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(({ + 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 ( + +
+ + {(fields, { add, remove }) => ( + <> + {/* Render each file entry with type selector and URL input */} + {fields.map(({ key, name, ...restField }) => ( + + + + + remove(name)} style={{ marginTop: 30 }} /> + + ))} + + + + + )} + +
+
+ ); +}); + +export default UploadFileListModal; \ No newline at end of file diff --git a/web/src/views/Conversation/index.tsx b/web/src/views/Conversation/index.tsx index 30b2a18a..0d09bcc4 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-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() const queryValues = Form.useWatch([], form) + + const uploadFileListModalRef = useRef(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 (
@@ -285,37 +329,75 @@ const Conversation: FC = () => {
} - 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} >
- - - - {t(`memoryConversation.web_search`)} - - - - - {t(`memoryConversation.memory`)} - - + + + + + ) + }, + ], + onClick: handleShowUpload + }} + > +
+
+
+ + + {t(`memoryConversation.web_search`)} + + + + + {t(`memoryConversation.memory`)} + + +
+ {/* + + + */}
+ +
) } diff --git a/web/src/views/Conversation/types.ts b/web/src/views/Conversation/types.ts index fd962ef5..deb14d1f 100644 --- a/web/src/views/Conversation/types.ts +++ b/web/src/views/Conversation/types.ts @@ -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; } \ No newline at end of file diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 4a1ac5a7..9b648505 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -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(({ appId, graphRef }, ref) => { const { t } = useTranslation() const { message: messageApi } = App.useApp() - const [form] = Form.useForm<{ message: string }>() const variableConfigModalRef = useRef(null) - 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) + // 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) + /** + * 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(({ 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 = {} if (variables.length > 0) { @@ -90,8 +148,8 @@ const Chat = forwardRef(({ 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(({ 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(({ 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(({ appId return newList }) break + // Track node execution start case 'node_start': setChatList(prev => { const newList = [...prev] @@ -170,6 +240,7 @@ const Chat = forwardRef(({ 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(({ appId return newList }) break + // Mark workflow as complete case 'workflow_end': setChatList(prev => { const newList = [...prev] @@ -221,14 +293,27 @@ const Chat = forwardRef(({ 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(({ 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 ( {t('workflow.run')} {variables.length > 0 && - + }
} classNames={{ @@ -275,7 +403,7 @@ const Chat = forwardRef(({ appId onClose={handleClose} > } data={chatList} @@ -365,19 +493,47 @@ const Chat = forwardRef(({ appId ) }} /> -
-
- - - -
- +
+ + + + + ) + }, + ], + onClick: handleShowUpload + }} + > +
+
+
+ {/* + + + */} +
+
(({ appId refresh={handleSave} variables={variables} /> + + ) })