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
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue