feat(web): chat's audio add status
This commit is contained in:
@@ -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`)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user