Implementa sistema de chat em tempo real entre agente e cliente

- Adiciona tabela liveChatSessions no schema Convex
- Cria convex/liveChat.ts com mutations e queries para chat
- Adiciona API routes para maquinas (sessions, messages, poll)
- Cria modulo chat.rs no Tauri com ChatRuntime e polling
- Adiciona comandos de chat no lib.rs (start/stop polling, open/close window)
- Cria componentes React do chat widget (ChatWidget, types)
- Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel)
- Implementa menu de chat no system tray
- Polling de 2 segundos para maior responsividade
- Janela de chat flutuante, frameless, always-on-top

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 01:00:27 -03:00
parent 0c8d53c0b6
commit ba91c1e0f5
15 changed files with 2004 additions and 15 deletions

View file

@ -0,0 +1,126 @@
import { z } from "zod"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
const getMessagesSchema = z.object({
machineToken: z.string().min(1),
ticketId: z.string().min(1),
since: z.number().optional(),
limit: z.number().optional(),
})
const postMessageSchema = z.object({
machineToken: z.string().min(1),
ticketId: z.string().min(1),
body: z.string().min(1).max(4000),
attachments: z
.array(
z.object({
storageId: z.string(),
name: z.string(),
size: z.number().optional(),
type: z.string().optional(),
})
)
.optional(),
})
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
// POST /api/machines/chat/messages
// action=list: Lista mensagens de um chat
// action=send: Envia nova mensagem
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let raw
try {
raw = await request.json()
} catch {
return jsonWithCors({ error: "JSON invalido" }, 400, origin, CORS_METHODS)
}
const action = raw.action ?? "list"
if (action === "list") {
let payload
try {
payload = getMessagesSchema.parse(raw)
} catch (error) {
return jsonWithCors(
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
try {
const result = await client.query(api.liveChat.listMachineMessages, {
machineToken: payload.machineToken,
ticketId: payload.ticketId as Id<"tickets">,
since: payload.since,
limit: payload.limit,
})
return jsonWithCors(result, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.chat.messages] Falha ao listar mensagens", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao listar mensagens", details }, 500, origin, CORS_METHODS)
}
}
if (action === "send") {
let payload
try {
payload = postMessageSchema.parse(raw)
} catch (error) {
return jsonWithCors(
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
try {
const result = await client.mutation(api.liveChat.postMachineMessage, {
machineToken: payload.machineToken,
ticketId: payload.ticketId as Id<"tickets">,
body: payload.body,
attachments: payload.attachments as
| Array<{
storageId: Id<"_storage">
name: string
size?: number
type?: string
}>
| undefined,
})
return jsonWithCors(result, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.chat.messages] Falha ao enviar mensagem", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao enviar mensagem", details }, 500, origin, CORS_METHODS)
}
}
return jsonWithCors({ error: "Acao invalida" }, 400, origin, CORS_METHODS)
}

View file

@ -0,0 +1,57 @@
import { z } from "zod"
import { api } from "@/convex/_generated/api"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
const pollSchema = z.object({
machineToken: z.string().min(1),
lastCheckedAt: z.number().optional(),
})
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
// POST /api/machines/chat/poll
// Endpoint leve para polling de atualizacoes de chat
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let payload
try {
const raw = await request.json()
payload = pollSchema.parse(raw)
} catch (error) {
return jsonWithCors(
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
try {
const result = await client.query(api.liveChat.checkMachineUpdates, {
machineToken: payload.machineToken,
lastCheckedAt: payload.lastCheckedAt,
})
return jsonWithCors(result, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.chat.poll] Falha ao verificar atualizacoes", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao verificar atualizacoes", details }, 500, origin, CORS_METHODS)
}
}

View file

@ -0,0 +1,55 @@
import { z } from "zod"
import { api } from "@/convex/_generated/api"
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
const sessionsSchema = z.object({
machineToken: z.string().min(1),
})
const CORS_METHODS = "POST, OPTIONS"
export async function OPTIONS(request: Request) {
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
}
// POST /api/machines/chat/sessions
// Lista sessoes de chat ativas para a maquina
export async function POST(request: Request) {
const origin = request.headers.get("origin")
let client
try {
client = createConvexClient()
} catch (error) {
if (error instanceof ConvexConfigurationError) {
return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS)
}
throw error
}
let payload
try {
const raw = await request.json()
payload = sessionsSchema.parse(raw)
} catch (error) {
return jsonWithCors(
{ error: "Payload invalido", details: error instanceof Error ? error.message : String(error) },
400,
origin,
CORS_METHODS
)
}
try {
const sessions = await client.query(api.liveChat.listMachineSessions, {
machineToken: payload.machineToken,
})
return jsonWithCors({ sessions }, 200, origin, CORS_METHODS)
} catch (error) {
console.error("[machines.chat.sessions] Falha ao listar sessoes", error)
const details = error instanceof Error ? error.message : String(error)
return jsonWithCors({ error: "Falha ao listar sessoes", details }, 500, origin, CORS_METHODS)
}
}

View file

@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { MessageCircle, MonitorSmartphone, WifiOff, X } from "lucide-react"
const MAX_MESSAGE_LENGTH = 4000
@ -41,6 +42,18 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
status: string
canPost: boolean
reopenDeadline: number | null
liveChat: {
hasMachine: boolean
machineOnline: boolean
machineHostname: string | null
activeSession: {
sessionId: Id<"liveChatSessions">
agentId: Id<"users">
agentName: string | null
startedAt: number
unreadByAgent: number
} | null
}
messages: Array<{
id: Id<"ticketChatMessages">
body: string
@ -58,9 +71,13 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
const markChatRead = useMutation(api.tickets.markChatRead)
const postChatMessage = useMutation(api.tickets.postChatMessage)
const startLiveChat = useMutation(api.liveChat.startSession)
const endLiveChat = useMutation(api.liveChat.endSession)
const messagesEndRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState("")
const [isSending, setIsSending] = useState(false)
const [isStartingChat, setIsStartingChat] = useState(false)
const [isEndingChat, setIsEndingChat] = useState(false)
const messages = chat?.messages ?? []
const canPost = Boolean(chat?.canPost && viewerId)
@ -96,10 +113,50 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
return null
}, [canPost, chatEnabled])
const handleStartLiveChat = async () => {
if (!viewerId) return
setIsStartingChat(true)
toast.dismiss("live-chat")
try {
const result = await startLiveChat({
ticketId: ticketId as Id<"tickets">,
actorId: viewerId as Id<"users">,
})
if (result.isNew) {
toast.success("Chat ao vivo iniciado! O cliente sera notificado.", { id: "live-chat" })
} else {
toast.info("Ja existe uma sessao de chat ativa.", { id: "live-chat" })
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Nao foi possivel iniciar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsStartingChat(false)
}
}
const handleEndLiveChat = async () => {
if (!viewerId || !chat?.liveChat?.activeSession) return
setIsEndingChat(true)
toast.dismiss("live-chat")
try {
await endLiveChat({
sessionId: chat.liveChat.activeSession.sessionId,
actorId: viewerId as Id<"users">,
})
toast.success("Chat ao vivo encerrado.", { id: "live-chat" })
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Nao foi possivel encerrar o chat"
toast.error(message, { id: "live-chat" })
} finally {
setIsEndingChat(false)
}
}
const handleSend = async () => {
if (!viewerId || !canPost || draft.trim().length === 0) return
if (draft.length > MAX_MESSAGE_LENGTH) {
toast.error(`Mensagem muito longa (máx. ${MAX_MESSAGE_LENGTH} caracteres).`)
toast.error(`Mensagem muito longa (max. ${MAX_MESSAGE_LENGTH} caracteres).`)
return
}
setIsSending(true)
@ -115,7 +172,7 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
toast.success("Mensagem enviada!", { id: "ticket-chat" })
} catch (error) {
console.error(error)
toast.error("Não foi possível enviar a mensagem.", { id: "ticket-chat" })
toast.error("Nao foi possivel enviar a mensagem.", { id: "ticket-chat" })
} finally {
setIsSending(false)
}
@ -125,15 +182,85 @@ export function TicketChatPanel({ ticketId }: TicketChatPanelProps) {
return null
}
const liveChat = chat?.liveChat
const hasActiveSession = Boolean(liveChat?.activeSession)
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
<div className="flex items-center gap-2">
<CardTitle className="text-base font-semibold text-neutral-800">Chat do atendimento</CardTitle>
{liveChat?.hasMachine && (
<div className="flex items-center gap-1.5">
{liveChat.machineOnline ? (
<span className="flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
<span className="size-1.5 rounded-full bg-green-500" />
Online
</span>
) : (
<span className="flex items-center gap-1 rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-500">
<WifiOff className="size-3" />
Offline
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
{!chatEnabled ? (
<span className="text-xs font-medium text-neutral-500">Chat desativado</span>
) : null}
{liveChat?.hasMachine && (
<>
{hasActiveSession ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleEndLiveChat}
disabled={isEndingChat}
className="gap-1.5 border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700"
>
{isEndingChat ? <Spinner className="size-3" /> : <X className="size-3" />}
Encerrar Chat
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleStartLiveChat}
disabled={isStartingChat || !liveChat.machineOnline}
className="gap-1.5"
title={!liveChat.machineOnline ? "A maquina precisa estar online para iniciar o chat" : undefined}
>
{isStartingChat ? <Spinner className="size-3" /> : <MessageCircle className="size-3" />}
Iniciar Chat
</Button>
)}
</>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{hasActiveSession && liveChat?.activeSession && (
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2">
<div className="flex size-6 items-center justify-center rounded-full bg-green-100">
<MessageCircle className="size-3.5 text-green-600" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-green-800">Chat ao vivo ativo</p>
<p className="text-xs text-green-600">
Iniciado por {liveChat.activeSession.agentName ?? "agente"} {formatRelative(liveChat.activeSession.startedAt)}
{liveChat.activeSession.unreadByAgent > 0 && (
<span className="ml-2 rounded-full bg-green-600 px-1.5 py-0.5 text-white">
{liveChat.activeSession.unreadByAgent} nao lidas
</span>
)}
</p>
</div>
</div>
)}
{chat === undefined ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-slate-200 py-6 text-sm text-neutral-500">
<Spinner className="size-4" /> Carregando mensagens...