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:
parent
0c8d53c0b6
commit
ba91c1e0f5
15 changed files with 2004 additions and 15 deletions
363
apps/desktop/src/chat/ChatWidget.tsx
Normal file
363
apps/desktop/src/chat/ChatWidget.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
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 { Send, X, Minus, Loader2, Headphones } from "lucide-react"
|
||||
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types"
|
||||
|
||||
interface ChatWidgetProps {
|
||||
ticketId: string
|
||||
}
|
||||
|
||||
export function ChatWidget({ ticketId }: ChatWidgetProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null)
|
||||
const [hasSession, setHasSession] = useState(false)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const lastFetchRef = useRef<number>(0)
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 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 store = await Store.load("machine-agent.json")
|
||||
const token = await store.get<string>("token")
|
||||
const config = await store.get<{ apiBaseUrl: string }>("config")
|
||||
|
||||
if (!token || !config?.apiBaseUrl) {
|
||||
setError("Maquina nao 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
|
||||
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 {
|
||||
// Primeira carga
|
||||
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
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
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 unlistenPromise = 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
|
||||
}
|
||||
return [...prev, event.payload.message]
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current)
|
||||
}
|
||||
unlistenPromise.then(unlisten => unlisten())
|
||||
}
|
||||
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
|
||||
|
||||
// Enviar mensagem
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || isSending) 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,
|
||||
body: messageText,
|
||||
})
|
||||
|
||||
// Adicionar mensagem localmente
|
||||
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)
|
||||
// Restaurar input em caso de erro
|
||||
setInputValue(messageText)
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
invoke("minimize_chat_window", { ticketId })
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
invoke("close_chat_window", { ticketId })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white">
|
||||
<Loader2 className="size-8 animate-spin text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-500">Carregando chat...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasSession) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||
<p className="text-sm text-slate-500">Nenhuma sessao de chat ativa</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-white">
|
||||
{/* Header - arrastavel */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
|
||||
<Headphones className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{ticketInfo?.agentName ?? "Suporte"}
|
||||
</p>
|
||||
{ticketInfo && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Chamado #{ticketInfo.ref}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mensagens */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{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">
|
||||
<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",
|
||||
})
|
||||
}
|
||||
20
apps/desktop/src/chat/index.tsx
Normal file
20
apps/desktop/src/chat/index.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { ChatWidget } from "./ChatWidget"
|
||||
|
||||
export function ChatApp() {
|
||||
// Obter ticketId da URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const ticketId = params.get("ticketId")
|
||||
|
||||
if (!ticketId) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||
<p className="text-sm text-red-600">Erro: ticketId nao fornecido</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ChatWidget ticketId={ticketId} />
|
||||
}
|
||||
|
||||
export { ChatWidget }
|
||||
export * from "./types"
|
||||
45
apps/desktop/src/chat/types.ts
Normal file
45
apps/desktop/src/chat/types.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Tipos para o sistema de chat
|
||||
|
||||
export interface ChatSession {
|
||||
sessionId: string
|
||||
ticketId: string
|
||||
ticketRef: number
|
||||
ticketSubject: string
|
||||
agentName: string
|
||||
agentEmail?: string
|
||||
agentAvatarUrl?: string
|
||||
unreadCount: number
|
||||
lastActivityAt: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
body: string
|
||||
authorName: string
|
||||
authorAvatarUrl?: string
|
||||
isFromMachine: boolean
|
||||
createdAt: number
|
||||
attachments: ChatAttachment[]
|
||||
}
|
||||
|
||||
export interface ChatAttachment {
|
||||
storageId: string
|
||||
name: string
|
||||
size?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface ChatMessagesResponse {
|
||||
messages: ChatMessage[]
|
||||
hasSession: boolean
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
messageId: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface SessionStartedEvent {
|
||||
session: ChatSession
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { appLocalDataDir, join } from "@tauri-apps/api/path"
|
|||
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
|
||||
import { cn } from "./lib/utils"
|
||||
import { ChatApp } from "./chat"
|
||||
import { DeactivationScreen } from "./components/DeactivationScreen"
|
||||
|
||||
type MachineOs = {
|
||||
|
|
@ -1642,5 +1643,18 @@ function StatusBadge({ status, className }: { status: string | null; className?:
|
|||
)
|
||||
}
|
||||
|
||||
// Roteamento simples baseado no path
|
||||
function RootApp() {
|
||||
const path = window.location.pathname
|
||||
|
||||
// Rota /chat - janela de chat flutuante
|
||||
if (path === "/chat" || path.startsWith("/chat?")) {
|
||||
return <ChatApp />
|
||||
}
|
||||
|
||||
// Rota padrao - aplicacao principal
|
||||
return <App />
|
||||
}
|
||||
|
||||
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
|
||||
createRoot(root).render(<App />)
|
||||
createRoot(root).render(<RootApp />)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue