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:
parent
2f89fa33fe
commit
c217a40030
8 changed files with 537 additions and 104 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -218,16 +218,21 @@ pub async fn send_message(
|
|||
token: &str,
|
||||
ticket_id: &str,
|
||||
body: &str,
|
||||
attachments: Option<Vec<AttachmentPayload>>,
|
||||
) -> Result<SendMessageResponse, String> {
|
||||
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<u64>,
|
||||
#[serde(rename = "type")]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String, String> {
|
||||
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<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<String, String> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -270,8 +270,57 @@ async fn send_chat_message(
|
|||
token: String,
|
||||
ticket_id: String,
|
||||
body: String,
|
||||
attachments: Option<Vec<chat::AttachmentPayload>>,
|
||||
) -> Result<SendMessageResponse, String> {
|
||||
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<chat::AttachmentPayload, String> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -105,6 +105,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",
|
||||
|
|
@ -689,6 +690,8 @@
|
|||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Vg50U74x1A4b2iBVtDcAVPbI1XVuzSmwlduuBM1VewxtRaVj5GDzWnYtBcnuIk+VGzNApRDfDhraAXGaW2a/Gw=="],
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
|
||||
|
||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { v } from "convex/values"
|
||||
import { mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"
|
||||
import { action, mutation, query, type MutationCtx, type QueryCtx } from "./_generated/server"
|
||||
import { ConvexError } from "convex/values"
|
||||
import { api } from "./_generated/api"
|
||||
import type { Doc, Id } from "./_generated/dataModel"
|
||||
import { sha256 } from "@noble/hashes/sha256"
|
||||
import { bytesToHex as toHex } from "@noble/hashes/utils"
|
||||
|
|
@ -690,3 +691,94 @@ export const getTicketChatHistory = query({
|
|||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================
|
||||
// UPLOAD DE ARQUIVOS (Maquina/Cliente)
|
||||
// ============================================
|
||||
|
||||
// Tipos de arquivo permitidos para upload
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
// Imagens
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
// Documentos
|
||||
"application/pdf",
|
||||
"text/plain",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
]
|
||||
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
||||
".pdf", ".txt", ".doc", ".docx", ".xls", ".xlsx",
|
||||
]
|
||||
|
||||
// Tamanho maximo: 10MB
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
// Mutation interna para validar token (usada pela action)
|
||||
export const validateMachineTokenForUpload = query({
|
||||
args: { machineToken: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const tokenHash = hashToken(args.machineToken)
|
||||
const tokenRecord = await ctx.db
|
||||
.query("machineTokens")
|
||||
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
|
||||
.first()
|
||||
|
||||
if (!tokenRecord) {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
const machine = await ctx.db.get(tokenRecord.machineId)
|
||||
if (!machine || machine.status === "REVOKED") {
|
||||
return { valid: false }
|
||||
}
|
||||
|
||||
return { valid: true, tenantId: tokenRecord.tenantId }
|
||||
},
|
||||
})
|
||||
|
||||
// Action para gerar URL de upload (validada por token de maquina)
|
||||
export const generateMachineUploadUrl = action({
|
||||
args: {
|
||||
machineToken: v.string(),
|
||||
fileName: v.string(),
|
||||
fileType: v.string(),
|
||||
fileSize: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
// Validar token
|
||||
const validation = await ctx.runQuery(api.liveChat.validateMachineTokenForUpload, {
|
||||
machineToken: args.machineToken,
|
||||
})
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new ConvexError("Token de máquina inválido")
|
||||
}
|
||||
|
||||
// Validar tipo de arquivo
|
||||
const ext = args.fileName.toLowerCase().slice(args.fileName.lastIndexOf("."))
|
||||
if (!ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
throw new ConvexError(`Tipo de arquivo não permitido. Permitidos: ${ALLOWED_EXTENSIONS.join(", ")}`)
|
||||
}
|
||||
|
||||
if (!ALLOWED_MIME_TYPES.includes(args.fileType)) {
|
||||
throw new ConvexError("Tipo MIME não permitido")
|
||||
}
|
||||
|
||||
// Validar tamanho
|
||||
if (args.fileSize > MAX_FILE_SIZE) {
|
||||
throw new ConvexError(`Arquivo muito grande. Máximo: ${MAX_FILE_SIZE / 1024 / 1024}MB`)
|
||||
}
|
||||
|
||||
// Gerar URL de upload
|
||||
const uploadUrl = await ctx.storage.generateUploadUrl()
|
||||
|
||||
return { uploadUrl }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
73
src/app/api/machines/chat/upload/route.ts
Normal file
73
src/app/api/machines/chat/upload/route.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { createCorsPreflight, jsonWithCors } from "@/server/cors"
|
||||
import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client"
|
||||
|
||||
const uploadUrlSchema = z.object({
|
||||
machineToken: z.string().min(1),
|
||||
fileName: z.string().min(1),
|
||||
fileType: z.string().min(1),
|
||||
fileSize: z.number().positive(),
|
||||
})
|
||||
|
||||
const CORS_METHODS = "POST, OPTIONS"
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return createCorsPreflight(request.headers.get("origin"), CORS_METHODS)
|
||||
}
|
||||
|
||||
// POST /api/machines/chat/upload
|
||||
// Gera URL de upload para anexos do 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 raw
|
||||
try {
|
||||
raw = await request.json()
|
||||
} catch {
|
||||
return jsonWithCors({ error: "JSON invalido" }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = uploadUrlSchema.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.action(api.liveChat.generateMachineUploadUrl, {
|
||||
machineToken: payload.machineToken,
|
||||
fileName: payload.fileName,
|
||||
fileType: payload.fileType,
|
||||
fileSize: payload.fileSize,
|
||||
})
|
||||
return jsonWithCors(result, 200, origin, CORS_METHODS)
|
||||
} catch (error) {
|
||||
console.error("[machines.chat.upload] Falha ao gerar URL de upload", error)
|
||||
const details = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// Se for erro de validacao, retornar 400
|
||||
if (details.includes("não permitido") || details.includes("muito grande") || details.includes("inválido")) {
|
||||
return jsonWithCors({ error: details }, 400, origin, CORS_METHODS)
|
||||
}
|
||||
|
||||
return jsonWithCors({ error: "Falha ao gerar URL de upload", details }, 500, origin, CORS_METHODS)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue