fix(chat): correções de SSE, inicialização e área de clique
- Substituir WebSocket por SSE para real-time (chat.rs) - Corrigir inicialização do chat runtime em lib.rs - Iniciar unreadByMachine em 0 ao criar sessão (liveChat.ts) - Corrigir área de clique do chip minimizado (pointer-events) - Corrigir roteamento SPA no Tauri (index.html?view=chat) - Corrigir estado inicial isMinimized como true 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3aee1a6694
commit
3c2d1824fb
7 changed files with 213 additions and 256 deletions
121
apps/desktop/src-tauri/Cargo.lock
generated
121
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -82,8 +82,6 @@ dependencies = [
|
|||
"tauri-plugin-updater",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
|
|
@ -763,12 +761,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "deranged"
|
||||
version = "0.5.4"
|
||||
|
|
@ -1694,12 +1686,12 @@ dependencies = [
|
|||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls 0.23.32",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.3",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3120,7 +3112,7 @@ dependencies = [
|
|||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.32",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
|
|
@ -3140,7 +3132,7 @@ dependencies = [
|
|||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.32",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
|
|
@ -3405,14 +3397,14 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.32",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
|
|
@ -3422,7 +3414,7 @@ dependencies = [
|
|||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.3",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3483,20 +3475,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustls"
|
||||
version = "0.23.32"
|
||||
|
|
@ -3506,7 +3484,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.7",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -3521,17 +3499,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.7"
|
||||
|
|
@ -3819,17 +3786,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
|
|
@ -4663,43 +4619,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.32",
|
||||
"rustls",
|
||||
"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]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.16"
|
||||
|
|
@ -4913,27 +4842,6 @@ version = "0.2.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
|
|
@ -5294,15 +5202,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.3"
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ serde = { version = "1", features = ["derive"] }
|
|||
serde_json = "1"
|
||||
sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] }
|
||||
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", "stream"], default-features = false }
|
||||
futures-util = "0.3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
once_cell = "1.19"
|
||||
|
|
@ -39,8 +39,7 @@ parking_lot = "0.12"
|
|||
hostname = "0.4"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
|
||||
url = "2.5"
|
||||
# SSE usa reqwest com stream, nao precisa de websocket
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.55"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
//! Modulo de Chat em Tempo Real
|
||||
//!
|
||||
//! Este modulo implementa o sistema de chat entre agentes (dashboard web)
|
||||
//! e clientes (Raven desktop). Usa WebSocket como metodo
|
||||
//! e clientes (Raven desktop). Usa Server-Sent Events (SSE) como metodo
|
||||
//! primario para atualizacoes em tempo real, com fallback para HTTP polling.
|
||||
|
||||
use futures_util::{StreamExt, SinkExt};
|
||||
use futures_util::StreamExt;
|
||||
use once_cell::sync::Lazy;
|
||||
use parking_lot::Mutex;
|
||||
use reqwest::Client;
|
||||
|
|
@ -16,8 +16,6 @@ use std::time::Duration;
|
|||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
|
||||
use url::Url;
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
|
|
@ -401,22 +399,46 @@ pub async fn upload_file(
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket TYPES
|
||||
// SSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WsUpdateEvent {
|
||||
struct SseUpdateEvent {
|
||||
has_active_sessions: bool,
|
||||
sessions: Vec<ChatSessionSummary>,
|
||||
total_unread: u32,
|
||||
ts: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WsEnvelope {
|
||||
/// Parser de eventos SSE
|
||||
struct SseEvent {
|
||||
event: String,
|
||||
data: serde_json::Value,
|
||||
data: String,
|
||||
}
|
||||
|
||||
fn parse_sse_line(buffer: &mut String, line: &str) -> Option<SseEvent> {
|
||||
if line.starts_with("event:") {
|
||||
buffer.clear();
|
||||
let event_type = line.trim_start_matches("event:").trim();
|
||||
buffer.push_str(event_type);
|
||||
buffer.push('\0'); // Separador interno
|
||||
None
|
||||
} else if line.starts_with("data:") {
|
||||
let data = line.trim_start_matches("data:").trim();
|
||||
let parts: Vec<&str> = buffer.split('\0').collect();
|
||||
let event_type = if parts.len() >= 1 && !parts[0].is_empty() {
|
||||
parts[0].to_string()
|
||||
} else {
|
||||
"message".to_string()
|
||||
};
|
||||
Some(SseEvent {
|
||||
event: event_type,
|
||||
data: data.to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -440,7 +462,7 @@ pub struct ChatRuntime {
|
|||
inner: Arc<Mutex<Option<ChatPollerHandle>>>,
|
||||
last_sessions: Arc<Mutex<Vec<ChatSession>>>,
|
||||
last_unread_count: Arc<Mutex<u32>>,
|
||||
is_using_ws: Arc<AtomicBool>,
|
||||
is_using_sse: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ChatRuntime {
|
||||
|
|
@ -449,17 +471,17 @@ impl ChatRuntime {
|
|||
inner: Arc::new(Mutex::new(None)),
|
||||
last_sessions: Arc::new(Mutex::new(Vec::new())),
|
||||
last_unread_count: Arc::new(Mutex::new(0)),
|
||||
is_using_ws: Arc::new(AtomicBool::new(false)),
|
||||
is_using_sse: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retorna true se esta usando WebSocket, false se usando polling HTTP
|
||||
pub fn is_using_ws(&self) -> bool {
|
||||
self.is_using_ws.load(Ordering::Relaxed)
|
||||
/// Retorna true se esta usando SSE, false se usando polling HTTP
|
||||
pub fn is_using_sse(&self) -> bool {
|
||||
self.is_using_sse.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Inicia o sistema de atualizacoes de chat.
|
||||
/// Tenta WebSocket primeiro, com fallback automatico para HTTP polling.
|
||||
/// Tenta SSE primeiro, com fallback automatico para HTTP polling.
|
||||
pub fn start_polling(
|
||||
&self,
|
||||
base_url: String,
|
||||
|
|
@ -471,7 +493,7 @@ impl ChatRuntime {
|
|||
return Err("URL base invalida".to_string());
|
||||
}
|
||||
|
||||
// Para polling/WS existente
|
||||
// Para polling/SSE existente
|
||||
{
|
||||
let mut guard = self.inner.lock();
|
||||
if let Some(handle) = guard.take() {
|
||||
|
|
@ -485,12 +507,12 @@ impl ChatRuntime {
|
|||
let token_clone = token.clone();
|
||||
let last_sessions = self.last_sessions.clone();
|
||||
let last_unread_count = self.last_unread_count.clone();
|
||||
let is_using_ws = self.is_using_ws.clone();
|
||||
let is_using_sse = self.is_using_sse.clone();
|
||||
|
||||
let join_handle = tauri::async_runtime::spawn(async move {
|
||||
crate::log_info!("Chat iniciando (tentando WebSocket primeiro)");
|
||||
crate::log_info!("Chat iniciando (tentando SSE primeiro)");
|
||||
|
||||
// Loop principal com WebSocket + fallback para polling
|
||||
// Loop principal com SSE + fallback para polling
|
||||
loop {
|
||||
// Verificar se deve parar
|
||||
if stop_clone.load(Ordering::Relaxed) {
|
||||
|
|
@ -498,14 +520,14 @@ impl ChatRuntime {
|
|||
break;
|
||||
}
|
||||
|
||||
// Tentar WebSocket primeiro
|
||||
let ws_result = run_ws_loop(
|
||||
// Tentar SSE primeiro
|
||||
let sse_result = run_sse_loop(
|
||||
&base_clone,
|
||||
&token_clone,
|
||||
&app,
|
||||
&last_sessions,
|
||||
&last_unread_count,
|
||||
&is_using_ws,
|
||||
&is_using_sse,
|
||||
&stop_clone,
|
||||
)
|
||||
.await;
|
||||
|
|
@ -516,16 +538,16 @@ impl ChatRuntime {
|
|||
break;
|
||||
}
|
||||
|
||||
match ws_result {
|
||||
match sse_result {
|
||||
Ok(()) => {
|
||||
// WS encerrado normalmente (stop signal)
|
||||
// SSE encerrado normalmente (stop signal)
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_warn!("WebSocket falhou: {e}. Usando polling HTTP...");
|
||||
is_using_ws.store(false, Ordering::Relaxed);
|
||||
crate::log_warn!("SSE falhou: {e}. Usando polling HTTP...");
|
||||
is_using_sse.store(false, Ordering::Relaxed);
|
||||
|
||||
// Executar polling HTTP por 5 minutos, depois tentar WebSocket novamente
|
||||
// Executar polling HTTP por 5 minutos, depois tentar SSE novamente
|
||||
let poll_duration = Duration::from_secs(300); // 5 minutos
|
||||
let poll_result = run_polling_loop(
|
||||
&base_clone,
|
||||
|
|
@ -542,7 +564,7 @@ impl ChatRuntime {
|
|||
break;
|
||||
}
|
||||
|
||||
crate::log_info!("Tentando reconectar WebSocket...");
|
||||
crate::log_info!("Tentando reconectar SSE...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -562,7 +584,7 @@ impl ChatRuntime {
|
|||
if let Some(handle) = guard.take() {
|
||||
handle.stop();
|
||||
}
|
||||
self.is_using_ws.store(false, Ordering::Relaxed);
|
||||
self.is_using_sse.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn get_sessions(&self) -> Vec<ChatSession> {
|
||||
|
|
@ -571,111 +593,144 @@ impl ChatRuntime {
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
// WS LOOP
|
||||
// SSE LOOP
|
||||
// ============================================================================
|
||||
|
||||
async fn run_ws_loop(
|
||||
/// Cliente HTTP para SSE com timeout mais longo (conexao persistente)
|
||||
static SSE_CLIENT: Lazy<Client> = Lazy::new(|| {
|
||||
Client::builder()
|
||||
.user_agent("raven-chat-sse/1.0")
|
||||
.timeout(Duration::from_secs(120)) // Timeout longo para SSE
|
||||
.connect_timeout(Duration::from_secs(15))
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.expect("failed to build SSE http client")
|
||||
});
|
||||
|
||||
async fn run_sse_loop(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
app: &tauri::AppHandle,
|
||||
last_sessions: &Arc<Mutex<Vec<ChatSession>>>,
|
||||
last_unread_count: &Arc<Mutex<u32>>,
|
||||
is_using_ws: &Arc<AtomicBool>,
|
||||
is_using_sse: &Arc<AtomicBool>,
|
||||
stop_flag: &Arc<AtomicBool>,
|
||||
) -> Result<(), String> {
|
||||
let ws_url = build_ws_url(base_url, token)?;
|
||||
crate::log_info!("Conectando WebSocket: {}", ws_url);
|
||||
let sse_url = format!("{}/api/machines/chat/stream?token={}", base_url, token);
|
||||
crate::log_info!("Conectando SSE: {}", sse_url);
|
||||
|
||||
let (ws_stream, _) = connect_async(ws_url)
|
||||
// Iniciar request SSE
|
||||
let response = SSE_CLIENT
|
||||
.get(&sse_url)
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Falha ao conectar WebSocket: {e}"))?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
.map_err(|e| format!("Falha ao conectar SSE: {e}"))?;
|
||||
|
||||
// Ativar ping periódico para manter conexão viva
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_secs(45));
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("SSE falhou: status={}, body={}", status, body));
|
||||
}
|
||||
|
||||
is_using_ws.store(true, Ordering::Relaxed);
|
||||
crate::log_info!("WebSocket conectado com sucesso");
|
||||
is_using_sse.store(true, Ordering::Relaxed);
|
||||
crate::log_info!("SSE conectado com sucesso");
|
||||
|
||||
// Stream de bytes
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buffer = String::new();
|
||||
let mut line_buffer = String::new();
|
||||
|
||||
// Timeout para detectar conexao morta (60s sem dados = reconectar)
|
||||
let mut last_data_time = std::time::Instant::now();
|
||||
let max_silence = Duration::from_secs(60);
|
||||
|
||||
loop {
|
||||
if stop_flag.load(Ordering::Relaxed) {
|
||||
crate::log_info!("WebSocket encerrado por stop flag");
|
||||
let _ = write.send(Message::Close(None)).await;
|
||||
crate::log_info!("SSE encerrado por stop flag");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = heartbeat.tick() => {
|
||||
let _ = write.send(Message::Ping(Vec::new())).await;
|
||||
}
|
||||
msg = read.next() => {
|
||||
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");
|
||||
}
|
||||
// Verificar timeout de silencio
|
||||
if last_data_time.elapsed() > max_silence {
|
||||
crate::log_warn!("SSE: timeout de silencio ({}s sem dados)", max_silence.as_secs());
|
||||
return Err("SSE timeout - sem dados".to_string());
|
||||
}
|
||||
|
||||
// Aguardar proximo chunk com timeout
|
||||
let chunk_result = tokio::time::timeout(
|
||||
Duration::from_secs(35), // Heartbeat do servidor e a cada 30s
|
||||
stream.next()
|
||||
).await;
|
||||
|
||||
match chunk_result {
|
||||
Ok(Some(Ok(bytes))) => {
|
||||
last_data_time = std::time::Instant::now();
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
line_buffer.push_str(&text);
|
||||
|
||||
// Processar linhas completas
|
||||
while let Some(newline_pos) = line_buffer.find('\n') {
|
||||
let line = line_buffer[..newline_pos].trim_end_matches('\r').to_string();
|
||||
line_buffer = line_buffer[newline_pos + 1..].to_string();
|
||||
|
||||
// Linha vazia = fim do evento
|
||||
if line.is_empty() {
|
||||
buffer.clear();
|
||||
continue;
|
||||
}
|
||||
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());
|
||||
|
||||
// Parsear evento SSE
|
||||
if let Some(event) = parse_sse_line(&mut buffer, &line) {
|
||||
handle_sse_event(
|
||||
&event,
|
||||
base_url,
|
||||
token,
|
||||
app,
|
||||
last_sessions,
|
||||
last_unread_count,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
crate::log_warn!("SSE erro de stream: {e}");
|
||||
return Err(format!("Erro SSE: {e}"));
|
||||
}
|
||||
Ok(None) => {
|
||||
crate::log_info!("SSE: stream encerrado pelo servidor");
|
||||
return Err("SSE encerrado".to_string());
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout aguardando chunk - verificar se conexao ainda viva
|
||||
if last_data_time.elapsed() > max_silence {
|
||||
return Err("SSE timeout".to_string());
|
||||
}
|
||||
// Caso contrario, continuar aguardando
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
async fn handle_sse_event(
|
||||
event: &SseEvent,
|
||||
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() {
|
||||
match event.event.as_str() {
|
||||
"connected" => {
|
||||
crate::log_info!("WebSocket: conectado");
|
||||
crate::log_info!("SSE: conectado");
|
||||
}
|
||||
"heartbeat" => {
|
||||
// noop
|
||||
// noop - apenas mantem conexao viva
|
||||
}
|
||||
"update" => {
|
||||
let update: WsUpdateEvent = serde_json::from_value(envelope.data)
|
||||
let update: SseUpdateEvent = serde_json::from_str(&event.data)
|
||||
.map_err(|e| format!("Payload update inválido: {e}"))?;
|
||||
process_chat_update(
|
||||
base_url,
|
||||
|
|
@ -689,15 +744,15 @@ async fn handle_ws_event(
|
|||
.await;
|
||||
}
|
||||
"error" => {
|
||||
let message = envelope
|
||||
.data
|
||||
let error_data: Value = serde_json::from_str(&event.data).unwrap_or_default();
|
||||
let message = error_data
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Erro WebSocket");
|
||||
.unwrap_or("Erro SSE");
|
||||
return Err(message.to_string());
|
||||
}
|
||||
_ => {
|
||||
crate::log_info!("WebSocket: evento desconhecido {}", envelope.event);
|
||||
crate::log_info!("SSE: evento desconhecido {}", event.event);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -924,7 +979,8 @@ fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str) -> Result<
|
|||
(100.0, 100.0)
|
||||
};
|
||||
|
||||
let url_path = format!("/chat?ticketId={}", ticket_id);
|
||||
// Usar query param ao inves de path para compatibilidade com SPA
|
||||
let url_path = format!("index.html?view=chat&ticketId={}", ticket_id);
|
||||
|
||||
WebviewWindowBuilder::new(
|
||||
app,
|
||||
|
|
|
|||
|
|
@ -245,8 +245,8 @@ fn stop_chat_polling(state: tauri::State<ChatRuntime>) -> Result<(), String> {
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_chat_using_ws(state: tauri::State<ChatRuntime>) -> bool {
|
||||
state.is_using_ws()
|
||||
fn is_chat_using_realtime(state: tauri::State<ChatRuntime>) -> bool {
|
||||
state.is_using_sse()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -492,7 +492,7 @@ pub fn run() {
|
|||
// Chat commands
|
||||
start_chat_polling,
|
||||
stop_chat_polling,
|
||||
is_chat_using_ws,
|
||||
is_chat_using_realtime,
|
||||
get_chat_sessions,
|
||||
fetch_chat_sessions,
|
||||
fetch_chat_messages,
|
||||
|
|
@ -632,7 +632,7 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
|
|||
async fn try_start_background_agent(
|
||||
app: &tauri::AppHandle,
|
||||
agent_runtime: AgentRuntime,
|
||||
_chat_runtime: ChatRuntime,
|
||||
chat_runtime: ChatRuntime,
|
||||
) -> Result<(), String> {
|
||||
log_info!("Verificando credenciais salvas para iniciar agente...");
|
||||
|
||||
|
|
@ -687,7 +687,18 @@ async fn try_start_background_agent(
|
|||
)
|
||||
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
|
||||
|
||||
log_info!("Agente iniciado com sucesso em background (chat via Convex WebSocket no frontend)");
|
||||
// Iniciar sistema de chat (WebSocket + fallback HTTP 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 em background: {e}");
|
||||
} else {
|
||||
log_info!("Chat iniciado com sucesso (WebSocket + fallback polling)");
|
||||
}
|
||||
|
||||
log_info!("Agente iniciado com sucesso em background");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
|
|||
const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null)
|
||||
const [hasSession, setHasSession] = useState(false)
|
||||
const [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
// Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente
|
||||
const [isMinimized, setIsMinimized] = useState(true)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -235,18 +236,26 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
|
|||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Mostrar chip compacto enquanto carrega (compativel com janela minimizada)
|
||||
// pointer-events-none no container para que a area transparente nao seja clicavel
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white">
|
||||
<Loader2 className="size-8 animate-spin text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-500">Carregando chat...</p>
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-sm font-medium">Carregando...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Mostrar chip compacto de erro (compativel com janela minimizada)
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||
<X className="size-4" />
|
||||
<span className="text-sm font-medium">Erro no chat</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -254,8 +263,8 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
|
|||
// Quando não há sessão, mostrar versão minimizada com indicador de offline
|
||||
if (!hasSession) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-end justify-end bg-transparent">
|
||||
<div className="flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{ticketInfo ? `Chat #${ticketInfo.ref}` : "Chat"}
|
||||
|
|
@ -268,12 +277,13 @@ export function ChatWidget({ ticketId }: ChatWidgetProps) {
|
|||
}
|
||||
|
||||
// Versão minimizada (chip compacto igual web)
|
||||
// pointer-events-none no container para que apenas o botao seja clicavel
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-end justify-end bg-transparent">
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent">
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
className="relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
|
||||
className="pointer-events-auto relative flex items-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow-lg hover:bg-black/90"
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -1728,12 +1728,13 @@ function StatusBadge({ status, className }: { status: string | null; className?:
|
|||
)
|
||||
}
|
||||
|
||||
// Roteamento simples baseado no path
|
||||
// Roteamento simples baseado em query params (compativel com Tauri SPA)
|
||||
function RootApp() {
|
||||
const path = window.location.pathname
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const view = params.get("view")
|
||||
|
||||
// Rota /chat - janela de chat flutuante
|
||||
if (path === "/chat" || path.startsWith("/chat?")) {
|
||||
// Janela de chat flutuante (view=chat ou path=/chat para compatibilidade)
|
||||
if (view === "chat" || window.location.pathname === "/chat") {
|
||||
return <ChatApp />
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue