diff --git a/apps/desktop/src-tauri/src/chat.rs b/apps/desktop/src-tauri/src/chat.rs index 864e829..1ed8e2f 100644 --- a/apps/desktop/src-tauri/src/chat.rs +++ b/apps/desktop/src-tauri/src/chat.rs @@ -83,6 +83,8 @@ pub struct ChatSessionSummary { pub struct ChatMessagesResponse { pub messages: Vec, pub has_session: bool, + #[serde(default)] + pub unread_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -259,6 +261,36 @@ pub async fn send_message( .map_err(|e| format!("Falha ao parsear resposta de send: {e}")) } +pub async fn mark_messages_read( + base_url: &str, + token: &str, + ticket_id: &str, + message_ids: &[String], +) -> Result<(), String> { + let url = format!("{}/api/machines/chat/read", base_url); + + let payload = serde_json::json!({ + "machineToken": token, + "ticketId": ticket_id, + "messageIds": message_ids, + }); + + let response = CHAT_CLIENT + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| format!("Falha na requisicao de mark read: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("Mark read falhou: status={}, body={}", status, body)); + } + + Ok(()) +} + // ============================================================================ // UPLOAD DE ARQUIVOS // ============================================================================ @@ -835,4 +867,3 @@ pub fn set_chat_minimized(app: &tauri::AppHandle, ticket_id: &str, minimized: bo crate::log_info!("Chat {} -> minimized={}", ticket_id, minimized); Ok(()) } - diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index fc97ecb..099d986 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -284,6 +284,19 @@ async fn send_chat_message( chat::send_message(&base_url, &token, &ticket_id, &body, attachments).await } +#[tauri::command] +async fn mark_chat_messages_read( + base_url: String, + token: String, + ticket_id: String, + message_ids: Vec, +) -> Result<(), String> { + if message_ids.is_empty() { + return Ok(()); + } + chat::mark_messages_read(&base_url, &token, &ticket_id, &message_ids).await +} + #[tauri::command] async fn upload_chat_file( base_url: String, @@ -501,6 +514,7 @@ pub fn run() { fetch_chat_sessions, fetch_chat_messages, send_chat_message, + mark_chat_messages_read, upload_chat_file, open_chat_window, close_chat_window, diff --git a/apps/desktop/src/chat/ChatWidget.tsx b/apps/desktop/src/chat/ChatWidget.tsx index 2c3b65b..97b4604 100644 --- a/apps/desktop/src/chat/ChatWidget.tsx +++ b/apps/desktop/src/chat/ChatWidget.tsx @@ -1,14 +1,10 @@ import { useCallback, useEffect, useRef, useState } from "react" import { open } from "@tauri-apps/plugin-dialog" import { invoke } from "@tauri-apps/api/core" +import { listen } from "@tauri-apps/api/event" import { Send, X, Loader2, MessageCircle, Paperclip, FileText, Image as ImageIcon, File, User, ChevronUp, Minimize2 } from "lucide-react" -import type { ChatMessage } from "./types" -import { - subscribeMachineMessages, - sendMachineMessage, - markMachineMessagesRead, - getMachineStoreConfig, -} from "./convexMachineClient" +import type { ChatMessage, ChatMessagesResponse, NewMessageEvent } from "./types" +import { getMachineStoreConfig } from "./machineStore" const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak @@ -56,7 +52,6 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { const [unreadCount, setUnreadCount] = useState(0) const messagesEndRef = useRef(null) - const messagesSubRef = useRef<(() => void) | null>(null) const hadSessionRef = useRef(false) // Scroll para o final quando novas mensagens chegam @@ -86,8 +81,78 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { isMinimizedRef.current = isMinimized }, [isMinimized]) - // Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions + const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null) + + const ensureConfig = useCallback(async () => { + const cfg = configRef.current ?? (await getMachineStoreConfig()) + configRef.current = cfg + return cfg + }, []) + + const loadMessages = useCallback(async () => { + try { + const cfg = await ensureConfig() + const result = await invoke("fetch_chat_messages", { + baseUrl: cfg.apiBaseUrl, + token: cfg.token, + ticketId, + since: null, + }) + + setHasSession(result.hasSession) + hadSessionRef.current = hadSessionRef.current || result.hasSession + setUnreadCount(result.unreadCount ?? 0) + setMessages(result.messages.slice(-MAX_MESSAGES_IN_MEMORY)) + + if (result.messages.length > 0) { + const first = result.messages[0] + setTicketInfo((prevInfo) => + prevInfo ?? { + ref: ticketRef ?? 0, + subject: "", + agentName: first.authorName ?? "Suporte", + } + ) + } + + setError(null) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message || "Erro ao carregar mensagens.") + } finally { + setIsLoading(false) + } + }, [ensureConfig, ticketId, ticketRef]) + + // Carregar mensagens na montagem / troca de ticket useEffect(() => { + setIsLoading(true) + setMessages([]) + setUnreadCount(0) + loadMessages() + }, [loadMessages]) + + // Recarregar quando o Rust sinalizar novas mensagens para este ticket + useEffect(() => { + let unlisten: (() => void) | null = null + listen("raven://chat/new-message", (event) => { + const sessions = event.payload?.sessions ?? [] + if (sessions.some((s) => s.ticketId === ticketId)) { + loadMessages() + } + }) + .then((u) => { + unlisten = u + }) + .catch((err) => console.error("Falha ao registrar listener new-message:", err)) + + return () => { + unlisten?.() + } + }, [ticketId, loadMessages]) + + // Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions + /* useEffect(() => { setIsLoading(true) setMessages([]) messagesSubRef.current?.() @@ -128,7 +193,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { messagesSubRef.current?.() messagesSubRef.current = null } - }, [ticketId]) // Removido isMinimized - evita memory leak de resubscriptions + }, [ticketId]) */ // Removido isMinimized - evita memory leak de resubscriptions // Sincroniza estado de minimizado com o tamanho da janela (apenas em resizes reais, nao na montagem) // O estado inicial isMinimized=true e definido no useState e nao deve ser sobrescrito na montagem @@ -158,10 +223,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { if (unreadCount === 0) return const unreadIds = 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)) + ensureConfig() + .then((cfg) => + invoke("mark_chat_messages_read", { + baseUrl: cfg.apiBaseUrl, + token: cfg.token, + ticketId, + messageIds: unreadIds, + }) + ) + .catch((err) => console.error("mark read falhou", err)) } // Nao setamos unreadCount aqui - o backend vai zerar unreadByMachine e a subscription vai atualizar - }, [isMinimized, messages, ticketId, unreadCount]) + }, [isMinimized, messages, ticketId, unreadCount, ensureConfig]) // Selecionar arquivo para anexar const handleAttach = async () => { @@ -217,7 +291,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) { try { const bodyToSend = messageText || (attachmentsToSend.length > 0 ? "[Anexo]" : "") - await sendMachineMessage({ + const cfg = await ensureConfig() + await invoke("send_chat_message", { + baseUrl: cfg.apiBaseUrl, + token: cfg.token, ticketId, body: bodyToSend, attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined, diff --git a/apps/desktop/src/chat/convexMachineClient.ts b/apps/desktop/src/chat/convexMachineClient.ts deleted file mode 100644 index 833284b..0000000 --- a/apps/desktop/src/chat/convexMachineClient.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { ConvexClient } from "convex/browser" -import type { FunctionReference } from "convex/server" -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; ticketRef: number; unreadCount: number; lastActivityAt: number }> - totalUnread: number -} - -// Nomes das functions no Convex (formato module:function) -const FN_CHECK_UPDATES = "liveChat:checkMachineUpdates" as const -const FN_LIST_MESSAGES = "liveChat:listMachineMessages" as const -const FN_POST_MESSAGE = "liveChat:postMachineMessage" as const -const FN_MARK_READ = "liveChat:markMachineMessagesRead" as const -const FN_UPLOAD_URL = "liveChat:generateMachineUploadUrl" as const - -async function loadStore(): Promise { - const appData = await appLocalDataDir() - const storePath = await join(appData, STORE_FILENAME) - const store = await Store.load(storePath) - const token = await store.get("token") - const config = await store.get("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 { - 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 - } - - // Fechar cliente antigo antes de criar novo (evita memory leak) - if (cached) { - try { - cached.client.close() - } catch { - // Ignora erro ao fechar cliente antigo - } - } - - 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 unknown as FunctionReference<"query">, - { machineToken: token }, - (value) => callback(value), - (err) => onError?.(err) - ) - - 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 unknown as FunctionReference<"query">, - { - machineToken: token, - ticketId, - }, - (value) => callback(value), - (err) => onError?.(err) - ) - - 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 unknown as FunctionReference<"mutation">, { - 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 unknown as FunctionReference<"mutation">, { - 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 unknown as FunctionReference<"action">, { - 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 -} diff --git a/apps/desktop/src/chat/machineStore.ts b/apps/desktop/src/chat/machineStore.ts new file mode 100644 index 0000000..4e0db43 --- /dev/null +++ b/apps/desktop/src/chat/machineStore.ts @@ -0,0 +1,52 @@ +import { Store } from "@tauri-apps/plugin-store" +import { appLocalDataDir, join } from "@tauri-apps/api/path" + +const STORE_FILENAME = "machine-agent.json" +const DEFAULT_API_BASE_URL = "https://tickets.esdrasrenan.com.br" + +type MachineStoreConfig = { + apiBaseUrl?: string + appUrl?: string + convexUrl?: string +} + +type MachineStoreData = { + token?: string + config?: MachineStoreConfig +} + +async function loadStore(): Promise { + const appData = await appLocalDataDir() + const storePath = await join(appData, STORE_FILENAME) + const store = await Store.load(storePath) + const token = await store.get("token") + const config = await store.get("config") + return { token: token ?? undefined, config: config ?? undefined } +} + +function normalizeUrl(value?: string | null, fallback?: string) { + const trimmed = (value ?? fallback ?? "").trim() + if (!trimmed) return fallback ?? "" + return trimmed.replace(/\/+$/, "") +} + +function resolveApiBaseUrl(config?: MachineStoreConfig): string { + const fromConfig = normalizeUrl(config?.apiBaseUrl, DEFAULT_API_BASE_URL) + return fromConfig || DEFAULT_API_BASE_URL +} + +function resolveAppUrl(config?: MachineStoreConfig, apiBaseUrl?: string): string { + const fromConfig = normalizeUrl(config?.appUrl, apiBaseUrl) + return fromConfig || apiBaseUrl || DEFAULT_API_BASE_URL +} + +export async function getMachineStoreConfig() { + const data = await loadStore() + if (!data.token) { + throw new Error("Token de maquina nao encontrado no store") + } + const apiBaseUrl = resolveApiBaseUrl(data.config) + const appUrl = resolveAppUrl(data.config, apiBaseUrl) + return { token: data.token, apiBaseUrl, appUrl } +} + diff --git a/apps/desktop/src/chat/types.ts b/apps/desktop/src/chat/types.ts index cddb5c6..8bf3f60 100644 --- a/apps/desktop/src/chat/types.ts +++ b/apps/desktop/src/chat/types.ts @@ -33,6 +33,7 @@ export interface ChatAttachment { export interface ChatMessagesResponse { messages: ChatMessage[] hasSession: boolean + unreadCount?: number } export interface SendMessageResponse { diff --git a/apps/desktop/src/components/ChatFloatingWidget.tsx b/apps/desktop/src/components/ChatFloatingWidget.tsx deleted file mode 100644 index d2a42d5..0000000 --- a/apps/desktop/src/components/ChatFloatingWidget.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react" -import { MessageCircle, X, Minus, Send, Loader2, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react" -import { cn } from "../lib/utils" -import type { ChatSession, ChatMessage, ChatHistorySession } from "../chat/types" -import { - subscribeMachineUpdates, - subscribeMachineMessages, - sendMachineMessage, - markMachineMessagesRead, -} from "../chat/convexMachineClient" - -interface ChatFloatingWidgetProps { - sessions: ChatSession[] - totalUnread: number - isOpen: boolean - onToggle: () => void - onMinimize: () => void -} - -export function ChatFloatingWidget({ - sessions, - totalUnread, - isOpen, - onToggle, - onMinimize, -}: ChatFloatingWidgetProps) { - const [selectedTicketId, setSelectedTicketId] = useState(null) - const [messages, setMessages] = useState([]) - const [inputValue, setInputValue] = useState("") - const [isLoading, setIsLoading] = useState(false) - const [isSending, setIsSending] = useState(false) - const [historyExpanded, setHistoryExpanded] = useState(false) - const [historySessions] = useState([]) - const [liveSessions, setLiveSessions] = useState(sessions) - const [liveUnread, setLiveUnread] = useState(totalUnread) - const sessionList = liveSessions.length > 0 ? liveSessions : sessions - - const messagesEndRef = useRef(null) - const messagesSubRef = useRef<(() => void) | null>(null) - const updatesSubRef = useRef<(() => void) | null>(null) - - // Selecionar ticket mais recente automaticamente - useEffect(() => { - const source = liveSessions.length > 0 ? liveSessions : sessions - if (source.length > 0 && !selectedTicketId) { - const sorted = [...source].sort((a, b) => b.lastActivityAt - a.lastActivityAt) - setSelectedTicketId(sorted[0].ticketId) - } - }, [sessions, liveSessions, selectedTicketId]) - - // Scroll para o final quando novas mensagens chegam - const scrollToBottom = useCallback(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) - }, []) - - useEffect(() => { - scrollToBottom() - }, [messages, scrollToBottom]) - - // Assinar updates de sessões/unread - useEffect(() => { - let cancelled = false - subscribeMachineUpdates( - (payload) => { - if (cancelled) return - const mapped: ChatSession[] = (payload.sessions ?? []).map((s) => ({ - sessionId: s.ticketId, - ticketId: s.ticketId, - ticketRef: 0, - ticketSubject: "", - agentName: "", - agentEmail: undefined, - agentAvatarUrl: undefined, - unreadCount: s.unreadCount, - lastActivityAt: s.lastActivityAt, - startedAt: 0, - })) - setLiveSessions(mapped) - setLiveUnread(payload.totalUnread ?? 0) - }, - (err) => console.error("chat updates erro:", err) - ).then((unsub) => { - updatesSubRef.current = unsub - }) - - return () => { - cancelled = true - updatesSubRef.current?.() - updatesSubRef.current = null - } - }, []) - - // Assinar mensagens do ticket selecionado - useEffect(() => { - if (!selectedTicketId || !isOpen) return - messagesSubRef.current?.() - setMessages([]) - setIsLoading(true) - - 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 - const handleSend = async () => { - if (!inputValue.trim() || isSending || !selectedTicketId) return - - const messageText = inputValue.trim() - setInputValue("") - setIsSending(true) - - try { - await sendMachineMessage({ ticketId: selectedTicketId, body: messageText }) - setMessages(prev => [...prev, { - id: crypto.randomUUID(), - body: messageText, - authorName: "Você", - isFromMachine: true, - createdAt: Date.now(), - attachments: [], - }]) - } catch (err) { - console.error("Erro ao enviar mensagem:", err) - setInputValue(messageText) - } finally { - setIsSending(false) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - handleSend() - } - } - - const currentSession = sessionList.find(s => s.ticketId === selectedTicketId) - - // Botao flutuante (fechado) - // DEBUG: Log do estado do widget - // console.log("[ChatFloatingWidget] Estado:", { - // isOpen, - // totalUnread: liveUnread, - // sessionsCount: liveSessions.length, - // }) - - if (!isOpen) { - return ( -
- {/* DEBUG: Indicador visual do estado */} -
- unread: {liveUnread} | sessions: {sessionList.length} -
- -
- ) - } - - // Widget expandido - return ( -
- {/* Header */} -
-
- {sessionList.length > 1 && selectedTicketId && ( - - )} -
- -
-
-

- {currentSession?.agentName ?? "Suporte"} -

- {currentSession && ( -

- Chamado #{currentSession.ticketRef} -

- )} -
-
-
- {/* Tabs de tickets (se houver mais de 1) */} - {sessionList.length > 1 && ( -
- {sessionList.slice(0, 3).map((session) => ( - - ))} - {sessionList.length > 3 && ( - +{sessionList.length - 3} - )} -
- )} - - -
-
- - {/* Selecao de ticket (se nenhum selecionado e ha multiplos) */} - {!selectedTicketId && sessionList.length > 1 ? ( -
-

Selecione um chamado:

-
- {sessionList.map((session) => ( - - ))} -
-
- ) : ( - <> - {/* Area de mensagens */} -
- {/* Historico de sessoes anteriores */} - {historySessions.length > 0 && ( -
- - {historyExpanded && ( -
- {historySessions.map((session) => ( -
-

{session.agentName}

-

{session.messages.length} mensagens

-
- ))} -
- )} -
- )} - - {isLoading ? ( -
- -

Carregando...

-
- ) : messages.length === 0 ? ( -
-

- Nenhuma mensagem ainda -

-

- O agente iniciara a conversa em breve -

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

- {msg.authorName} -

- )} -

{msg.body}

-

- {formatTime(msg.createdAt)} -

-
-
- ))} -
-
- )} -
- - {/* Input */} -
-
-