feat(chat): desktop usando Convex WS direto e fallback WS dedicado

This commit is contained in:
esdrasrenan 2025-12-09 01:01:54 -03:00
parent 8db7c3c810
commit a8f5ff9d51
14 changed files with 735 additions and 458 deletions

View file

@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { open } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types"
import type { ChatMessage } from "./types"
import {
subscribeMachineMessages,
sendMachineMessage,
markMachineMessagesRead,
getMachineStoreConfig,
} from "./convexMachineClient"
const STORE_FILENAME = "machine-agent.json"
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
// Tipos de arquivo permitidos
@ -52,8 +54,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
const [unreadCount, setUnreadCount] = useState(0)
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const messagesSubRef = useRef<(() => void) | null>(null)
const hadSessionRef = useRef<boolean>(false)
// Scroll para o final quando novas mensagens chegam
@ -77,153 +78,48 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
hadSessionRef.current = hasSession
}, [hasSession, ticketId])
// Carregar configuracao do store
const loadConfig = useCallback(async () => {
try {
const appData = await appLocalDataDir()
const storePath = await join(appData, STORE_FILENAME)
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) {
setError("Máquina não registrada")
setIsLoading(false)
return null
}
return { token, baseUrl: config.apiBaseUrl }
} catch (err) {
setError("Erro ao carregar configuracao")
setIsLoading(false)
return null
}
}, [])
// Buscar mensagens
const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => {
try {
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId,
since: since ?? null,
})
setHasSession(response.hasSession)
if (response.messages.length > 0) {
if (since) {
// Adicionar apenas novas mensagens (com limite para evitar memory leak)
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
const combined = [...prev, ...newMsgs]
// Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
})
} else {
// Primeira carga (já limitada)
setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY))
}
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
}
return response
} catch (err) {
console.error("Erro ao buscar mensagens:", err)
return null
}
}, [ticketId])
// Buscar info da sessao
const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => {
try {
const sessions = await invoke<Array<{
ticketId: string
ticketRef: number
ticketSubject: string
agentName: string
}>>("fetch_chat_sessions", { baseUrl, token })
const session = sessions.find(s => s.ticketId === ticketId)
if (session) {
setTicketInfo({
ref: session.ticketRef,
subject: session.ticketSubject,
agentName: session.agentName,
})
}
} catch (err) {
console.error("Erro ao buscar sessao:", err)
}
}, [ticketId])
// Inicializacao
// Inicializacao via Convex (WS)
useEffect(() => {
let mounted = true
setIsLoading(true)
setMessages([])
messagesSubRef.current?.()
const init = async () => {
const config = await loadConfig()
if (!config || !mounted) return
const { baseUrl, token } = config
// Buscar sessao e mensagens iniciais
await Promise.all([
fetchSessionInfo(baseUrl, token),
fetchMessages(baseUrl, token),
])
if (!mounted) return
setIsLoading(false)
// Iniciar polling (2 segundos para maior responsividade)
pollIntervalRef.current = setInterval(async () => {
await fetchMessages(baseUrl, token, lastFetchRef.current)
}, 2000)
}
init()
// Listener para eventos de nova mensagem do Tauri
const unlistenNewMessage = listen<{ ticketId: string; message: ChatMessage }>(
"raven://chat/new-message",
(event) => {
if (event.payload.ticketId === ticketId) {
setMessages(prev => {
if (prev.some(m => m.id === event.payload.message.id)) {
return prev
}
const combined = [...prev, event.payload.message]
// Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
})
subscribeMachineMessages(
ticketId,
(payload) => {
setIsLoading(false)
setHasSession(payload.hasSession)
hadSessionRef.current = hadSessionRef.current || payload.hasSession
const unread = payload.messages.filter(m => !m.isFromMachine).length
setUnreadCount(unread)
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))]
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
})
// Atualiza info basica do ticket
if (payload.messages.length > 0) {
const first = payload.messages[0]
setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" })
}
}
)
// Listener para atualização de mensagens não lidas
const unlistenUnread = listen<{ totalUnread: number; sessions: Array<{ ticketId: string; unreadCount: number }> }>(
"raven://chat/unread-update",
(event) => {
// Encontrar o unread count para este ticket
const session = event.payload.sessions?.find(s => s.ticketId === ticketId)
if (session) {
setUnreadCount(session.unreadCount ?? 0)
const unreadIds = payload.messages.filter(m => !m.isFromMachine).map(m => m.id as string)
if (unreadIds.length > 0) {
markMachineMessagesRead(ticketId, unreadIds).catch(err => console.error("mark read falhou", err))
}
},
(err) => {
setIsLoading(false)
setError(err.message || "Erro ao carregar mensagens.")
}
)
).then((unsub) => {
messagesSubRef.current = unsub
})
return () => {
mounted = false
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
}
unlistenNewMessage.then(unlisten => unlisten())
unlistenUnread.then(unlisten => unlisten())
messagesSubRef.current?.()
messagesSubRef.current = null
}
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
}, [ticketId])
// Selecionar arquivo para anexar
const handleAttach = async () => {
@ -245,14 +141,10 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
setIsUploading(true)
const config = await loadConfig()
if (!config) {
setIsUploading(false)
return
}
const config = await getMachineStoreConfig()
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
baseUrl: config.baseUrl,
baseUrl: config.apiBaseUrl,
token: config.token,
filePath,
})
@ -282,29 +174,20 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
setIsSending(true)
try {
const config = await loadConfig()
if (!config) {
setIsSending(false)
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "")
await sendMachineMessage({
ticketId,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""),
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : null,
body: bodyToSend,
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
})
// Adicionar mensagem localmente
setMessages(prev => [...prev, {
id: response.messageId,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""),
id: crypto.randomUUID(),
body: bodyToSend,
authorName: "Você",
isFromMachine: true,
createdAt: response.createdAt,
createdAt: Date.now(),
attachments: attachmentsToSend.map(a => ({
storageId: a.storageId,
name: a.name,
@ -312,8 +195,6 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
type: a.type,
})),
}])
lastFetchRef.current = response.createdAt
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
// Restaurar input e anexos em caso de erro