From cbec2c13560cd1f21c914c330968b1b0d9223454 Mon Sep 17 00:00:00 2001 From: zhaoying Date: Tue, 24 Mar 2026 10:20:53 +0800 Subject: [PATCH] feat(web): chat's audio add status --- web/src/api/fileStorage.ts | 6 +- web/src/components/Chat/ChatContent.tsx | 16 ++-- web/src/components/Chat/ChatInput.tsx | 83 ++++++++----------- web/src/components/Chat/types.ts | 3 +- .../ApplicationConfig/TestChat/index.tsx | 68 +++++++++++++-- .../ApplicationConfig/components/Chat.tsx | 62 +++++++++++++- web/src/views/Conversation/index.tsx | 53 ++++++++++-- 7 files changed, 217 insertions(+), 74 deletions(-) diff --git a/web/src/api/fileStorage.ts b/web/src/api/fileStorage.ts index a992d460..83f5b212 100644 --- a/web/src/api/fileStorage.ts +++ b/web/src/api/fileStorage.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 13:59:56 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-23 17:48:40 + * @Last Modified time: 2026-03-23 18:05:43 */ import { request, API_PREFIX } from '@/utils/request' @@ -37,4 +37,8 @@ export const shareFileUploadUrl = `${API_PREFIX}${shareFileUploadUrlWithoutApiPr // Get file info export const getFileInfoByUrl = (url: string) => { return request.get('/storage/files/info-by-url', {url}) +} +// Get file status +export const getFileStatusById = (file_id: string) => { + return request.get(`/storage/files/${file_id}/status`) } \ No newline at end of file diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index aa6f28bd..a1439746 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-03-19 20:45:39 + * @Last Modified time: 2026-03-23 18:24:33 */ import { type FC, useRef, useEffect, useState } from 'react' import clsx from 'clsx' @@ -38,7 +38,8 @@ const ChatContent: FC = ({ const audioRef = useRef(null) const [playingIndex, setPlayingIndex] = useState(null) - const handlePlay = (index: number, audio_url: string) => { + const handlePlay = (index: number, audio_url: string, audio_status?: string) => { + if (audio_status !== 'completed' && !audio_status) return if (playingIndex === index) { audioRef.current?.pause() setPlayingIndex(null) @@ -180,11 +181,16 @@ const ChatContent: FC = ({ {item.meta_data?.audio_url && <> - {playingIndex !== index - ? handlePlay(index, item.meta_data?.audio_url!)} /> + {playingIndex !== index && item.meta_data?.audio_status === 'pending' + ? + : playingIndex !== index + ? handlePlay(index, item.meta_data?.audio_url!, item.meta_data?.audio_status)} /> :
handlePlay(index, item.meta_data?.audio_url!)} + onClick={() => handlePlay(index, item.meta_data?.audio_url!, item.meta_data?.audio_status)} /> } diff --git a/web/src/components/Chat/ChatInput.tsx b/web/src/components/Chat/ChatInput.tsx index e2fcb82f..6495ff06 100644 --- a/web/src/components/Chat/ChatInput.tsx +++ b/web/src/components/Chat/ChatInput.tsx @@ -5,7 +5,7 @@ * @Last Modified time: 2026-03-23 17:46:25 */ import { type FC, useEffect, useMemo, useState } from 'react' -import { Flex, Input, Form, Spin } from 'antd' +import { Flex, Input, Spin } from 'antd' import clsx from 'clsx' import type { ChatInputProps } from './types' @@ -24,30 +24,19 @@ const ChatInput: FC = ({ className = '', onChange }) => { - const [form] = Form.useForm() - const values = Form.useWatch([], form) + const [inputValue, setInputValue] = useState('') const [isFocus, setIsFocus] = useState(false) - // Monitor form value changes to control send button state - // Clear form when external message is empty + // Clear input when external message is cleared useEffect(() => { - if (!message) { - form.setFieldsValue({ - message: undefined, - }) - } - }, [form, message]) + if (!message) setInputValue('') + }, [message]) // Clear input when loading useEffect(() => { - if (loading) { - form.setFieldsValue({ - message: undefined, - }) - } + if (loading) setInputValue('') }, [loading]) - const handleDelete = (file: any) => { fileChange?.(fileList?.filter(item => { return item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl @@ -55,7 +44,7 @@ const ChatInput: FC = ({ : item.uid !== file.uid }) || []) } - // Convert file object to preview URL + const previewFileList = useMemo(() => { return fileList?.map(file => ({ ...file, @@ -64,16 +53,11 @@ const ChatInput: FC = ({ }, [fileList]) const handleSend = () => { - if (loading || !values || !values?.message || values?.message?.trim() === '') return - onSend(values.message) + if (loading || !inputValue || inputValue.trim() === '') return + onSend(inputValue) } - const handleFocus = () => { - setIsFocus(true) - } - const handleBlur = () => { - setIsFocus(false) - } + const canSend = !loading && inputValue.trim() !== '' return (
@@ -174,41 +158,40 @@ const ChatInput: FC = ({ })}
} - {/* Message input form */} -
- - 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(); - } - }} - onFocus={handleFocus} - onBlur={handleBlur} - /> - -
+ {/* Message input area */} + { + setInputValue(e.target.value) + 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(); + } + }} + onFocus={() => setIsFocus(true)} + onBlur={() => setIsFocus(false)} + /> {/* Bottom action area */} - {/* Child component content (such as buttons) */}
{children}
diff --git a/web/src/components/Chat/types.ts b/web/src/components/Chat/types.ts index dbce8faa..ceb42c3b 100644 --- a/web/src/components/Chat/types.ts +++ b/web/src/components/Chat/types.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2025-12-10 16:45:54 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-19 20:47:12 + * @Last Modified time: 2026-03-23 18:15:05 */ import { type ReactNode } from 'react' @@ -25,6 +25,7 @@ export interface ChatItem { error?: string; meta_data?: { audio_url?: string; + audio_status?: string; files?: any[]; }, } diff --git a/web/src/views/ApplicationConfig/TestChat/index.tsx b/web/src/views/ApplicationConfig/TestChat/index.tsx index c324622d..af291d1f 100644 --- a/web/src/views/ApplicationConfig/TestChat/index.tsx +++ b/web/src/views/ApplicationConfig/TestChat/index.tsx @@ -1,8 +1,8 @@ /* * @Author: ZhaoYing * @Date: 2026-03-13 17:27:52 - * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-18 20:54:35 + * @Last Modified by: ZhaoYing + * @Last Modified time: 2026-03-24 10:19:31 */ import { type FC, useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -26,6 +26,7 @@ import type { Variable } from '@/views/Workflow/components/Properties/VariableLi import type { TestChatProps } from './type' import type { SSEMessage } from '@/utils/stream' import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types' +import { getFileStatusById } from '@/api/fileStorage' const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record) => { return { @@ -79,6 +80,9 @@ const TestChat: FC = ({ const [message, setMessage] = useState(undefined) const [fileList, setFileList] = useState([]) const [features, setFeatures] = useState({} as FeaturesConfigForm) + + const audioPollingRef = useRef>>(new Map()) + const [audioStatusMap, setAudioStatusMap] = useState>({}) useEffect(() => { getVariables() @@ -142,9 +146,12 @@ const TestChat: FC = ({ setChatList(prev => { const newList = [...prev] const lastMsg = newList[newList.length - 1] - if (lastMsg.role === 'assistant') { - lastMsg.content += content; - lastMsg.meta_data = {audio_url} + if (lastMsg?.role === 'assistant') { + newList[newList.length - 1] = { + ...lastMsg, + content: lastMsg.content + content, + ...(audio_url !== undefined ? { meta_data: { ...lastMsg.meta_data, audio_url, audio_status: 'pending' } } : {}) + } } return newList }) @@ -211,6 +218,22 @@ const TestChat: FC = ({ }) } + useEffect(() => { + if (!Object.keys(audioStatusMap).length) return + setChatList(prev => prev.map(msg => { + if (msg.role === 'assistant' && msg.meta_data?.audio_url && audioStatusMap[msg.meta_data.audio_url]) { + return { + ...msg, + meta_data: { + ...msg.meta_data, + audio_status: audioStatusMap[msg.meta_data.audio_url] + } + } + } + return msg + })) + }, [audioStatusMap, chatList.length]) + const handleStreamMessage = (data: SSEMessage[]) => { data.map(item => { const { conversation_id, content, message_length, audio_url } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; }; @@ -223,8 +246,38 @@ const TestChat: FC = ({ if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id) break case 'end': + if (audio_url && !audioStatusMap[audio_url]) { + setAudioStatusMap(prev => ({ + ...prev, + [audio_url]: 'pending' + })) + } if (audio_url) { - updateAssistantMessage(content, audio_url) + updateAssistantMessage(content || '', audio_url) + const { file_id } = item.data as { file_id?: string } + const idToPoll = file_id || audio_url || '' + const fileId = audio_url.split('/').pop() + if (fileId && idToPoll && !audioPollingRef.current.has(idToPoll)) { + const timer = setInterval(() => { + getFileStatusById(fileId) + .then(res => { + const { status } = res as { status: string } + if (status && status !== 'pending') { + setAudioStatusMap(prev => ({ + ...prev, + [audio_url]: status + })) + clearInterval(audioPollingRef.current.get(idToPoll)) + audioPollingRef.current.delete(idToPoll) + } + }) + .catch(() => { + clearInterval(audioPollingRef.current.get(idToPoll)) + audioPollingRef.current.delete(idToPoll) + }) + }, 2000) + audioPollingRef.current.set(idToPoll, timer) + } } updateErrorAssistantMessage(message_length) setStreamLoading(false) @@ -426,7 +479,7 @@ const TestChat: FC = ({ } const updateWorkflowEndMessage = (data: NodeData) => { - const { error, status, audio_url } = data; + const { error, status } = data; setChatList(prev => { const newList = [...prev] const lastIndex = newList.length - 1 @@ -436,7 +489,6 @@ const TestChat: FC = ({ status, error, content: newList[lastIndex].content === '' ? null : newList[lastIndex].content, - meta_data: { audio_url: audio_url } } } return newList diff --git a/web/src/views/ApplicationConfig/components/Chat.tsx b/web/src/views/ApplicationConfig/components/Chat.tsx index 38225104..7608c238 100644 --- a/web/src/views/ApplicationConfig/components/Chat.tsx +++ b/web/src/views/ApplicationConfig/components/Chat.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-20 15:40:44 + * @Last Modified time: 2026-03-24 10:12:09 */ /** * Chat debugging component for application testing @@ -28,6 +28,7 @@ import ChatInput from '@/components/Chat/ChatInput' import ChatToolbar from '@/components/Chat/ChatToolbar' import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar' import type { Variable } from './VariableList/types' +import { getFileStatusById } from '@/api/fileStorage' /** @@ -62,6 +63,8 @@ const Chat: FC = ({ const { id } = useParams() const { message: messageApi } = App.useApp() const toolbarRef = useRef(null) + const audioPollingRef = useRef>>(new Map()) + const msgCreatedAtRef = useRef>(new Map()) const [loading, setLoading] = useState(false) const [isCluster, setIsCluster] = useState(source === 'multi_agent') const [conversationId, setConversationId] = useState(null) @@ -69,6 +72,7 @@ const Chat: FC = ({ const [fileList, setFileList] = useState([]) const [message, setMessage] = useState(undefined) const [features, setFeatures] = useState({} as FeaturesConfigForm) + const [audioStatusMap, setAudioStatusMap] = useState>({}) useEffect(() => { setCompareLoading(false) @@ -117,6 +121,7 @@ const Chat: FC = ({ const assistantMessages: Record = {} chatList.forEach(item => { assistantMessages[item.model_config_id as string] = assistantMessage + msgCreatedAtRef.current.set(item.model_config_id as string, assistantMessage.created_at as number) }) updateChatList(prev => prev.map(item => ({ ...item, @@ -143,7 +148,7 @@ const Chat: FC = ({ { ...lastMsg, content: lastMsg.content + (content || ''), - meta_data: { audio_url } + ...(audio_url !== undefined ? { meta_data: { audio_url, audio_status: 'pending' } } : {}) } ] } @@ -180,6 +185,26 @@ const Chat: FC = ({ return prev }) } + + useEffect(() => { + updateChatList(prev => prev.map(item => ({ + ...item, + list: item.list?.map(msg => { + console.log('item', item) + const id = `${item.model_config_id}_${msg.meta_data?.audio_url}` + if (msg.role === 'assistant' && msg.meta_data?.audio_url && audioStatusMap[id]) { + return { + ...msg, + meta_data: { + ...msg.meta_data, + audio_status: audioStatusMap[id] + } + } + } + return msg + }) + }))) + }, [chatList.length, audioStatusMap]) /** Send message for agent comparison mode */ const handleSend = (msg?: string) => { if (loading || !id) return @@ -215,7 +240,7 @@ const Chat: FC = ({ } addUserMessage(message, files) - setMessage(message) + setMessage(undefined) toolbarRef.current?.setFiles([]) setFileList([]) addAssistantMessage() @@ -231,8 +256,38 @@ const Chat: FC = ({ updateAssistantMessage(content, model_config_id, conversation_id, audio_url) break; case 'model_end': + const idToPoll = `${model_config_id}_${audio_url}` + if (audio_url && !audioStatusMap[idToPoll]) { + setAudioStatusMap(prev => ({ + ...prev, + [idToPoll]: 'pending' + })) + } if (audio_url) { updateAssistantMessage(content, model_config_id, conversation_id, audio_url) + const fileId = audio_url.split('/').pop() + if (fileId && idToPoll && !audioPollingRef.current.has(idToPoll)) { + const timer = setInterval(() => { + getFileStatusById(fileId) + .then(res => { + const { status } = res as { status: string } + if (status && status !== 'pending') { + clearInterval(audioPollingRef.current.get(idToPoll)) + audioPollingRef.current.delete(idToPoll) + setAudioStatusMap(prev => ({ + ...prev, + [idToPoll]: status + })) + } + }) + .catch((e) => { + console.log('[audio poll] error', e) + clearInterval(audioPollingRef.current.get(idToPoll)) + audioPollingRef.current.delete(idToPoll) + }) + }, 2000) + audioPollingRef.current.set(idToPoll, timer) + } } updateErrorAssistantMessage(message_length, model_config_id) break; @@ -504,7 +559,6 @@ const Chat: FC = ({ }} fileList={fileList} onSend={isCluster ? handleClusterSend : handleSend} - onChange={setMessage} > { const { t } = useTranslation() @@ -51,6 +52,7 @@ const Conversation: FC = () => { const [hasMore, setHasMore] = useState(true); const scrollRef = useRef(null); const toolbarRef = useRef(null) + const audioPollingRef = useRef>>(new Map()) const [shareToken, setShareToken] = useState(localStorage.getItem(`shareToken_${token}`)) const [fileList, setFileList] = useState([]) const [webSearch, setWebSearch] = useState(false) @@ -58,6 +60,7 @@ const Conversation: FC = () => { const [memory, setMemory] = useState(true) const [features, setFeatures] = useState({} as FeaturesConfigForm) const [config, setConfig] = useState>({}) + const [audioStatusMap, setAudioStatusMap] = useState>({}) useEffect(() => { const shareToken = localStorage.getItem(`shareToken_${token}`) @@ -170,7 +173,7 @@ const Conversation: FC = () => { }]) } - const updateAssistantMessage = (content: string = '', audio_url?: string) => { + const updateAssistantMessage = (content: string = '', audio_url?: string, audio_status?: string) => { if (!content && !audio_url) return if (streamLoading) setStreamLoading(false) setChatList(prev => { @@ -183,13 +186,28 @@ const Conversation: FC = () => { { ...lastMsg, content: lastMsg.content + content, - meta_data: { audio_url } + meta_data: { audio_url, audio_status } } ] } return prev }) } + useEffect(() => { + if (!Object.keys(audioStatusMap).length) return + setChatList(prev => prev.map(msg => { + if (msg.role === 'assistant' && msg.meta_data?.audio_url && audioStatusMap[msg.meta_data.audio_url]) { + return { + ...msg, + meta_data: { + ...msg.meta_data, + audio_status: audioStatusMap[msg.meta_data.audio_url] + } + } + } + return msg + })) + }, [audioStatusMap, chatList.length]) /** Send message and handle streaming response */ const handleSend = () => { @@ -232,13 +250,38 @@ const Conversation: FC = () => { currentConversationId = newId break case 'message': - updateAssistantMessage(content, audio_url) + updateAssistantMessage(content, audio_url, audio_url ? 'pending' : undefined) if (curId) currentConversationId = curId; break case 'end': case 'workflow_end': if (audio_url) { - updateAssistantMessage(content, audio_url) + updateAssistantMessage(content, audio_url, 'pending') + const { file_id } = item.data as { file_id?: string } + const idToPoll = file_id || audio_url || '' + const fileId = audio_url.split('/').pop() + if (fileId && idToPoll && !audioPollingRef.current.has(idToPoll)) { + + const timer = setInterval(() => { + getFileStatusById(fileId) + .then(res => { + const { status } = res as { status: string } + if (status && status !== 'pending') { + setAudioStatusMap(prev => ({ + ...prev, + [idToPoll]: status + })) + clearInterval(audioPollingRef.current.get(idToPoll)) + audioPollingRef.current.delete(idToPoll) + } + }) + .catch(() => { + clearInterval(audioPollingRef.current.get(idToPoll)) + audioPollingRef.current.delete(idToPoll) + }) + }, 2000) + audioPollingRef.current.set(idToPoll, timer) + } } setLoading(false) if (currentConversationId && currentConversationId !== conversation_id) {