mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 09:07:07 +08:00
Compare commits
3 Commits
1.15.0
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f51107260 | |||
| 44eda16261 | |||
| fe8b87d460 |
@ -853,7 +853,7 @@
|
||||
},
|
||||
"web/app/components/base/chat/chat/chat-input-area/index.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/chat/chat/check-input-forms-hooks.ts": {
|
||||
|
||||
@ -6,7 +6,6 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { decode } from 'html-entities'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
@ -14,12 +13,18 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
|
||||
const VoiceInput = dynamic(() => import('@/app/components/base/voice-input'), { ssr: false })
|
||||
|
||||
type RecorderConstructorWithPermission = typeof import('js-audio-recorder').default & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
@ -128,12 +133,16 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
const handleShowVoiceInput = useCallback(async () => {
|
||||
const { default: Recorder } = await import('js-audio-recorder')
|
||||
|
||||
try {
|
||||
await (Recorder as RecorderConstructorWithPermission).getPermission()
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
}
|
||||
catch {
|
||||
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
|
||||
})
|
||||
}
|
||||
}, [t])
|
||||
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
|
||||
return (
|
||||
|
||||
@ -98,6 +98,9 @@ describe('VoiceInput', () => {
|
||||
|
||||
it('should start recording on mount and show speaking state', async () => {
|
||||
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(mockState.recorderInstances).toHaveLength(1)
|
||||
})
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
expect(recorder.start).toHaveBeenCalled()
|
||||
@ -390,8 +393,11 @@ describe('VoiceInput', () => {
|
||||
expect(await screen.findByText('common.voiceInput.speaking'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cleanup on unmount', () => {
|
||||
it('should cleanup on unmount', async () => {
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(mockState.recorderInstances).toHaveLength(1)
|
||||
})
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
|
||||
@ -400,6 +406,31 @@ describe('VoiceInput', () => {
|
||||
expect(recorder.stop).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop without cancelling after unmount while recording start is pending', async () => {
|
||||
let resolveStart!: () => void
|
||||
mockState.startOverride = () => new Promise<void>((resolve) => {
|
||||
resolveStart = resolve
|
||||
})
|
||||
|
||||
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
|
||||
await waitFor(() => {
|
||||
expect(mockState.recorderInstances).toHaveLength(1)
|
||||
})
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
const recorder = mockState.recorderInstances[0] as any
|
||||
|
||||
unmount()
|
||||
expect(recorder.stop).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveStart()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(recorder.stop).toHaveBeenCalledTimes(2)
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle all data in recordAnalyseData for canvas drawing', async () => {
|
||||
const allDataValues = []
|
||||
for (let i = 0; i < 256; i++) {
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type Recorder from 'js-audio-recorder'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useRafInterval } from 'ahooks'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { AppSourceType, audioToText } from '@/service/share'
|
||||
import s from './index.module.css'
|
||||
import { convertToMp3 } from './utils'
|
||||
|
||||
type VoiceInputTypes = {
|
||||
onConverted: (text: string) => void
|
||||
@ -20,15 +21,11 @@ const VoiceInput = ({
|
||||
wordTimestamps,
|
||||
}: VoiceInputTypes) => {
|
||||
const { t } = useTranslation()
|
||||
const recorder = useRef(new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
}))
|
||||
const recorderRef = useRef<Recorder | null>(null)
|
||||
const mountedRef = useRef(false)
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
||||
const drawRecordId = useRef<number | null>(null)
|
||||
const drawRecordIdRef = useRef<number | null>(null)
|
||||
const [originDuration, setOriginDuration] = useState(0)
|
||||
const [startRecord, setStartRecord] = useState(false)
|
||||
const [startConvert, setStartConvert] = useState(false)
|
||||
@ -38,11 +35,29 @@ const VoiceInput = ({
|
||||
setOriginDuration(originDuration + 1)
|
||||
}, 1000)
|
||||
|
||||
const getRecorder = useCallback(async () => {
|
||||
if (!recorderRef.current) {
|
||||
const { default: Recorder } = await import('js-audio-recorder')
|
||||
recorderRef.current = new Recorder({
|
||||
sampleBits: 16,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
compiling: false,
|
||||
})
|
||||
}
|
||||
|
||||
return recorderRef.current
|
||||
}, [])
|
||||
|
||||
const drawRecord = useCallback(() => {
|
||||
drawRecordId.current = requestAnimationFrame(drawRecord)
|
||||
drawRecordIdRef.current = requestAnimationFrame(drawRecord)
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
const dataUnit8Array = recorder.current.getRecordAnalyseData()
|
||||
const currentRecorder = recorderRef.current
|
||||
if (!currentRecorder)
|
||||
return
|
||||
|
||||
const dataUnit8Array = currentRecorder.getRecordAnalyseData()
|
||||
const dataArray = [].slice.call(dataUnit8Array)
|
||||
const lineLength = Number.parseInt(`${canvas.width / 3}`)
|
||||
const gap = Number.parseInt(`${1024 / lineLength}`)
|
||||
@ -72,17 +87,22 @@ const VoiceInput = ({
|
||||
ctx.closePath()
|
||||
}, [])
|
||||
const handleStopRecorder = useCallback(async () => {
|
||||
const currentRecorder = recorderRef.current
|
||||
if (!currentRecorder)
|
||||
return
|
||||
|
||||
clearInterval()
|
||||
setStartRecord(false)
|
||||
setStartConvert(true)
|
||||
recorder.current.stop()
|
||||
if (drawRecordId.current)
|
||||
cancelAnimationFrame(drawRecordId.current)
|
||||
drawRecordId.current = null
|
||||
currentRecorder.stop()
|
||||
if (drawRecordIdRef.current)
|
||||
cancelAnimationFrame(drawRecordIdRef.current)
|
||||
drawRecordIdRef.current = null
|
||||
const canvas = canvasRef.current!
|
||||
const ctx = ctxRef.current!
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
const mp3Blob = convertToMp3(recorder.current)
|
||||
const { convertToMp3 } = await import('./utils')
|
||||
const mp3Blob = convertToMp3(currentRecorder)
|
||||
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
|
||||
const formData = new FormData()
|
||||
formData.append('file', mp3File)
|
||||
@ -114,7 +134,20 @@ const VoiceInput = ({
|
||||
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
|
||||
const handleStartRecord = useCallback(async () => {
|
||||
try {
|
||||
await recorder.current.start()
|
||||
const currentRecorder = await getRecorder()
|
||||
|
||||
if (!mountedRef.current) {
|
||||
currentRecorder.stop()
|
||||
return
|
||||
}
|
||||
|
||||
await currentRecorder.start()
|
||||
|
||||
if (!mountedRef.current) {
|
||||
currentRecorder.stop()
|
||||
return
|
||||
}
|
||||
|
||||
setStartRecord(true)
|
||||
setStartConvert(false)
|
||||
|
||||
@ -122,9 +155,10 @@ const VoiceInput = ({
|
||||
drawRecord()
|
||||
}
|
||||
catch {
|
||||
onCancel()
|
||||
if (mountedRef.current)
|
||||
onCancel()
|
||||
}
|
||||
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
|
||||
}, [drawRecord, getRecorder, onCancel])
|
||||
const initCanvas = useCallback(() => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
|
||||
@ -148,11 +182,14 @@ const VoiceInput = ({
|
||||
handleStopRecorder()
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
initCanvas()
|
||||
handleStartRecord()
|
||||
const recorderRef = recorder?.current
|
||||
return () => {
|
||||
recorderRef?.stop()
|
||||
mountedRef.current = false
|
||||
if (drawRecordIdRef.current)
|
||||
cancelAnimationFrame(drawRecordIdRef.current)
|
||||
recorderRef.current?.stop()
|
||||
}
|
||||
}, [handleStartRecord, initCanvas])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user