Implementa sistema de chat em tempo real entre agente e cliente

- Adiciona tabela liveChatSessions no schema Convex
- Cria convex/liveChat.ts com mutations e queries para chat
- Adiciona API routes para maquinas (sessions, messages, poll)
- Cria modulo chat.rs no Tauri com ChatRuntime e polling
- Adiciona comandos de chat no lib.rs (start/stop polling, open/close window)
- Cria componentes React do chat widget (ChatWidget, types)
- Adiciona botao "Iniciar Chat" no dashboard (ticket-chat-panel)
- Implementa menu de chat no system tray
- Polling de 2 segundos para maior responsividade
- Janela de chat flutuante, frameless, always-on-top

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-07 01:00:27 -03:00
parent 0c8d53c0b6
commit ba91c1e0f5
15 changed files with 2004 additions and 15 deletions

View file

@ -11,6 +11,9 @@
"gen:icon": "node ./scripts/build-icon.mjs"
},
"dependencies": {
"@assistant-ui/react": "^0.7.0",
"@assistant-ui/react-markdown": "^0.3.0",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
@ -19,7 +22,8 @@
"@tauri-apps/plugin-updater": "^2",
"lucide-react": "^0.544.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2",

View file

@ -0,0 +1,482 @@
//! Modulo de Chat em Tempo Real
//!
//! Este modulo implementa o sistema de chat entre agentes (dashboard web)
//! e clientes (Raven desktop). Inclui polling de mensagens, gerenciamento
//! de janelas de chat e emissao de eventos.
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tauri::async_runtime::JoinHandle;
use tauri::{Emitter, Manager, WebviewWindowBuilder, WebviewUrl};
use tokio::sync::Notify;
// ============================================================================
// TYPES
// ============================================================================
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatSession {
pub session_id: String,
pub ticket_id: String,
pub ticket_ref: u64,
pub ticket_subject: String,
pub agent_name: String,
pub agent_email: Option<String>,
pub agent_avatar_url: Option<String>,
pub unread_count: u32,
pub last_activity_at: i64,
pub started_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatMessage {
pub id: String,
pub body: String,
pub author_name: String,
pub author_avatar_url: Option<String>,
pub is_from_machine: bool,
pub created_at: i64,
pub attachments: Vec<ChatAttachment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatAttachment {
pub storage_id: String,
pub name: String,
pub size: Option<u64>,
#[serde(rename = "type")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatPollResponse {
pub has_active_sessions: bool,
pub sessions: Vec<ChatSessionSummary>,
pub total_unread: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatSessionSummary {
pub ticket_id: String,
pub unread_count: u32,
pub last_activity_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatMessagesResponse {
pub messages: Vec<ChatMessage>,
pub has_session: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendMessageResponse {
pub message_id: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionStartedEvent {
pub session: ChatSession,
}
// ============================================================================
// HTTP CLIENT
// ============================================================================
static CHAT_CLIENT: Lazy<Client> = Lazy::new(|| {
Client::builder()
.user_agent("raven-chat/1.0")
.timeout(Duration::from_secs(15))
.use_rustls_tls()
.build()
.expect("failed to build chat http client")
});
// ============================================================================
// API FUNCTIONS
// ============================================================================
pub async fn poll_chat_updates(
base_url: &str,
token: &str,
last_checked_at: Option<i64>,
) -> Result<ChatPollResponse, String> {
let url = format!("{}/api/machines/chat/poll", base_url);
let mut payload = serde_json::json!({
"machineToken": token,
});
if let Some(ts) = last_checked_at {
payload["lastCheckedAt"] = serde_json::json!(ts);
}
let response = CHAT_CLIENT
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| format!("Falha na requisicao de poll: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Poll falhou: status={}, body={}", status, body));
}
response
.json()
.await
.map_err(|e| format!("Falha ao parsear resposta de poll: {e}"))
}
pub async fn fetch_sessions(base_url: &str, token: &str) -> Result<Vec<ChatSession>, String> {
let url = format!("{}/api/machines/chat/sessions", base_url);
let payload = serde_json::json!({
"machineToken": token,
});
let response = CHAT_CLIENT
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| format!("Falha na requisicao de sessions: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Sessions falhou: status={}, body={}", status, body));
}
#[derive(Deserialize)]
struct SessionsResponse {
sessions: Vec<ChatSession>,
}
let data: SessionsResponse = response
.json()
.await
.map_err(|e| format!("Falha ao parsear resposta de sessions: {e}"))?;
Ok(data.sessions)
}
pub async fn fetch_messages(
base_url: &str,
token: &str,
ticket_id: &str,
since: Option<i64>,
) -> Result<ChatMessagesResponse, String> {
let url = format!("{}/api/machines/chat/messages", base_url);
let mut payload = serde_json::json!({
"machineToken": token,
"ticketId": ticket_id,
"action": "list",
});
if let Some(ts) = since {
payload["since"] = serde_json::json!(ts);
}
let response = CHAT_CLIENT
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| format!("Falha na requisicao de messages: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Messages falhou: status={}, body={}", status, body));
}
response
.json()
.await
.map_err(|e| format!("Falha ao parsear resposta de messages: {e}"))
}
pub async fn send_message(
base_url: &str,
token: &str,
ticket_id: &str,
body: &str,
) -> Result<SendMessageResponse, String> {
let url = format!("{}/api/machines/chat/messages", base_url);
let payload = serde_json::json!({
"machineToken": token,
"ticketId": ticket_id,
"action": "send",
"body": body,
});
let response = CHAT_CLIENT
.post(&url)
.json(&payload)
.send()
.await
.map_err(|e| format!("Falha na requisicao de send: {e}"))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Send falhou: status={}, body={}", status, body));
}
response
.json()
.await
.map_err(|e| format!("Falha ao parsear resposta de send: {e}"))
}
// ============================================================================
// CHAT RUNTIME
// ============================================================================
struct ChatPollerHandle {
stop_signal: Arc<Notify>,
join_handle: JoinHandle<()>,
}
impl ChatPollerHandle {
fn stop(self) {
self.stop_signal.notify_waiters();
self.join_handle.abort();
}
}
#[derive(Default, Clone)]
pub struct ChatRuntime {
inner: Arc<Mutex<Option<ChatPollerHandle>>>,
last_sessions: Arc<Mutex<Vec<ChatSession>>>,
}
impl ChatRuntime {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(None)),
last_sessions: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn start_polling(
&self,
base_url: String,
token: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let sanitized_base = base_url.trim().trim_end_matches('/').to_string();
if sanitized_base.is_empty() {
return Err("URL base invalida".to_string());
}
// Para polling existente
{
let mut guard = self.inner.lock();
if let Some(handle) = guard.take() {
handle.stop();
}
}
let stop_signal = Arc::new(Notify::new());
let stop_clone = stop_signal.clone();
let base_clone = sanitized_base.clone();
let token_clone = token.clone();
let last_sessions = self.last_sessions.clone();
let join_handle = tauri::async_runtime::spawn(async move {
crate::log_info!("Chat polling iniciado");
let mut last_checked_at: Option<i64> = None;
let poll_interval = Duration::from_secs(2); // Intervalo reduzido para maior responsividade
loop {
tokio::select! {
_ = stop_clone.notified() => {
crate::log_info!("Chat polling encerrado");
break;
}
_ = tokio::time::sleep(poll_interval) => {
match poll_chat_updates(&base_clone, &token_clone, last_checked_at).await {
Ok(result) => {
last_checked_at = Some(chrono::Utc::now().timestamp_millis());
if result.has_active_sessions {
// Verificar novas sessoes
let prev_sessions: Vec<String> = {
last_sessions.lock().iter().map(|s| s.session_id.clone()).collect()
};
// Buscar detalhes das sessoes
if let Ok(sessions) = fetch_sessions(&base_clone, &token_clone).await {
for session in &sessions {
if !prev_sessions.contains(&session.session_id) {
// Nova sessao! Emitir evento
crate::log_info!(
"Nova sessao de chat: ticket={}",
session.ticket_id
);
let _ = app.emit(
"raven://chat/session-started",
SessionStartedEvent {
session: session.clone(),
},
);
// Abrir janela de chat automaticamente
if let Err(e) = open_chat_window_internal(
&app,
&session.ticket_id,
) {
crate::log_error!(
"Falha ao abrir janela de chat: {e}"
);
}
}
}
// Atualizar cache
*last_sessions.lock() = sessions;
}
// Verificar mensagens nao lidas e emitir evento
if result.total_unread > 0 {
crate::log_info!(
"Chat: {} mensagens nao lidas",
result.total_unread
);
let _ = app.emit(
"raven://chat/unread-update",
serde_json::json!({
"totalUnread": result.total_unread,
"sessions": result.sessions
}),
);
}
} else {
// Sem sessoes ativas
*last_sessions.lock() = Vec::new();
}
}
Err(e) => {
crate::log_warn!("Falha no polling de chat: {e}");
}
}
}
}
}
});
let mut guard = self.inner.lock();
*guard = Some(ChatPollerHandle {
stop_signal,
join_handle,
});
Ok(())
}
pub fn stop(&self) {
let mut guard = self.inner.lock();
if let Some(handle) = guard.take() {
handle.stop();
}
}
pub fn get_sessions(&self) -> Vec<ChatSession> {
self.last_sessions.lock().clone()
}
}
// ============================================================================
// WINDOW MANAGEMENT
// ============================================================================
fn open_chat_window_internal(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let label = format!("chat-{}", ticket_id);
// Verificar se ja existe
if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
return Ok(());
}
// Obter tamanho da tela para posicionar no canto inferior direito
let monitors = app.available_monitors().map_err(|e| e.to_string())?;
let primary = monitors.into_iter().next();
let (x, y) = if let Some(monitor) = primary {
let size = monitor.size();
let scale = monitor.scale_factor();
let width = 380.0;
let height = 520.0;
let margin = 20.0;
let taskbar_height = 50.0;
(
(size.width as f64 / scale) - width - margin,
(size.height as f64 / scale) - height - margin - taskbar_height,
)
} else {
(100.0, 100.0)
};
let url_path = format!("/chat?ticketId={}", ticket_id);
WebviewWindowBuilder::new(
app,
&label,
WebviewUrl::App(url_path.into()),
)
.title("Chat de Suporte")
.inner_size(380.0, 520.0)
.min_inner_size(300.0, 400.0)
.position(x, y)
.decorations(false) // Frameless
.always_on_top(true)
.skip_taskbar(true)
.focused(true)
.visible(true)
.build()
.map_err(|e| e.to_string())?;
crate::log_info!("Janela de chat aberta: {}", label);
Ok(())
}
pub fn open_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
open_chat_window_internal(app, ticket_id)
}
pub fn close_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) {
window.close().map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn minimize_chat_window(app: &tauri::AppHandle, ticket_id: &str) -> Result<(), String> {
let label = format!("chat-{}", ticket_id);
if let Some(window) = app.get_webview_window(&label) {
window.hide().map_err(|e| e.to_string())?;
}
Ok(())
}

View file

@ -1,9 +1,11 @@
mod agent;
mod chat;
#[cfg(target_os = "windows")]
mod rustdesk;
mod usb_control;
use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile};
use chat::{ChatRuntime, ChatSession, ChatMessagesResponse, SendMessageResponse};
use chrono::Local;
use usb_control::{UsbPolicy, UsbPolicyResult};
use tauri::{Emitter, Manager, WindowEvent};
@ -222,10 +224,76 @@ fn refresh_usb_policy() -> Result<(), String> {
usb_control::refresh_group_policy().map_err(|e| e.to_string())
}
// ============================================================================
// COMANDOS DE CHAT
// ============================================================================
#[tauri::command]
fn start_chat_polling(
state: tauri::State<ChatRuntime>,
app: tauri::AppHandle,
base_url: String,
token: String,
) -> Result<(), String> {
state.start_polling(base_url, token, app)
}
#[tauri::command]
fn stop_chat_polling(state: tauri::State<ChatRuntime>) -> Result<(), String> {
state.stop();
Ok(())
}
#[tauri::command]
fn get_chat_sessions(state: tauri::State<ChatRuntime>) -> Vec<ChatSession> {
state.get_sessions()
}
#[tauri::command]
async fn fetch_chat_sessions(base_url: String, token: String) -> Result<Vec<ChatSession>, String> {
chat::fetch_sessions(&base_url, &token).await
}
#[tauri::command]
async fn fetch_chat_messages(
base_url: String,
token: String,
ticket_id: String,
since: Option<i64>,
) -> Result<ChatMessagesResponse, String> {
chat::fetch_messages(&base_url, &token, &ticket_id, since).await
}
#[tauri::command]
async fn send_chat_message(
base_url: String,
token: String,
ticket_id: String,
body: String,
) -> Result<SendMessageResponse, String> {
chat::send_message(&base_url, &token, &ticket_id, &body).await
}
#[tauri::command]
fn open_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> {
chat::open_chat_window(&app, &ticket_id)
}
#[tauri::command]
fn close_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> {
chat::close_chat_window(&app, &ticket_id)
}
#[tauri::command]
fn minimize_chat_window(app: tauri::AppHandle, ticket_id: String) -> Result<(), String> {
chat::minimize_chat_window(&app, &ticket_id)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AgentRuntime::new())
.manage(ChatRuntime::new())
.plugin(tauri_plugin_opener::init())
.plugin(StorePluginBuilder::default().build())
.plugin(tauri_plugin_updater::Builder::new().build())
@ -249,13 +317,14 @@ pub fn run() {
setup_raven_autostart();
setup_tray(&app.handle())?;
// Tenta iniciar o agente em background se houver credenciais salvas
// Tenta iniciar o agente e chat em background se houver credenciais salvas
let app_handle = app.handle().clone();
let runtime = app.state::<AgentRuntime>().inner().clone();
let agent_runtime = app.state::<AgentRuntime>().inner().clone();
let chat_runtime = app.state::<ChatRuntime>().inner().clone();
tauri::async_runtime::spawn(async move {
// Aguarda um pouco para o app estabilizar
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
if let Err(e) = try_start_background_agent(&app_handle, runtime).await {
if let Err(e) = try_start_background_agent(&app_handle, agent_runtime, chat_runtime).await {
log_warn!("Agente nao iniciado em background: {e}");
}
});
@ -272,7 +341,17 @@ pub fn run() {
ensure_rustdesk_and_emit,
apply_usb_policy,
get_usb_policy,
refresh_usb_policy
refresh_usb_policy,
// Chat commands
start_chat_polling,
stop_chat_polling,
get_chat_sessions,
fetch_chat_sessions,
fetch_chat_messages,
send_chat_message,
open_chat_window,
close_chat_window,
minimize_chat_window
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@ -333,9 +412,10 @@ fn setup_raven_autostart() {
#[cfg(target_os = "windows")]
fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
let show_item = MenuItemBuilder::with_id("show", "Mostrar").build(app)?;
let chat_item = MenuItemBuilder::with_id("chat", "Abrir Chat").build(app)?;
let quit_item = MenuItemBuilder::with_id("quit", "Sair").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&show_item, &quit_item])
.items(&[&show_item, &chat_item, &quit_item])
.build()?;
let mut builder = TrayIconBuilder::new()
@ -348,6 +428,17 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
let _ = win.set_focus();
}
}
"chat" => {
// Abrir janela de chat se houver sessao ativa
if let Some(chat_runtime) = tray.app_handle().try_state::<ChatRuntime>() {
let sessions = chat_runtime.get_sessions();
if let Some(session) = sessions.first() {
if let Err(e) = chat::open_chat_window(tray.app_handle(), &session.ticket_id) {
log_error!("Falha ao abrir janela de chat: {e}");
}
}
}
}
"quit" => {
tray.app_handle().exit(0);
}
@ -376,7 +467,8 @@ fn setup_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
async fn try_start_background_agent(
app: &tauri::AppHandle,
runtime: AgentRuntime,
agent_runtime: AgentRuntime,
chat_runtime: ChatRuntime,
) -> Result<(), String> {
log_info!("Verificando credenciais salvas para iniciar agente...");
@ -422,7 +514,7 @@ async fn try_start_background_agent(
interval
);
runtime
agent_runtime
.start_heartbeat(
api_base_url.to_string(),
token.to_string(),
@ -432,5 +524,17 @@ async fn try_start_background_agent(
.map_err(|e| format!("Falha ao iniciar heartbeat: {e}"))?;
log_info!("Agente iniciado com sucesso em background");
// Iniciar chat polling
if let Err(e) = chat_runtime.start_polling(
api_base_url.to_string(),
token.to_string(),
app.clone(),
) {
log_warn!("Falha ao iniciar chat polling: {e}");
} else {
log_info!("Chat polling iniciado com sucesso");
}
Ok(())
}

View file

@ -0,0 +1,363 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Store } from "@tauri-apps/plugin-store"
import { Send, X, Minus, Loader2, Headphones } from "lucide-react"
import type { ChatMessage, ChatMessagesResponse, SendMessageResponse } from "./types"
interface ChatWidgetProps {
ticketId: string
}
export function ChatWidget({ ticketId }: ChatWidgetProps) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [isSending, setIsSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ticketInfo, setTicketInfo] = useState<{ ref: number; subject: string; agentName: string } | null>(null)
const [hasSession, setHasSession] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const lastFetchRef = useRef<number>(0)
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Scroll para o final quando novas mensagens chegam
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [])
useEffect(() => {
scrollToBottom()
}, [messages, scrollToBottom])
// Carregar configuracao do store
const loadConfig = useCallback(async () => {
try {
const store = await Store.load("machine-agent.json")
const token = await store.get<string>("token")
const config = await store.get<{ apiBaseUrl: string }>("config")
if (!token || !config?.apiBaseUrl) {
setError("Maquina nao registrada")
setIsLoading(false)
return null
}
return { token, baseUrl: config.apiBaseUrl }
} catch (err) {
setError("Erro ao carregar configuracao")
setIsLoading(false)
return null
}
}, [])
// Buscar mensagens
const fetchMessages = useCallback(async (baseUrl: string, token: string, since?: number) => {
try {
const response = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
baseUrl,
token,
ticketId,
since: since ?? null,
})
setHasSession(response.hasSession)
if (response.messages.length > 0) {
if (since) {
// Adicionar apenas novas mensagens
setMessages(prev => {
const existingIds = new Set(prev.map(m => m.id))
const newMsgs = response.messages.filter(m => !existingIds.has(m.id))
return [...prev, ...newMsgs]
})
} else {
// Primeira carga
setMessages(response.messages)
}
lastFetchRef.current = Math.max(...response.messages.map(m => m.createdAt))
}
return response
} catch (err) {
console.error("Erro ao buscar mensagens:", err)
return null
}
}, [ticketId])
// Buscar info da sessao
const fetchSessionInfo = useCallback(async (baseUrl: string, token: string) => {
try {
const sessions = await invoke<Array<{
ticketId: string
ticketRef: number
ticketSubject: string
agentName: string
}>>("fetch_chat_sessions", { baseUrl, token })
const session = sessions.find(s => s.ticketId === ticketId)
if (session) {
setTicketInfo({
ref: session.ticketRef,
subject: session.ticketSubject,
agentName: session.agentName,
})
}
} catch (err) {
console.error("Erro ao buscar sessao:", err)
}
}, [ticketId])
// Inicializacao
useEffect(() => {
let mounted = true
const init = async () => {
const config = await loadConfig()
if (!config || !mounted) return
const { baseUrl, token } = config
// Buscar sessao e mensagens iniciais
await Promise.all([
fetchSessionInfo(baseUrl, token),
fetchMessages(baseUrl, token),
])
if (!mounted) return
setIsLoading(false)
// Iniciar polling (2 segundos para maior responsividade)
pollIntervalRef.current = setInterval(async () => {
await fetchMessages(baseUrl, token, lastFetchRef.current)
}, 2000)
}
init()
// Listener para eventos de nova mensagem do Tauri
const unlistenPromise = listen<{ ticketId: string; message: ChatMessage }>(
"raven://chat/new-message",
(event) => {
if (event.payload.ticketId === ticketId) {
setMessages(prev => {
if (prev.some(m => m.id === event.payload.message.id)) {
return prev
}
return [...prev, event.payload.message]
})
}
}
)
return () => {
mounted = false
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current)
}
unlistenPromise.then(unlisten => unlisten())
}
}, [ticketId, loadConfig, fetchMessages, fetchSessionInfo])
// Enviar mensagem
const handleSend = async () => {
if (!inputValue.trim() || isSending) return
const messageText = inputValue.trim()
setInputValue("")
setIsSending(true)
try {
const config = await loadConfig()
if (!config) {
setIsSending(false)
return
}
const response = await invoke<SendMessageResponse>("send_chat_message", {
baseUrl: config.baseUrl,
token: config.token,
ticketId,
body: messageText,
})
// Adicionar mensagem localmente
setMessages(prev => [...prev, {
id: response.messageId,
body: messageText,
authorName: "Voce",
isFromMachine: true,
createdAt: response.createdAt,
attachments: [],
}])
lastFetchRef.current = response.createdAt
} catch (err) {
console.error("Erro ao enviar mensagem:", err)
// Restaurar input em caso de erro
setInputValue(messageText)
} finally {
setIsSending(false)
}
}
const handleMinimize = () => {
invoke("minimize_chat_window", { ticketId })
}
const handleClose = () => {
invoke("close_chat_window", { ticketId })
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
if (isLoading) {
return (
<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>
)
}
if (error) {
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>
)
}
if (!hasSession) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
<p className="text-sm text-slate-500">Nenhuma sessao de chat ativa</p>
</div>
)
}
return (
<div className="flex h-screen flex-col bg-white">
{/* Header - arrastavel */}
<div
data-tauri-drag-region
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3"
>
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-black text-white">
<Headphones className="size-5" />
</div>
<div>
<p className="text-sm font-semibold text-slate-900">
{ticketInfo?.agentName ?? "Suporte"}
</p>
{ticketInfo && (
<p className="text-xs text-slate-500">
Chamado #{ticketInfo.ref}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleMinimize}
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<Minus className="size-4" />
</button>
<button
onClick={handleClose}
className="rounded p-1.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
>
<X className="size-4" />
</button>
</div>
</div>
{/* Mensagens */}
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<p className="text-sm text-slate-400">
Nenhuma mensagem ainda
</p>
<p className="mt-1 text-xs text-slate-400">
O agente iniciara a conversa em breve
</p>
</div>
) : (
<div className="space-y-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.isFromMachine ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
msg.isFromMachine
? "bg-black text-white"
: "bg-slate-100 text-slate-900"
}`}
>
{!msg.isFromMachine && (
<p className="mb-1 text-xs font-medium text-slate-500">
{msg.authorName}
</p>
)}
<p className="whitespace-pre-wrap text-sm">{msg.body}</p>
<p
className={`mt-1 text-right text-xs ${
msg.isFromMachine ? "text-white/60" : "text-slate-400"
}`}
>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-slate-200 p-3">
<div className="flex items-end gap-2">
<textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="max-h-24 min-h-[40px] flex-1 resize-none rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-1 focus:ring-black"
rows={1}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isSending}
className="flex size-10 items-center justify-center rounded-lg bg-black text-white transition hover:bg-black/90 disabled:opacity-50"
>
{isSending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</button>
</div>
</div>
</div>
)
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
return date.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
})
}

View file

@ -0,0 +1,20 @@
import { ChatWidget } from "./ChatWidget"
export function ChatApp() {
// Obter ticketId da URL
const params = new URLSearchParams(window.location.search)
const ticketId = params.get("ticketId")
if (!ticketId) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-white p-4">
<p className="text-sm text-red-600">Erro: ticketId nao fornecido</p>
</div>
)
}
return <ChatWidget ticketId={ticketId} />
}
export { ChatWidget }
export * from "./types"

View file

@ -0,0 +1,45 @@
// Tipos para o sistema de chat
export interface ChatSession {
sessionId: string
ticketId: string
ticketRef: number
ticketSubject: string
agentName: string
agentEmail?: string
agentAvatarUrl?: string
unreadCount: number
lastActivityAt: number
startedAt: number
}
export interface ChatMessage {
id: string
body: string
authorName: string
authorAvatarUrl?: string
isFromMachine: boolean
createdAt: number
attachments: ChatAttachment[]
}
export interface ChatAttachment {
storageId: string
name: string
size?: number
type?: string
}
export interface ChatMessagesResponse {
messages: ChatMessage[]
hasSession: boolean
}
export interface SendMessageResponse {
messageId: string
createdAt: number
}
export interface SessionStartedEvent {
session: ChatSession
}

View file

@ -8,6 +8,7 @@ import { appLocalDataDir, join } from "@tauri-apps/api/path"
import { ExternalLink, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs"
import { cn } from "./lib/utils"
import { ChatApp } from "./chat"
import { DeactivationScreen } from "./components/DeactivationScreen"
type MachineOs = {
@ -1642,5 +1643,18 @@ function StatusBadge({ status, className }: { status: string | null; className?:
)
}
// Roteamento simples baseado no path
function RootApp() {
const path = window.location.pathname
// Rota /chat - janela de chat flutuante
if (path === "/chat" || path.startsWith("/chat?")) {
return <ChatApp />
}
// Rota padrao - aplicacao principal
return <App />
}
const root = document.getElementById("root") || (() => { const el = document.createElement("div"); el.id = "root"; document.body.appendChild(el); return el })()
createRoot(root).render(<App />)
createRoot(root).render(<RootApp />)