feat(chat): desktop usando Convex WS direto e fallback WS dedicado
This commit is contained in:
parent
8db7c3c810
commit
a8f5ff9d51
14 changed files with 735 additions and 458 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
171
apps/desktop/src-tauri/Cargo.lock
generated
171
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,90 +571,138 @@ 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))) => {
|
|
||||||
crate::log_info!("SSE: conexao aberta");
|
|
||||||
}
|
}
|
||||||
Ok(Some(Ok(Event::Message(msg)))) => {
|
msg = read.next() => {
|
||||||
let event_type = msg.event.as_str();
|
match msg {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
match event_type {
|
if let Ok(parsed) = serde_json::from_str::<WsEnvelope>(&text) {
|
||||||
"connected" => {
|
handle_ws_event(
|
||||||
crate::log_info!("SSE: evento connected recebido");
|
parsed,
|
||||||
}
|
|
||||||
"heartbeat" => {
|
|
||||||
// Ignorar heartbeats silenciosamente
|
|
||||||
}
|
|
||||||
"update" => {
|
|
||||||
// Processar update de chat
|
|
||||||
if let Ok(update) = serde_json::from_str::<SseUpdateEvent>(&msg.data) {
|
|
||||||
process_chat_update(
|
|
||||||
base_url,
|
base_url,
|
||||||
token,
|
token,
|
||||||
app,
|
app,
|
||||||
last_sessions,
|
last_sessions,
|
||||||
last_unread_count,
|
last_unread_count,
|
||||||
update.has_active_sessions,
|
).await?;
|
||||||
update.total_unread,
|
} else {
|
||||||
)
|
crate::log_warn!("WebSocket: payload inválido");
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"error" => {
|
Some(Ok(Message::Close(_))) => {
|
||||||
crate::log_warn!("SSE: erro recebido do servidor: {}", msg.data);
|
crate::log_info!("WebSocket: conexão encerrada pelo servidor");
|
||||||
return Err(format!("Erro SSE do servidor: {}", msg.data));
|
return Err("WebSocket fechado pelo servidor".to_string());
|
||||||
}
|
}
|
||||||
_ => {
|
Some(Ok(_)) => {
|
||||||
crate::log_info!("SSE: evento desconhecido: {}", event_type);
|
// 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(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" => {
|
||||||
|
crate::log_info!("WebSocket: conectado");
|
||||||
|
}
|
||||||
|
"heartbeat" => {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
"update" => {
|
||||||
|
let update: WsUpdateEvent = serde_json::from_value(envelope.data)
|
||||||
|
.map_err(|e| format!("Payload update inválido: {e}"))?;
|
||||||
|
process_chat_update(
|
||||||
|
base_url,
|
||||||
|
token,
|
||||||
|
app,
|
||||||
|
last_sessions,
|
||||||
|
last_unread_count,
|
||||||
|
update.has_active_sessions,
|
||||||
|
update.total_unread,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
"error" => {
|
||||||
|
let message = envelope
|
||||||
|
.data
|
||||||
|
.get("message")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Erro WebSocket");
|
||||||
|
return Err(message.to_string());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
crate::log_info!("WebSocket: evento desconhecido {}", envelope.event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HTTP POLLING LOOP (FALLBACK)
|
// HTTP POLLING LOOP (FALLBACK)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
],
|
],
|
||||||
"dialog": true,
|
"dialog": true,
|
||||||
"active": true,
|
"active": true,
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5MTMxRTQwODA1NEFCRjAKUldUd3ExU0FRQjRUR2VqcHBNdXhBMUV3WlM2cFA4dmNnNEhtMUJ2a3VVWVlTQnoxbEo5YUtlUTMK"
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZDRTBFNkY1NUQ3QzU0QkEKUldTNlZIeGQ5ZWJnYk5mY0J4aWRlb0dRdVZ4TGpBSUZXMnRVUFhmdmlLT0tlY084UjJQUHFWWUkK"
|
||||||
},
|
},
|
||||||
"deep-link": {
|
"deep-link": {
|
||||||
"desktop": {
|
"desktop": {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
|
||||||
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) {
|
|
||||||
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,
|
|
||||||
since: since ?? null,
|
|
||||||
})
|
|
||||||
|
|
||||||
setHasSession(response.hasSession)
|
|
||||||
|
|
||||||
if (response.messages.length > 0) {
|
|
||||||
if (since) {
|
|
||||||
// Adicionar apenas novas mensagens (com limite para evitar memory leak)
|
|
||||||
setMessages(prev => {
|
|
||||||
const existingIds = new Set(prev.map(m => m.id))
|
|
||||||
const newMsgs = response.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)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Primeira carga (já limitada)
|
|
||||||
setMessages(response.messages.slice(-MAX_MESSAGES_IN_MEMORY))
|
|
||||||
}
|
|
||||||
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<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(() => {
|
useEffect(() => {
|
||||||
let mounted = true
|
setIsLoading(true)
|
||||||
|
setMessages([])
|
||||||
|
messagesSubRef.current?.()
|
||||||
|
|
||||||
const init = async () => {
|
subscribeMachineMessages(
|
||||||
const config = await loadConfig()
|
ticketId,
|
||||||
if (!config || !mounted) return
|
(payload) => {
|
||||||
|
setIsLoading(false)
|
||||||
const { baseUrl, token } = config
|
setHasSession(payload.hasSession)
|
||||||
|
hadSessionRef.current = hadSessionRef.current || payload.hasSession
|
||||||
// Buscar sessao e mensagens iniciais
|
const unread = payload.messages.filter(m => !m.isFromMachine).length
|
||||||
await Promise.all([
|
setUnreadCount(unread)
|
||||||
fetchSessionInfo(baseUrl, token),
|
setMessages(prev => {
|
||||||
fetchMessages(baseUrl, token),
|
const existingIds = new Set(prev.map(m => m.id))
|
||||||
])
|
const combined = [...prev, ...payload.messages.filter(m => !existingIds.has(m.id))]
|
||||||
|
return combined.slice(-MAX_MESSAGES_IN_MEMORY)
|
||||||
if (!mounted) return
|
})
|
||||||
setIsLoading(false)
|
// Atualiza info basica do ticket
|
||||||
|
if (payload.messages.length > 0) {
|
||||||
// Iniciar polling (2 segundos para maior responsividade)
|
const first = payload.messages[0]
|
||||||
pollIntervalRef.current = setInterval(async () => {
|
setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" })
|
||||||
await fetchMessages(baseUrl, token, lastFetchRef.current)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
init()
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
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))
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setIsLoading(false)
|
||||||
|
setError(err.message || "Erro ao carregar mensagens.")
|
||||||
}
|
}
|
||||||
)
|
).then((unsub) => {
|
||||||
|
messagesSubRef.current = unsub
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false
|
messagesSubRef.current?.()
|
||||||
if (pollIntervalRef.current) {
|
messagesSubRef.current = null
|
||||||
clearInterval(pollIntervalRef.current)
|
|
||||||
}
|
|
||||||
unlistenNewMessage.then(unlisten => unlisten())
|
|
||||||
unlistenUnread.then(unlisten => unlisten())
|
|
||||||
}
|
}
|
||||||
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
|
}, [ticketId])
|
||||||
|
|
||||||
// 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
|
||||||
|
|
|
||||||
180
apps/desktop/src/chat/convexMachineClient.ts
Normal file
180
apps/desktop/src/chat/convexMachineClient.ts
Normal 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"
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
@ -234,11 +197,11 @@ export function ChatFloatingWidget({
|
||||||
|
|
||||||
// Widget expandido
|
// Widget expandido
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
<div className="fixed bottom-4 right-4 z-50 flex h-[520px] w-[380px] flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl">
|
||||||
{/* 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)}
|
||||||
|
|
|
||||||
1
bun.lock
1
bun.lock
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
130
scripts/chat-ws-server.mjs
Normal 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)
|
||||||
|
})
|
||||||
|
|
@ -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 "")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue