feat(web): chat's audio add status

This commit is contained in:
zhaoying
2026-03-24 10:20:53 +08:00
parent 59f8010519
commit cbec2c1356
7 changed files with 217 additions and 74 deletions

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 13:59:56 * @Date: 2026-02-03 13:59:56
* @Last Modified by: ZhaoYing * @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' import { request, API_PREFIX } from '@/utils/request'
@@ -37,4 +37,8 @@ export const shareFileUploadUrl = `${API_PREFIX}${shareFileUploadUrlWithoutApiPr
// Get file info // Get file info
export const getFileInfoByUrl = (url: string) => { export const getFileInfoByUrl = (url: string) => {
return request.get('/storage/files/info-by-url', {url}) 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`)
} }

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-10 16:46:17 * @Date: 2025-12-10 16:46:17
* @Last Modified by: ZhaoYing * @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 { type FC, useRef, useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -38,7 +38,8 @@ const ChatContent: FC<ChatContentProps> = ({
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingIndex, setPlayingIndex] = useState<number | null>(null) const [playingIndex, setPlayingIndex] = useState<number | null>(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) { if (playingIndex === index) {
audioRef.current?.pause() audioRef.current?.pause()
setPlayingIndex(null) setPlayingIndex(null)
@@ -180,11 +181,16 @@ const ChatContent: FC<ChatContentProps> = ({
{item.meta_data?.audio_url && <> {item.meta_data?.audio_url && <>
<Divider className="rb:my-3!" /> <Divider className="rb:my-3!" />
<Space size={12} className="rb:pb-2 rb:pl-1"> <Space size={12} className="rb:pb-2 rb:pl-1">
{playingIndex !== index {playingIndex !== index && item.meta_data?.audio_status === 'pending'
? <SoundOutlined className="rb:cursor-pointer rb:hover:text-[#155EEF]! rb:size-5.5" onClick={() => handlePlay(index, item.meta_data?.audio_url!)} /> ? <Spin />
: playingIndex !== index
? <SoundOutlined className={clsx("rb:cursor-pointer rb:size-5.5", {
'rb:text-[#FF5D34]': item.meta_data?.audio_status === 'error',
'rb:hover:text-[#155EEF]!': !item.meta_data?.audio_status || !['pending', 'error'].includes(item.meta_data?.audio_status)
})} onClick={() => handlePlay(index, item.meta_data?.audio_url!, item.meta_data?.audio_status)} />
: <div : <div
className="rb:size-5.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]" className="rb:size-5.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]"
onClick={() => handlePlay(index, item.meta_data?.audio_url!)} onClick={() => handlePlay(index, item.meta_data?.audio_url!, item.meta_data?.audio_status)}
/> />
} }
</Space> </Space>

View File

@@ -5,7 +5,7 @@
* @Last Modified time: 2026-03-23 17:46:25 * @Last Modified time: 2026-03-23 17:46:25
*/ */
import { type FC, useEffect, useMemo, useState } from 'react' 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 clsx from 'clsx'
import type { ChatInputProps } from './types' import type { ChatInputProps } from './types'
@@ -24,30 +24,19 @@ const ChatInput: FC<ChatInputProps> = ({
className = '', className = '',
onChange onChange
}) => { }) => {
const [form] = Form.useForm() const [inputValue, setInputValue] = useState('')
const values = Form.useWatch([], form)
const [isFocus, setIsFocus] = useState(false) 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(() => { useEffect(() => {
if (!message) { if (!message) setInputValue('')
form.setFieldsValue({ }, [message])
message: undefined,
})
}
}, [form, message])
// Clear input when loading // Clear input when loading
useEffect(() => { useEffect(() => {
if (loading) { if (loading) setInputValue('')
form.setFieldsValue({
message: undefined,
})
}
}, [loading]) }, [loading])
const handleDelete = (file: any) => { const handleDelete = (file: any) => {
fileChange?.(fileList?.filter(item => { fileChange?.(fileList?.filter(item => {
return item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl return item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl
@@ -55,7 +44,7 @@ const ChatInput: FC<ChatInputProps> = ({
: item.uid !== file.uid : item.uid !== file.uid
}) || []) }) || [])
} }
// Convert file object to preview URL
const previewFileList = useMemo(() => { const previewFileList = useMemo(() => {
return fileList?.map(file => ({ return fileList?.map(file => ({
...file, ...file,
@@ -64,16 +53,11 @@ const ChatInput: FC<ChatInputProps> = ({
}, [fileList]) }, [fileList])
const handleSend = () => { const handleSend = () => {
if (loading || !values || !values?.message || values?.message?.trim() === '') return if (loading || !inputValue || inputValue.trim() === '') return
onSend(values.message) onSend(inputValue)
} }
const handleFocus = () => { const canSend = !loading && inputValue.trim() !== ''
setIsFocus(true)
}
const handleBlur = () => {
setIsFocus(false)
}
return ( return (
<div className={`rb:absolute rb:bottom-3 rb:left-0 rb:right-0 rb:w-full ${className}`}> <div className={`rb:absolute rb:bottom-3 rb:left-0 rb:right-0 rb:w-full ${className}`}>
@@ -174,41 +158,40 @@ const ChatInput: FC<ChatInputProps> = ({
})} })}
</Flex> </Flex>
</div>} </div>}
{/* Message input form */} {/* Message input area */}
<Form form={form} layout="vertical"> <Input.TextArea
<Form.Item name="message" noStyle> value={inputValue}
<Input.TextArea className="rb:m-[10px_12px_10px_12px]! rb:p-0! rb:w-[calc(100%-24px)]! rb:flex-[1_1_auto] rb:h-15! rb:resize-none! rb:rounded-none!"
className="rb:m-[10px_12px_10px_12px]! rb:p-0! rb:w-[calc(100%-24px)]! rb:flex-[1_1_auto] rb:h-15! rb:resize-none! rb:rounded-none!" variant="borderless"
variant="borderless" onChange={(e) => {
onChange={(e) => onChange?.(e.target.value)} setInputValue(e.target.value)
onKeyDown={(e) => { onChange?.(e.target.value)
// Enter to send, Shift+Enter for new line }}
if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) { onKeyDown={(e) => {
e.preventDefault(); // Enter to send, Shift+Enter for new line
handleSend(); if (e.key === 'Enter' && !e.shiftKey && (e.target as HTMLTextAreaElement).value?.trim() !== '' && !loading) {
} e.preventDefault();
}} handleSend();
onFocus={handleFocus} }
onBlur={handleBlur} }}
/> onFocus={() => setIsFocus(true)}
</Form.Item> onBlur={() => setIsFocus(false)}
</Form> />
{/* Bottom action area */} {/* Bottom action area */}
<Flex align="center" justify="space-between" gap={8} className="rb:mx-2.5! rb:mb-2.5!"> <Flex align="center" justify="space-between" gap={8} className="rb:mx-2.5! rb:mb-2.5!">
{/* Child component content (such as buttons) */}
<div className="rb:flex-1">{children}</div> <div className="rb:flex-1">{children}</div>
<Flex align="center" justify="center" <Flex align="center" justify="center"
className={clsx('rb:size-7 rb:rounded-full rb:shadow-[0px 2px 12px 0px rgba(23,23,25,0.1)]', { className={clsx('rb:size-7 rb:rounded-full rb:shadow-[0px 2px 12px 0px rgba(23,23,25,0.1)]', {
'rb:cursor-not-allowed rb:bg-[#F6F6F6]': loading || !values || !values?.message || values?.message?.trim() === '', 'rb:cursor-not-allowed rb:bg-[#F6F6F6]': !canSend,
'rb:cursor-pointer rb:bg-[#171719]': !loading && !(!values || !values?.message || values?.message?.trim() === '') 'rb:cursor-pointer rb:bg-[#171719]': canSend
})} })}
onClick={handleSend} onClick={handleSend}
> >
<div className={clsx("rb:size-4 rb:bg-cover", { <div className={clsx("rb:size-4 rb:bg-cover", {
"rb:bg-[url('@/assets/images/conversation/loading.svg')]": loading, "rb:bg-[url('@/assets/images/conversation/loading.svg')]": loading,
"rb:bg-[url('@/assets/images/conversation/sendDisabled.svg')]": !loading && (!values || !values?.message || values?.message?.trim() === ''), "rb:bg-[url('@/assets/images/conversation/sendDisabled.svg')]": !loading && !canSend,
"rb:bg-[url('@/assets/images/conversation/send.svg')]": !loading && !(!values || !values?.message || values?.message?.trim() === '') "rb:bg-[url('@/assets/images/conversation/send.svg')]": canSend
})}></div> })}></div>
</Flex> </Flex>
</Flex> </Flex>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2025-12-10 16:45:54 * @Date: 2025-12-10 16:45:54
* @Last Modified by: ZhaoYing * @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' import { type ReactNode } from 'react'
@@ -25,6 +25,7 @@ export interface ChatItem {
error?: string; error?: string;
meta_data?: { meta_data?: {
audio_url?: string; audio_url?: string;
audio_status?: string;
files?: any[]; files?: any[];
}, },
} }

View File

@@ -1,8 +1,8 @@
/* /*
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-03-13 17:27:52 * @Date: 2026-03-13 17:27:52
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 20:54:35 * @Last Modified time: 2026-03-24 10:19:31
*/ */
import { type FC, useState, useRef, useEffect } from 'react' import { type FC, useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next' 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 { TestChatProps } from './type'
import type { SSEMessage } from '@/utils/stream' import type { SSEMessage } from '@/utils/stream'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types' import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
import { getFileStatusById } from '@/api/fileStorage'
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => { const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
return { return {
@@ -79,6 +80,9 @@ const TestChat: FC<TestChatProps> = ({
const [message, setMessage] = useState<string | undefined>(undefined) const [message, setMessage] = useState<string | undefined>(undefined)
const [fileList, setFileList] = useState<any[]>([]) const [fileList, setFileList] = useState<any[]>([])
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
getVariables() getVariables()
@@ -142,9 +146,12 @@ const TestChat: FC<TestChatProps> = ({
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastMsg = newList[newList.length - 1] const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') { if (lastMsg?.role === 'assistant') {
lastMsg.content += content; newList[newList.length - 1] = {
lastMsg.meta_data = {audio_url} ...lastMsg,
content: lastMsg.content + content,
...(audio_url !== undefined ? { meta_data: { ...lastMsg.meta_data, audio_url, audio_status: 'pending' } } : {})
}
} }
return newList return newList
}) })
@@ -211,6 +218,22 @@ const TestChat: FC<TestChatProps> = ({
}) })
} }
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[]) => { const handleStreamMessage = (data: SSEMessage[]) => {
data.map(item => { 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; }; 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<TestChatProps> = ({
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id) if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
break break
case 'end': case 'end':
if (audio_url && !audioStatusMap[audio_url]) {
setAudioStatusMap(prev => ({
...prev,
[audio_url]: 'pending'
}))
}
if (audio_url) { 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) updateErrorAssistantMessage(message_length)
setStreamLoading(false) setStreamLoading(false)
@@ -426,7 +479,7 @@ const TestChat: FC<TestChatProps> = ({
} }
const updateWorkflowEndMessage = (data: NodeData) => { const updateWorkflowEndMessage = (data: NodeData) => {
const { error, status, audio_url } = data; const { error, status } = data;
setChatList(prev => { setChatList(prev => {
const newList = [...prev] const newList = [...prev]
const lastIndex = newList.length - 1 const lastIndex = newList.length - 1
@@ -436,7 +489,6 @@ const TestChat: FC<TestChatProps> = ({
status, status,
error, error,
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content, content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
meta_data: { audio_url: audio_url }
} }
} }
return newList return newList

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:27:39 * @Date: 2026-02-03 16:27:39
* @Last Modified by: ZhaoYing * @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 * Chat debugging component for application testing
@@ -28,6 +28,7 @@ import ChatInput from '@/components/Chat/ChatInput'
import ChatToolbar from '@/components/Chat/ChatToolbar' import ChatToolbar from '@/components/Chat/ChatToolbar'
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar' import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import type { Variable } from './VariableList/types' import type { Variable } from './VariableList/types'
import { getFileStatusById } from '@/api/fileStorage'
/** /**
@@ -62,6 +63,8 @@ const Chat: FC<ChatProps> = ({
const { id } = useParams() const { id } = useParams()
const { message: messageApi } = App.useApp() const { message: messageApi } = App.useApp()
const toolbarRef = useRef<ChatToolbarRef>(null) const toolbarRef = useRef<ChatToolbarRef>(null)
const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
const msgCreatedAtRef = useRef<Map<string, number | string>>(new Map())
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [isCluster, setIsCluster] = useState(source === 'multi_agent') const [isCluster, setIsCluster] = useState(source === 'multi_agent')
const [conversationId, setConversationId] = useState<string | null>(null) const [conversationId, setConversationId] = useState<string | null>(null)
@@ -69,6 +72,7 @@ const Chat: FC<ChatProps> = ({
const [fileList, setFileList] = useState<any[]>([]) const [fileList, setFileList] = useState<any[]>([])
const [message, setMessage] = useState<string | undefined>(undefined) const [message, setMessage] = useState<string | undefined>(undefined)
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
setCompareLoading(false) setCompareLoading(false)
@@ -117,6 +121,7 @@ const Chat: FC<ChatProps> = ({
const assistantMessages: Record<string, ChatItem> = {} const assistantMessages: Record<string, ChatItem> = {}
chatList.forEach(item => { chatList.forEach(item => {
assistantMessages[item.model_config_id as string] = assistantMessage 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 => ({ updateChatList(prev => prev.map(item => ({
...item, ...item,
@@ -143,7 +148,7 @@ const Chat: FC<ChatProps> = ({
{ {
...lastMsg, ...lastMsg,
content: lastMsg.content + (content || ''), 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<ChatProps> = ({
return prev 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 */ /** Send message for agent comparison mode */
const handleSend = (msg?: string) => { const handleSend = (msg?: string) => {
if (loading || !id) return if (loading || !id) return
@@ -215,7 +240,7 @@ const Chat: FC<ChatProps> = ({
} }
addUserMessage(message, files) addUserMessage(message, files)
setMessage(message) setMessage(undefined)
toolbarRef.current?.setFiles([]) toolbarRef.current?.setFiles([])
setFileList([]) setFileList([])
addAssistantMessage() addAssistantMessage()
@@ -231,8 +256,38 @@ const Chat: FC<ChatProps> = ({
updateAssistantMessage(content, model_config_id, conversation_id, audio_url) updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
break; break;
case 'model_end': case 'model_end':
const idToPoll = `${model_config_id}_${audio_url}`
if (audio_url && !audioStatusMap[idToPoll]) {
setAudioStatusMap(prev => ({
...prev,
[idToPoll]: 'pending'
}))
}
if (audio_url) { if (audio_url) {
updateAssistantMessage(content, model_config_id, conversation_id, 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) updateErrorAssistantMessage(message_length, model_config_id)
break; break;
@@ -504,7 +559,6 @@ const Chat: FC<ChatProps> = ({
}} }}
fileList={fileList} fileList={fileList}
onSend={isCluster ? handleClusterSend : handleSend} onSend={isCluster ? handleClusterSend : handleSend}
onChange={setMessage}
> >
<ChatToolbar <ChatToolbar
ref={toolbarRef} ref={toolbarRef}

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 16:58:03 * @Date: 2026-02-03 16:58:03
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-20 15:38:36 * @Last Modified time: 2026-03-24 10:19:34
*/ */
/** /**
* Conversation Page * Conversation Page
@@ -31,6 +31,7 @@ import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar' import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types' import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
import { getFileStatusById } from '@/api/fileStorage';
const Conversation: FC = () => { const Conversation: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -51,6 +52,7 @@ const Conversation: FC = () => {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<ChatToolbarRef>(null) const toolbarRef = useRef<ChatToolbarRef>(null)
const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`)) const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
const [fileList, setFileList] = useState<any[]>([]) const [fileList, setFileList] = useState<any[]>([])
const [webSearch, setWebSearch] = useState(false) const [webSearch, setWebSearch] = useState(false)
@@ -58,6 +60,7 @@ const Conversation: FC = () => {
const [memory, setMemory] = useState(true) const [memory, setMemory] = useState(true)
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm) const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
const [config, setConfig] = useState<Record<string, any>>({}) const [config, setConfig] = useState<Record<string, any>>({})
const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({})
useEffect(() => { useEffect(() => {
const shareToken = localStorage.getItem(`shareToken_${token}`) 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 (!content && !audio_url) return
if (streamLoading) setStreamLoading(false) if (streamLoading) setStreamLoading(false)
setChatList(prev => { setChatList(prev => {
@@ -183,13 +186,28 @@ const Conversation: FC = () => {
{ {
...lastMsg, ...lastMsg,
content: lastMsg.content + content, content: lastMsg.content + content,
meta_data: { audio_url } meta_data: { audio_url, audio_status }
} }
] ]
} }
return prev 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 */ /** Send message and handle streaming response */
const handleSend = () => { const handleSend = () => {
@@ -232,13 +250,38 @@ const Conversation: FC = () => {
currentConversationId = newId currentConversationId = newId
break break
case 'message': case 'message':
updateAssistantMessage(content, audio_url) updateAssistantMessage(content, audio_url, audio_url ? 'pending' : undefined)
if (curId) currentConversationId = curId; if (curId) currentConversationId = curId;
break break
case 'end': case 'end':
case 'workflow_end': case 'workflow_end':
if (audio_url) { 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) setLoading(false)
if (currentConversationId && currentConversationId !== conversation_id) { if (currentConversationId && currentConversationId !== conversation_id) {