feat(desktop): add file attachments and native chat window

- Add file upload support in chat (PDF, images, txt, docs, xlsx)
  - Limited to 10MB max file size
  - Only allowed extensions for security
- Use native Windows decorations for chat window
- Remove ChatFloatingWidget (replaced by native window)
- Simplify chat event listeners (window managed by Rust)
- Fix typo "sessao" -> "sessão"

🤖 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 13:09:55 -03:00
parent 2f89fa33fe
commit c217a40030
8 changed files with 537 additions and 104 deletions

View file

@ -3,11 +3,36 @@ 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 { Send, X, Minus, Loader2, Headphones } from "lucide-react"
import { open } from "@tauri-apps/plugin-dialog"
import { Send, X, Loader2, Headphones, Paperclip, FileText, Image as ImageIcon, File } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types"
const STORE_FILENAME = "machine-agent.json"
// Tipos de arquivo permitidos
const ALLOWED_EXTENSIONS = [
"jpg", "jpeg", "png", "gif", "webp",
"pdf", "txt", "doc", "docx", "xls", "xlsx",
]
interface UploadedAttachment {
storageId: string
name: string
size?: number
type?: string
}
function getFileIcon(fileName: string) {
const ext = fileName.toLowerCase().split(".").pop() ?? ""
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
return <ImageIcon className="size-4" />
}
if (["pdf", "doc", "docx", "txt"].includes(ext)) {
return <FileText className="size-4" />
}
return <File className="size-4" />
}
interface ChatWidgetProps {
ticketId: string
}
@ -17,9 +42,11 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
const [inputValue, setInputValue] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isSending, setIsSending] = useState(false)
const [isUploading, setIsUploading] = 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 [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0)
@ -165,18 +192,68 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
}
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
// Selecionar arquivo para anexar
const handleAttach = async () => {
if (isUploading || isSending) return
try {
const selected = await open({
multiple: false,
filters: [{
name: "Arquivos permitidos",
extensions: ALLOWED_EXTENSIONS,
}],
})
if (!selected) return
// O retorno pode ser string (path único) ou objeto com path
const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path
setIsUploading(true)
const config = await loadConfig()
if (!config) {
setIsUploading(false)
return
}
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
baseUrl: config.baseUrl,
token: config.token,
filePath,
})
setPendingAttachments(prev => [...prev, attachment])
} catch (err) {
console.error("Erro ao anexar arquivo:", err)
alert(typeof err === "string" ? err : "Erro ao anexar arquivo")
} finally {
setIsUploading(false)
}
}
// Remover anexo pendente
const handleRemoveAttachment = (storageId: string) => {
setPendingAttachments(prev => prev.filter(a => a.storageId !== storageId))
}
// Enviar mensagem
const handleSend = async () => {
if (!inputValue.trim() || isSending) return
if ((!inputValue.trim() && pendingAttachments.length === 0) || isSending) return
const messageText = inputValue.trim()
const attachmentsToSend = [...pendingAttachments]
setInputValue("")
setPendingAttachments([])
setIsSending(true)
try {
const config = await loadConfig()
if (!config) {
setIsSending(false)
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
return
}
@ -184,37 +261,36 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
baseUrl: config.baseUrl,
token: config.token,
ticketId,
body: messageText,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""),
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : null,
})
// Adicionar mensagem localmente
setMessages(prev => [...prev, {
id: response.messageId,
body: messageText,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""),
authorName: "Voce",
isFromMachine: true,
createdAt: response.createdAt,
attachments: [],
attachments: attachmentsToSend.map(a => ({
storageId: a.storageId,
name: a.name,
size: a.size,
type: a.type,
})),
}])
lastFetchRef.current = response.createdAt
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
// Restaurar input em caso de erro
// Restaurar input e anexos em caso de erro
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
} 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()
@ -242,46 +318,27 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
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>
<p className="text-sm text-slate-500">Nenhuma sessão 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>
{/* Header */}
<div className="flex items-center gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<Headphones className="size-5" />
</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>
<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>
@ -316,6 +373,29 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
</p>
)}
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
{/* Anexos */}
{msg.attachments && msg.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{msg.attachments.map((att) => (
<div
key={att.storageId}
className={`flex items-center gap-2 rounded-lg p-2 text-xs ${
msg.isFromMachine
? "bg-white/10"
: "bg-slate-200"
}`}
>
{getFileIcon(att.name)}
<span className="truncate">{att.name}</span>
{att.size && (
<span className="text-xs opacity-60">
({Math.round(att.size / 1024)}KB)
</span>
)}
</div>
))}
</div>
)}
<p
className={`mt-1 text-right text-xs ${
msg.isFromMachine ? "text-white/60" : "text-slate-400"
@ -333,7 +413,39 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
{/* Input */}
<div className="border-t border-slate-200 p-3">
{/* Anexos pendentes */}
{pendingAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{pendingAttachments.map((att) => (
<div
key={att.storageId}
className="flex items-center gap-1 rounded-lg bg-slate-100 px-2 py-1 text-xs"
>
{getFileIcon(att.name)}
<span className="max-w-[100px] truncate">{att.name}</span>
<button
onClick={() => handleRemoveAttachment(att.storageId)}
className="ml-1 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<button
onClick={handleAttach}
disabled={isUploading || isSending}
className="flex size-10 items-center justify-center rounded-lg border border-slate-300 text-slate-500 transition hover:bg-slate-100 disabled:opacity-50"
title="Anexar arquivo"
>
{isUploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Paperclip className="size-4" />
)}
</button>
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
@ -344,7 +456,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isSending}
disabled={(!inputValue.trim() && pendingAttachments.length === 0) || isSending}
className="flex size-10 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
>
{isSending ? (

View file

@ -10,8 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils"
import { ChatApp } from "./chat"
import { DeactivationScreen } from "./components/DeactivationScreen"
import { ChatFloatingWidget } from "./components/ChatFloatingWidget"
import type { ChatSession, SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
import type { SessionStartedEvent, UnreadUpdateEvent, NewMessageEvent, SessionEndedEvent } from "./chat/types"
type MachineOs = {
name: string
@ -340,10 +339,6 @@ function App() {
const emailRegex = useRef(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/i)
const isEmailValid = useMemo(() => emailRegex.current.test(collabEmail.trim()), [collabEmail])
// Estados do chat
const [chatSessions, setChatSessions] = useState<ChatSession[]>([])
const [chatUnreadCount, setChatUnreadCount] = useState(0)
const [isChatOpen, setIsChatOpen] = useState(false)
const ensureProfile = useCallback(async () => {
if (profile) return profile
@ -1039,7 +1034,7 @@ const resolvedAppUrl = useMemo(() => {
}
}, [store, config?.machineId, rustdeskInfo, isRustdeskProvisioning, ensureRustdesk, syncRemoteAccessDirect])
// Listeners de eventos do chat
// Listeners de eventos do chat (apenas para logging - a janela nativa e gerenciada pelo Rust)
useEffect(() => {
if (!token) return
@ -1050,14 +1045,6 @@ const resolvedAppUrl = useMemo(() => {
listen<SessionStartedEvent>("raven://chat/session-started", (event) => {
if (disposed) return
logDesktop("chat:session-started", { ticketId: event.payload.session.ticketId, sessionId: event.payload.session.sessionId })
setChatSessions(prev => {
// Evitar duplicatas
if (prev.some(s => s.sessionId === event.payload.session.sessionId)) {
return prev
}
return [...prev, event.payload.session]
})
setIsChatOpen(true) // Abre automaticamente quando agente inicia chat
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
@ -1067,48 +1054,24 @@ const resolvedAppUrl = useMemo(() => {
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
if (disposed) return
logDesktop("chat:session-ended", { ticketId: event.payload.ticketId, sessionId: event.payload.sessionId })
setChatSessions(prev => prev.filter(s => s.sessionId !== event.payload.sessionId))
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
}).catch(err => console.error("Falha ao registrar listener session-ended:", err))
// Listener para atualizacao de mensagens nao lidas (sincroniza sessoes completas)
// Listener para atualizacao de mensagens nao lidas
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
if (disposed) return
console.log("[CHAT DEBUG] unread-update recebido:", JSON.stringify(event.payload, null, 2))
logDesktop("chat:unread-update", { totalUnread: event.payload.totalUnread, sessionsCount: event.payload.sessions?.length ?? 0 })
setChatUnreadCount(event.payload.totalUnread)
// Atualiza sessoes com dados completos do backend
if (event.payload.sessions && event.payload.sessions.length > 0) {
console.log("[CHAT DEBUG] Atualizando chatSessions com", event.payload.sessions.length, "sessoes")
setChatSessions(event.payload.sessions)
} else if (event.payload.totalUnread === 0) {
// Sem sessoes ativas
console.log("[CHAT DEBUG] Sem sessoes ativas, limpando chatSessions")
setChatSessions([])
}
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
}).catch(err => console.error("Falha ao registrar listener unread-update:", err))
// Listener para nova mensagem (abre widget se fechado)
// Listener para nova mensagem (a janela de chat nativa e aberta automaticamente pelo Rust)
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
if (disposed) return
console.log("[CHAT DEBUG] new-message recebido:", JSON.stringify(event.payload, null, 2))
logDesktop("chat:new-message", { totalUnread: event.payload.totalUnread, newCount: event.payload.newCount })
setChatUnreadCount(event.payload.totalUnread)
// Atualiza sessoes com dados completos do backend
if (event.payload.sessions && event.payload.sessions.length > 0) {
console.log("[CHAT DEBUG] Atualizando chatSessions com", event.payload.sessions.length, "sessoes")
setChatSessions(event.payload.sessions)
}
// Abre o widget quando chega nova mensagem
if (event.payload.newCount > 0) {
console.log("[CHAT DEBUG] Nova mensagem! Abrindo widget...")
setIsChatOpen(true)
}
}).then(unlisten => {
if (disposed) unlisten()
else unlisteners.push(unlisten)
@ -1706,16 +1669,6 @@ const resolvedAppUrl = useMemo(() => {
</div>
)}
{/* Chat Widget Flutuante - aparece quando provisionado e ha sessoes ativas */}
{token && isMachineActive && chatSessions.length > 0 && (
<ChatFloatingWidget
sessions={chatSessions}
totalUnread={chatUnreadCount}
isOpen={isChatOpen}
onToggle={() => setIsChatOpen(!isChatOpen)}
onMinimize={() => setIsChatOpen(false)}
/>
)}
</div>
)
}