@@ -0,0 +1,320 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import useSWR from "swr" ;
import { Mic , RotateCcw , Square , Upload , X } from "lucide-react" ;
// plane imports
import { Button } from "@plane/propel/button" ;
import { Tooltip } from "@plane/propel/tooltip" ;
import { TOAST_TYPE , setToast } from "@plane/propel/toast" ;
import { EModalPosition , EModalWidth , ModalCore } from "@plane/ui" ;
import { cn } from "@plane/utils" ;
// services
import { WorkspaceAIService } from "@/services/workspace-ai.service" ;
const workspaceAIService = new WorkspaceAIService ( ) ;
type TVoiceTaskerStatus = "idle" | "recording" | "uploading" | "success" | "error" ;
const UNAVAILABLE_LABELS = {
disabled : "AI-функции не активированы для этого workspace" ,
missing_api_key : "OpenAI key не сохранен для этого workspace" ,
not_configured : "AI-функции не настроены для этого workspace" ,
role_denied : "Voice Task недоступен для вашей роли" ,
} as const ;
function getSupportedMimeType() {
if ( typeof MediaRecorder === "undefined" ) return "" ;
const candidates = [ "audio/webm;codecs=opus" , "audio/webm" , "audio/mp4" ] ;
return candidates . find ( ( candidate ) = > MediaRecorder . isTypeSupported ( candidate ) ) ? ? "" ;
}
function formatDuration ( seconds : number ) {
const roundedSeconds = Math . max ( 0 , Math . floor ( seconds ) ) ;
const minutes = Math . floor ( roundedSeconds / 60 ) ;
const remainingSeconds = roundedSeconds % 60 ;
return ` ${ minutes } : ${ remainingSeconds . toString ( ) . padStart ( 2 , "0" ) } ` ;
}
type Props = {
workspaceSlug : string ;
} ;
export function VoiceTaskerGlobalControl ( { workspaceSlug } : Props ) {
const [ isOpen , setIsOpen ] = useState ( false ) ;
const [ status , setStatus ] = useState < TVoiceTaskerStatus > ( "idle" ) ;
const [ duration , setDuration ] = useState ( 0 ) ;
const [ audioBlob , setAudioBlob ] = useState < Blob | null > ( null ) ;
const [ audioUrl , setAudioUrl ] = useState < string | null > ( null ) ;
const [ error , setError ] = useState < string | null > ( null ) ;
const mediaRecorderRef = useRef < MediaRecorder | null > ( null ) ;
const streamRef = useRef < MediaStream | null > ( null ) ;
const chunksRef = useRef < BlobPart [ ] > ( [ ] ) ;
const timerRef = useRef < number | null > ( null ) ;
const startedAtRef = useRef ( 0 ) ;
const { data : preflight } = useSWR (
workspaceSlug ? ` VOICE_TASK_PREFLIGHT_ ${ workspaceSlug } ` : null ,
workspaceSlug ? ( ) = > workspaceAIService . retrieveVoiceTaskPreflight ( workspaceSlug ) : null ,
{ refreshInterval : 30000 }
) ;
const maxDuration = preflight ? . max_audio_duration_seconds ? ? 120 ;
const isAvailable = ! ! preflight ? . available ;
const isRecording = status === "recording" ;
const isUploading = status === "uploading" ;
const tooltipContent = useMemo ( ( ) = > {
if ( ! preflight ) return "Voice Task" ;
if ( preflight . available ) return "Voice Task" ;
return UNAVAILABLE_LABELS [ preflight . reason ? ? "not_configured" ] ;
} , [ preflight ] ) ;
const clearTimer = useCallback ( ( ) = > {
if ( timerRef . current ) {
window . clearInterval ( timerRef . current ) ;
timerRef . current = null ;
}
} , [ ] ) ;
const stopStream = useCallback ( ( ) = > {
streamRef . current ? . getTracks ( ) . forEach ( ( track ) = > track . stop ( ) ) ;
streamRef . current = null ;
} , [ ] ) ;
const stopRecording = useCallback ( ( ) = > {
const recorder = mediaRecorderRef . current ;
clearTimer ( ) ;
if ( recorder && recorder . state === "recording" ) {
recorder . stop ( ) ;
return ;
}
stopStream ( ) ;
} , [ clearTimer , stopStream ] ) ;
const resetRecording = useCallback ( ( ) = > {
stopRecording ( ) ;
setAudioBlob ( null ) ;
setAudioUrl ( null ) ;
setDuration ( 0 ) ;
setError ( null ) ;
setStatus ( "idle" ) ;
} , [ stopRecording ] ) ;
const handleClose = useCallback ( ( ) = > {
resetRecording ( ) ;
setIsOpen ( false ) ;
} , [ resetRecording ] ) ;
useEffect (
( ) = > ( ) = > {
clearTimer ( ) ;
stopStream ( ) ;
} ,
[ clearTimer , stopStream ]
) ;
useEffect ( ( ) = > {
if ( ! audioBlob ) {
setAudioUrl ( null ) ;
return ;
}
const objectUrl = URL . createObjectURL ( audioBlob ) ;
setAudioUrl ( objectUrl ) ;
return ( ) = > URL . revokeObjectURL ( objectUrl ) ;
} , [ audioBlob ] ) ;
const startRecording = async ( ) = > {
if ( typeof navigator === "undefined" || ! navigator . mediaDevices ? . getUserMedia || typeof MediaRecorder === "undefined" ) {
setError ( "Браузер не поддерживает запись аудио." ) ;
setStatus ( "error" ) ;
return ;
}
try {
resetRecording ( ) ;
const stream = await navigator . mediaDevices . getUserMedia ( {
audio : {
echoCancellation : true ,
noiseSuppression : true ,
} ,
} ) ;
const mimeType = getSupportedMimeType ( ) ;
const recorder = new MediaRecorder ( stream , mimeType ? { mimeType } : undefined ) ;
chunksRef . current = [ ] ;
streamRef . current = stream ;
mediaRecorderRef . current = recorder ;
recorder . ondataavailable = ( event ) = > {
if ( event . data . size > 0 ) chunksRef . current . push ( event . data ) ;
} ;
recorder . onstop = ( ) = > {
const type = recorder . mimeType || mimeType || "audio/webm" ;
setAudioBlob ( new Blob ( chunksRef . current , { type } ) ) ;
setStatus ( "idle" ) ;
stopStream ( ) ;
} ;
recorder . start ( ) ;
startedAtRef . current = Date . now ( ) ;
setDuration ( 0 ) ;
setError ( null ) ;
setStatus ( "recording" ) ;
timerRef . current = window . setInterval ( ( ) = > {
const elapsed = ( Date . now ( ) - startedAtRef . current ) / 1000 ;
setDuration ( elapsed ) ;
if ( elapsed >= maxDuration ) stopRecording ( ) ;
} , 250 ) ;
} catch {
setError ( "Не удалось получить доступ к микрофону." ) ;
setStatus ( "error" ) ;
stopStream ( ) ;
clearTimer ( ) ;
}
} ;
const uploadAudio = async ( ) = > {
if ( ! audioBlob ) return ;
setStatus ( "uploading" ) ;
setError ( null ) ;
const audioType = audioBlob . type || "audio/webm" ;
const extension = audioType . includes ( "mp4" ) ? "m4a" : "webm" ;
const formData = new FormData ( ) ;
formData . append ( "audio" , audioBlob , ` voice-task. ${ extension } ` ) ;
formData . append ( "duration_seconds" , String ( Math . max ( 1 , Math . ceil ( duration ) ) ) ) ;
formData . append (
"client_context" ,
JSON . stringify ( {
current_page : window.location.pathname ,
locale : navigator.language ,
timezone : Intl.DateTimeFormat ( ) . resolvedOptions ( ) . timeZone ,
} )
) ;
try {
await workspaceAIService . uploadVoiceTaskAudio ( workspaceSlug , formData ) ;
setStatus ( "success" ) ;
setToast ( {
type : TOAST_TYPE . SUCCESS ,
title : "Аудио отправлено" ,
message : "Backend принял запись. Распознавание будет подключено следующим этапом." ,
} ) ;
} catch ( err ) {
const message = typeof err === "object" && err && "error" in err ? String ( err . error ) : "Не удалось отправить аудио." ;
setError ( message ) ;
setStatus ( "error" ) ;
setToast ( {
type : TOAST_TYPE . ERROR ,
title : "Voice Task не отправлен" ,
message ,
} ) ;
}
} ;
return (
< >
< div className = "pointer-events-none fixed right-4 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]" >
< Tooltip tooltipContent = { tooltipContent } position = "left" >
< button
type = "button"
className = { cn (
"pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] shadow-lg transition" ,
isAvailable
? "border-pink-500/40 bg-pink-500 text-white hover:bg-pink-600"
: "cursor-not-allowed border-subtle bg-layer-2 text-tertiary"
) }
disabled = { ! isAvailable }
onClick = { ( ) = > setIsOpen ( true ) }
>
< Mic className = "size-5" / >
< / button >
< / Tooltip >
< / div >
< ModalCore isOpen = { isOpen } handleClose = { handleClose } position = { EModalPosition . CENTER } width = { EModalWidth . MD } >
< div className = "px-5 py-4" >
< div className = "flex items-start justify-between gap-4" >
< div >
< h3 className = "text-18 font-medium text-primary" > Voice Task < / h3 >
< p className = "mt-1 text-13 text-secondary" > З а п и с ь д о { maxDuration } с е к у н д < / p >
< / div >
< button
type = "button"
className = "flex size-8 items-center justify-center rounded-md text-tertiary hover:bg-layer-2 hover:text-primary"
onClick = { handleClose }
>
< X className = "size-4" / >
< / button >
< / div >
< div className = "mt-5 rounded-lg border-[0.5px] border-subtle bg-layer-1 p-4" >
< div className = "flex items-center justify-between gap-4" >
< div >
< div className = "text-24 font-semibold text-primary" > { formatDuration ( duration ) } < / div >
< div className = "mt-1 text-12 text-tertiary" >
{ status === "success" ? "Audio uploaded" : isRecording ? "Recording" : "Ready" }
< / div >
< / div >
< div
className = { cn (
"flex size-14 items-center justify-center rounded-full" ,
isRecording ? "bg-red-500/15 text-red-500" : "bg-pink-500/10 text-pink-500"
) }
>
< Mic className = { cn ( "size-6" , { "animate-pulse" : isRecording } ) } / >
< / div >
< / div >
{ audioUrl && ! isRecording && (
< audio controls src = { audioUrl } className = "mt-4 w-full" >
< track kind = "captions" / >
< / audio >
) }
{ error && (
< div className = "mt-4 rounded-md border-[0.5px] border-red-500/30 bg-red-500/10 px-3 py-2 text-12 text-red-500" >
{ error }
< / div >
) }
< / div >
< div className = "mt-5 flex flex-wrap justify-end gap-2" >
{ audioBlob && ! isRecording && (
< Button variant = "secondary" size = "lg" onClick = { resetRecording } disabled = { isUploading } >
< RotateCcw className = "mr-2 size-4" / >
П е р е з а п и с а т ь
< / Button >
) }
< Button
variant = { isRecording ? "error-fill" : "secondary" }
size = "lg"
onClick = { isRecording ? stopRecording : startRecording }
disabled = { isUploading }
>
{ isRecording ? < Square className = "mr-2 size-4" / > : < Mic className = "mr-2 size-4" / > }
{ isRecording ? "Стоп" : "Записать" }
< / Button >
< Button variant = "primary" size = "lg" onClick = { uploadAudio } loading = { isUploading } disabled = { ! audioBlob || isRecording } >
< Upload className = "mr-2 size-4" / >
О т п р а в и т ь
< / Button >
< / div >
< / div >
< / ModalCore >
< / >
) ;
}