fix(chat): melhora realtime e anexos no desktop

This commit is contained in:
esdrasrenan 2025-12-12 21:36:32 -03:00
parent 3d45fe3b04
commit 8cf13c43de
5 changed files with 603 additions and 141 deletions

View file

@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { open } from "@tauri-apps/plugin-dialog"
import { open as openDialog } from "@tauri-apps/plugin-dialog"
import { open as openExternal } from "@tauri-apps/plugin-opener"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, NewMessageEvent } from "./types"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2, Eye, Download, Check } from "lucide-react"
import type { ChatAttachment, ChatMessage, ChatMessagesResponse, NewMessageEvent, SessionEndedEvent } from "./types"
import { getMachineStoreConfig } from "./machineStore"
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
@ -32,6 +33,170 @@ function getFileIcon(fileName: string) {
return <File className="size-4" />
}
function isImageAttachment(attachment: ChatAttachment) {
if (attachment.type?.startsWith("image/")) return true
const ext = attachment.name.toLowerCase().split(".").pop() ?? ""
return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext)
}
function formatAttachmentSize(size?: number) {
if (!size) return null
if (size < 1024) return `${size}B`
const kb = size / 1024
if (kb < 1024) return `${Math.round(kb)}KB`
return `${(kb / 1024).toFixed(1)}MB`
}
function MessageAttachment({
attachment,
isAgent,
loadUrl,
}: {
attachment: ChatAttachment
isAgent: boolean
loadUrl: (storageId: string) => Promise<string>
}) {
const [url, setUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [downloading, setDownloading] = useState(false)
const [downloaded, setDownloaded] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
loadUrl(attachment.storageId)
.then((resolved) => {
if (!cancelled) setUrl(resolved)
})
.catch((err) => {
console.error("Falha ao carregar URL do anexo:", err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [attachment.storageId, loadUrl])
const handleView = async () => {
if (!url) return
try {
await openExternal(url)
} catch (err) {
console.error("Falha ao abrir anexo:", err)
}
}
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 (err) {
console.error("Falha ao baixar anexo:", err)
// Fallback: abrir no navegador/sistema
await handleView()
} finally {
setDownloading(false)
}
}
const sizeLabel = formatAttachmentSize(attachment.size)
const isImage = isImageAttachment(attachment)
if (loading) {
return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
<Loader2 className="size-4 animate-spin" />
<span className="truncate">Carregando anexo...</span>
</div>
)
}
if (isImage && url) {
return (
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={attachment.name}
className="size-24 cursor-pointer object-cover"
onClick={handleView}
/>
<div className="absolute inset-0 flex items-center justify-center gap-1 bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={handleView}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
title="Visualizar"
>
<Eye className="size-4 text-white" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className="flex size-7 items-center justify-center rounded-full bg-white/20 hover:bg-white/30 disabled:opacity-60"
title="Baixar"
>
{downloading ? (
<Loader2 className="size-4 animate-spin text-white" />
) : downloaded ? (
<Check className="size-4 text-emerald-300" />
) : (
<Download className="size-4 text-white" />
)}
</button>
</div>
</div>
)
}
return (
<div className={`flex items-center gap-2 rounded-lg p-2 text-xs ${isAgent ? "bg-white/10" : "bg-slate-100"}`}>
{getFileIcon(attachment.name)}
<button onClick={handleView} className="flex-1 truncate text-left hover:underline" title="Visualizar">
{attachment.name}
</button>
{sizeLabel && <span className="text-xs opacity-60">({sizeLabel})</span>}
<div className="ml-1 flex items-center gap-1">
<button
onClick={handleView}
className={`flex size-7 items-center justify-center rounded-md ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Visualizar"
>
<Eye className="size-4" />
</button>
<button
onClick={handleDownload}
disabled={downloading}
className={`flex size-7 items-center justify-center rounded-md disabled:opacity-60 ${isAgent ? "hover:bg-white/10" : "hover:bg-slate-200"}`}
title="Baixar"
>
{downloading ? (
<Loader2 className="size-4 animate-spin" />
) : downloaded ? (
<Check className="size-4 text-emerald-500" />
) : (
<Download className="size-4" />
)}
</button>
</div>
</div>
)
}
interface ChatWidgetProps {
ticketId: string
ticketRef?: number
@ -89,6 +254,37 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
return cfg
}, [])
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
const loadAttachmentUrl = useCallback(async (storageId: string) => {
const cached = attachmentUrlCacheRef.current.get(storageId)
if (cached) return cached
const cfg = await ensureConfig()
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineToken: cfg.token,
ticketId,
storageId,
}),
})
if (!response.ok) {
const text = await response.text().catch(() => "")
throw new Error(text || `Falha ao obter URL do anexo (${response.status})`)
}
const data = (await response.json()) as { url?: string }
if (!data.url) {
throw new Error("Resposta invalida ao obter URL do anexo")
}
attachmentUrlCacheRef.current.set(storageId, data.url)
return data.url
}, [ensureConfig, ticketId])
const loadMessages = useCallback(async () => {
try {
const cfg = await ensureConfig()
@ -151,6 +347,24 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
}
}, [ticketId, loadMessages])
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
useEffect(() => {
let unlisten: (() => void) | null = null
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
if (event.payload?.ticketId === ticketId) {
loadMessages()
}
})
.then((u) => {
unlisten = u
})
.catch((err) => console.error("Falha ao registrar listener session-ended:", err))
return () => {
unlisten?.()
}
}, [ticketId, loadMessages])
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
/* useEffect(() => {
setIsLoading(true)
@ -242,7 +456,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
if (isUploading || isSending) return
try {
const selected = await open({
const selected = await openDialog({
multiple: false,
filters: [{
name: "Arquivos permitidos",
@ -513,22 +727,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
{/* Anexos */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-1">
<div className="mt-2 space-y-2">
{msg.attachments.map((att) => (
<div
<MessageAttachment
key={att.storageId}
className={`flex items-center gap-2 rounded-lg p-2 text-xs ${
isAgent ? "bg-white/10" : "bg-slate-100"
}`}
>
{getFileIcon(att.name)}
<span className="truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-60">
({Math.round(att.size / 1024)}KB)
</span>
)}
</div>
attachment={att}
isAgent={isAgent}
loadUrl={loadAttachmentUrl}
/>
))}
</div>
)}