ChatWidget and ChatFloatingWidget were using relative path "machine-agent.json" instead of the full path with appLocalDataDir(). This caused "Maquina nao registrada" error in chat window. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { invoke } from "@tauri-apps/api/core"
|
|
import { Store } from "@tauri-apps/plugin-store"
|
|
import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
|
import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react"
|
|
import { cn } from "../lib/utils"
|
|
import type { ChatSession, ChatMessage, ChatMessagesResponse, SendMessageResponse, ChatHistorySession } from "../chat/types"
|
|
|
|
const STORE_FILENAME = "machine-agent.json"
|
|
|
|
interface ChatFloatingWidgetProps {
|
|
sessions: ChatSession[]
|
|
totalUnread: number
|
|
isOpen: boolean
|
|
onToggle: () => void
|
|
onMinimize: () => void
|
|
}
|
|
|
|
export function ChatFloatingWidget({
|
|
sessions,
|
|
totalUnread,
|
|
isOpen,
|
|
onToggle,
|
|
onMinimize,
|
|
}: ChatFloatingWidgetProps) {
|
|
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null)
|
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
const [inputValue, setInputValue] = useState("")
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [isSending, setIsSending] = useState(false)
|
|
const [historyExpanded, setHistoryExpanded] = useState(false)
|
|
const [historySessions] = useState<ChatHistorySession[]>([])
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const lastFetchRef = useRef<number>(0)
|
|
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
// Selecionar ticket mais recente automaticamente
|
|
useEffect(() => {
|
|
if (sessions.length > 0 && !selectedTicketId) {
|
|
// Ordenar por lastActivityAt e pegar o mais recente
|
|
const sorted = [...sessions].sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
setSelectedTicketId(sorted[0].ticketId)
|
|
}
|
|
}, [sessions, selectedTicketId])
|
|
|
|
// Scroll para o final quando novas mensagens chegam
|
|
const scrollToBottom = useCallback(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
scrollToBottom()
|
|
}, [messages, scrollToBottom])
|
|
|
|
// 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) {
|
|
return null
|
|
}
|
|
|
|
return { token, baseUrl: config.apiBaseUrl }
|
|
} catch {
|
|
return null
|
|
}
|
|
}, [])
|
|
|
|
// Buscar mensagens
|
|
const fetchMessages = useCallback(async (baseUrl: string, token: string, ticketId: string, since?: number) => {
|
|
try {
|
|
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
|
|
baseUrl,
|
|
token,
|
|
ticketId,
|
|
since: since ?? null,
|
|
})
|
|
|
|
if (response.messages.length > 0) {
|
|
if (since) {
|
|
setMessages(prev => {
|
|
const existingIds = new Set(prev.map(m => m.id))
|
|
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
|
|
return [...prev, ...newMsgs]
|
|
})
|
|
} else {
|
|
setMessages(response.messages)
|
|
}
|
|
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
|
|
}
|
|
|
|
return response
|
|
} catch (err) {
|
|
console.error("Erro ao buscar mensagens:", err)
|
|
return null
|
|
}
|
|
}, [])
|
|
|
|
// Inicializar e fazer polling quando ticket selecionado
|
|
useEffect(() => {
|
|
if (!selectedTicketId || !isOpen) return
|
|
|
|
let mounted = true
|
|
|
|
const init = async () => {
|
|
setIsLoading(true)
|
|
const config = await loadConfig()
|
|
if (!config || !mounted) {
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
|
|
const { baseUrl, token } = config
|
|
|
|
// Buscar mensagens iniciais
|
|
await fetchMessages(baseUrl, token, selectedTicketId)
|
|
|
|
if (!mounted) return
|
|
setIsLoading(false)
|
|
|
|
// Iniciar polling (2 segundos)
|
|
pollIntervalRef.current = setInterval(async () => {
|
|
await fetchMessages(baseUrl, token, selectedTicketId, lastFetchRef.current)
|
|
}, 2000)
|
|
}
|
|
|
|
init()
|
|
|
|
return () => {
|
|
mounted = false
|
|
if (pollIntervalRef.current) {
|
|
clearInterval(pollIntervalRef.current)
|
|
pollIntervalRef.current = null
|
|
}
|
|
}
|
|
}, [selectedTicketId, isOpen, loadConfig, fetchMessages])
|
|
|
|
// Limpar mensagens quando trocar de ticket
|
|
useEffect(() => {
|
|
setMessages([])
|
|
lastFetchRef.current = 0
|
|
}, [selectedTicketId])
|
|
|
|
// Enviar mensagem
|
|
const handleSend = async () => {
|
|
if (!inputValue.trim() || isSending || !selectedTicketId) return
|
|
|
|
const messageText = inputValue.trim()
|
|
setInputValue("")
|
|
setIsSending(true)
|
|
|
|
try {
|
|
const config = await loadConfig()
|
|
if (!config) {
|
|
setIsSending(false)
|
|
return
|
|
}
|
|
|
|
const response = await invoke<SendMessageResponse>("send_chat_message", {
|
|
baseUrl: config.baseUrl,
|
|
token: config.token,
|
|
ticketId: selectedTicketId,
|
|
body: messageText,
|
|
})
|
|
|
|
setMessages(prev => [...prev, {
|
|
id: response.messageId,
|
|
body: messageText,
|
|
authorName: "Voce",
|
|
isFromMachine: true,
|
|
createdAt: response.createdAt,
|
|
attachments: [],
|
|
}])
|
|
|
|
lastFetchRef.current = response.createdAt
|
|
} catch (err) {
|
|
console.error("Erro ao enviar mensagem:", err)
|
|
setInputValue(messageText)
|
|
} finally {
|
|
setIsSending(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
const currentSession = sessions.find(s => s.ticketId === selectedTicketId)
|
|
|
|
// Botao flutuante (fechado)
|
|
// DEBUG: Log do estado do widget
|
|
console.log("[ChatFloatingWidget] Estado:", {
|
|
isOpen,
|
|
totalUnread,
|
|
sessionsCount: sessions.length,
|
|
sessions: sessions.map(s => ({ id: s.sessionId, ticketId: s.ticketId, unread: s.unreadCount }))
|
|
})
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-50">
|
|
{/* DEBUG: Indicador visual do estado */}
|
|
<div className="absolute -left-32 bottom-0 rounded bg-yellow-100 p-1 text-[10px] text-yellow-800 shadow">
|
|
unread: {totalUnread} | sessions: {sessions.length}
|
|
</div>
|
|
<button
|
|
onClick={onToggle}
|
|
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition hover:bg-black/90"
|
|
>
|
|
<MessageCircle className="size-6" />
|
|
{totalUnread > 0 && (
|
|
<>
|
|
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
|
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
|
|
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
|
{totalUnread > 99 ? "99+" : totalUnread}
|
|
</span>
|
|
</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Widget expandido
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl">
|
|
<div className="flex items-center gap-3">
|
|
{sessions.length > 1 && selectedTicketId && (
|
|
<button
|
|
onClick={() => setSelectedTicketId(null)}
|
|
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
>
|
|
<ChevronLeft className="size-4" />
|
|
</button>
|
|
)}
|
|
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
|
<MessageCircle className="size-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-900">
|
|
{currentSession?.agentName ?? "Suporte"}
|
|
</p>
|
|
{currentSession && (
|
|
<p className="text-xs text-slate-500">
|
|
Chamado #{currentSession.ticketRef}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{/* Tabs de tickets (se houver mais de 1) */}
|
|
{sessions.length > 1 && (
|
|
<div className="mr-2 flex items-center gap-1">
|
|
{sessions.slice(0, 3).map((session) => (
|
|
<button
|
|
key={session.ticketId}
|
|
onClick={() => setSelectedTicketId(session.ticketId)}
|
|
className={cn(
|
|
"rounded px-2 py-1 text-xs font-medium transition",
|
|
session.ticketId === selectedTicketId
|
|
? "bg-black text-white"
|
|
: "bg-slate-200 text-slate-600 hover:bg-slate-300"
|
|
)}
|
|
>
|
|
#{session.ticketRef}
|
|
{session.unreadCount > 0 && (
|
|
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
|
|
{session.unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
{sessions.length > 3 && (
|
|
<span className="text-xs text-slate-400">+{sessions.length - 3}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<button
|
|
onClick={onMinimize}
|
|
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
>
|
|
<Minus className="size-4" />
|
|
</button>
|
|
<button
|
|
onClick={onMinimize}
|
|
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Selecao de ticket (se nenhum selecionado e ha multiplos) */}
|
|
{!selectedTicketId && sessions.length > 1 ? (
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<p className="mb-3 text-sm font-medium text-slate-700">Selecione um chamado:</p>
|
|
<div className="space-y-2">
|
|
{sessions.map((session) => (
|
|
<button
|
|
key={session.ticketId}
|
|
onClick={() => setSelectedTicketId(session.ticketId)}
|
|
className="w-full rounded-lg border border-slate-200 p-3 text-left transition hover:border-slate-300 hover:bg-slate-50"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-900">
|
|
#{session.ticketRef}
|
|
</span>
|
|
{session.unreadCount > 0 && (
|
|
<span className="inline-flex size-5 items-center justify-center rounded-full bg-red-500 text-xs text-white">
|
|
{session.unreadCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-1 truncate text-xs text-slate-500">
|
|
{session.ticketSubject}
|
|
</p>
|
|
<p className="mt-1 text-xs text-slate-400">
|
|
{session.agentName}
|
|
</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Area de mensagens */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{/* Historico de sessoes anteriores */}
|
|
{historySessions.length > 0 && (
|
|
<div className="mb-4">
|
|
<button
|
|
onClick={() => setHistoryExpanded(!historyExpanded)}
|
|
className="flex w-full items-center justify-between rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600"
|
|
>
|
|
<span>Historico ({historySessions.length} sessoes)</span>
|
|
{historyExpanded ? (
|
|
<ChevronDown className="size-4" />
|
|
) : (
|
|
<ChevronRight className="size-4" />
|
|
)}
|
|
</button>
|
|
{historyExpanded && (
|
|
<div className="mt-2 space-y-2 rounded-lg border border-slate-200 p-2">
|
|
{historySessions.map((session) => (
|
|
<div key={session.sessionId} className="text-xs text-slate-500">
|
|
<p className="font-medium">{session.agentName}</p>
|
|
<p>{session.messages.length} mensagens</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<div className="flex h-full flex-col items-center justify-center">
|
|
<Loader2 className="size-8 animate-spin text-slate-400" />
|
|
<p className="mt-2 text-sm text-slate-500">Carregando...</p>
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
|
<p className="text-sm text-slate-400">
|
|
Nenhuma mensagem ainda
|
|
</p>
|
|
<p className="mt-1 text-xs text-slate-400">
|
|
O agente iniciara a conversa em breve
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{messages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className={`flex ${msg.isFromMachine ? "justify-end" : "justify-start"}`}
|
|
>
|
|
<div
|
|
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
|
|
msg.isFromMachine
|
|
? "bg-black text-white"
|
|
: "bg-slate-100 text-slate-900"
|
|
}`}
|
|
>
|
|
{!msg.isFromMachine && (
|
|
<p className="mb-1 text-xs font-medium text-slate-500">
|
|
{msg.authorName}
|
|
</p>
|
|
)}
|
|
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
|
|
<p
|
|
className={`mt-1 text-right text-xs ${
|
|
msg.isFromMachine ? "text-white/60" : "text-slate-400"
|
|
}`}
|
|
>
|
|
{formatTime(msg.createdAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="border-t border-slate-200 p-3 rounded-b-2xl">
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
value={inputValue}
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Digite sua mensagem..."
|
|
className="max-h-24 min-h-[40px] flex-1 resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
|
|
rows={1}
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!inputValue.trim() || isSending}
|
|
className="flex size-10 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
|
|
>
|
|
{isSending ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Send className="size-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatTime(timestamp: number): string {
|
|
const date = new Date(timestamp)
|
|
return date.toLocaleTimeString("pt-BR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
}
|