feat(chat): desktop usando Convex WS direto e fallback WS dedicado

This commit is contained in:
esdrasrenan 2025-12-09 01:01:54 -03:00
parent 8db7c3c810
commit a8f5ff9d51
14 changed files with 735 additions and 458 deletions

View file

@ -12,6 +12,8 @@ NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
CONVEX_INTERNAL_URL=http://127.0.0.1:3210 CONVEX_INTERNAL_URL=http://127.0.0.1:3210
# Intervalo (ms) para aceitar token revogado ao sincronizar acessos remotos (opcional) # Intervalo (ms) para aceitar token revogado ao sincronizar acessos remotos (opcional)
REMOTE_ACCESS_TOKEN_GRACE_MS=900000 REMOTE_ACCESS_TOKEN_GRACE_MS=900000
# Porta do servidor WebSocket de chat (processo dedicado iniciado no container)
CHAT_WS_PORT=3030
# SQLite database (local dev) # SQLite database (local dev)
DATABASE_URL=file:./prisma/db.dev.sqlite DATABASE_URL=file:./prisma/db.dev.sqlite

View file

@ -68,7 +68,6 @@ dependencies = [
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"reqwest", "reqwest",
"reqwest-eventsource",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -83,6 +82,8 @@ dependencies = [
"tauri-plugin-updater", "tauri-plugin-updater",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-tungstenite",
"url",
"winreg", "winreg",
] ]
@ -762,6 +763,12 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.4" version = "0.5.4"
@ -1023,17 +1030,6 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "eventsource-stream"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
dependencies = [
"futures-core",
"nom",
"pin-project-lite",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@ -1208,12 +1204,6 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.31" version = "0.3.31"
@ -1704,12 +1694,12 @@ dependencies = [
"http", "http",
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls 0.23.32",
"rustls-pki-types", "rustls-pki-types",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls 0.26.4",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots 1.0.3",
] ]
[[package]] [[package]]
@ -2227,12 +2217,6 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "minisign-verify" name = "minisign-verify"
version = "0.2.4" version = "0.2.4"
@ -2336,16 +2320,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.11.7" version = "4.11.7"
@ -3146,7 +3120,7 @@ dependencies = [
"quinn-proto", "quinn-proto",
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls 0.23.32",
"socket2", "socket2",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
@ -3166,7 +3140,7 @@ dependencies = [
"rand 0.9.2", "rand 0.9.2",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls 0.23.32",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror 2.0.17", "thiserror 2.0.17",
@ -3431,14 +3405,14 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
"rustls", "rustls 0.23.32",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls 0.26.4",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@ -3448,23 +3422,7 @@ dependencies = [
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots 1.0.3",
]
[[package]]
name = "reqwest-eventsource"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde"
dependencies = [
"eventsource-stream",
"futures-core",
"futures-timer",
"mime",
"nom",
"pin-project-lite",
"reqwest",
"thiserror 1.0.69",
] ]
[[package]] [[package]]
@ -3525,6 +3483,20 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.32" version = "0.23.32"
@ -3534,7 +3506,7 @@ dependencies = [
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"rustls-webpki", "rustls-webpki 0.103.7",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -3549,6 +3521,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.7" version = "0.103.7"
@ -3836,6 +3819,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@ -4669,16 +4663,43 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "tokio-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.26.4" version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [ dependencies = [
"rustls", "rustls 0.23.32",
"tokio", "tokio",
] ]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"rustls 0.22.4",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.25.0",
"tungstenite",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.16" version = "0.7.16"
@ -4892,6 +4913,27 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.8.5",
"rustls 0.22.4",
"rustls-pki-types",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@ -5252,6 +5294,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.3",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.3" version = "1.0.3"

View file

@ -30,7 +30,6 @@ serde_json = "1"
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
get_if_addrs = "0.5" get_if_addrs = "0.5"
reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false } reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"], default-features = false }
reqwest-eventsource = "0.6"
futures-util = "0.3" futures-util = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
once_cell = "1.19" once_cell = "1.19"
@ -40,6 +39,8 @@ parking_lot = "0.12"
hostname = "0.4" hostname = "0.4"
base64 = "0.22" base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
url = "2.5"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.55" winreg = "0.55"

View file

@ -1,21 +1,23 @@
//! Modulo de Chat em Tempo Real //! Modulo de Chat em Tempo Real
//! //!
//! Este modulo implementa o sistema de chat entre agentes (dashboard web) //! Este modulo implementa o sistema de chat entre agentes (dashboard web)
//! e clientes (Raven desktop). Usa SSE (Server-Sent Events) como metodo //! e clientes (Raven desktop). Usa WebSocket como metodo
//! primario para atualizacoes em tempo real, com fallback para HTTP polling. //! primario para atualizacoes em tempo real, com fallback para HTTP polling.
use futures_util::StreamExt; use futures_util::{StreamExt, SinkExt};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use parking_lot::Mutex; use parking_lot::Mutex;
use reqwest::Client; use reqwest::Client;
use reqwest_eventsource::{Event, EventSource};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tauri::async_runtime::JoinHandle; use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl}; use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
use url::Url;
// ============================================================================ // ============================================================================
// TYPES // TYPES
@ -399,18 +401,24 @@ pub async fn upload_file(
} }
// ============================================================================ // ============================================================================
// SSE (Server-Sent Events) TYPES // WebSocket TYPES
// ============================================================================ // ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SseUpdateEvent { struct WsUpdateEvent {
has_active_sessions: bool, has_active_sessions: bool,
sessions: Vec<ChatSessionSummary>, sessions: Vec<ChatSessionSummary>,
total_unread: u32, total_unread: u32,
ts: i64, ts: i64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WsEnvelope {
event: String,
data: serde_json::Value,
}
// ============================================================================ // ============================================================================
// CHAT RUNTIME // CHAT RUNTIME
// ============================================================================ // ============================================================================
@ -432,7 +440,7 @@ pub struct ChatRuntime {
inner: Arc<Mutex<Option<ChatPollerHandle>>>, inner: Arc<Mutex<Option<ChatPollerHandle>>>,
last_sessions: Arc<Mutex<Vec<ChatSession>>>, last_sessions: Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: Arc<Mutex<u32>>, last_unread_count: Arc<Mutex<u32>>,
is_using_sse: Arc<AtomicBool>, is_using_ws: Arc<AtomicBool>,
} }
impl ChatRuntime { impl ChatRuntime {
@ -441,17 +449,17 @@ impl ChatRuntime {
inner: Arc::new(Mutex::new(None)), inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())), last_sessions: Arc::new(Mutex::new(Vec::new())),
last_unread_count: Arc::new(Mutex::new(0)), last_unread_count: Arc::new(Mutex::new(0)),
is_using_sse: Arc::new(AtomicBool::new(false)), is_using_ws: Arc::new(AtomicBool::new(false)),
} }
} }
/// Retorna true se esta usando SSE, false se usando polling HTTP /// Retorna true se esta usando WebSocket, false se usando polling HTTP
pub fn is_using_sse(&self) -> bool { pub fn is_using_ws(&self) -> bool {
self.is_using_sse.load(Ordering::Relaxed) self.is_using_ws.load(Ordering::Relaxed)
} }
/// Inicia o sistema de atualizacoes de chat. /// Inicia o sistema de atualizacoes de chat.
/// Tenta SSE primeiro, com fallback automatico para HTTP polling. /// Tenta WebSocket primeiro, com fallback automatico para HTTP polling.
pub fn start_polling( pub fn start_polling(
&self, &self,
base_url: String, base_url: String,
@ -463,7 +471,7 @@ impl ChatRuntime {
return Err("URL base invalida".to_string()); return Err("URL base invalida".to_string());
} }
// Para polling/SSE existente // Para polling/WS existente
{ {
let mut guard = self.inner.lock(); let mut guard = self.inner.lock();
if let Some(handle) = guard.take() { if let Some(handle) = guard.take() {
@ -477,12 +485,12 @@ impl ChatRuntime {
let token_clone = token.clone(); let token_clone = token.clone();
let last_sessions = self.last_sessions.clone(); let last_sessions = self.last_sessions.clone();
let last_unread_count = self.last_unread_count.clone(); let last_unread_count = self.last_unread_count.clone();
let is_using_sse = self.is_using_sse.clone(); let is_using_ws = self.is_using_ws.clone();
let join_handle = tauri::async_runtime::spawn(async move { let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat iniciando (tentando SSE primeiro)"); crate::log_info!("Chat iniciando (tentando WebSocket primeiro)");
// Loop principal com SSE + fallback para polling // Loop principal com WebSocket + fallback para polling
loop { loop {
// Verificar se deve parar // Verificar se deve parar
if stop_clone.load(Ordering::Relaxed) { if stop_clone.load(Ordering::Relaxed) {
@ -490,14 +498,14 @@ impl ChatRuntime {
break; break;
} }
// Tentar SSE primeiro // Tentar WebSocket primeiro
let sse_result = run_sse_loop( let ws_result = run_ws_loop(
&base_clone, &base_clone,
&token_clone, &token_clone,
&app, &app,
&last_sessions, &last_sessions,
&last_unread_count, &last_unread_count,
&is_using_sse, &is_using_ws,
&stop_clone, &stop_clone,
) )
.await; .await;
@ -508,16 +516,16 @@ impl ChatRuntime {
break; break;
} }
match sse_result { match ws_result {
Ok(()) => { Ok(()) => {
// SSE encerrado normalmente (stop signal) // WS encerrado normalmente (stop signal)
break; break;
} }
Err(e) => { Err(e) => {
crate::log_warn!("SSE falhou: {e}. Usando polling HTTP..."); crate::log_warn!("WebSocket falhou: {e}. Usando polling HTTP...");
is_using_sse.store(false, Ordering::Relaxed); is_using_ws.store(false, Ordering::Relaxed);
// Executar polling HTTP por 5 minutos, depois tentar SSE novamente // Executar polling HTTP por 5 minutos, depois tentar WebSocket novamente
let poll_duration = Duration::from_secs(300); // 5 minutos let poll_duration = Duration::from_secs(300); // 5 minutos
let poll_result = run_polling_loop( let poll_result = run_polling_loop(
&base_clone, &base_clone,
@ -534,7 +542,7 @@ impl ChatRuntime {
break; break;
} }
crate::log_info!("Tentando reconectar SSE..."); crate::log_info!("Tentando reconectar WebSocket...");
} }
} }
} }
@ -554,7 +562,7 @@ impl ChatRuntime {
if let Some(handle) = guard.take() { if let Some(handle) = guard.take() {
handle.stop(); handle.stop();
} }
self.is_using_sse.store(false, Ordering::Relaxed); self.is_using_ws.store(false, Ordering::Relaxed);
} }
pub fn get_sessions(&self) -> Vec<ChatSession> { pub fn get_sessions(&self) -> Vec<ChatSession> {
@ -563,54 +571,112 @@ impl ChatRuntime {
} }
// ============================================================================ // ============================================================================
// SSE LOOP // WS LOOP
// ============================================================================ // ============================================================================
async fn run_sse_loop( async fn run_ws_loop(
base_url: &str, base_url: &str,
token: &str, token: &str,
app: &tauri::AppHandle, app: &tauri::AppHandle,
last_sessions: &Arc<Mutex<Vec<ChatSession>>>, last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>, last_unread_count: &Arc<Mutex<u32>>,
is_using_sse: &Arc<AtomicBool>, is_using_ws: &Arc<AtomicBool>,
stop_flag: &Arc<AtomicBool>, stop_flag: &Arc<AtomicBool>,
) -> Result<(), String> { ) -> Result<(), String> {
let sse_url = format!("{}/api/machines/chat/stream?token={}", base_url, token); let ws_url = build_ws_url(base_url, token)?;
crate::log_info!("Conectando SSE: {}", sse_url); crate::log_info!("Conectando WebSocket: {}", ws_url);
let request = CHAT_CLIENT.get(&sse_url); let (ws_stream, _) = connect_async(ws_url)
let mut es = EventSource::new(request).map_err(|e| format!("Falha ao criar EventSource: {e}"))?; .await
.map_err(|e| format!("Falha ao conectar WebSocket: {e}"))?;
let (mut write, mut read) = ws_stream.split();
is_using_sse.store(true, Ordering::Relaxed); // Ativar ping periódico para manter conexão viva
crate::log_info!("SSE conectado com sucesso"); let mut heartbeat = tokio::time::interval(Duration::from_secs(45));
is_using_ws.store(true, Ordering::Relaxed);
crate::log_info!("WebSocket conectado com sucesso");
loop { loop {
// Verificar stop flag periodicamente
if stop_flag.load(Ordering::Relaxed) { if stop_flag.load(Ordering::Relaxed) {
crate::log_info!("SSE encerrado por stop flag"); crate::log_info!("WebSocket encerrado por stop flag");
let _ = write.send(Message::Close(None)).await;
return Ok(()); return Ok(());
} }
// Usar timeout para poder verificar stop flag tokio::select! {
let event = tokio::time::timeout(Duration::from_secs(1), es.next()).await; _ = heartbeat.tick() => {
let _ = write.send(Message::Ping(Vec::new())).await;
match event { }
Ok(Some(Ok(Event::Open))) => { msg = read.next() => {
crate::log_info!("SSE: conexao aberta"); match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(parsed) = serde_json::from_str::<WsEnvelope>(&text) {
handle_ws_event(
parsed,
base_url,
token,
app,
last_sessions,
last_unread_count,
).await?;
} else {
crate::log_warn!("WebSocket: payload inválido");
}
}
Some(Ok(Message::Close(_))) => {
crate::log_info!("WebSocket: conexão encerrada pelo servidor");
return Err("WebSocket fechado pelo servidor".to_string());
}
Some(Ok(_)) => {
// Ignorar outros frames
}
Some(Err(e)) => {
crate::log_warn!("WebSocket erro: {e}");
return Err(format!("Erro WebSocket: {e}"));
}
None => {
crate::log_info!("WebSocket: stream encerrado");
return Err("WebSocket encerrado".to_string());
}
}
}
}
}
} }
Ok(Some(Ok(Event::Message(msg)))) => {
let event_type = msg.event.as_str();
match event_type { fn build_ws_url(base_url: &str, token: &str) -> Result<Url, String> {
let trimmed = base_url.trim_end_matches('/');
let mut ws_url = if trimmed.starts_with("https://") {
trimmed.replacen("https://", "wss://", 1)
} else if trimmed.starts_with("http://") {
trimmed.replacen("http://", "ws://", 1)
} else {
format!("wss://{}", trimmed)
};
ws_url.push_str("/chat-ws?token=");
ws_url.push_str(token);
Url::parse(&ws_url).map_err(|e| format!("URL WS inválida: {e}"))
}
async fn handle_ws_event(
envelope: WsEnvelope,
base_url: &str,
token: &str,
app: &tauri::AppHandle,
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
last_unread_count: &Arc<Mutex<u32>>,
) -> Result<(), String> {
match envelope.event.as_str() {
"connected" => { "connected" => {
crate::log_info!("SSE: evento connected recebido"); crate::log_info!("WebSocket: conectado");
} }
"heartbeat" => { "heartbeat" => {
// Ignorar heartbeats silenciosamente // noop
} }
"update" => { "update" => {
// Processar update de chat let update: WsUpdateEvent = serde_json::from_value(envelope.data)
if let Ok(update) = serde_json::from_str::<SseUpdateEvent>(&msg.data) { .map_err(|e| format!("Payload update inválido: {e}"))?;
process_chat_update( process_chat_update(
base_url, base_url,
token, token,
@ -622,29 +688,19 @@ async fn run_sse_loop(
) )
.await; .await;
} }
}
"error" => { "error" => {
crate::log_warn!("SSE: erro recebido do servidor: {}", msg.data); let message = envelope
return Err(format!("Erro SSE do servidor: {}", msg.data)); .data
.get("message")
.and_then(Value::as_str)
.unwrap_or("Erro WebSocket");
return Err(message.to_string());
} }
_ => { _ => {
crate::log_info!("SSE: evento desconhecido: {}", event_type); crate::log_info!("WebSocket: evento desconhecido {}", envelope.event);
}
}
}
Ok(Some(Err(e))) => {
crate::log_warn!("SSE erro: {e}");
return Err(format!("Erro SSE: {e}"));
}
Ok(None) => {
crate::log_info!("SSE: stream encerrado");
return Err("Stream SSE encerrado".to_string());
}
Err(_) => {
// Timeout - continuar loop para verificar stop flag
}
} }
} }
Ok(())
} }
// ============================================================================ // ============================================================================

View file

@ -245,8 +245,8 @@ fn stop_chat_polling(state: tauri::State<ChatRuntime>) -> Result<(), String> {
} }
#[tauri::command] #[tauri::command]
fn is_chat_using_sse(state: tauri::State<ChatRuntime>) -> bool { fn is_chat_using_ws(state: tauri::State<ChatRuntime>) -> bool {
state.is_using_sse() state.is_using_ws()
} }
#[tauri::command] #[tauri::command]
@ -492,7 +492,7 @@ pub fn run() {
// Chat commands // Chat commands
start_chat_polling, start_chat_polling,
stop_chat_polling, stop_chat_polling,
is_chat_using_sse, is_chat_using_ws,
get_chat_sessions, get_chat_sessions,
fetch_chat_sessions, fetch_chat_sessions,
fetch_chat_messages, fetch_chat_messages,
@ -632,7 +632,7 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
async fn try_start_background_agent( async fn try_start_background_agent(
app: &tauri::AppHandle, app: &tauri::AppHandle,
agent_runtime: AgentRuntime, agent_runtime: AgentRuntime,
chat_runtime: ChatRuntime, _chat_runtime: ChatRuntime,
) -> Result<(), String> { ) -> Result<(), String> {
log_info!("Verificando credenciais salvas para iniciar agente..."); log_info!("Verificando credenciais salvas para iniciar agente...");
@ -687,18 +687,7 @@ async fn try_start_background_agent(
) )
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?; .map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
log_info!("Agente iniciado com sucesso em background"); log_info!("Agente iniciado com sucesso em background (chat via Convex WebSocket no frontend)");
// 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(()) Ok(())
} }

View file

@ -32,7 +32,7 @@
], ],
"dialog": true, "dialog": true,
"active": true, "active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK" "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZDRTBFNkY1NUQ3QzU0QkEKUldTNlZIeGQ5ZWJnYk5mY0J4aWRlb0dRdVZ4TGpBSUZXMnRVUFhmdmlLT0tlY084UjJQUHFWWUkK"
}, },
"deep-link": { "deep-link": {
"desktop": { "desktop": {

View file

@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from "react" 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 { appLocalDataDir, join } from "@tauri-apps/api/path"
import { open } from "@tauri-apps/plugin-dialog" import { open } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types" import type { ChatMessage } from "./types"
import {
subscribeMachineMessages,
sendMachineMessage,
markMachineMessagesRead,
getMachineStoreConfig,
} from "./convexMachineClient"
const STORE_FILENAME = "machine-agent.json"
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
// Tipos de arquivo permitidos // Tipos de arquivo permitidos
@ -52,8 +54,7 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
const [unreadCount, setUnreadCount] = useState(0) const [unreadCount, setUnreadCount] = useState(0)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0) const messagesSubRef = useRef<(() => void) | null>(null)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const hadSessionRef = useRef<boolean>(false) const hadSessionRef = useRef<boolean>(false)
// Scroll para o final quando novas mensagens chegam // Scroll para o final quando novas mensagens chegam
@ -77,153 +78,48 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
hadSessionRef.current = hasSession hadSessionRef.current = hasSession
}, [hasSession, ticketId]) }, [hasSession, ticketId])
// Carregar configuracao do store // Inicializacao via Convex (WS)
const loadConfig = useCallback(async () => { useEffect(() => {
try { setIsLoading(true)
const appData = await appLocalDataDir() setMessages([])
const storePath = await join(appData, STORE_FILENAME) messagesSubRef.current?.()
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) { subscribeMachineMessages(
setError("Máquina não 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<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId, ticketId,
since: since ?? null, (payload) => {
}) setIsLoading(false)
setHasSession(payload.hasSession)
setHasSession(response.hasSession) hadSessionRef.current = hadSessionRef.current || payload.hasSession
const unread = payload.messages.filter(m => !m.isFromMachine).length
if (response.messages.length > 0) { setUnreadCount(unread)
if (since) {
// Adicionar apenas novas mensagens (com limite para evitar memory leak)
setMessages(prev => { setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id)) const existingIds = new Set(prev.map(m => m.id))
const newMsgs = response.messages.filter(m => !existingIds.has(m.id)) const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))]
const combined = [...prev, ...newMsgs]
// Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens
return combined.slice(-MAX_MESSAGES_IN_MEMORY) return combined.slice(-MAX_MESSAGES_IN_MEMORY)
}) })
} else { // Atualiza info basica do ticket
// Primeira carga (já limitada) if (payload.messages.length > 0) {
setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY)) const first = payload.messages[0]
setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" })
} }
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt)) const unreadIds = payload.messages.filter(m => !m.isFromMachine).map(m => m.id as string)
if (unreadIds.length > 0) {
markMachineMessagesRead(ticketId, unreadIds).catch(err => console.error("mark read falhou", err))
} }
},
return response (err) => {
} 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<Array<{
ticketId: string
ticketRef: number
ticketSubject: string
agentName: string
}>>("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) setIsLoading(false)
setError(err.message || "Erro ao carregar mensagens.")
// Iniciar polling (2 segundos para maior responsividade)
pollIntervalRef.current = setInterval(async () => {
await fetchMessages(baseUrl, token, lastFetchRef.current)
}, 2000)
} }
).then((unsub) => {
init() messagesSubRef.current = unsub
// Listener para eventos de nova mensagem do Tauri
const unlistenNewMessage = 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
}
const combined = [...prev, event.payload.message]
// Manter apenas as últimas MAX_MESSAGES_IN_MEMORY mensagens
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
}) })
}
}
)
// Listener para atualização de mensagens não lidas
const unlistenUnread = listen<{ totalUnread: number; sessions: Array<{ ticketId: string; unreadCount: number }> }>(
"raven://chat/unread-update",
(event) => {
// Encontrar o unread count para este ticket
const session = event.payload.sessions?.find(s => s.ticketId === ticketId)
if (session) {
setUnreadCount(session.unreadCount ?? 0)
}
}
)
return () => { return () => {
mounted = false messagesSubRef.current?.()
if (pollIntervalRef.current) { messagesSubRef.current = null
clearInterval(pollIntervalRef.current)
} }
unlistenNewMessage.then(unlisten => unlisten()) }, [ticketId])
unlistenUnread.then(unlisten => unlisten())
}
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
// Selecionar arquivo para anexar // Selecionar arquivo para anexar
const handleAttach = async () => { const handleAttach = async () => {
@ -245,14 +141,10 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
setIsUploading(true) setIsUploading(true)
const config = await loadConfig() const config = await getMachineStoreConfig()
if (!config) {
setIsUploading(false)
return
}
const attachment = await invoke<UploadedAttachment>("upload_chat_file", { const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
baseUrl: config.baseUrl, baseUrl: config.apiBaseUrl,
token: config.token, token: config.token,
filePath, filePath,
}) })
@ -282,29 +174,20 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
setIsSending(true) setIsSending(true)
try { try {
const config = await loadConfig() const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "")
if (!config) { await sendMachineMessage({
setIsSending(false)
setInputValue(messageText)
setPendingAttachments(attachmentsToSend)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
ticketId, ticketId,
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""), body: bodyToSend,
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : null, attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
}) })
// Adicionar mensagem localmente // Adicionar mensagem localmente
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: response.messageId, id: crypto.randomUUID(),
body: messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : ""), body: bodyToSend,
authorName: "Você", authorName: "Você",
isFromMachine: true, isFromMachine: true,
createdAt: response.createdAt, createdAt: Date.now(),
attachments: attachmentsToSend.map(a => ({ attachments: attachmentsToSend.map(a => ({
storageId: a.storageId, storageId: a.storageId,
name: a.name, name: a.name,
@ -312,8 +195,6 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
type: a.type, type: a.type,
})), })),
}]) }])
lastFetchRef.current = response.createdAt
} catch (err) { } catch (err) {
console.error("Erro ao enviar mensagem:", err) console.error("Erro ao enviar mensagem:", err)
// Restaurar input e anexos em caso de erro // Restaurar input e anexos em caso de erro

View file

@ -0,0 +1,180 @@
import { ConvexClient } from "convex/browser"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import type { ChatMessage } from "./types"
const STORE_FILENAME = "machine-agent.json"
const DEFAULT_CONVEX_URL =
import.meta.env.VITE_CONVEX_URL?.trim() ||
"https://convex.esdrasrenan.com.br"
type MachineStoreConfig = {
apiBaseUrl?: string
appUrl?: string
convexUrl?: string
}
type MachineStoreData = {
token?: string
config?: MachineStoreConfig
}
type ClientCache = {
client: ConvexClient
token: string
convexUrl: string
}
let cached: ClientCache | null = null
type MachineUpdatePayload = {
hasActiveSessions: boolean
sessions: Array<{ ticketId: string; unreadCount: number; lastActivityAt: number }>
totalUnread: number
}
async function loadStore(): Promise<MachineStoreData> {
const appData = await appLocalDataDir()
const storePath = await join(appData, STORE_FILENAME)
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<MachineStoreConfig>("config")
return { token: token ?? undefined, config: config ?? undefined }
}
function resolveConvexUrl(config?: MachineStoreConfig): string {
const fromConfig = config?.convexUrl?.trim()
if (fromConfig) return fromConfig.replace(/\/+$/, "")
return DEFAULT_CONVEX_URL
}
function resolveApiBaseUrl(config?: MachineStoreConfig): string {
const fromConfig = config?.apiBaseUrl?.trim()
if (fromConfig) return fromConfig.replace(/\/+$/, "")
return "https://tickets.esdrasrenan.com.br"
}
export async function getMachineStoreConfig() {
const data = await loadStore()
if (!data.token) {
throw new Error("Token de máquina não encontrado no store")
}
const apiBaseUrl = resolveApiBaseUrl(data.config)
const appUrl = data.config?.appUrl?.trim() || apiBaseUrl
return { token: data.token, apiBaseUrl, appUrl, convexUrl: resolveConvexUrl(data.config) }
}
async function ensureClient(): Promise<ClientCache> {
const data = await loadStore()
if (!data.token) {
throw new Error("Token de máquina não encontrado no store")
}
const convexUrl = resolveConvexUrl(data.config)
if (cached && cached.token === data.token && cached.convexUrl === convexUrl) {
return cached
}
const client = new ConvexClient(convexUrl)
cached = { client, token: data.token, convexUrl }
return cached
}
export async function subscribeMachineUpdates(
callback: (payload: MachineUpdatePayload) => void,
onError?: (error: Error) => void
): Promise<() => void> {
const { client, token } = await ensureClient()
const sub = client.onUpdate(
FN_CHECK_UPDATES as any,
{ machineToken: token },
(value) => callback(value),
onError
)
return () => sub.unsubscribe()
}
export async function subscribeMachineMessages(
ticketId: string,
callback: (payload: { messages: ChatMessage[]; hasSession: boolean }) => void,
onError?: (error: Error) => void
): Promise<() => void> {
const { client, token } = await ensureClient()
const sub = client.onUpdate(
FN_LIST_MESSAGES as any,
{
machineToken: token,
ticketId,
},
(value) => callback(value),
onError
)
return () => sub.unsubscribe()
}
export async function sendMachineMessage(input: {
ticketId: string
body: string
attachments?: Array<{
storageId: string
name: string
size?: number
type?: string
}>
}) {
const { client, token } = await ensureClient()
return client.mutation(FN_POST_MESSAGE as any, {
machineToken: token,
ticketId: input.ticketId,
body: input.body,
attachments: input.attachments?.map((att) => ({
storageId: att.storageId,
name: att.name,
size: att.size,
type: att.type,
})),
})
}
export async function markMachineMessagesRead(ticketId: string, messageIds: string[]) {
if (messageIds.length === 0) return
const { client, token } = await ensureClient()
await client.mutation(FN_MARK_READ as any, {
machineToken: token,
ticketId,
messageIds,
})
}
export async function generateMachineUploadUrl(opts: {
fileName: string
fileType: string
fileSize: number
}) {
const { client, token } = await ensureClient()
return client.action(FN_UPLOAD_URL as any, {
machineToken: token,
fileName: opts.fileName,
fileType: opts.fileType,
fileSize: opts.fileSize,
})
}
export async function uploadToConvexStorage(uploadUrl: string, file: Blob | ArrayBuffer, contentType: string) {
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": contentType },
body: file,
})
if (!response.ok) {
const body = await response.text()
throw new Error(`Upload falhou: ${response.status} ${body}`)
}
const json = await response.json().catch(() => ({}))
return json.storageId || json.storage_id
}
const FN_CHECK_UPDATES = "liveChat.checkMachineUpdates"
const FN_LIST_MESSAGES = "liveChat.listMachineMessages"
const FN_POST_MESSAGE = "liveChat.postMachineMessage"
const FN_MARK_READ = "liveChat.markMachineMessagesRead"
const FN_UPLOAD_URL = "liveChat.generateMachineUploadUrl"

View file

@ -1,12 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react" import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { Store } from "@tauri-apps/plugin-store"
import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react" import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react"
import { cn } from "../lib/utils" import { cn } from "../lib/utils"
import type { ChatSession, ChatMessage, ChatMessagesResponse, SendMessageResponse, ChatHistorySession } from "../chat/types" import type { ChatSession, ChatMessage, ChatHistorySession } from "../chat/types"
import {
const STORE_FILENAME = "machine-agent.json" subscribeMachineUpdates,
subscribeMachineMessages,
sendMachineMessage,
markMachineMessagesRead,
} from "../chat/convexMachineClient"
interface ChatFloatingWidgetProps { interface ChatFloatingWidgetProps {
sessions: ChatSession[] sessions: ChatSession[]
@ -30,19 +31,22 @@ export function ChatFloatingWidget({
const [isSending, setIsSending] = useState(false) const [isSending, setIsSending] = useState(false)
const [historyExpanded, setHistoryExpanded] = useState(false) const [historyExpanded, setHistoryExpanded] = useState(false)
const [historySessions] = useState<ChatHistorySession[]>([]) const [historySessions] = useState<ChatHistorySession[]>([])
const [liveSessions, setLiveSessions] = useState<ChatSession[]>(sessions)
const [liveUnread, setLiveUnread] = useState<number>(totalUnread)
const sessionList = liveSessions.length > 0 ? liveSessions : sessions
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0) const messagesSubRef = useRef<(() => void) | null>(null)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const updatesSubRef = useRef<(() => void) | null>(null)
// Selecionar ticket mais recente automaticamente // Selecionar ticket mais recente automaticamente
useEffect(() => { useEffect(() => {
if (sessions.length > 0 && !selectedTicketId) { const source = liveSessions.length > 0 ? liveSessions : sessions
// Ordenar por lastActivityAt e pegar o mais recente if (source.length > 0 && !selectedTicketId) {
const sorted = [...sessions].sort((a, b) => b.lastActivityAt - a.lastActivityAt) const sorted = [...source].sort((a, b) => b.lastActivityAt - a.lastActivityAt)
setSelectedTicketId(sorted[0].ticketId) setSelectedTicketId(sorted[0].ticketId)
} }
}, [sessions, selectedTicketId]) }, [sessions, liveSessions, selectedTicketId])
// Scroll para o final quando novas mensagens chegam // Scroll para o final quando novas mensagens chegam
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
@ -53,99 +57,73 @@ export function ChatFloatingWidget({
scrollToBottom() scrollToBottom()
}, [messages, scrollToBottom]) }, [messages, scrollToBottom])
// Carregar configuracao do store // Assinar updates de sessões/unread
const loadConfig = useCallback(async () => {
try {
const appData = await appLocalDataDir()
const storePath = await join(appData, STORE_FILENAME)
const store = await Store.load(storePath)
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) {
return null
}
return { token, baseUrl: config.apiBaseUrl }
} catch {
return null
}
}, [])
// Buscar mensagens
const fetchMessages = useCallback(async (baseUrl: string, token: string, ticketId: string, since?: number) => {
try {
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId,
since: since ?? null,
})
if (response.messages.length > 0) {
if (since) {
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 {
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
}
}, [])
// Inicializar e fazer polling quando ticket selecionado
useEffect(() => { useEffect(() => {
if (!selectedTicketId || !isOpen) return let cancelled = false
subscribeMachineUpdates(
let mounted = true (payload) => {
if (cancelled) return
const init = async () => { const mapped: ChatSession[] = (payload.sessions ?? []).map((s) => ({
setIsLoading(true) sessionId: s.ticketId,
const config = await loadConfig() ticketId: s.ticketId,
if (!config || !mounted) { ticketRef: 0,
setIsLoading(false) ticketSubject: "",
return agentName: "",
} agentEmail: undefined,
agentAvatarUrl: undefined,
const { baseUrl, token } = config unreadCount: s.unreadCount,
lastActivityAt: s.lastActivityAt,
// Buscar mensagens iniciais startedAt: 0,
await fetchMessages(baseUrl, token, selectedTicketId) }))
setLiveSessions(mapped)
if (!mounted) return setLiveUnread(payload.totalUnread ?? 0)
setIsLoading(false) },
(err) => console.error("chat updates erro:", err)
// Iniciar polling (2 segundos) ).then((unsub) => {
pollIntervalRef.current = setInterval(async () => { updatesSubRef.current = unsub
await fetchMessages(baseUrl, token, selectedTicketId, lastFetchRef.current) })
}, 2000)
}
init()
return () => { return () => {
mounted = false cancelled = true
if (pollIntervalRef.current) { updatesSubRef.current?.()
clearInterval(pollIntervalRef.current) updatesSubRef.current = null
pollIntervalRef.current = null
} }
} }, [])
}, [selectedTicketId, isOpen, loadConfig, fetchMessages])
// Limpar mensagens quando trocar de ticket // Assinar mensagens do ticket selecionado
useEffect(() => { useEffect(() => {
if (!selectedTicketId || !isOpen) return
messagesSubRef.current?.()
setMessages([]) setMessages([])
lastFetchRef.current = 0 setIsLoading(true)
}, [selectedTicketId])
subscribeMachineMessages(
selectedTicketId,
(payload) => {
setIsLoading(false)
setMessages(payload.messages)
const unreadIds = payload.messages
.filter((m) => !m.isFromMachine)
.map((m) => m.id as string)
if (unreadIds.length) {
markMachineMessagesRead(selectedTicketId, unreadIds).catch((err) =>
console.error("mark read falhou", err)
)
}
},
(err) => {
setIsLoading(false)
console.error("chat messages erro:", err)
}
).then((unsub) => {
messagesSubRef.current = unsub
})
return () => {
messagesSubRef.current?.()
messagesSubRef.current = null
}
}, [selectedTicketId, isOpen])
// Enviar mensagem // Enviar mensagem
const handleSend = async () => { const handleSend = async () => {
@ -156,29 +134,15 @@ export function ChatFloatingWidget({
setIsSending(true) setIsSending(true)
try { try {
const config = await loadConfig() await sendMachineMessage({ ticketId: selectedTicketId, body: messageText })
if (!config) {
setIsSending(false)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
ticketId: selectedTicketId,
body: messageText,
})
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
id: response.messageId, id: crypto.randomUUID(),
body: messageText, body: messageText,
authorName: "Você", authorName: "Você",
isFromMachine: true, isFromMachine: true,
createdAt: response.createdAt, createdAt: Date.now(),
attachments: [], attachments: [],
}]) }])
lastFetchRef.current = response.createdAt
} catch (err) { } catch (err) {
console.error("Erro ao enviar mensagem:", err) console.error("Erro ao enviar mensagem:", err)
setInputValue(messageText) setInputValue(messageText)
@ -194,35 +158,34 @@ export function ChatFloatingWidget({
} }
} }
const currentSession = sessions.find(s => s.ticketId === selectedTicketId) const currentSession = sessionList.find(s => s.ticketId === selectedTicketId)
// Botao flutuante (fechado) // Botao flutuante (fechado)
// DEBUG: Log do estado do widget // DEBUG: Log do estado do widget
console.log("[ChatFloatingWidget] Estado:", { // console.log("[ChatFloatingWidget] Estado:", {
isOpen, // isOpen,
totalUnread, // totalUnread: liveUnread,
sessionsCount: sessions.length, // sessionsCount: liveSessions.length,
sessions: sessions.map(s => ({ id: s.sessionId, ticketId: s.ticketId, unread: s.unreadCount })) // })
})
if (!isOpen) { if (!isOpen) {
return ( return (
<div className="fixed bottom-4 right-4 z-50"> <div className="fixed bottom-4 right-4 z-50">
{/* DEBUG: Indicador visual do estado */} {/* DEBUG: Indicador visual do estado */}
<div className="absolute -left-32 bottom-0 rounded bg-yellow-100 p-1 text-[10px] text-yellow-800 shadow"> <div className="absolute -left-32 bottom-0 rounded bg-yellow-100 p-1 text-[10px] text-yellow-800 shadow">
unread: {totalUnread} | sessions: {sessions.length} unread: {liveUnread} | sessions: {sessionList.length}
</div> </div>
<button <button
onClick={onToggle} onClick={onToggle}
className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition hover:bg-black/90" className="relative flex size-14 items-center justify-center rounded-full bg-black text-white shadow-lg transition hover:bg-black/90"
> >
<MessageCircle className="size-6" /> <MessageCircle className="size-6" />
{totalUnread > 0 && ( {liveUnread > 0 && (
<> <>
<span className="absolute -right-1 -top-1 flex size-6 items-center justify-center"> <span className="absolute -right-1 -top-1 flex size-6 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" /> <span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white"> <span className="relative flex size-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
{totalUnread > 99 ? "99+" : totalUnread} {liveUnread > 99 ? "99+" : liveUnread}
</span> </span>
</span> </span>
</> </>
@ -238,7 +201,7 @@ export function ChatFloatingWidget({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"> <div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{sessions.length > 1 && selectedTicketId && ( {sessionList.length > 1 && selectedTicketId && (
<button <button
onClick={() => setSelectedTicketId(null)} onClick={() => setSelectedTicketId(null)}
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600" className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
@ -262,9 +225,9 @@ export function ChatFloatingWidget({
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Tabs de tickets (se houver mais de 1) */} {/* Tabs de tickets (se houver mais de 1) */}
{sessions.length > 1 && ( {sessionList.length > 1 && (
<div className="mr-2 flex items-center gap-1"> <div className="mr-2 flex items-center gap-1">
{sessions.slice(0, 3).map((session) => ( {sessionList.slice(0, 3).map((session) => (
<button <button
key={session.ticketId} key={session.ticketId}
onClick={() => setSelectedTicketId(session.ticketId)} onClick={() => setSelectedTicketId(session.ticketId)}
@ -283,8 +246,8 @@ export function ChatFloatingWidget({
)} )}
</button> </button>
))} ))}
{sessions.length > 3 && ( {sessionList.length > 3 && (
<span className="text-xs text-slate-400">+{sessions.length - 3}</span> <span className="text-xs text-slate-400">+{sessionList.length - 3}</span>
)} )}
</div> </div>
)} )}
@ -304,11 +267,11 @@ export function ChatFloatingWidget({
</div> </div>
{/* Selecao de ticket (se nenhum selecionado e ha multiplos) */} {/* Selecao de ticket (se nenhum selecionado e ha multiplos) */}
{!selectedTicketId && sessions.length > 1 ? ( {!selectedTicketId && sessionList.length > 1 ? (
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<p className="mb-3 text-sm font-medium text-slate-700">Selecione um chamado:</p> <p className="mb-3 text-sm font-medium text-slate-700">Selecione um chamado:</p>
<div className="space-y-2"> <div className="space-y-2">
{sessions.map((session) => ( {sessionList.map((session) => (
<button <button
key={session.ticketId} key={session.ticketId}
onClick={() => setSelectedTicketId(session.ticketId)} onClick={() => setSelectedTicketId(session.ticketId)}

View file

@ -64,6 +64,7 @@
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"unicornstudio-react": "^1.4.31", "unicornstudio-react": "^1.4.31",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.18.0",
"zod": "^4.1.9", "zod": "^4.1.9",
}, },
"devDependencies": { "devDependencies": {

View file

@ -84,6 +84,7 @@
"three": "0.181.2", "three": "0.181.2",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"unicornstudio-react": "^1.4.31", "unicornstudio-react": "^1.4.31",
"ws": "^8.18.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "^4.1.9" "zod": "^4.1.9"
}, },

130
scripts/chat-ws-server.mjs Normal file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Servidor WebSocket dedicado para notificações de chat (máquinas).
*
* Por enquanto ele replica a lógica de streaming via SSE/poll:
* - autentica via machineToken (query ?token=)
* - consulta checkMachineUpdates a cada 1s
* - envia eventos "connected", "update" e "heartbeat"
* - fecha em caso de erro de autenticação
*
* Isso permite remover SSE/poll no cliente, mantendo compatibilidade com o
* backend Convex existente.
*/
import { WebSocketServer } from "ws"
import { ConvexHttpClient } from "convex/browser"
import { api } from "../convex/_generated/api.js"
const PORT = Number(process.env.CHAT_WS_PORT ?? process.env.PORT_WS ?? 3030)
const POLL_MS = Number(process.env.CHAT_WS_POLL_MS ?? 1000)
const HEARTBEAT_MS = Number(process.env.CHAT_WS_HEARTBEAT_MS ?? 30000)
const convexUrl =
process.env.CONVEX_INTERNAL_URL ??
process.env.NEXT_PUBLIC_CONVEX_URL ??
process.env.CONVEX_URL ??
null
if (!convexUrl) {
console.error("[chat-ws] ERRO: defina CONVEX_INTERNAL_URL ou NEXT_PUBLIC_CONVEX_URL")
process.exit(1)
}
const wss = new WebSocketServer({ port: PORT })
console.log(`[chat-ws] Servidor WebSocket iniciado na porta ${PORT}`)
function buildClient() {
return new ConvexHttpClient(convexUrl)
}
function parseToken(urlString) {
try {
const url = new URL(urlString, "http://localhost")
return url.searchParams.get("token")
} catch {
return null
}
}
wss.on("connection", (ws, req) => {
const token = parseToken(req.url ?? "")
if (!token) {
ws.close(1008, "Missing token")
return
}
const client = buildClient()
let previousState = null
let closed = false
const send = (event, data) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ event, data }))
}
}
// Heartbeat
const heartbeat = setInterval(() => {
if (closed) return
send("heartbeat", { ts: Date.now() })
}, HEARTBEAT_MS)
// Poll
const poll = setInterval(async () => {
if (closed) return
try {
const result = await client.query(api.liveChat.checkMachineUpdates, {
machineToken: token,
})
const currentState = JSON.stringify({
hasActiveSessions: result.hasActiveSessions,
totalUnread: result.totalUnread,
sessions: result.sessions,
})
if (currentState !== previousState) {
previousState = currentState
send("update", { ...result, ts: Date.now() })
}
} catch (error) {
console.error("[chat-ws] Poll error:", error?.message ?? error)
send("error", { message: "Poll failed" })
ws.close(1011, "Poll failed")
}
}, POLL_MS)
// Primeira validação + evento inicial
client
.query(api.liveChat.checkMachineUpdates, { machineToken: token })
.then((result) => {
previousState = JSON.stringify({
hasActiveSessions: result.hasActiveSessions,
totalUnread: result.totalUnread,
sessions: result.sessions,
})
send("connected", { ts: Date.now(), ...result })
})
.catch((error) => {
console.error("[chat-ws] Token inválido:", error?.message ?? error)
send("error", { message: "Token inválido" })
ws.close(1008, "Invalid token")
})
ws.on("close", () => {
closed = true
clearInterval(poll)
clearInterval(heartbeat)
})
ws.on("error", (err) => {
console.error("[chat-ws] WS erro:", err?.message ?? err)
closed = true
clearInterval(poll)
clearInterval(heartbeat)
})
})
wss.on("error", (err) => {
console.error("[chat-ws] Erro no servidor:", err?.message ?? err)
})

View file

@ -16,6 +16,7 @@ echo "[start-web] Using bun cache dir: $BUN_INSTALL_CACHE_DIR"
echo "[start-web] Using APP_DIR=$(pwd)" echo "[start-web] Using APP_DIR=$(pwd)"
echo "[start-web] NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-}" echo "[start-web] NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-}"
echo "[start-web] NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-}" echo "[start-web] NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-}"
echo "[start-web] CHAT_WS_PORT=${CHAT_WS_PORT:-3030}"
ensure_db_writable() { ensure_db_writable() {
mkdir -p "$(dirname "$DB_PATH")" mkdir -p "$(dirname "$DB_PATH")"
@ -203,6 +204,19 @@ else
echo "[start-web] skipping auth seed (SKIP_AUTH_SEED=true)" echo "[start-web] skipping auth seed (SKIP_AUTH_SEED=true)"
fi fi
# Iniciar servidor WebSocket de chat (processo dedicado)
CHAT_WS_PORT="${CHAT_WS_PORT:-3030}"
CHAT_WS_SCRIPT="/app/scripts/chat-ws-server.mjs"
if [ -f "$CHAT_WS_SCRIPT" ]; then
echo "[start-web] iniciando chat-ws-server em :$CHAT_WS_PORT"
node "$CHAT_WS_SCRIPT" &
CHAT_WS_PID=$!
# Garantir cleanup
trap "kill $CHAT_WS_PID 2>/dev/null || true" EXIT
else
echo "[start-web] chat-ws-server não encontrado em $CHAT_WS_SCRIPT" >&2
fi
echo "[start-web] launching Next.js" echo "[start-web] launching Next.js"
PORT=${PORT:-3000} PORT=${PORT:-3000}
NODE_MAJOR=$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo "") NODE_MAJOR=$(command -v node >/dev/null 2>&1 && node -v | sed -E 's/^v([0-9]+).*/\1/' || echo "")

View file

@ -29,6 +29,7 @@ services:
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}"
REPORTS_CRON_SECRET: "${REPORTS_CRON_SECRET}" REPORTS_CRON_SECRET: "${REPORTS_CRON_SECRET}"
REPORTS_CRON_BASE_URL: "${REPORTS_CRON_BASE_URL}" REPORTS_CRON_BASE_URL: "${REPORTS_CRON_BASE_URL}"
CHAT_WS_PORT: "${CHAT_WS_PORT:-3030}"
# Mantém o SQLite fora do repositório # Mantém o SQLite fora do repositório
DATABASE_URL: "file:/app/data/db.sqlite" DATABASE_URL: "file:/app/data/db.sqlite"
# Evita apt-get na inicialização porque a imagem já vem com toolchain pronta # Evita apt-get na inicialização porque a imagem já vem com toolchain pronta
@ -67,6 +68,13 @@ services:
- traefik.http.routers.sistema_web.tls=true - traefik.http.routers.sistema_web.tls=true
- traefik.http.routers.sistema_web.tls.certresolver=le - traefik.http.routers.sistema_web.tls.certresolver=le
- traefik.http.services.sistema_web.loadbalancer.server.port=3000 - traefik.http.services.sistema_web.loadbalancer.server.port=3000
# Roteador dedicado para WebSocket do chat
- traefik.http.routers.sistema_web_ws.rule=Host(`tickets.esdrasrenan.com.br`) && PathPrefix(`/chat-ws`)
- traefik.http.routers.sistema_web_ws.entrypoints=websecure
- traefik.http.routers.sistema_web_ws.tls=true
- traefik.http.routers.sistema_web_ws.tls.certresolver=le
- traefik.http.routers.sistema_web_ws.service=sistema_web_ws
- traefik.http.services.sistema_web_ws.loadbalancer.server.port=3030
networks: networks:
- traefik_public - traefik_public
healthcheck: healthcheck: