### What problem does this PR solve? feat: Play audio #2088 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.11.0
| "jsencrypt": "^3.3.2", | "jsencrypt": "^3.3.2", | ||||
| "lodash": "^4.17.21", | "lodash": "^4.17.21", | ||||
| "mammoth": "^1.7.2", | "mammoth": "^1.7.2", | ||||
| "openai-speech-stream-player": "^1.0.8", | |||||
| "rc-tween-one": "^3.0.6", | "rc-tween-one": "^3.0.6", | ||||
| "react-copy-to-clipboard": "^5.1.0", | "react-copy-to-clipboard": "^5.1.0", | ||||
| "react-force-graph": "^1.44.4", | "react-force-graph": "^1.44.4", | ||||
| "node": ">=12" | "node": ">=12" | ||||
| } | } | ||||
| }, | }, | ||||
| "node_modules/openai-speech-stream-player": { | |||||
| "version": "1.0.8", | |||||
| "resolved": "https://registry.npmmirror.com/openai-speech-stream-player/-/openai-speech-stream-player-1.0.8.tgz", | |||||
| "integrity": "sha512-0SUybbhStl65s66ezh2QaoZE5k1kNb2t5M8tDOqJFILdHpwHaBqnYy4uHl3Hk/8F5VFWxxHaLamjKOnfNDKgbw==" | |||||
| }, | |||||
| "node_modules/option": { | "node_modules/option": { | ||||
| "version": "0.2.4", | "version": "0.2.4", | ||||
| "resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", | "resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", |
| "jsencrypt": "^3.3.2", | "jsencrypt": "^3.3.2", | ||||
| "lodash": "^4.17.21", | "lodash": "^4.17.21", | ||||
| "mammoth": "^1.7.2", | "mammoth": "^1.7.2", | ||||
| "openai-speech-stream-player": "^1.0.8", | |||||
| "rc-tween-one": "^3.0.6", | "rc-tween-one": "^3.0.6", | ||||
| "react-copy-to-clipboard": "^5.1.0", | "react-copy-to-clipboard": "^5.1.0", | ||||
| "react-force-graph": "^1.44.4", | "react-force-graph": "^1.44.4", |
| DeleteOutlined, | DeleteOutlined, | ||||
| DislikeOutlined, | DislikeOutlined, | ||||
| LikeOutlined, | LikeOutlined, | ||||
| PauseCircleOutlined, | |||||
| SoundOutlined, | SoundOutlined, | ||||
| SyncOutlined, | SyncOutlined, | ||||
| } from '@ant-design/icons'; | } from '@ant-design/icons'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import SvgIcon from '../svg-icon'; | import SvgIcon from '../svg-icon'; | ||||
| import FeedbackModal from './feedback-modal'; | import FeedbackModal from './feedback-modal'; | ||||
| import { useRemoveMessage, useSendFeedback } from './hooks'; | |||||
| import { useRemoveMessage, useSendFeedback, useSpeech } from './hooks'; | |||||
| import PromptModal from './prompt-modal'; | import PromptModal from './prompt-modal'; | ||||
| interface IProps { | interface IProps { | ||||
| showModal: showPromptModal, | showModal: showPromptModal, | ||||
| } = useSetModalState(); | } = useSetModalState(); | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { handleRead, ref, isPlaying } = useSpeech(content); | |||||
| const handleLike = useCallback(() => { | const handleLike = useCallback(() => { | ||||
| onFeedbackOk({ thumbup: true }); | onFeedbackOk({ thumbup: true }); | ||||
| <Radio.Button value="a"> | <Radio.Button value="a"> | ||||
| <CopyToClipboard text={content}></CopyToClipboard> | <CopyToClipboard text={content}></CopyToClipboard> | ||||
| </Radio.Button> | </Radio.Button> | ||||
| <Radio.Button value="b"> | |||||
| <Radio.Button value="b" onClick={handleRead}> | |||||
| <Tooltip title={t('chat.read')}> | <Tooltip title={t('chat.read')}> | ||||
| <SoundOutlined /> | |||||
| {isPlaying ? <PauseCircleOutlined /> : <SoundOutlined />} | |||||
| </Tooltip> | </Tooltip> | ||||
| <audio src="" ref={ref}></audio> | |||||
| </Radio.Button> | </Radio.Button> | ||||
| {showLikeButton && ( | {showLikeButton && ( | ||||
| <> | <> |
| import { useDeleteMessage, useFeedback } from '@/hooks/chat-hooks'; | import { useDeleteMessage, useFeedback } from '@/hooks/chat-hooks'; | ||||
| import { useSetModalState } from '@/hooks/common-hooks'; | import { useSetModalState } from '@/hooks/common-hooks'; | ||||
| import { IRemoveMessageById } from '@/hooks/logic-hooks'; | |||||
| import { IRemoveMessageById, useSpeechWithSse } from '@/hooks/logic-hooks'; | |||||
| import { IFeedbackRequestBody } from '@/interfaces/request/chat'; | import { IFeedbackRequestBody } from '@/interfaces/request/chat'; | ||||
| import { getMessagePureId } from '@/utils/chat'; | import { getMessagePureId } from '@/utils/chat'; | ||||
| import { useCallback } from 'react'; | |||||
| import { SpeechPlayer } from 'openai-speech-stream-player'; | |||||
| import { useCallback, useEffect, useRef, useState } from 'react'; | |||||
| export const useSendFeedback = (messageId: string) => { | export const useSendFeedback = (messageId: string) => { | ||||
| const { visible, hideModal, showModal } = useSetModalState(); | const { visible, hideModal, showModal } = useSetModalState(); | ||||
| return { onRemoveMessage, loading }; | return { onRemoveMessage, loading }; | ||||
| }; | }; | ||||
| export const useSpeech = (content: string) => { | |||||
| const ref = useRef<HTMLAudioElement>(null); | |||||
| const { read } = useSpeechWithSse(); | |||||
| const player = useRef<SpeechPlayer>(); | |||||
| const [isPlaying, setIsPlaying] = useState<boolean>(false); | |||||
| const initialize = useCallback(async () => { | |||||
| player.current = new SpeechPlayer({ | |||||
| audio: ref.current!, | |||||
| onPlaying: () => { | |||||
| setIsPlaying(true); | |||||
| }, | |||||
| onPause: () => { | |||||
| setIsPlaying(false); | |||||
| }, | |||||
| onChunkEnd: () => {}, | |||||
| mimeType: 'audio/mpeg', | |||||
| }); | |||||
| await player.current.init(); | |||||
| }, []); | |||||
| const pause = useCallback(() => { | |||||
| player.current?.pause(); | |||||
| }, []); | |||||
| const speech = useCallback(async () => { | |||||
| const response = await read({ text: content }); | |||||
| if (response) { | |||||
| player?.current?.feedWithResponse(response); | |||||
| } | |||||
| }, [read, content]); | |||||
| const handleRead = useCallback(async () => { | |||||
| if (isPlaying) { | |||||
| setIsPlaying(false); | |||||
| pause(); | |||||
| } else { | |||||
| setIsPlaying(true); | |||||
| speech(); | |||||
| } | |||||
| }, [setIsPlaying, speech, isPlaying, pause]); | |||||
| useEffect(() => { | |||||
| initialize(); | |||||
| }, [initialize]); | |||||
| return { ref, handleRead, isPlaying }; | |||||
| }; |
| return { send, answer, done, setDone }; | return { send, answer, done, setDone }; | ||||
| }; | }; | ||||
| export const useSpeechWithSse = (url: string = api.tts) => { | |||||
| const read = useCallback( | |||||
| (body: any) => { | |||||
| const response = fetch(url, { | |||||
| method: 'POST', | |||||
| headers: { | |||||
| [Authorization]: getAuthorization(), | |||||
| 'Content-Type': 'application/json', | |||||
| }, | |||||
| body: JSON.stringify(body), | |||||
| }); | |||||
| return response; | |||||
| }, | |||||
| [url], | |||||
| ); | |||||
| return { read }; | |||||
| }; | |||||
| export const useFetchAudioWithSse = (url: string = api.tts) => { | |||||
| // const [answer, setAnswer] = useState<IAnswer>({} as IAnswer); | |||||
| const [done, setDone] = useState(true); | |||||
| const read = useCallback( | |||||
| async ( | |||||
| body: any, | |||||
| ): Promise<{ response: Response; data: ResponseType } | undefined> => { | |||||
| try { | |||||
| setDone(false); | |||||
| const response = await fetch(url, { | |||||
| method: 'POST', | |||||
| headers: { | |||||
| [Authorization]: getAuthorization(), | |||||
| 'Content-Type': 'application/json', | |||||
| }, | |||||
| body: JSON.stringify(body), | |||||
| }); | |||||
| const res = response.clone().json(); | |||||
| const reader = response?.body?.getReader(); | |||||
| while (true) { | |||||
| const x = await reader?.read(); | |||||
| if (x) { | |||||
| const { done, value } = x; | |||||
| try { | |||||
| // const val = JSON.parse(value || ''); | |||||
| const val = value; | |||||
| // const d = val?.data; | |||||
| // if (typeof d !== 'boolean') { | |||||
| // console.info('data:', d); | |||||
| // setAnswer({ | |||||
| // ...d, | |||||
| // conversationId: body?.conversation_id, | |||||
| // }); | |||||
| // } | |||||
| } catch (e) { | |||||
| console.warn(e); | |||||
| } | |||||
| if (done) { | |||||
| console.info('done'); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| console.info('done?'); | |||||
| setDone(true); | |||||
| // setAnswer({} as IAnswer); | |||||
| return { data: await res, response }; | |||||
| } catch (e) { | |||||
| setDone(true); | |||||
| // setAnswer({} as IAnswer); | |||||
| console.warn(e); | |||||
| } | |||||
| }, | |||||
| [url], | |||||
| ); | |||||
| return { read, done, setDone }; | |||||
| }; | |||||
| //#region chat hooks | //#region chat hooks | ||||
| export const useScrollToBottom = (messages?: unknown) => { | export const useScrollToBottom = (messages?: unknown) => { |