feat(chat): desktop usando Convex WS direto e fallback WS dedicado
This commit is contained in:
parent
8db7c3c810
commit
a8f5ff9d51
14 changed files with 735 additions and 458 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue