From c217a4003084e918867dfc5d68c6678200fa60da Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 7 Dec 2025 13:09:55 -0300 Subject: [PATCH] feat(desktop): add file attachments and native chat window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/desktop/package.json | 1 + apps/desktop/src-tauri/src/chat.rs | 155 +++++++++++++++- apps/desktop/src-tauri/src/lib.rs | 52 +++++- apps/desktop/src/chat/ChatWidget.tsx | 208 +++++++++++++++++----- apps/desktop/src/main.tsx | 55 +----- bun.lock | 3 + convex/liveChat.ts | 94 +++++++++- src/app/api/machines/chat/upload/route.ts | 73 ++++++++ 8 files changed, 537 insertions(+), 104 deletions(-) create mode 100644 src/app/api/machines/chat/upload/route.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6a11f01..345e4a7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-store": "^2", diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 6448184..e4cfc06 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -218,16 +218,21 @@ pub async fn send_message( token: &str, ticket_id: &str, body: &str, + attachments: Option>, ) -> Result { let url = format!("{}/api/machines/chat/messages", base_url); - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "machineToken": token, "ticketId": ticket_id, "action": "send", "body": body, }); + if let Some(atts) = attachments { + payload["attachments"] = serde_json::to_value(atts).unwrap_or_default(); + } + let response = CHAT_CLIENT .post(&url) .json(&payload) @@ -247,6 +252,150 @@ pub async fn send_message( .map_err(|e| format!("Falha ao parsear resposta de send: {e}")) } +// ============================================================================ +// UPLOAD DE ARQUIVOS +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentPayload { + pub storage_id: String, + pub name: String, + pub size: Option, + #[serde(rename = "type")] + pub mime_type: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadUrlResponse { + pub upload_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UploadResult { + pub storage_id: String, +} + +// Extensoes permitidas +const ALLOWED_EXTENSIONS: &[&str] = &[ + ".jpg", ".jpeg", ".png", ".gif", ".webp", + ".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx", +]; + +// Tamanho maximo: 10MB +const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; + +pub fn is_allowed_file(file_name: &str, file_size: u64) -> Result<(), String> { + let ext = file_name + .to_lowercase() + .rsplit('.') + .next() + .map(|e| format!(".{}", e)) + .unwrap_or_default(); + + if !ALLOWED_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!( + "Tipo de arquivo não permitido. Permitidos: {}", + ALLOWED_EXTENSIONS.join(", ") + )); + } + + if file_size > MAX_FILE_SIZE { + return Err(format!( + "Arquivo muito grande. Máximo: {}MB", + MAX_FILE_SIZE / 1024 / 1024 + )); + } + + Ok(()) +} + +pub fn get_mime_type(file_name: &str) -> String { + let lower = file_name.to_lowercase(); + let ext = lower.rsplit('.').next().unwrap_or(""); + + match ext { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + "txt" => "text/plain", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + _ => "application/octet-stream", + } + .to_string() +} + +pub async fn generate_upload_url( + base_url: &str, + token: &str, + file_name: &str, + file_type: &str, + file_size: u64, +) -> Result { + let url = format!("{}/api/machines/chat/upload", base_url); + + let payload = serde_json::json!({ + "machineToken": token, + "fileName": file_name, + "fileType": file_type, + "fileSize": file_size, + }); + + let response = CHAT_CLIENT + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| format!("Falha na requisicao de upload URL: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Upload URL falhou: status={}, body={}", status, body)); + } + + let data: UploadUrlResponse = response + .json() + .await + .map_err(|e| format!("Falha ao parsear resposta de upload URL: {e}"))?; + + Ok(data.upload_url) +} + +pub async fn upload_file( + upload_url: &str, + file_data: Vec, + content_type: &str, +) -> Result { + let response = CHAT_CLIENT + .post(upload_url) + .header("Content-Type", content_type) + .body(file_data) + .send() + .await + .map_err(|e| format!("Falha no upload: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Upload falhou: status={}, body={}", status, body)); + } + + let data: UploadResult = response + .json() + .await + .map_err(|e| format!("Falha ao parsear resposta de upload: {e}"))?; + + Ok(data.storage_id) +} + // ============================================================================ // CHAT RUNTIME // ============================================================================ @@ -569,9 +718,9 @@ fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str) -> Result< .inner_size(380.0, 520.0) .min_inner_size(300.0, 400.0) .position(x, y) - .decorations(false) // Frameless + .decorations(true) // Usar decoracoes nativas do Windows .always_on_top(true) - .skip_taskbar(true) + .skip_taskbar(false) // Mostrar na taskbar .focused(true) .visible(true) .build() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ba122fc..4833a6a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -270,8 +270,57 @@ async fn send_chat_message( token: String, ticket_id: String, body: String, + attachments: Option>, ) -> Result { - chat::send_message(&base_url, &token, &ticket_id, &body).await + chat::send_message(&base_url, &token, &ticket_id, &body, attachments).await +} + +#[tauri::command] +async fn upload_chat_file( + base_url: String, + token: String, + file_path: String, +) -> Result { + use std::path::Path; + + // Ler o arquivo + let path = Path::new(&file_path); + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or("Nome de arquivo inválido")? + .to_string(); + + let file_data = std::fs::read(&file_path) + .map_err(|e| format!("Falha ao ler arquivo: {e}"))?; + + let file_size = file_data.len() as u64; + + // Validar arquivo + chat::is_allowed_file(&file_name, file_size)?; + + // Obter tipo MIME + let mime_type = chat::get_mime_type(&file_name); + + // Gerar URL de upload + let upload_url = chat::generate_upload_url( + &base_url, + &token, + &file_name, + &mime_type, + file_size, + ) + .await?; + + // Fazer upload + let storage_id = chat::upload_file(&upload_url, file_data, &mime_type).await?; + + Ok(chat::AttachmentPayload { + storage_id, + name: file_name, + size: Some(file_size), + mime_type: Some(mime_type), + }) } #[tauri::command] @@ -350,6 +399,7 @@ pub fn run() { fetch_chat_sessions, fetch_chat_messages, send_chat_message, + upload_chat_file, open_chat_window, close_chat_window, minimize_chat_window diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index bfae1fa..2b64d26 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -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 + } + if (["pdf", "doc", "docx", "txt"].includes(ext)) { + return + } + return +} + 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(null) const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null) const [hasSession, setHasSession] = useState(false) + const [pendingAttachments, setPendingAttachments] = useState([]) const messagesEndRef = useRef(null) const lastFetchRef = useRef(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("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 (
-

Nenhuma sessao de chat ativa

+

Nenhuma sessão de chat ativa

) } return (
- {/* Header - arrastavel */} -
-
-
- -
-
-

- {ticketInfo?.agentName ?? "Suporte"} -

- {ticketInfo && ( -

- Chamado #{ticketInfo.ref} -

- )} -
+ {/* Header */} +
+
+
-
- - +
+

+ {ticketInfo?.agentName ?? "Suporte"} +

+ {ticketInfo && ( +

+ Chamado #{ticketInfo.ref} +

+ )}
@@ -316,6 +373,29 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {

)}

{msg.body}

+ {/* Anexos */} + {msg.attachments && msg.attachments.length > 0 && ( +
+ {msg.attachments.map((att) => ( +
+ {getFileIcon(att.name)} + {att.name} + {att.size && ( + + ({Math.round(att.size / 1024)}KB) + + )} +
+ ))} +
+ )}

+ {/* Anexos pendentes */} + {pendingAttachments.length > 0 && ( +

+ {pendingAttachments.map((att) => ( +
+ {getFileIcon(att.name)} + {att.name} + +
+ ))} +
+ )}
+