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 00000000..358f0a9d Binary files /dev/null and b/web/src/assets/images/conversation/audio_ing.gif differ 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} /> + + ) })