From ba91c1e0f54fa6715db8b9d0792326ecfca92f37 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 7 Dec 2025 01:00:27 -0300 Subject: [PATCH] Implementa sistema de chat em tempo real entre agente e cliente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona tabela liveChatSessions no schema Convex - Cria convex/liveChat.ts com mutations e queries para chat - Adiciona API routes para maquinas (sessions, messages, poll) - Cria modulo chat.rs no Tauri com ChatRuntime e polling - Adiciona comandos de chat no lib.rs (start/stop polling, open/close window) - Cria componentes React do chat widget (ChatWidget, types) - Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel) - Implementa menu de chat no system tray - Polling de 2 segundos para maior responsividade - Janela de chat flutuante, frameless, always-on-top 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/desktop/package.json | 6 +- apps/desktop/src-tauri/src/chat.rs | 482 +++++++++++++++++ apps/desktop/src-tauri/src/lib.rs | 118 ++++- apps/desktop/src/chat/ChatWidget.tsx | 363 +++++++++++++ apps/desktop/src/chat/index.tsx | 20 + apps/desktop/src/chat/types.ts | 45 ++ apps/desktop/src/main.tsx | 16 +- convex/liveChat.ts | 512 +++++++++++++++++++ convex/machines.ts | 8 + convex/schema.ts | 25 + convex/tickets.ts | 47 ++ src/app/api/machines/chat/messages/route.ts | 126 +++++ src/app/api/machines/chat/poll/route.ts | 57 +++ src/app/api/machines/chat/sessions/route.ts | 55 ++ src/components/tickets/ticket-chat-panel.tsx | 139 ++++- 15 files changed, 2004 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src-tauri/src/chat.rs create mode 100644 apps/desktop/src/chat/ChatWidget.tsx create mode 100644 apps/desktop/src/chat/index.tsx create mode 100644 apps/desktop/src/chat/types.ts create mode 100644 convex/liveChat.ts create mode 100644 src/app/api/machines/chat/messages/route.ts create mode 100644 src/app/api/machines/chat/poll/route.ts create mode 100644 src/app/api/machines/chat/sessions/route.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7760037..6a0fd62 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,9 @@ "gen:icon": "node ./scripts/build-icon.mjs" }, "dependencies": { + "@assistant-ui/react": "^0.7.0", + "@assistant-ui/react-markdown": "^0.3.0", + "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", @@ -19,7 +22,8 @@ "@tauri-apps/plugin-updater": "^2", "lucide-react": "^0.544.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "remark-gfm": "^4.0.0" }, "devDependencies": { "@tauri-apps/cli": "^2", diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs new file mode 100644 index 0000000..e06efdc --- /dev/null +++ b/apps/desktop/src-tauri/src/chat.rs @@ -0,0 +1,482 @@ +//! Modulo de Chat em Tempo Real +//! +//! Este modulo implementa o sistema de chat entre agentes (dashboard web) +//! e clientes (Raven desktop). Inclui polling de mensagens, gerenciamento +//! de janelas de chat e emissao de eventos. + +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; +use tauri::async_runtime::JoinHandle; +use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; +use tokio::sync::Notify; + +// ============================================================================ +// TYPES +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatSession { + pub session_id: String, + pub ticket_id: String, + pub ticket_ref: u64, + pub ticket_subject: String, + pub agent_name: String, + pub agent_email: Option, + pub agent_avatar_url: Option, + pub unread_count: u32, + pub last_activity_at: i64, + pub started_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatMessage { + pub id: String, + pub body: String, + pub author_name: String, + pub author_avatar_url: Option, + pub is_from_machine: bool, + pub created_at: i64, + pub attachments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatAttachment { + 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 ChatPollResponse { + pub has_active_sessions: bool, + pub sessions: Vec, + pub total_unread: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatSessionSummary { + pub ticket_id: String, + pub unread_count: u32, + pub last_activity_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatMessagesResponse { + pub messages: Vec, + pub has_session: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendMessageResponse { + pub message_id: String, + pub created_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionStartedEvent { + pub session: ChatSession, +} + +// ============================================================================ +// HTTP CLIENT +// ============================================================================ + +static CHAT_CLIENT: Lazy = Lazy::new(|| { + Client::builder() + .user_agent("raven-chat/1.0") + .timeout(Duration::from_secs(15)) + .use_rustls_tls() + .build() + .expect("failed to build chat http client") +}); + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +pub async fn poll_chat_updates( + base_url: &str, + token: &str, + last_checked_at: Option, +) -> Result { + let url = format!("{}/api/machines/chat/poll", base_url); + + let mut payload = serde_json::json!({ + "machineToken": token, + }); + + if let Some(ts) = last_checked_at { + payload["lastCheckedAt"] = serde_json::json!(ts); + } + + let response = CHAT_CLIENT + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| format!("Falha na requisicao de poll: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Poll falhou: status={}, body={}", status, body)); + } + + response + .json() + .await + .map_err(|e| format!("Falha ao parsear resposta de poll: {e}")) +} + +pub async fn fetch_sessions(base_url: &str, token: &str) -> Result, String> { + let url = format!("{}/api/machines/chat/sessions", base_url); + + let payload = serde_json::json!({ + "machineToken": token, + }); + + let response = CHAT_CLIENT + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| format!("Falha na requisicao de sessions: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Sessions falhou: status={}, body={}", status, body)); + } + + #[derive(Deserialize)] + struct SessionsResponse { + sessions: Vec, + } + + let data: SessionsResponse = response + .json() + .await + .map_err(|e| format!("Falha ao parsear resposta de sessions: {e}"))?; + + Ok(data.sessions) +} + +pub async fn fetch_messages( + base_url: &str, + token: &str, + ticket_id: &str, + since: Option, +) -> Result { + let url = format!("{}/api/machines/chat/messages", base_url); + + let mut payload = serde_json::json!({ + "machineToken": token, + "ticketId": ticket_id, + "action": "list", + }); + + if let Some(ts) = since { + payload["since"] = serde_json::json!(ts); + } + + let response = CHAT_CLIENT + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| format!("Falha na requisicao de messages: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Messages falhou: status={}, body={}", status, body)); + } + + response + .json() + .await + .map_err(|e| format!("Falha ao parsear resposta de messages: {e}")) +} + +pub async fn send_message( + base_url: &str, + token: &str, + ticket_id: &str, + body: &str, +) -> Result { + let url = format!("{}/api/machines/chat/messages", base_url); + + let payload = serde_json::json!({ + "machineToken": token, + "ticketId": ticket_id, + "action": "send", + "body": body, + }); + + let response = CHAT_CLIENT + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| format!("Falha na requisicao de send: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Send falhou: status={}, body={}", status, body)); + } + + response + .json() + .await + .map_err(|e| format!("Falha ao parsear resposta de send: {e}")) +} + +// ============================================================================ +// CHAT RUNTIME +// ============================================================================ + +struct ChatPollerHandle { + stop_signal: Arc, + join_handle: JoinHandle<()>, +} + +impl ChatPollerHandle { + fn stop(self) { + self.stop_signal.notify_waiters(); + self.join_handle.abort(); + } +} + +#[derive(Default, Clone)] +pub struct ChatRuntime { + inner: Arc>>, + last_sessions: Arc>>, +} + +impl ChatRuntime { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + last_sessions: Arc::new(Mutex::new(Vec::new())), + } + } + + pub fn start_polling( + &self, + base_url: String, + token: String, + app: tauri::AppHandle, + ) -> Result<(), String> { + let sanitized_base = base_url.trim().trim_end_matches('/').to_string(); + if sanitized_base.is_empty() { + return Err("URL base invalida".to_string()); + } + + // Para polling existente + { + let mut guard = self.inner.lock(); + if let Some(handle) = guard.take() { + handle.stop(); + } + } + + let stop_signal = Arc::new(Notify::new()); + let stop_clone = stop_signal.clone(); + let base_clone = sanitized_base.clone(); + let token_clone = token.clone(); + let last_sessions = self.last_sessions.clone(); + + let join_handle = tauri::async_runtime::spawn(async move { + crate::log_info!("Chat polling iniciado"); + + let mut last_checked_at: Option = None; + let poll_interval = Duration::from_secs(2); // Intervalo reduzido para maior responsividade + + loop { + tokio::select! { + _ = stop_clone.notified() => { + crate::log_info!("Chat polling encerrado"); + break; + } + _ = tokio::time::sleep(poll_interval) => { + match poll_chat_updates(&base_clone, &token_clone, last_checked_at).await { + Ok(result) => { + last_checked_at = Some(chrono::Utc::now().timestamp_millis()); + + if result.has_active_sessions { + // Verificar novas sessoes + let prev_sessions: Vec = { + last_sessions.lock().iter().map(|s| s.session_id.clone()).collect() + }; + + // Buscar detalhes das sessoes + if let Ok(sessions) = fetch_sessions(&base_clone, &token_clone).await { + for session in &sessions { + if !prev_sessions.contains(&session.session_id) { + // Nova sessao! Emitir evento + crate::log_info!( + "Nova sessao de chat: ticket={}", + session.ticket_id + ); + let _ = app.emit( + "raven://chat/session-started", + SessionStartedEvent { + session: session.clone(), + }, + ); + + // Abrir janela de chat automaticamente + if let Err(e) = open_chat_window_internal( + &app, + &session.ticket_id, + ) { + crate::log_error!( + "Falha ao abrir janela de chat: {e}" + ); + } + } + } + + // Atualizar cache + *last_sessions.lock() = sessions; + } + + // Verificar mensagens nao lidas e emitir evento + if result.total_unread > 0 { + crate::log_info!( + "Chat: {} mensagens nao lidas", + result.total_unread + ); + let _ = app.emit( + "raven://chat/unread-update", + serde_json::json!({ + "totalUnread": result.total_unread, + "sessions": result.sessions + }), + ); + } + } else { + // Sem sessoes ativas + *last_sessions.lock() = Vec::new(); + } + } + Err(e) => { + crate::log_warn!("Falha no polling de chat: {e}"); + } + } + } + } + } + }); + + let mut guard = self.inner.lock(); + *guard = Some(ChatPollerHandle { + stop_signal, + join_handle, + }); + + Ok(()) + } + + pub fn stop(&self) { + let mut guard = self.inner.lock(); + if let Some(handle) = guard.take() { + handle.stop(); + } + } + + pub fn get_sessions(&self) -> Vec { + self.last_sessions.lock().clone() + } +} + +// ============================================================================ +// WINDOW MANAGEMENT +// ============================================================================ + +fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { + let label = format!("chat-{}", ticket_id); + + // Verificar se ja existe + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|e| e.to_string())?; + window.set_focus().map_err(|e| e.to_string())?; + return Ok(()); + } + + // Obter tamanho da tela para posicionar no canto inferior direito + let monitors = app.available_monitors().map_err(|e| e.to_string())?; + let primary = monitors.into_iter().next(); + + let (x, y) = if let Some(monitor) = primary { + let size = monitor.size(); + let scale = monitor.scale_factor(); + let width = 380.0; + let height = 520.0; + let margin = 20.0; + let taskbar_height = 50.0; + ( + (size.width as f64 / scale) - width - margin, + (size.height as f64 / scale) - height - margin - taskbar_height, + ) + } else { + (100.0, 100.0) + }; + + let url_path = format!("/chat?ticketId={}", ticket_id); + + WebviewWindowBuilder::new( + app, + &label, + WebviewUrl::App(url_path.into()), + ) + .title("Chat de Suporte") + .inner_size(380.0, 520.0) + .min_inner_size(300.0, 400.0) + .position(x, y) + .decorations(false) // Frameless + .always_on_top(true) + .skip_taskbar(true) + .focused(true) + .visible(true) + .build() + .map_err(|e| e.to_string())?; + + crate::log_info!("Janela de chat aberta: {}", label); + Ok(()) +} + +pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { + open_chat_window_internal(app, ticket_id) +} + +pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { + let label = format!("chat-{}", ticket_id); + if let Some(window) = app.get_webview_window(&label) { + window.close().map_err(|e| e.to_string())?; + } + Ok(()) +} + +pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> { + let label = format!("chat-{}", ticket_id); + if let Some(window) = app.get_webview_window(&label) { + window.hide().map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 76f8ac0..75b8be2 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ mod agent; +mod chat; #[cfg(target_os = "windows")] mod rustdesk; mod usb_control; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; +use chat::{ChatRuntime, ChatSession, ChatMessagesResponse, SendMessageResponse}; use chrono::Local; use usb_control::{UsbPolicy, UsbPolicyResult}; use tauri::{Emitter, Manager, WindowEvent}; @@ -222,10 +224,76 @@ fn refresh_usb_policy() -> Result<(), String> { usb_control::refresh_group_policy().map_err(|e| e.to_string()) } +// ============================================================================ +// COMANDOS DE CHAT +// ============================================================================ + +#[tauri::command] +fn start_chat_polling( + state: tauri::State, + app: tauri::AppHandle, + base_url: String, + token: String, +) -> Result<(), String> { + state.start_polling(base_url, token, app) +} + +#[tauri::command] +fn stop_chat_polling(state: tauri::State) -> Result<(), String> { + state.stop(); + Ok(()) +} + +#[tauri::command] +fn get_chat_sessions(state: tauri::State) -> Vec { + state.get_sessions() +} + +#[tauri::command] +async fn fetch_chat_sessions(base_url: String, token: String) -> Result, String> { + chat::fetch_sessions(&base_url, &token).await +} + +#[tauri::command] +async fn fetch_chat_messages( + base_url: String, + token: String, + ticket_id: String, + since: Option, +) -> Result { + chat::fetch_messages(&base_url, &token, &ticket_id, since).await +} + +#[tauri::command] +async fn send_chat_message( + base_url: String, + token: String, + ticket_id: String, + body: String, +) -> Result { + chat::send_message(&base_url, &token, &ticket_id, &body).await +} + +#[tauri::command] +fn open_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> { + chat::open_chat_window(&app, &ticket_id) +} + +#[tauri::command] +fn close_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> { + chat::close_chat_window(&app, &ticket_id) +} + +#[tauri::command] +fn minimize_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> { + chat::minimize_chat_window(&app, &ticket_id) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .manage(AgentRuntime::new()) + .manage(ChatRuntime::new()) .plugin(tauri_plugin_opener::init()) .plugin(StorePluginBuilder::default().build()) .plugin(tauri_plugin_updater::Builder::new().build()) @@ -249,13 +317,14 @@ pub fn run() { setup_raven_autostart(); setup_tray(&app.handle())?; - // Tenta iniciar o agente em background se houver credenciais salvas + // Tenta iniciar o agente e chat em background se houver credenciais salvas let app_handle = app.handle().clone(); - let runtime = app.state::().inner().clone(); + let agent_runtime = app.state::().inner().clone(); + let chat_runtime = app.state::().inner().clone(); tauri::async_runtime::spawn(async move { // Aguarda um pouco para o app estabilizar tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - if let Err(e) = try_start_background_agent(&app_handle, runtime).await { + if let Err(e) = try_start_background_agent(&app_handle, agent_runtime, chat_runtime).await { log_warn!("Agente nao iniciado em background: {e}"); } }); @@ -272,7 +341,17 @@ pub fn run() { ensure_rustdesk_and_emit, apply_usb_policy, get_usb_policy, - refresh_usb_policy + refresh_usb_policy, + // Chat commands + start_chat_polling, + stop_chat_polling, + get_chat_sessions, + fetch_chat_sessions, + fetch_chat_messages, + send_chat_message, + open_chat_window, + close_chat_window, + minimize_chat_window ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); @@ -333,9 +412,10 @@ fn setup_raven_autostart() { #[cfg(target_os = "windows")] fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { let show_item = MenuItemBuilder::with_id("show", "Mostrar").build(app)?; + let chat_item = MenuItemBuilder::with_id("chat", "Abrir Chat").build(app)?; let quit_item = MenuItemBuilder::with_id("quit", "Sair").build(app)?; let menu = MenuBuilder::new(app) - .items(&[&show_item, &quit_item]) + .items(&[&show_item, &chat_item, &quit_item]) .build()?; let mut builder = TrayIconBuilder::new() @@ -348,6 +428,17 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { let _ = win.set_focus(); } } + "chat" => { + // Abrir janela de chat se houver sessao ativa + if let Some(chat_runtime) = tray.app_handle().try_state::() { + let sessions = chat_runtime.get_sessions(); + if let Some(session) = sessions.first() { + if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id) { + log_error!("Falha ao abrir janela de chat: {e}"); + } + } + } + } "quit" => { tray.app_handle().exit(0); } @@ -376,7 +467,8 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> { #[cfg(target_os = "windows")] async fn try_start_background_agent( app: &tauri::AppHandle, - runtime: AgentRuntime, + agent_runtime: AgentRuntime, + chat_runtime: ChatRuntime, ) -> Result<(), String> { log_info!("Verificando credenciais salvas para iniciar agente..."); @@ -422,7 +514,7 @@ async fn try_start_background_agent( interval ); - runtime + agent_runtime .start_heartbeat( api_base_url.to_string(), token.to_string(), @@ -432,5 +524,17 @@ async fn try_start_background_agent( .map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?; log_info!("Agente iniciado com sucesso em background"); + + // Iniciar chat polling + if let Err(e) = chat_runtime.start_polling( + api_base_url.to_string(), + token.to_string(), + app.clone(), + ) { + log_warn!("Falha ao iniciar chat polling: {e}"); + } else { + log_info!("Chat polling iniciado com sucesso"); + } + Ok(()) } diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx new file mode 100644 index 0000000..ee8c42d --- /dev/null +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -0,0 +1,363 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" +import { Store } from "@tauri-apps/plugin-store" +import { Send, X, Minus, Loader2, Headphones } from "lucide-react" +import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types" + +interface ChatWidgetProps { + ticketId: string +} + +export function ChatWidget({ ticketId }: ChatWidgetProps) { + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState("") + const [isLoading, setIsLoading] = useState(true) + const [isSending, setIsSending] = 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 messagesEndRef = useRef(null) + const lastFetchRef = useRef(0) + const pollIntervalRef = useRef | null>(null) + + // Scroll para o final quando novas mensagens chegam + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, scrollToBottom]) + + // Carregar configuracao do store + const loadConfig = useCallback(async () => { + try { + const store = await Store.load("machine-agent.json") + const token = await store.get("token") + const config = await store.get<{ apiBaseUrl: string }>("config") + + if (!token || !config?.apiBaseUrl) { + setError("Maquina nao registrada") + setIsLoading(false) + return null + } + + return { token, baseUrl: config.apiBaseUrl } + } catch (err) { + setError("Erro ao carregar configuracao") + setIsLoading(false) + return null + } + }, []) + + // Buscar mensagens + const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => { + try { + const response = await invoke("fetch_chat_messages", { + baseUrl, + token, + ticketId, + since: since ?? null, + }) + + setHasSession(response.hasSession) + + if (response.messages.length > 0) { + if (since) { + // Adicionar apenas novas mensagens + setMessages(prev => { + const existingIds = new Set(prev.map(m => m.id)) + const newMsgs = response.messages.filter(m => !existingIds.has(m.id)) + return [...prev, ...newMsgs] + }) + } else { + // Primeira carga + setMessages(response.messages) + } + lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt)) + } + + return response + } catch (err) { + console.error("Erro ao buscar mensagens:", err) + return null + } + }, [ticketId]) + + // Buscar info da sessao + const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => { + try { + const sessions = await invoke>("fetch_chat_sessions", { baseUrl, token }) + + const session = sessions.find(s => s.ticketId === ticketId) + if (session) { + setTicketInfo({ + ref: session.ticketRef, + subject: session.ticketSubject, + agentName: session.agentName, + }) + } + } catch (err) { + console.error("Erro ao buscar sessao:", err) + } + }, [ticketId]) + + // Inicializacao + useEffect(() => { + let mounted = true + + const init = async () => { + const config = await loadConfig() + if (!config || !mounted) return + + const { baseUrl, token } = config + + // Buscar sessao e mensagens iniciais + await Promise.all([ + fetchSessionInfo(baseUrl, token), + fetchMessages(baseUrl, token), + ]) + + if (!mounted) return + setIsLoading(false) + + // Iniciar polling (2 segundos para maior responsividade) + pollIntervalRef.current = setInterval(async () => { + await fetchMessages(baseUrl, token, lastFetchRef.current) + }, 2000) + } + + init() + + // Listener para eventos de nova mensagem do Tauri + const unlistenPromise = listen<{ ticketId: string; message: ChatMessage }>( + "raven://chat/new-message", + (event) => { + if (event.payload.ticketId === ticketId) { + setMessages(prev => { + if (prev.some(m => m.id === event.payload.message.id)) { + return prev + } + return [...prev, event.payload.message] + }) + } + } + ) + + return () => { + mounted = false + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current) + } + unlistenPromise.then(unlisten => unlisten()) + } + }, [ticketId, loadConfig, fetchMessages, fetchSessionInfo]) + + // Enviar mensagem + const handleSend = async () => { + if (!inputValue.trim() || isSending) return + + const messageText = inputValue.trim() + setInputValue("") + setIsSending(true) + + try { + const config = await loadConfig() + if (!config) { + setIsSending(false) + return + } + + const response = await invoke("send_chat_message", { + baseUrl: config.baseUrl, + token: config.token, + ticketId, + body: messageText, + }) + + // Adicionar mensagem localmente + setMessages(prev => [...prev, { + id: response.messageId, + body: messageText, + authorName: "Voce", + isFromMachine: true, + createdAt: response.createdAt, + attachments: [], + }]) + + lastFetchRef.current = response.createdAt + } catch (err) { + console.error("Erro ao enviar mensagem:", err) + // Restaurar input em caso de erro + setInputValue(messageText) + } 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() + handleSend() + } + } + + if (isLoading) { + return ( +
+ +

Carregando chat...

+
+ ) + } + + if (error) { + return ( +
+

{error}

+
+ ) + } + + if (!hasSession) { + return ( +
+

Nenhuma sessao de chat ativa

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

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

+ {ticketInfo && ( +

+ Chamado #{ticketInfo.ref} +

+ )} +
+
+
+ + +
+
+ + {/* Mensagens */} +
+ {messages.length === 0 ? ( +
+

+ Nenhuma mensagem ainda +

+

+ O agente iniciara a conversa em breve +

+
+ ) : ( +
+ {messages.map((msg) => ( +
+
+ {!msg.isFromMachine && ( +

+ {msg.authorName} +

+ )} +

{msg.body}

+

+ {formatTime(msg.createdAt)} +

+
+
+ ))} +
+
+ )} +
+ + {/* Input */} +
+
+