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

@ -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()

View file

@ -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