diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 57b5700..7bbcc80 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -394,6 +394,7 @@ pub struct UploadResult { // Extensoes permitidas const ALLOWED_EXTENSIONS: &[&str] = &[ ".jpg", ".jpeg", ".png", ".gif", ".webp", + ".mp3", ".wav", ".ogg", ".webm", ".m4a", ".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", ]; @@ -434,6 +435,11 @@ pub fn get_mime_type(file_name: &str) -> String { "png" => "image/png", "gif" => "image/gif", "webp" => "image/webp", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "ogg" => "audio/ogg", + "webm" => "audio/webm", + "m4a" => "audio/mp4", "pdf" => "application/pdf", "txt" => "text/plain", "doc" => "application/msword", diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 3c5e9c5..74d4435 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -12,10 +12,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { open as openDialog } from "@tauri-apps/plugin-dialog" import { openUrl as openExternal } from "@tauri-apps/plugin-opener" import { invoke } from "@tauri-apps/api/core" -import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check, MessagesSquare } from "lucide-react" +import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check, MessagesSquare, Mic, Square, Trash2, Play, Pause } from "lucide-react" import type { Id } from "@convex/_generated/dataModel" -import { useMachineMessages, useMachineSessions, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries" +import { useMachineMessages, useMachineSessions, usePostMachineMessage, useMarkMachineMessagesRead, useGenerateMachineUploadUrl, type MachineMessage } from "./useConvexMachineQueries" import { useConvexMachine } from "./ConvexMachineProvider" +import { useAudioRecorder } from "./useAudioRecorder" const MAX_MESSAGES_IN_MEMORY = 200 const MARK_READ_BATCH_SIZE = 50 @@ -23,14 +24,19 @@ const SCROLL_BOTTOM_THRESHOLD_PX = 120 const ALLOWED_EXTENSIONS = [ "jpg", "jpeg", "png", "gif", "webp", + "mp3", "wav", "ogg", "webm", "m4a", "pdf", "txt", "doc", "docx", "xls", "xlsx", ] +const MAX_AUDIO_BYTES = 5 * 1024 * 1024 +const MAX_AUDIO_DURATION_SECONDS = 300 + interface UploadedAttachment { storageId: string name: string size?: number type?: string + previewUrl?: string } interface ChatAttachment { @@ -57,6 +63,11 @@ function isImageAttachment(attachment: ChatAttachment) { return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) } +function isAudioAttachment(attachment: ChatAttachment) { + if (attachment.type?.startsWith("audio/")) return true + return /\.(mp3|wav|ogg|webm|m4a)$/i.test(attachment.name) +} + function formatAttachmentSize(size?: number) { if (!size) return null if (size < 1024) return `${size}B` @@ -65,6 +76,13 @@ function formatAttachmentSize(size?: number) { return `${(kb / 1024).toFixed(1)}MB` } +function formatDuration(seconds: number) { + const safe = Math.max(0, Math.floor(seconds)) + const mins = Math.floor(safe / 60) + const secs = safe % 60 + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}` +} + function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] { if (unreadCount <= 0 || messages.length === 0) return [] const ids: string[] = [] @@ -86,6 +104,217 @@ function chunkArray(items: T[], size: number): T[][] { return result } +function extractPeaks(buffer: AudioBuffer, bars = 48) { + const channel = buffer.getChannelData(0) + const blockSize = Math.max(1, Math.floor(channel.length / bars)) + const peaks = new Array(bars).fill(0) + for (let i = 0; i < bars; i += 1) { + let max = 0 + const start = i * blockSize + const end = Math.min(start + blockSize, channel.length) + for (let j = start; j < end; j += 1) { + const value = Math.abs(channel[j]) + if (value > max) max = value + } + peaks[i] = max + } + const maxPeak = Math.max(...peaks, 0.001) + return peaks.map((value) => value / maxPeak) +} + +function AudioWaveform({ + peaks, + progress, + isAgent, +}: { + peaks: number[] + progress: number + isAgent: boolean +}) { + const playedBars = Math.round(progress * peaks.length) + return ( +
+ {peaks.map((value, index) => { + const height = Math.max(4, Math.round(value * 24)) + const played = index <= playedBars + const color = played + ? isAgent + ? "bg-emerald-300" + : "bg-emerald-500" + : isAgent + ? "bg-white/30" + : "bg-slate-300" + return ( + + ) + })} +
+ ) +} + +function AudioAttachmentPlayer({ + url, + name, + size, + isAgent, +}: { + url: string + name: string + size?: number + isAgent: boolean +}) { + const audioRef = useRef(null) + const [isPlaying, setIsPlaying] = useState(false) + const [duration, setDuration] = useState(0) + const [currentTime, setCurrentTime] = useState(0) + const [peaks, setPeaks] = useState([]) + const [isLoadingWaveform, setIsLoadingWaveform] = useState(true) + const [downloading, setDownloading] = useState(false) + const [downloaded, setDownloaded] = useState(false) + + useEffect(() => { + let cancelled = false + setIsLoadingWaveform(true) + setPeaks([]) + const loadWaveform = async () => { + try { + if (typeof AudioContext === "undefined") { + return + } + const response = await fetch(url) + const buffer = await response.arrayBuffer() + const audioContext = new AudioContext() + const decoded = await audioContext.decodeAudioData(buffer) + await audioContext.close() + if (!cancelled) { + setPeaks(extractPeaks(decoded)) + } + } catch (error) { + console.error("Falha ao gerar waveform:", error) + } finally { + if (!cancelled) { + setIsLoadingWaveform(false) + } + } + } + + loadWaveform() + return () => { + cancelled = true + } + }, [url]) + + const handleToggle = async () => { + if (!audioRef.current) return + if (isPlaying) { + audioRef.current.pause() + return + } + try { + await audioRef.current.play() + } catch (error) { + console.error("Falha ao tocar audio:", error) + } + } + + const handleDownload = async () => { + if (downloading) return + setDownloading(true) + try { + const response = await fetch(url) + const blob = await response.blob() + const downloadUrl = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = downloadUrl + anchor.download = name + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(downloadUrl) + setDownloaded(true) + setTimeout(() => setDownloaded(false), 2000) + } catch (error) { + console.error("Falha ao baixar audio:", error) + } finally { + setDownloading(false) + } + } + + const progress = duration > 0 ? currentTime / duration : 0 + const sizeLabel = formatAttachmentSize(size) + + return ( +
+ + +
+ {isLoadingWaveform ? ( +
+ ) : peaks.length > 0 ? ( + + ) : ( +
+ )} +
+ {formatDuration(currentTime)} + {sizeLabel ?? formatDuration(duration)} +
+
+ + + +
+ ) +} + function MessageAttachment({ attachment, isAgent, @@ -154,6 +383,7 @@ function MessageAttachment({ const sizeLabel = formatAttachmentSize(attachment.size) const isImage = isImageAttachment(attachment) + const isAudio = isAudioAttachment(attachment) if (loading) { return ( @@ -164,6 +394,17 @@ function MessageAttachment({ ) } + if (isAudio && url) { + return ( + + ) + } + if (isImage && url) { return (
@@ -260,6 +501,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { { limit: MAX_MESSAGES_IN_MEMORY } ) const postMessage = usePostMachineMessage() + const generateUploadUrl = useGenerateMachineUploadUrl() const markMessagesRead = useMarkMachineMessagesRead() // Limitar mensagens em memoria @@ -279,6 +521,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { >(null) const autoReadInFlightRef = useRef(false) const lastAutoReadCountRef = useRef(null) + const hasInitialScrollRef = useRef(false) const unreadAgentMessageIds = useMemo(() => getUnreadAgentMessageIds(messages, unreadCount), [messages, unreadCount]) const firstUnreadAgentMessageId = unreadAgentMessageIds[0] ?? null @@ -290,14 +533,67 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { }, 0) }, [machineSessions, ticketId]) + const queueAudioAttachment = useCallback(async (file: File) => { + const fileType = file.type || "audio/webm" + if (file.size > MAX_AUDIO_BYTES) { + alert("Áudio excede o limite de 5MB.") + return + } + + const { uploadUrl } = await generateUploadUrl({ + fileName: file.name, + fileType, + fileSize: file.size, + }) + + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": fileType }, + body: file, + }) + + if (!uploadResponse.ok) { + const text = await uploadResponse.text().catch(() => "") + throw new Error(text || "Falha ao enviar áudio") + } + + const { storageId } = (await uploadResponse.json()) as { storageId: string } + + const previewUrl = URL.createObjectURL(file) + setPendingAttachments((prev) => [ + ...prev, + { + storageId: storageId as Id<"_storage">, + name: file.name, + size: file.size, + type: fileType, + previewUrl, + }, + ]) + }, [generateUploadUrl]) + + const audioRecorder = useAudioRecorder({ + maxDurationSeconds: MAX_AUDIO_DURATION_SECONDS, + maxFileSizeBytes: MAX_AUDIO_BYTES, + onAudioReady: async ({ file }) => { + await queueAudioAttachment(file) + }, + onError: (message) => { + alert(message) + }, + }) + + const isAudioBusy = audioRecorder.isRecording || audioRecorder.isProcessing + const handleOpenHub = useCallback(async () => { try { await invoke("open_hub_window") await invoke("set_hub_minimized", { minimized: false }) + await invoke("close_chat_window", { ticketId }) } catch (err) { console.error("Erro ao abrir hub:", err) } - }, []) + }, [ticketId]) const updateIsAtBottom = useCallback(() => { const el = messagesContainerRef.current @@ -416,6 +712,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { prevMessagesLengthRef.current = messages.length }, [messages.length]) + // Scroll inicial ao carregar as mensagens + useEffect(() => { + if (hasInitialScrollRef.current) return + if (isMinimized || messages.length === 0) return + pendingScrollActionRef.current = { type: "bottom", behavior: "auto", markRead: unreadCount > 0 } + hasInitialScrollRef.current = true + }, [isMinimized, messages.length, unreadCount]) + // Executar scroll pendente useEffect(() => { if (isMinimized) return @@ -514,7 +818,13 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { // Remover anexo pendente const handleRemoveAttachment = (storageId: string) => { - setPendingAttachments(prev => prev.filter(a => a.storageId !== storageId)) + setPendingAttachments((prev) => { + const removed = prev.find((a) => a.storageId === storageId) + if (removed?.previewUrl) { + URL.revokeObjectURL(removed.previewUrl) + } + return prev.filter((a) => a.storageId !== storageId) + }) } // Enviar mensagem @@ -538,6 +848,11 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { type: a.type, })) : undefined, }) + attachmentsToSend.forEach((attachment) => { + if (attachment.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl) + } + }) pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false } } catch (err) { console.error("Erro ao enviar mensagem:", err) @@ -563,11 +878,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { } const handleExpand = async () => { - if (firstUnreadAgentMessageId) { - pendingScrollActionRef.current = { type: "message", messageId: firstUnreadAgentMessageId, behavior: "auto", markRead: unreadCount > 0 } - } else { - pendingScrollActionRef.current = { type: "bottom", behavior: "auto", markRead: false } - } + pendingScrollActionRef.current = { type: "bottom", behavior: "auto", markRead: unreadCount > 0 } setIsMinimized(false) try { @@ -830,22 +1141,85 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { {/* Anexos pendentes */} {pendingAttachments.length > 0 && (
- {pendingAttachments.map((att) => ( -
- {getFileIcon(att.name)} - {att.name} - +
+ ) + } + + return ( +
- + {getFileIcon(att.name)} + {att.name} + +
+ ) + })} +
+ )} + {(audioRecorder.isRecording || audioRecorder.isProcessing) && ( +
+ {audioRecorder.isRecording ? ( + <> +
+ + + {formatDuration(audioRecorder.durationSeconds)} / {formatDuration(MAX_AUDIO_DURATION_SECONDS)} + +
+ {audioRecorder.levels.map((level, index) => ( + + ))} +
+
+ + + ) : ( +
+ + Anexando áudio...
- ))} + )}
)}
@@ -856,10 +1230,25 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { placeholder="Digite sua mensagem..." className="max-h-24 min-h-[36px] flex-1 resize-none rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-slate-400 focus:outline-none focus:ring-1 focus:ring-slate-400" rows={1} + disabled={isSending || isAudioBusy} /> + + +
+ {isLoadingWaveform ? ( +
+ ) : peaks.length > 0 ? ( + + ) : ( +
+ )} +
+ {formatDuration(currentTime)} + {sizeLabel ?? formatDuration(duration)} +
+
+ + + +
+ ) +} + +export function ChatMessageAttachment({ attachment, tone = "light" }: ChatMessageAttachmentProps) { + const getFileUrl = useAction(api.files.getUrl) + const [url, setUrl] = useState(null) + const [loading, setLoading] = useState(true) + const [downloading, setDownloading] = useState(false) + const [downloaded, setDownloaded] = useState(false) + + const sizeLabel = formatAttachmentSize(attachment.size) + const isAudio = isAudioAttachment(attachment) + const isImage = isImageAttachment(attachment) + + useEffect(() => { + let cancelled = false + async function loadUrl() { + try { + const fileUrl = await getFileUrl({ storageId: attachment.storageId }) + if (!cancelled && fileUrl) { + setUrl(fileUrl) + } + } catch (error) { + console.error("Erro ao carregar anexo:", error) + } finally { + if (!cancelled) setLoading(false) + } + } + loadUrl() + return () => { cancelled = true } + }, [attachment.storageId, getFileUrl]) + + const handleView = () => { + if (!url) return + window.open(url, "_blank", "noopener,noreferrer") + } + + const handleDownload = async () => { + if (!url || downloading) return + setDownloading(true) + try { + const response = await fetch(url) + const blob = await response.blob() + const downloadUrl = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = downloadUrl + anchor.download = attachment.name + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(downloadUrl) + setDownloaded(true) + setTimeout(() => setDownloaded(false), 2000) + } catch (error) { + toast.error("Erro ao baixar arquivo") + } finally { + setDownloading(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (isAudio && url) { + return ( + + ) + } + + if (isImage && url) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {attachment.name} +
+ + +
+
+ ) + } + + return ( +
+ {attachment.type?.startsWith("image/") ? ( + + ) : ( + + )} + + {attachment.name} + + {sizeLabel && ( + + ({sizeLabel}) + + )} + + +
+ ) +} diff --git a/src/components/chat/chat-widget.tsx b/src/components/chat/chat-widget.tsx index 90d7dff..4590038 100644 --- a/src/components/chat/chat-widget.tsx +++ b/src/components/chat/chat-widget.tsx @@ -1,6 +1,5 @@ "use client" -import Image from "next/image" import { useCallback, useEffect, useRef, useState } from "react" import { useAction, useMutation, useQuery } from "convex/react" import type { Id } from "@/convex/_generated/dataModel" @@ -22,16 +21,19 @@ import { XCircle, Paperclip, FileText, - Image as ImageIcon, - Download, + Mic, + Square, + Trash2, ExternalLink, - Eye, - Check, } from "lucide-react" import { ChatSessionList } from "./chat-session-list" +import { ChatMessageAttachment } from "./chat-message-attachment" +import { useAudioRecorder } from "./use-audio-recorder" const MAX_MESSAGE_LENGTH = 4000 const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024 // 5MB +const MAX_AUDIO_BYTES = 5 * 1024 * 1024 // 5MB +const MAX_AUDIO_DURATION_SECONDS = 300 const MAX_ATTACHMENTS = 5 const STORAGE_KEY = "chat-widget-state" @@ -51,6 +53,13 @@ function formatTime(timestamp: number) { }) } +function formatDuration(seconds: number) { + const safe = Math.max(0, Math.floor(seconds)) + const mins = Math.floor(safe / 60) + const secs = safe % 60 + return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}` +} + function formatDateSeparator(timestamp: number) { const date = new Date(timestamp) const today = new Date() @@ -141,131 +150,6 @@ type ChatData = { }> } -// Componente de preview de anexo na mensagem -function MessageAttachment({ attachment }: { attachment: ChatAttachment }) { - const getFileUrl = useAction(api.files.getUrl) - const [url, setUrl] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - let cancelled = false - async function loadUrl() { - try { - const fileUrl = await getFileUrl({ storageId: attachment.storageId }) - if (!cancelled && fileUrl) { - setUrl(fileUrl) - } - } catch (error) { - console.error("Erro ao carregar anexo:", error) - } finally { - if (!cancelled) setLoading(false) - } - } - loadUrl() - return () => { cancelled = true } - }, [attachment.storageId, getFileUrl]) - - const isImage = attachment.type?.startsWith("image/") - const [downloading, setDownloading] = useState(false) - const [downloaded, setDownloaded] = useState(false) - - const handleView = () => { - if (!url) return - window.open(url, "_blank", "noopener,noreferrer") - } - - const handleDownload = async () => { - if (!url || downloading) return - setDownloading(true) - try { - const response = await fetch(url) - const blob = await response.blob() - const downloadUrl = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = downloadUrl - a.download = attachment.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(downloadUrl) - setDownloaded(true) - setTimeout(() => setDownloaded(false), 2000) - } catch (error) { - toast.error("Erro ao baixar arquivo") - } finally { - setDownloading(false) - } - } - - if (loading) { - return ( -
- -
- ) - } - - if (isImage && url) { - return ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {attachment.name} -
- - -
-
- ) - } - - return ( -
- - {attachment.name} - - -
- ) -} export function ChatWidget() { // Detectar se esta rodando no Tauri (desktop) - nesse caso, nao renderizar @@ -353,11 +237,60 @@ export function ChatWidget() { const endLiveChat = useMutation(api.liveChat.endSession) const generateUploadUrl = useAction(api.files.generateUploadUrl) + const queueAudioAttachment = useCallback(async (file: File) => { + if (!viewerId || !activeTicketId) return + if (attachments.length >= MAX_ATTACHMENTS) { + throw new Error("Limite de anexos atingido. Remova um anexo para gravar outro áudio.") + } + if (file.size > MAX_AUDIO_BYTES) { + throw new Error("Áudio excede o limite de 5MB.") + } + + const uploadUrl = await generateUploadUrl() + const fileType = file.type || "audio/webm" + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { "Content-Type": fileType }, + body: file, + }) + + if (!uploadResponse.ok) { + const text = await uploadResponse.text().catch(() => "") + throw new Error(text || "Erro ao enviar áudio") + } + + const { storageId } = (await uploadResponse.json()) as { storageId: string } + const previewUrl = URL.createObjectURL(file) + setAttachments((prev) => [ + ...prev, + { + storageId, + name: file.name, + size: file.size, + type: fileType, + previewUrl, + }, + ]) + }, [activeTicketId, attachments.length, generateUploadUrl, viewerId]) + + const audioRecorder = useAudioRecorder({ + maxDurationSeconds: MAX_AUDIO_DURATION_SECONDS, + maxFileSizeBytes: MAX_AUDIO_BYTES, + onAudioReady: async ({ file }) => { + await queueAudioAttachment(file) + }, + onError: (message) => { + toast.error(message) + }, + }) + const messages = chat?.messages ?? [] + const lastMessageId = messages.length > 0 ? messages[messages.length - 1]?.id : null const totalUnread = activeSessions?.reduce((sum, s) => sum + s.unreadCount, 0) ?? 0 const liveChat = chat?.liveChat const machineOnline = liveChat?.machineOnline ?? false const machineHostname = liveChat?.machineHostname + const isAudioBusy = audioRecorder.isRecording || audioRecorder.isProcessing // Sincronizar estado entre abas usando evento storage do localStorage // O evento storage dispara automaticamente em TODAS as outras abas quando localStorage muda @@ -447,12 +380,14 @@ export function ChatWidget() { setActiveTicketId(mine.ticketId) }, [activeSessions, viewerId]) - // Scroll para última mensagem + // Scroll para última mensagem ao abrir ou trocar de sessão useEffect(() => { - if (messagesEndRef.current && isOpen && !isMinimized) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }) - } - }, [messages.length, isOpen, isMinimized]) + if (!isOpen || isMinimized || viewMode !== "chat") return + if (!messagesEndRef.current) return + requestAnimationFrame(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }) + }, [activeTicketId, isMinimized, isOpen, lastMessageId, viewMode]) // Ref para rastrear se ja marcamos como lidas nesta abertura do chat const hasMarkedReadRef = useRef(false) @@ -859,9 +794,13 @@ export function ChatWidget() { {shouldShowBody &&

{msg.body}

} {/* Anexos da mensagem */} {msg.attachments && msg.attachments.length > 0 && ( -
- {msg.attachments.map((att, i) => ( - +
+ {msg.attachments.map((att) => ( + ))}
)} @@ -881,31 +820,53 @@ export function ChatWidget() { {/* Preview de anexos pendentes */} {attachments.length > 0 && (
- {attachments.map((file, index) => ( -
- {file.type?.startsWith("image/") && file.previewUrl ? ( - /* eslint-disable-next-line @next/next/no-img-element */ - {file.name} - ) : ( -
- -
- )} + {attachments.map((file, index) => { + const isAudio = file.type?.startsWith("audio/") || /\.(mp3|wav|ogg|webm|m4a)$/i.test(file.name) + if (isAudio && file.previewUrl) { + return ( +
+
- ))} + ) + } + + return ( +
+ {file.type?.startsWith("image/") && file.previewUrl ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + {file.name} + ) : ( +
+ +
+ )} + +

+ {file.name} +

+
+ ) + })} {isUploading && (
@@ -929,6 +890,52 @@ export function ChatWidget() {
)} + {(audioRecorder.isRecording || audioRecorder.isProcessing) && ( +
+ {audioRecorder.isRecording ? ( + <> +
+ + + {formatDuration(audioRecorder.durationSeconds)} / {formatDuration(MAX_AUDIO_DURATION_SECONDS)} + +
+ {audioRecorder.levels.map((level, index) => ( + + ))} +
+
+ + + ) : ( +
+ + Anexando áudio... +
+ )} +
+ )} +