@@ -7,7 +7,7 @@
import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
import { useParams } from "next/navigation" ;
import useSWR from "swr" ;
import { CheckCircle2 , Mic , Plus , RotateCcw , Square , Upload , X } from "lucide-react" ;
import { CheckCircle2 , Mic , Pencil , Plus , RotateCcw , Square , Trash2 , Upload , X } from "lucide-react" ;
// plane imports
import { Button } from "@plane/propel/button" ;
import { Tooltip } from "@plane/propel/tooltip" ;
@@ -61,6 +61,43 @@ function getRouteParam(value: string | string[] | undefined) {
return value ? . toString ( ) ;
}
function getCommitButtonLabel ( intent? : string ) {
if ( intent === "update_task" ) return "Применить изменения" ;
if ( intent === "delete_task" ) return "Удалить задачу" ;
return "Создать задачу" ;
}
function getCommitStatusLabel ( status : TVoiceTaskerStatus ) {
if ( status === "committed" ) return "Committed" ;
if ( status === "success" ) return "Draft parsed" ;
if ( status === "committing" ) return "Committing" ;
if ( status === "uploading" ) return "Processing" ;
if ( status === "recording" ) return "Recording" ;
if ( status === "error" ) return "Error" ;
return "Ready" ;
}
function getCommitSuccessTitle ( result : TVoiceTaskCommitResult ) {
if ( result . status === "updated" ) return "Задача обновлена" ;
if ( result . status === "deleted" ) return "Задача удалена" ;
return "Задача создана" ;
}
function getCommitSuccessMessage ( result : TVoiceTaskCommitResult ) {
if ( result . task_key && result . status === "updated" ) return ` Обновлена ${ result . task_key } ` ;
if ( result . task_key && result . status === "deleted" ) return ` Удалена ${ result . task_key } ` ;
if ( result . task_key ) return ` Создана ${ result . task_key } ` ;
if ( result . status === "updated" ) return "Work item обновлен." ;
if ( result . status === "deleted" ) return "Work item удален." ;
return "Work item создан." ;
}
function getVoiceTaskWarnings ( result : TVoiceTaskUploadResult ) {
return Array . from (
new Set ( [ . . . ( result . warnings ? ? [ ] ) , . . . ( result . resolution ? . warnings ? ? [ ] ) , . . . ( result . draft ? . questions ? ? [ ] ) ] )
) ;
}
type Props = {
workspaceSlug : string ;
} ;
@@ -174,7 +211,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
} , [ audioBlob ] ) ;
const startRecording = async ( ) = > {
if ( typeof navigator === "undefined" || ! navigator . mediaDevices ? . getUserMedia || typeof MediaRecorder === "undefined" ) {
if (
typeof navigator === "undefined" ||
! navigator . mediaDevices ? . getUserMedia ||
typeof MediaRecorder === "undefined"
) {
setError ( "Браузер не поддерживает запись аудио." ) ;
setStatus ( "error" ) ;
return ;
@@ -230,6 +271,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus ( "uploading" ) ;
setError ( null ) ;
setParseResult ( null ) ;
setCommitResult ( null ) ;
const audioType = audioBlob . type || "audio/webm" ;
const extension = audioType . includes ( "mp4" ) ? "m4a" : "webm" ;
@@ -256,7 +298,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
message : "Transcript и draft получены." ,
} ) ;
} catch ( err ) {
const message = typeof err === "object" && err && "error" in err ? String ( err . error ) : "Не удалось отправить аудио." ;
const message =
typeof err === "object" && err && "error" in err ? String ( err . error ) : "Не удалось отправить аудио." ;
setError ( message ) ;
setStatus ( "error" ) ;
setToast ( {
@@ -299,13 +342,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const commitVoiceTask = async ( ) = > {
if ( ! parseResult ? . voice_session_id || ! parseResult . draft ) return ;
const action = parseResult . draft . intent ;
if ( action === "unknown" ) return ;
if ( action === "delete_task" ) {
const targetTitle =
parseResult . resolution ? . target_task ? . key || parseResult . resolution ? . target_task ? . title || "последнюю задачу" ;
const confirmed = window . confirm ( ` Удалить ${ targetTitle } ? ` ) ;
if ( ! confirmed ) return ;
}
setStatus ( "committing" ) ;
setError ( null ) ;
try {
const result = await workspaceAIService . commitVoiceTask ( workspaceSlug , {
voice_session_id : parseResult.voice_session_id ,
action : "create_task" ,
action ,
draft : parseResult.draft ,
} ) ;
await refreshVisibleIssueStores ( result . project_id ) ;
@@ -313,16 +366,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus ( "committed" ) ;
setToast ( {
type : TOAST_TYPE . SUCCESS ,
title : "Задача создана" ,
message : result.task_key ? ` Создана ${ result . task_key } ` : "Work item создан." ,
title : getCommitSuccessTitle ( result ) ,
message : getCommitSuccessMessage ( result ) ,
} ) ;
} catch ( err ) {
const message = typeof err === "object" && err && "error" in err ? String ( err . error ) : "Не удалось создать задачу." ;
const message =
typeof err === "object" && err && "error" in err ? String ( err . error ) : "Не удалось применить Voice Task." ;
setError ( message ) ;
setStatus ( "error" ) ;
setToast ( {
type : TOAST_TYPE . ERROR ,
title : "Задача не создана " ,
title : "Voice Task не применен " ,
message ,
} ) ;
}
@@ -330,14 +384,14 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
return (
< >
< div className = "pointer-events-none fixed right-4 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]" >
< div className = "pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29] " >
< 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" ,
"shadow-lg pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] transition" ,
isAvailable
? "border-pink-500/40 bg-pink-500 text-white hover:bg-pink-600"
? "border-pink-500/40 bg-pink-500 hover:bg-pink-600 text-white "
: "cursor-not-allowed border-subtle bg-layer-2 text-tertiary"
) }
disabled = { ! isAvailable }
@@ -368,19 +422,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
< 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 === "committed"
? "Created"
: status === "success"
? "Draft parsed"
: isCommitting
? "Creating"
: isUploading
? "Processing"
: isRecording
? "Recording"
: "Ready" }
< / div >
< div className = "mt-1 text-12 text-tertiary" > { getCommitStatusLabel ( status ) } < / div >
< / div >
< div
className = { cn (
@@ -399,7 +441,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
) }
{ 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 " >
< div className = "border-red-500/30 bg-red-500/10 text-red-500 mt-4 rounded-md border-[0.5px] px-3 py-2 text-12" >
{ error }
< / div >
) }
@@ -407,13 +449,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{ parseResult ? . draft && (
< div className = "mt-4 space-y-3 rounded-md border-[0.5px] border-subtle bg-layer-2 p-3" >
< div className = "flex items-center gap-2 text-13 font-medium text-primary" >
< CheckCircle2 className = "size-4 text-green-500" / >
< CheckCircle2 className = "text-green-500 size-4 " / >
Draft г о т о в
< / div >
{ parseResult . transcript && (
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > Т р а н с к р и п т < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > Т р а н с к р и п т < / div >
< p className = "mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary" >
{ parseResult . transcript }
< / p >
@@ -422,40 +464,66 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
< div className = "grid grid-cols-1 gap-2 text-12 sm:grid-cols-2" >
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > Н а з в а н и е < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > Н а з в а н и е < / div >
< div className = "mt-0.5 text-primary" > { parseResult . draft . title || "не распознано" } < / div >
< / div >
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > Intent < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > Intent < / div >
< div className = "mt-0.5 text-primary" > { parseResult . draft . intent } < / div >
< / div >
{ parseResult . resolution ? . target_task && (
< div className = "sm:col-span-2" >
< div className = "text-11 font-medium text-tertiary uppercase" > Ц е л е в а я з а д а ч а < / div >
< div className = "mt-0.5 text-primary" >
{ [ parseResult . resolution . target_task . key , parseResult . resolution . target_task . title ]
. filter ( Boolean )
. join ( " · " ) }
< / div >
< / div >
) }
{ parseResult . resolution ? . project_change && (
< div className = "sm:col-span-2" >
< div className = "text-11 font-medium text-tertiary uppercase" > П е р е н о с п р о е к т а < / div >
< div className = "mt-0.5 text-primary" >
{ parseResult . resolution . project_change . from . name } - & gt ; { " " }
{ parseResult . resolution . project_change . to . name }
< / div >
< / div >
) }
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > П р о е к т < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > П р о е к т < / div >
< div className = "mt-0.5 text-primary" >
{ parseResult . resolution ? . project ? . name || parseResult . draft . project_hint || "не распознано" }
< / div >
< / div >
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > И с п о л н и т е л ь < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > С т а т у с < / div >
< div className = "mt-0.5 text-primary" >
{ parseResult . resolution ? . state ? . name || parseResult . draft . state_hint || "не распознано" }
< / div >
< / div >
< div >
< div className = "text-11 font-medium text-tertiary uppercase" > И с п о л н и т е л ь < / div >
< div className = "mt-0.5 text-primary" >
{ parseResult . resolution ? . assignee ? . name || parseResult . draft . assignee_hint || "не распознано" }
< / div >
< / div >
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > С р о к < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > С р о к < / div >
< div className = "mt-0.5 text-primary" >
{ [ parseResult . draft . due_date , parseResult . draft . due_time ] . filter ( Boolean ) . join ( " " ) || "не распознано" }
{ [ parseResult . draft . due_date , parseResult . draft . due_time ] . filter ( Boolean ) . join ( " " ) ||
"не распознано" }
< / div >
< / div >
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > П р и о р и т е т < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > П р и о р и т е т < / div >
< div className = "mt-0.5 text-primary" > { parseResult . draft . priority || "не распознано" } < / div >
< / div >
< / div >
{ parseResult . draft . description && (
< div >
< div className = "text-11 font-medium uppercase text-tertiary" > О п и с а н и е < / div >
< div className = "text-11 font-medium text-tertiary uppercase " > О п и с а н и е < / div >
< p className = "mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary" >
{ parseResult . draft . description }
< / p >
@@ -463,10 +531,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
) }
< div className = "flex flex-wrap gap-1.5 text-11 text-secondary" >
< span className = "rounded bg-layer-1 px-2 py-1" > intent { formatConfidence ( parseResult . draft . confidence . intent ) } < / span >
< span className = "rounded bg-layer-1 px-2 py-1" > project { formatConfidence ( parseResult . draft . confidence . project ) } < / span >
< span className = "rounded bg-layer-1 px-2 py-1" > assignee { formatConfidence ( parseResult . draft . confidence . assignee ) } < / span >
< span className = "rounded bg-layer-1 px-2 py-1" > task { formatConfidence ( parseResult . draft . confidence . task ) } < / span >
< span className = "rounded bg-layer-1 px-2 py-1" >
intent { formatConfidence ( parseResult . draft . confidence . intent ) }
< / span >
< span className = "rounded bg-layer-1 px-2 py-1" >
project { formatConfidence ( parseResult . draft . confidence . project ) }
< / span >
< span className = "rounded bg-layer-1 px-2 py-1" >
assignee { formatConfidence ( parseResult . draft . confidence . assignee ) }
< / span >
< span className = "rounded bg-layer-1 px-2 py-1" >
task { formatConfidence ( parseResult . draft . confidence . task ) }
< / span >
{ parseResult . resolution ? . project && (
< span className = "rounded bg-layer-1 px-2 py-1" >
resolved project { formatConfidence ( parseResult . resolution . project . confidence ) }
@@ -474,15 +550,15 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
) }
< / div >
{ Boolean ( parseResult . warnings ? . length || parseResult . draft . questions . length ) && (
< div className = "rounded border-[0.5px] border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-11 text-yellow-600 " >
{ [ . . . ( parseResult . warnings ? ? [ ] ) , . . . parseResult . draft . questions ] . join ( " · " ) }
{ Boolean ( getVoiceTaskWarnings ( parseResult ) . length ) && (
< div className = "border-yellow-500/30 bg-yellow-500/10 text-yellow-600 rounded border-[0.5px] px-3 py-2 text-11" >
{ getVoiceTaskWarnings ( parseResult ) . join ( " · " ) }
< / div >
) }
{ commitResult ? . task_key && (
< div className = "rounded border-[0.5px] border-green-500/30 bg-green-500/10 px-3 py-2 text-12 text-green-600 " >
С о з д а н а з а д а ч а { commitResult . task_key }
< div className = "border-green-500/30 bg-green-500/10 text-green-600 rounded border-[0.5px] px-3 py-2 text-12" >
{ getCommitSuccessMessage ( commitResult ) }
< / div >
) }
< / div >
@@ -505,26 +581,34 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{ 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 || isCommitting }
>
< Upload className = "mr-2 size-4" / >
О т п р а в и т ь
< / Button >
{ parseResult ? . draft ? . intent === "create_task" && ! commitResult ? . task_id && (
{ ! parseResult ? . draft && (
< Button
variant = "primary"
size = "lg"
onClick = { uploadAudio }
loading = { isUploading }
disabled = { ! audioBlob || isRecording || isCommitting }
>
< Upload className = "mr-2 size-4" / >
О т п р а в и т ь
< / Button >
) }
{ parseResult ? . draft ? . intent && parseResult . draft . intent !== "unknown" && ! commitResult ? . task_id && (
< Button
variant = { parseResult . draft . intent === "delete_task" ? "error-fill" : "primary" }
size = "lg"
onClick = { commitVoiceTask }
loading = { isCommitting }
disabled = { ! parseResult . voice_session_id || ! parseResult . resolution ? . can_commit || isUploading }
>
< Plus className = "mr-2 size-4" / >
С о з д а т ь з а д а ч у
{ parseResult . draft . intent === "update_task" ? (
< Pencil className = "mr-2 size-4" / >
) : parseResult . draft . intent === "delete_task" ? (
< Trash2 className = "mr-2 size-4" / >
) : (
< Plus className = "mr-2 size-4" / >
) }
{ getCommitButtonLabel ( parseResult . draft . intent ) }
< / Button >
) }
< / div >