feat(desktop): implementa Convex React subscriptions para chat em tempo real
- Adiciona ConvexMachineProvider para autenticacao via machine token - Cria hooks customizados (useMachineSessions, useMachineMessages, etc) - Refatora ChatWidget e ChatHubWidget para usar useQuery/useMutation - Remove polling e dependencia de Tauri events para mensagens - Adiciona copia local dos arquivos _generated do Convex - Remove componentes obsoletos (ChatSessionItem, ChatSessionList) Beneficios: - Tempo real verdadeiro via WebSocket (sem polling) - Melhor escalabilidade e performance - Codigo mais simples e maintivel - Consistencia de estado entre multiplas janelas 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a6af4aa580
commit
c51b08f127
16 changed files with 1181 additions and 650 deletions
|
|
@ -21,6 +21,7 @@
|
|||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"convex": "^1.31.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
|
|
|||
|
|
@ -1,95 +1,26 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
/**
|
||||
* ChatHubWidget - Lista de sessoes de chat ativas usando Convex subscriptions
|
||||
*
|
||||
* Arquitetura:
|
||||
* - Usa useQuery do Convex React para subscription reativa (tempo real verdadeiro)
|
||||
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
|
||||
* - Tauri usado apenas para gerenciamento de janelas
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { Loader2, MessageCircle, ChevronUp } from "lucide-react"
|
||||
import { ChatSessionList } from "./ChatSessionList"
|
||||
import type { ChatSession, NewMessageEvent, SessionStartedEvent, SessionEndedEvent, UnreadUpdateEvent } from "./types"
|
||||
import { getMachineStoreConfig } from "./machineStore"
|
||||
import { Loader2, MessageCircle, ChevronUp, X, Minimize2 } from "lucide-react"
|
||||
import { useMachineSessions, type MachineSession } from "./useConvexMachineQueries"
|
||||
|
||||
/**
|
||||
* Hub Widget - Lista todas as sessoes de chat ativas
|
||||
* Ao clicar em uma sessao, abre/foca a janela de chat daquele ticket
|
||||
*/
|
||||
export function ChatHubWidget() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isMinimized, setIsMinimized] = useState(true) // Inicia minimizado
|
||||
const [isMinimized, setIsMinimized] = useState(true)
|
||||
|
||||
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
||||
|
||||
const ensureConfig = useCallback(async () => {
|
||||
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
||||
configRef.current = cfg
|
||||
return cfg
|
||||
}, [])
|
||||
|
||||
// Buscar sessoes do backend
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await ensureConfig()
|
||||
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineToken: cfg.token }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Falha ao buscar sessoes: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as { sessions: ChatSession[] }
|
||||
setSessions(data.sessions || [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error("Erro ao carregar sessoes:", err)
|
||||
setError(err instanceof Error ? err.message : "Erro desconhecido")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [ensureConfig])
|
||||
|
||||
// Carregar sessoes na montagem
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
}, [loadSessions])
|
||||
|
||||
// Escutar eventos de atualizacao
|
||||
useEffect(() => {
|
||||
const unlisteners: (() => void)[] = []
|
||||
|
||||
// Quando nova sessao inicia
|
||||
listen<SessionStartedEvent>("raven://chat/session-started", () => {
|
||||
loadSessions()
|
||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
||||
console.error("Erro ao registrar listener session-started:", err)
|
||||
})
|
||||
|
||||
// Quando sessao encerra
|
||||
listen<SessionEndedEvent>("raven://chat/session-ended", () => {
|
||||
loadSessions()
|
||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
||||
console.error("Erro ao registrar listener session-ended:", err)
|
||||
})
|
||||
|
||||
// Quando contador de nao lidos muda
|
||||
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
|
||||
setSessions(event.payload.sessions || [])
|
||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
||||
console.error("Erro ao registrar listener unread-update:", err)
|
||||
})
|
||||
|
||||
// Quando nova mensagem chega
|
||||
listen<NewMessageEvent>("raven://chat/new-message", (event) => {
|
||||
setSessions(event.payload.sessions || [])
|
||||
}).then((unlisten) => unlisteners.push(unlisten)).catch((err) => {
|
||||
console.error("Erro ao registrar listener new-message:", err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach((unlisten) => unlisten())
|
||||
}
|
||||
}, [loadSessions])
|
||||
// Convex subscription reativa
|
||||
const { sessions = [], isLoading, hasToken } = useMachineSessions()
|
||||
|
||||
// Sincronizar estado minimizado com tamanho da janela
|
||||
useEffect(() => {
|
||||
|
|
@ -109,7 +40,6 @@ export function ChatHubWidget() {
|
|||
|
||||
const handleSelectSession = async (ticketId: string, ticketRef: number) => {
|
||||
try {
|
||||
// Tauri 2 espera snake_case nos parametros
|
||||
await invoke("open_chat_window", { ticket_id: ticketId, ticket_ref: ticketRef })
|
||||
} catch (err) {
|
||||
console.error("Erro ao abrir janela de chat:", err)
|
||||
|
|
@ -128,11 +58,10 @@ export function ChatHubWidget() {
|
|||
const handleExpand = async () => {
|
||||
try {
|
||||
await invoke("set_hub_minimized", { minimized: false })
|
||||
// Aguarda a janela redimensionar antes de atualizar o estado
|
||||
setTimeout(() => setIsMinimized(false), 100)
|
||||
} catch (err) {
|
||||
console.error("Erro ao expandir hub:", err)
|
||||
setIsMinimized(false) // Fallback
|
||||
setIsMinimized(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +73,17 @@ export function ChatHubWidget() {
|
|||
|
||||
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||
|
||||
// Sem token
|
||||
if (!hasToken) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||
<span className="text-sm font-medium">Token nao configurado</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -156,26 +96,7 @@ export function ChatHubWidget() {
|
|||
)
|
||||
}
|
||||
|
||||
// Erro
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsLoading(true)
|
||||
loadSessions()
|
||||
}}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
|
||||
title="Tentar novamente"
|
||||
>
|
||||
<span className="text-sm font-medium">Erro - Tentar novamente</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sem sessoes ativas - mostrar chip cinza
|
||||
// Sem sessoes ativas
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
|
|
@ -187,7 +108,7 @@ export function ChatHubWidget() {
|
|||
)
|
||||
}
|
||||
|
||||
// Minimizado - mostrar chip com contador
|
||||
// Minimizado
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
||||
|
|
@ -211,15 +132,114 @@ export function ChatHubWidget() {
|
|||
)
|
||||
}
|
||||
|
||||
// Expandido - mostrar lista
|
||||
// Expandido
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
<ChatSessionList
|
||||
sessions={sessions}
|
||||
onSelectSession={handleSelectSession}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
/>
|
||||
{/* Header */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
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 size-10 items-center justify-center rounded-full bg-black text-white">
|
||||
<MessageCircle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Chats Ativos</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Minimizar"
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de sessoes */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
onClick={() => handleSelectSession(session.ticketId, session.ticketRef)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
onClick,
|
||||
}: {
|
||||
session: MachineSession
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-3 text-left transition hover:bg-slate-50"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full bg-black text-white">
|
||||
<MessageCircle className="size-5" />
|
||||
{/* Indicador online */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-white bg-emerald-500" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
Ticket #{session.ticketRef}
|
||||
</p>
|
||||
<span className="shrink-0 text-xs text-slate-400">
|
||||
{formatRelativeTime(session.lastActivityAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-slate-500">
|
||||
{session.agentName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge nao lidas */}
|
||||
{session.unreadCount > 0 && (
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||
{session.unreadCount > 9 ? "9+" : session.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return "agora"
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
import { MessageCircle } from "lucide-react"
|
||||
import type { ChatSession } from "./types"
|
||||
|
||||
type ChatSessionItemProps = {
|
||||
session: ChatSession
|
||||
isActive?: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return "Agora"
|
||||
if (minutes < 60) return `${minutes}min`
|
||||
if (hours < 24) return `${hours}h`
|
||||
if (days === 1) return "Ontem"
|
||||
|
||||
return new Date(timestamp).toLocaleDateString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function ChatSessionItem({ session, isActive, onClick }: ChatSessionItemProps) {
|
||||
const hasUnread = session.unreadCount > 0
|
||||
|
||||
const handleClick = () => {
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`flex w-full items-start gap-3 border-b border-slate-100 px-4 py-3 text-left transition-colors hover:bg-slate-50 ${
|
||||
isActive ? "bg-slate-100" : ""
|
||||
} ${hasUnread ? "bg-red-50/50 hover:bg-red-50" : ""}`}
|
||||
>
|
||||
{/* Avatar/Icone */}
|
||||
<div
|
||||
className={`flex size-10 shrink-0 items-center justify-center rounded-full ${
|
||||
hasUnread ? "bg-red-100 text-red-600" : "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
</div>
|
||||
|
||||
{/* Conteudo */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-semibold ${hasUnread ? "text-red-700" : "text-slate-900"}`}>
|
||||
#{session.ticketRef}
|
||||
</span>
|
||||
{/* Indicador online - sessao ativa significa online */}
|
||||
<span className="size-2 rounded-full bg-emerald-500" title="Online" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatTime(session.lastActivityAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-0.5 truncate text-sm text-slate-600">
|
||||
{session.ticketSubject}
|
||||
</p>
|
||||
|
||||
<p className="mt-0.5 truncate text-xs text-slate-400">
|
||||
{session.agentName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badge de nao lidos */}
|
||||
{hasUnread && (
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||
{session.unreadCount > 9 ? "9+" : session.unreadCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { useMemo } from "react"
|
||||
import { MessageCircle, X } from "lucide-react"
|
||||
import { ChatSessionItem } from "./ChatSessionItem"
|
||||
import type { ChatSession } from "./types"
|
||||
|
||||
type ChatSessionListProps = {
|
||||
sessions: ChatSession[]
|
||||
onSelectSession: (ticketId: string, ticketRef: number) => void
|
||||
onClose: () => void
|
||||
onMinimize: () => void
|
||||
}
|
||||
|
||||
export function ChatSessionList({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onClose,
|
||||
onMinimize,
|
||||
}: ChatSessionListProps) {
|
||||
// Ordenar: nao lidos primeiro, depois por ultima atividade (desc)
|
||||
const sortedSessions = useMemo(() => {
|
||||
return [...sessions].sort((a, b) => {
|
||||
// Nao lidos primeiro
|
||||
if (a.unreadCount > 0 && b.unreadCount === 0) return -1
|
||||
if (a.unreadCount === 0 && b.unreadCount > 0) return 1
|
||||
// Depois por ultima atividade (mais recente primeiro)
|
||||
return b.lastActivityAt - a.lastActivityAt
|
||||
})
|
||||
}, [sessions])
|
||||
|
||||
const totalUnread = sessions.reduce((sum, s) => sum + s.unreadCount, 0)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header - arrastavel */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center justify-between rounded-t-2xl 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">
|
||||
<MessageCircle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Chats</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{sessions.length} conversa{sessions.length !== 1 ? "s" : ""} ativa{sessions.length !== 1 ? "s" : ""}
|
||||
{totalUnread > 0 && (
|
||||
<span className="ml-1 text-red-500">
|
||||
({totalUnread} não lida{totalUnread !== 1 ? "s" : ""})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onMinimize}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Minimizar"
|
||||
>
|
||||
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1.5 text-slate-500 hover:bg-slate-100"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de sessoes */}
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
{sortedSessions.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center p-4 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-slate-100">
|
||||
<MessageCircle className="size-6 text-slate-400" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-600">Nenhum chat ativo</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Os chats aparecerao aqui quando iniciados
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedSessions.map((session) => (
|
||||
<ChatSessionItem
|
||||
key={session.ticketId}
|
||||
session={session}
|
||||
onClick={() => onSelectSession(session.ticketId, session.ticketRef)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,25 +1,26 @@
|
|||
/**
|
||||
* ChatWidget - Componente de chat em tempo real usando Convex subscriptions
|
||||
*
|
||||
* Arquitetura:
|
||||
* - Usa useQuery do Convex React para subscriptions reativas (tempo real verdadeiro)
|
||||
* - Usa useMutation do Convex React para enviar mensagens
|
||||
* - Mantém Tauri apenas para: upload de arquivos, gerenciamento de janela
|
||||
* - Sem polling - todas as atualizacoes sao push-based via WebSocket
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog"
|
||||
import { openUrl as openExternal } from "@tauri-apps/plugin-opener"
|
||||
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, Eye, Download, Check } from "lucide-react"
|
||||
import type {
|
||||
ChatAttachment,
|
||||
ChatMessage,
|
||||
ChatMessagesResponse,
|
||||
NewMessageEvent,
|
||||
SessionEndedEvent,
|
||||
SessionStartedEvent,
|
||||
UnreadUpdateEvent,
|
||||
} from "./types"
|
||||
import { getMachineStoreConfig } from "./machineStore"
|
||||
import type { Id } from "@convex/_generated/dataModel"
|
||||
import { useMachineMessages, usePostMachineMessage, useMarkMachineMessagesRead, type MachineMessage } from "./useConvexMachineQueries"
|
||||
import { useConvexMachine } from "./ConvexMachineProvider"
|
||||
|
||||
const MAX_MESSAGES_IN_MEMORY = 200 // Limite de mensagens para evitar memory leak
|
||||
const MAX_MESSAGES_IN_MEMORY = 200
|
||||
const MARK_READ_BATCH_SIZE = 50
|
||||
const SCROLL_BOTTOM_THRESHOLD_PX = 120
|
||||
|
||||
// Tipos de arquivo permitidos
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
"jpg", "jpeg", "png", "gif", "webp",
|
||||
"pdf", "txt", "doc", "docx", "xls", "xlsx",
|
||||
|
|
@ -32,6 +33,13 @@ interface UploadedAttachment {
|
|||
type?: string
|
||||
}
|
||||
|
||||
interface ChatAttachment {
|
||||
storageId: string
|
||||
name: string
|
||||
size?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
function getFileIcon(fileName: string) {
|
||||
const ext = fileName.toLowerCase().split(".").pop() ?? ""
|
||||
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
|
||||
|
|
@ -57,7 +65,7 @@ function formatAttachmentSize(size?: number) {
|
|||
return `${(kb / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function getUnreadAgentMessageIds(messages: ChatMessage[], unreadCount: number): string[] {
|
||||
function getUnreadAgentMessageIds(messages: MachineMessage[], unreadCount: number): string[] {
|
||||
if (unreadCount <= 0 || messages.length === 0) return []
|
||||
const ids: string[] = []
|
||||
for (let i = messages.length - 1; i >= 0 && ids.length < unreadCount; i--) {
|
||||
|
|
@ -138,7 +146,6 @@ function MessageAttachment({
|
|||
setTimeout(() => setDownloaded(false), 2000)
|
||||
} catch (err) {
|
||||
console.error("Falha ao baixar anexo:", err)
|
||||
// Fallback: abrir no navegador/sistema
|
||||
await handleView()
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
|
|
@ -160,7 +167,7 @@ function MessageAttachment({
|
|||
if (isImage && url) {
|
||||
return (
|
||||
<div className={`group relative overflow-hidden rounded-lg border ${isAgent ? "border-white/10" : "border-slate-200"}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- Tauri desktop app, not Next.js */}
|
||||
<img
|
||||
src={url}
|
||||
alt={attachment.name}
|
||||
|
|
@ -234,24 +241,28 @@ interface ChatWidgetProps {
|
|||
}
|
||||
|
||||
export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isUploading, setIsUploading] = 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 [pendingAttachments, setPendingAttachments] = useState<UploadedAttachment[]>([])
|
||||
// Inicializa minimizado porque o Rust abre a janela e minimiza imediatamente
|
||||
const [isMinimized, setIsMinimized] = useState(true)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
// Convex hooks
|
||||
const { apiBaseUrl, machineToken } = useConvexMachine()
|
||||
const { messages: convexMessages, hasSession, unreadCount, isLoading } = useMachineMessages(
|
||||
ticketId as Id<"tickets">,
|
||||
{ limit: MAX_MESSAGES_IN_MEMORY }
|
||||
)
|
||||
const postMessage = usePostMachineMessage()
|
||||
const markMessagesRead = useMarkMachineMessagesRead()
|
||||
|
||||
// Limitar mensagens em memoria
|
||||
const messages = useMemo(() => convexMessages.slice(-MAX_MESSAGES_IN_MEMORY), [convexMessages])
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const messageElementsRef = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
const prevHasSessionRef = useRef<boolean>(false)
|
||||
const retryDelayMsRef = useRef<number>(1_000)
|
||||
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const isAtBottomRef = useRef(true)
|
||||
|
|
@ -288,43 +299,39 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
return true
|
||||
}, [updateIsAtBottom])
|
||||
|
||||
// Quando a sessão termina (hasSession muda de true -> false), fechar a janela para não ficar "Offline" preso
|
||||
// Fechar janela quando sessao termina
|
||||
useEffect(() => {
|
||||
const prevHasSession = prevHasSessionRef.current
|
||||
if (prevHasSession && !hasSession) {
|
||||
invoke("close_chat_window", { ticket_id: ticketId }).catch((err) => {
|
||||
console.error("Erro ao fechar janela ao encerrar sessão:", err)
|
||||
console.error("Erro ao fechar janela ao encerrar sessao:", err)
|
||||
})
|
||||
}
|
||||
prevHasSessionRef.current = hasSession
|
||||
}, [hasSession, ticketId])
|
||||
|
||||
// Ref para acessar isMinimized dentro do callback sem causar resubscription
|
||||
// Ref para acessar isMinimized dentro de callbacks
|
||||
const isMinimizedRef = useRef(isMinimized)
|
||||
useEffect(() => {
|
||||
isMinimizedRef.current = isMinimized
|
||||
}, [isMinimized])
|
||||
|
||||
const configRef = useRef<{ apiBaseUrl: string; token: string } | null>(null)
|
||||
|
||||
const ensureConfig = useCallback(async () => {
|
||||
const cfg = configRef.current ?? (await getMachineStoreConfig())
|
||||
configRef.current = cfg
|
||||
return cfg
|
||||
}, [])
|
||||
|
||||
// Cache de URLs de anexos
|
||||
const attachmentUrlCacheRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const loadAttachmentUrl = useCallback(async (storageId: string) => {
|
||||
const cached = attachmentUrlCacheRef.current.get(storageId)
|
||||
if (cached) return cached
|
||||
|
||||
const cfg = await ensureConfig()
|
||||
const response = await fetch(`${cfg.apiBaseUrl}/api/machines/chat/attachments/url`, {
|
||||
if (!apiBaseUrl || !machineToken) {
|
||||
throw new Error("Configuracao nao disponivel")
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/machines/chat/attachments/url`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
machineToken: cfg.token,
|
||||
machineToken,
|
||||
ticketId,
|
||||
storageId,
|
||||
}),
|
||||
|
|
@ -342,148 +349,32 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
|
||||
attachmentUrlCacheRef.current.set(storageId, data.url)
|
||||
return data.url
|
||||
}, [ensureConfig, ticketId])
|
||||
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
const cfg = await ensureConfig()
|
||||
const result = await invoke<ChatMessagesResponse>("fetch_chat_messages", {
|
||||
baseUrl: cfg.apiBaseUrl,
|
||||
token: cfg.token,
|
||||
ticketId,
|
||||
since: null,
|
||||
})
|
||||
|
||||
setHasSession(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)
|
||||
retryDelayMsRef.current = 1_000
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(message || "Erro ao carregar mensagens.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [ensureConfig, ticketId, ticketRef])
|
||||
|
||||
// Auto-retry leve quando houver erro (evita ficar "morto" após falha transiente).
|
||||
useEffect(() => {
|
||||
if (!error) return
|
||||
|
||||
const delayMs = retryDelayMsRef.current
|
||||
const timeout = window.setTimeout(() => {
|
||||
loadMessages()
|
||||
}, delayMs)
|
||||
|
||||
retryDelayMsRef.current = Math.min(retryDelayMsRef.current * 2, 30_000)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}, [error, loadMessages])
|
||||
}, [apiBaseUrl, machineToken, ticketId])
|
||||
|
||||
const markUnreadMessagesRead = useCallback(async () => {
|
||||
if (unreadCount <= 0) return
|
||||
const ids = getUnreadAgentMessageIds(messages, unreadCount)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const cfg = await ensureConfig()
|
||||
const chunks = chunkArray(ids, MARK_READ_BATCH_SIZE)
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await invoke("mark_chat_messages_read", {
|
||||
base_url: cfg.apiBaseUrl,
|
||||
token: cfg.token,
|
||||
ticket_id: ticketId,
|
||||
message_ids: chunk,
|
||||
await markMessagesRead({
|
||||
ticketId: ticketId as Id<"tickets">,
|
||||
messageIds: chunk as Id<"ticketChatMessages">[],
|
||||
})
|
||||
}
|
||||
}, [messages, ticketId, unreadCount, markMessagesRead])
|
||||
|
||||
setUnreadCount(0)
|
||||
}, [ensureConfig, messages, ticketId, unreadCount])
|
||||
|
||||
// Carregar mensagens na montagem / troca de ticket
|
||||
// Auto-scroll quando novas mensagens chegam (se ja estava no bottom)
|
||||
const prevMessagesLengthRef = useRef(messages.length)
|
||||
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<NewMessageEvent>("raven://chat/new-message", (event) => {
|
||||
const sessions = event.payload?.sessions ?? []
|
||||
if (sessions.some((s) => s.ticketId === ticketId)) {
|
||||
const shouldAutoScroll = !isMinimizedRef.current && isAtBottomRef.current
|
||||
if (shouldAutoScroll) {
|
||||
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
|
||||
}
|
||||
loadMessages()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener new-message:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
if (messages.length > prevMessagesLengthRef.current && isAtBottomRef.current && !isMinimizedRef.current) {
|
||||
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: true }
|
||||
}
|
||||
}, [ticketId, loadMessages])
|
||||
prevMessagesLengthRef.current = messages.length
|
||||
}, [messages.length])
|
||||
|
||||
// Recarregar quando uma nova sessão iniciar (usuário pode estar com o chat aberto em "Offline")
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
listen<SessionStartedEvent>("raven://chat/session-started", (event) => {
|
||||
if (event.payload?.session?.ticketId === ticketId) {
|
||||
loadMessages()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener session-started:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [ticketId, loadMessages])
|
||||
|
||||
// Atualizar contador em tempo real (inclui decremento quando a máquina marca como lida)
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
listen<UnreadUpdateEvent>("raven://chat/unread-update", (event) => {
|
||||
const sessions = event.payload?.sessions ?? []
|
||||
const session = sessions.find((s) => s.ticketId === ticketId)
|
||||
setUnreadCount(session?.unreadCount ?? 0)
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener unread-update:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [ticketId])
|
||||
|
||||
// Executar scroll pendente (após expandir ou após novas mensagens)
|
||||
// Executar scroll pendente
|
||||
useEffect(() => {
|
||||
if (isMinimized) return
|
||||
|
||||
|
|
@ -517,84 +408,16 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
}
|
||||
}, [isMinimized, messages, markUnreadMessagesRead, scrollToBottom, scrollToMessage])
|
||||
|
||||
// Recarregar quando a sessao for encerrada (para refletir offline/minimizar corretamente)
|
||||
// Sincronizar estado minimizado com tamanho da janela
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
listen<SessionEndedEvent>("raven://chat/session-ended", (event) => {
|
||||
if (event.payload?.ticketId === ticketId) {
|
||||
loadMessages()
|
||||
}
|
||||
})
|
||||
.then((u) => {
|
||||
unlisten = u
|
||||
})
|
||||
.catch((err) => console.error("Falha ao registrar listener session-ended:", err))
|
||||
|
||||
return () => {
|
||||
unlisten?.()
|
||||
}
|
||||
}, [ticketId, loadMessages])
|
||||
|
||||
// Inicializacao via Convex (WS) - NAO depende de isMinimized para evitar resubscriptions
|
||||
/* useEffect(() => {
|
||||
setIsLoading(true)
|
||||
setMessages([])
|
||||
messagesSubRef.current?.()
|
||||
// reset contador ao trocar ticket
|
||||
setUnreadCount(0)
|
||||
|
||||
subscribeMachineMessages(
|
||||
ticketId,
|
||||
(payload) => {
|
||||
setIsLoading(false)
|
||||
setHasSession(payload.hasSession)
|
||||
hadSessionRef.current = hadSessionRef.current || payload.hasSession
|
||||
// Usa o unreadCount do backend (baseado em unreadByMachine da sessao)
|
||||
const backendUnreadCount = (payload as { unreadCount?: number }).unreadCount ?? 0
|
||||
setUnreadCount(backendUnreadCount)
|
||||
setMessages(prev => {
|
||||
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)
|
||||
})
|
||||
// Atualiza info basica do ticket
|
||||
if (payload.messages.length > 0) {
|
||||
const first = payload.messages[0]
|
||||
setTicketInfo((prevInfo) => prevInfo ?? { ref: 0, subject: "", agentName: first.authorName ?? "Suporte" })
|
||||
}
|
||||
// NAO marca como lidas aqui - deixa o useEffect de expansao fazer isso
|
||||
// Isso evita marcar como lidas antes do usuario expandir o chat
|
||||
},
|
||||
(err) => {
|
||||
setIsLoading(false)
|
||||
setError(err.message || "Erro ao carregar mensagens.")
|
||||
}
|
||||
).then((unsub) => {
|
||||
messagesSubRef.current = unsub
|
||||
})
|
||||
|
||||
return () => {
|
||||
messagesSubRef.current?.()
|
||||
messagesSubRef.current = null
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
// Ignorar todos os eventos de resize nos primeiros 500ms apos a montagem
|
||||
// Isso da tempo ao Tauri de aplicar o tamanho correto da janela
|
||||
// e evita que resizes transitórios durante a criação da janela alterem o estado
|
||||
const mountTime = Date.now()
|
||||
const STABILIZATION_DELAY = 500 // ms para a janela estabilizar
|
||||
const STABILIZATION_DELAY = 500
|
||||
|
||||
const handler = () => {
|
||||
// Ignorar eventos de resize durante o periodo de estabilizacao
|
||||
if (Date.now() - mountTime < STABILIZATION_DELAY) {
|
||||
return
|
||||
}
|
||||
const h = window.innerHeight
|
||||
// thresholds alinhados com set_chat_minimized (52px minimizado, 520px expandido)
|
||||
setIsMinimized(h < 100)
|
||||
}
|
||||
window.addEventListener("resize", handler)
|
||||
|
|
@ -616,16 +439,17 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
|
||||
if (!selected) return
|
||||
|
||||
// O retorno pode ser string (path único) ou objeto com path
|
||||
const filePath = typeof selected === "string" ? selected : (selected as { path: string }).path
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
const config = await getMachineStoreConfig()
|
||||
if (!apiBaseUrl || !machineToken) {
|
||||
throw new Error("Configuracao nao disponivel")
|
||||
}
|
||||
|
||||
const attachment = await invoke<UploadedAttachment>("upload_chat_file", {
|
||||
baseUrl: config.apiBaseUrl,
|
||||
token: config.token,
|
||||
baseUrl: apiBaseUrl,
|
||||
token: machineToken,
|
||||
filePath,
|
||||
})
|
||||
|
||||
|
|
@ -654,34 +478,19 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
setIsSending(true)
|
||||
|
||||
try {
|
||||
const bodyToSend = messageText
|
||||
const cfg = await ensureConfig()
|
||||
await invoke("send_chat_message", {
|
||||
base_url: cfg.apiBaseUrl,
|
||||
token: cfg.token,
|
||||
ticket_id: ticketId,
|
||||
body: bodyToSend,
|
||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||
})
|
||||
|
||||
// Adicionar mensagem localmente
|
||||
setMessages(prev => [...prev, {
|
||||
id: crypto.randomUUID(),
|
||||
body: bodyToSend,
|
||||
authorName: "Você",
|
||||
isFromMachine: true,
|
||||
createdAt: Date.now(),
|
||||
attachments: attachmentsToSend.map(a => ({
|
||||
storageId: a.storageId,
|
||||
await postMessage({
|
||||
ticketId: ticketId as Id<"tickets">,
|
||||
body: messageText,
|
||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend.map(a => ({
|
||||
storageId: a.storageId as Id<"_storage">,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
type: a.type,
|
||||
})),
|
||||
}])
|
||||
})) : undefined,
|
||||
})
|
||||
pendingScrollActionRef.current = { type: "bottom", behavior: "smooth", markRead: false }
|
||||
} catch (err) {
|
||||
console.error("Erro ao enviar mensagem:", err)
|
||||
// Restaurar input e anexos em caso de erro
|
||||
setInputValue(messageText)
|
||||
setPendingAttachments(attachmentsToSend)
|
||||
} finally {
|
||||
|
|
@ -726,9 +535,8 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
// Mostrar chip compacto enquanto carrega (compativel com janela minimizada)
|
||||
// pointer-events-none no container para que a area transparente nao seja clicavel
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
|
|
@ -739,31 +547,14 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Mostrar chip compacto de erro (compativel com janela minimizada)
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadMessages()}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg transition hover:bg-red-200/60"
|
||||
title="Tentar novamente"
|
||||
>
|
||||
<X className="size-4" />
|
||||
<span className="text-sm font-medium">Erro no chat</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Quando não há sessão, mostrar versão minimizada com indicador de offline
|
||||
// Sem sessao ativa
|
||||
if (!hasSession) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{ticketRef ? `Ticket #${ticketRef}` : ticketInfo?.ref ? `Ticket #${ticketInfo.ref}` : "Chat"}
|
||||
{ticketRef ? `Ticket #${ticketRef}` : "Chat"}
|
||||
</span>
|
||||
<span className="size-2 rounded-full bg-slate-400" />
|
||||
<span className="text-xs text-slate-500">Offline</span>
|
||||
|
|
@ -779,8 +570,7 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Versão minimizada (chip compacto igual web)
|
||||
// pointer-events-none no container para que apenas o botao seja clicavel
|
||||
// Minimizado
|
||||
if (isMinimized) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent pr-3">
|
||||
|
|
@ -790,11 +580,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
>
|
||||
<MessageCircle className="size-4" />
|
||||
<span className="text-sm font-medium">
|
||||
Ticket #{ticketRef ?? ticketInfo?.ref}
|
||||
Ticket #{ticketRef}
|
||||
</span>
|
||||
<span className="size-2 rounded-full bg-emerald-400" />
|
||||
<ChevronUp className="size-4" />
|
||||
{/* Badge de mensagens não lidas */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
|
|
@ -805,9 +594,10 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
)
|
||||
}
|
||||
|
||||
// Expandido
|
||||
return (
|
||||
<div className="flex h-screen flex-col overflow-hidden rounded-2xl bg-white shadow-xl">
|
||||
{/* Header - arrastavel */}
|
||||
{/* Header */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-3 rounded-t-2xl"
|
||||
|
|
@ -824,11 +614,9 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
Online
|
||||
</span>
|
||||
</div>
|
||||
{(ticketRef || ticketInfo) && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Ticket #{ticketRef ?? ticketInfo?.ref} - {ticketInfo?.agentName ?? "Suporte"}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
Ticket #{ticketRef} - Suporte
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -861,14 +649,12 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
Nenhuma mensagem ainda
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
O agente iniciará a conversa em breve
|
||||
O agente iniciara a conversa em breve
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => {
|
||||
// No desktop: isFromMachine=true significa mensagem do cliente (maquina)
|
||||
// Layout igual à web: cliente à esquerda, agente à direita
|
||||
const isAgent = !msg.isFromMachine
|
||||
const bodyText = msg.body.trim()
|
||||
const shouldShowBody =
|
||||
|
|
@ -883,62 +669,67 @@ export function ChatWidget({ ticketId, ticketRef }: ChatWidgetProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
messageElementsRef.current.set(msg.id, el)
|
||||
} else {
|
||||
messageElementsRef.current.delete(msg.id)
|
||||
}
|
||||
}}
|
||||
className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${
|
||||
isAgent ? "bg-black text-white" : "bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
messageElementsRef.current.set(msg.id, el)
|
||||
} else {
|
||||
messageElementsRef.current.delete(msg.id)
|
||||
}
|
||||
}}
|
||||
className={`flex gap-2 ${isAgent ? "flex-row-reverse" : "flex-row"}`}
|
||||
>
|
||||
{isAgent ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2 ${
|
||||
isAgent
|
||||
? "rounded-br-md bg-black text-white"
|
||||
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{!isAgent && (
|
||||
<p className="mb-1 text-xs font-medium text-slate-500">
|
||||
{msg.authorName}
|
||||
</p>
|
||||
)}
|
||||
{shouldShowBody && <p className="whitespace-pre-wrap text-sm">{msg.body}</p>}
|
||||
{/* Anexos */}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.attachments.map((att) => (
|
||||
<MessageAttachment
|
||||
key={att.storageId}
|
||||
attachment={att}
|
||||
isAgent={isAgent}
|
||||
loadUrl={loadAttachmentUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={`mt-1 text-right text-xs ${
|
||||
isAgent ? "text-white/60" : "text-slate-400"
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex size-7 shrink-0 items-center justify-center rounded-full ${
|
||||
isAgent ? "bg-black text-white" : "bg-slate-200 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
{isAgent ? <MessageCircle className="size-3.5" /> : <User className="size-3.5" />}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2 ${
|
||||
isAgent
|
||||
? "rounded-br-md bg-black text-white"
|
||||
: "rounded-bl-md border border-slate-100 bg-white text-slate-900 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{!isAgent && (
|
||||
<p className="mb-1 text-xs font-medium text-slate-500">
|
||||
{msg.authorName}
|
||||
</p>
|
||||
)}
|
||||
{shouldShowBody && <p className="whitespace-pre-wrap text-sm">{msg.body}</p>}
|
||||
{/* Anexos */}
|
||||
{msg.attachments && msg.attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{msg.attachments.map((att) => (
|
||||
<MessageAttachment
|
||||
key={att.storageId}
|
||||
attachment={{
|
||||
storageId: att.storageId as string,
|
||||
name: att.name,
|
||||
size: att.size,
|
||||
type: att.type,
|
||||
}}
|
||||
isAgent={isAgent}
|
||||
loadUrl={loadAttachmentUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={`mt-1 text-right text-xs ${
|
||||
isAgent ? "text-white/60" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{formatTime(msg.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
|
|
|
|||
146
apps/desktop/src/chat/ConvexMachineProvider.tsx
Normal file
146
apps/desktop/src/chat/ConvexMachineProvider.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* ConvexMachineProvider - Provider Convex para autenticacao via token de maquina
|
||||
*
|
||||
* Este provider inicializa o ConvexReactClient usando o token da maquina
|
||||
* armazenado no Tauri Store, permitindo subscriptions reativas em tempo real.
|
||||
*
|
||||
* Arquitetura:
|
||||
* - Carrega o token do Tauri Store na montagem
|
||||
* - Inicializa o ConvexReactClient com a URL do Convex
|
||||
* - Disponibiliza o cliente para componentes filhos via Context
|
||||
* - Reconecta automaticamente quando o token muda
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { ConvexReactClient } from "convex/react"
|
||||
import { getMachineStoreConfig } from "./machineStore"
|
||||
|
||||
// URL do Convex - em producao, usa o dominio personalizado
|
||||
const CONVEX_URL = import.meta.env.MODE === "production"
|
||||
? "https://convex.esdrasrenan.com.br"
|
||||
: (import.meta.env.VITE_CONVEX_URL ?? "https://convex.esdrasrenan.com.br")
|
||||
|
||||
type MachineAuthState = {
|
||||
token: string | null
|
||||
apiBaseUrl: string | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type ConvexMachineContextValue = {
|
||||
client: ConvexReactClient | null
|
||||
machineToken: string | null
|
||||
apiBaseUrl: string | null
|
||||
isReady: boolean
|
||||
error: string | null
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
const ConvexMachineContext = createContext<ConvexMachineContextValue | null>(null)
|
||||
|
||||
export function useConvexMachine() {
|
||||
const ctx = useContext(ConvexMachineContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useConvexMachine must be used within ConvexMachineProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useMachineToken() {
|
||||
const { machineToken } = useConvexMachine()
|
||||
return machineToken
|
||||
}
|
||||
|
||||
interface ConvexMachineProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ConvexMachineProvider({ children }: ConvexMachineProviderProps) {
|
||||
const [authState, setAuthState] = useState<MachineAuthState>({
|
||||
token: null,
|
||||
apiBaseUrl: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const [client, setClient] = useState<ConvexReactClient | null>(null)
|
||||
|
||||
// Funcao para carregar configuracao do Tauri Store
|
||||
const loadConfig = async () => {
|
||||
setAuthState(prev => ({ ...prev, isLoading: true, error: null }))
|
||||
|
||||
try {
|
||||
const config = await getMachineStoreConfig()
|
||||
|
||||
if (!config.token) {
|
||||
setAuthState({
|
||||
token: null,
|
||||
apiBaseUrl: config.apiBaseUrl,
|
||||
isLoading: false,
|
||||
error: "Token da maquina nao encontrado",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
token: config.token,
|
||||
apiBaseUrl: config.apiBaseUrl,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setAuthState({
|
||||
token: null,
|
||||
apiBaseUrl: null,
|
||||
isLoading: false,
|
||||
error: message || "Erro ao carregar configuracao",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Carregar configuracao na montagem
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
// Inicializar/reinicializar cliente Convex quando token muda
|
||||
useEffect(() => {
|
||||
if (!authState.token) {
|
||||
// Limpar cliente se nao tem token
|
||||
if (client) {
|
||||
client.close()
|
||||
setClient(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Criar novo cliente Convex
|
||||
const newClient = new ConvexReactClient(CONVEX_URL, {
|
||||
// Desabilitar retry agressivo para evitar loops infinitos
|
||||
unsavedChangesWarning: false,
|
||||
})
|
||||
|
||||
setClient(newClient)
|
||||
|
||||
// Cleanup ao desmontar ou trocar token
|
||||
return () => {
|
||||
newClient.close()
|
||||
}
|
||||
}, [authState.token]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const contextValue: ConvexMachineContextValue = {
|
||||
client,
|
||||
machineToken: authState.token,
|
||||
apiBaseUrl: authState.apiBaseUrl,
|
||||
isReady: !authState.isLoading && !!client && !!authState.token,
|
||||
error: authState.error,
|
||||
reload: loadConfig,
|
||||
}
|
||||
|
||||
return (
|
||||
<ConvexMachineContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ConvexMachineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,20 +1,63 @@
|
|||
import { ConvexProvider } from "convex/react"
|
||||
import { ChatWidget } from "./ChatWidget"
|
||||
import { ChatHubWidget } from "./ChatHubWidget"
|
||||
import { ConvexMachineProvider, useConvexMachine } from "./ConvexMachineProvider"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function ChatAppContent() {
|
||||
const { client, isReady, error } = useConvexMachine()
|
||||
|
||||
export function ChatApp() {
|
||||
// Obter ticketId e ticketRef da URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const ticketId = params.get("ticketId")
|
||||
const ticketRef = params.get("ticketRef")
|
||||
const isHub = params.get("hub") === "true"
|
||||
|
||||
// Aguardar cliente Convex estar pronto
|
||||
if (!isReady || !client) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-red-100 px-4 py-2 text-red-600 shadow-lg">
|
||||
<span className="text-sm font-medium">Erro: {error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-end justify-end bg-transparent p-2">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-200 px-4 py-2 text-slate-600 shadow-lg">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-sm font-medium">Conectando...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Modo hub - lista de todas as sessoes
|
||||
if (isHub || !ticketId) {
|
||||
return <ChatHubWidget />
|
||||
return (
|
||||
<ConvexProvider client={client}>
|
||||
<ChatHubWidget />
|
||||
</ConvexProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Modo chat - conversa de um ticket especifico
|
||||
return <ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
||||
return (
|
||||
<ConvexProvider client={client}>
|
||||
<ChatWidget ticketId={ticketId} ticketRef={ticketRef ? Number(ticketRef) : undefined} />
|
||||
</ConvexProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatApp() {
|
||||
return (
|
||||
<ConvexMachineProvider>
|
||||
<ChatAppContent />
|
||||
</ConvexMachineProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ChatWidget }
|
||||
|
|
|
|||
206
apps/desktop/src/chat/useConvexMachineQueries.ts
Normal file
206
apps/desktop/src/chat/useConvexMachineQueries.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Hooks customizados para queries/mutations do Convex com token de maquina
|
||||
*
|
||||
* Estes hooks encapsulam a logica de passar o machineToken automaticamente
|
||||
* para as queries e mutations do Convex, proporcionando uma API simples
|
||||
* e reativa para os componentes de chat.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useAction } from "convex/react"
|
||||
import { api } from "@convex/_generated/api"
|
||||
import type { Id } from "@convex/_generated/dataModel"
|
||||
import { useMachineToken } from "./ConvexMachineProvider"
|
||||
|
||||
// ============================================
|
||||
// TIPOS
|
||||
// ============================================
|
||||
|
||||
export type MachineSession = {
|
||||
sessionId: Id<"liveChatSessions">
|
||||
ticketId: Id<"tickets">
|
||||
ticketRef: number
|
||||
ticketSubject: string
|
||||
agentName: string
|
||||
agentEmail?: string
|
||||
agentAvatarUrl?: string
|
||||
unreadCount: number
|
||||
lastActivityAt: number
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export type MachineMessage = {
|
||||
id: Id<"ticketChatMessages">
|
||||
body: string
|
||||
authorName: string
|
||||
authorAvatarUrl?: string
|
||||
isFromMachine: boolean
|
||||
createdAt: number
|
||||
attachments: Array<{
|
||||
storageId: Id<"_storage">
|
||||
name: string
|
||||
size?: number
|
||||
type?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type MachineMessagesResult = {
|
||||
messages: MachineMessage[]
|
||||
hasSession: boolean
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export type MachineUpdatesResult = {
|
||||
hasActiveSessions: boolean
|
||||
sessions: Array<{
|
||||
ticketId: Id<"tickets">
|
||||
ticketRef: number
|
||||
unreadCount: number
|
||||
lastActivityAt: number
|
||||
}>
|
||||
totalUnread: number
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HOOKS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Hook para listar sessoes ativas da maquina
|
||||
* Subscription reativa - atualiza automaticamente quando ha mudancas
|
||||
*/
|
||||
export function useMachineSessions() {
|
||||
const machineToken = useMachineToken()
|
||||
|
||||
const sessions = useQuery(
|
||||
api.liveChat.listMachineSessions,
|
||||
machineToken ? { machineToken } : "skip"
|
||||
)
|
||||
|
||||
return {
|
||||
sessions: sessions as MachineSession[] | undefined,
|
||||
isLoading: sessions === undefined && !!machineToken,
|
||||
hasToken: !!machineToken,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para listar mensagens de um ticket especifico
|
||||
* Subscription reativa - atualiza automaticamente quando ha novas mensagens
|
||||
*/
|
||||
export function useMachineMessages(ticketId: Id<"tickets"> | null, options?: { limit?: number }) {
|
||||
const machineToken = useMachineToken()
|
||||
|
||||
const result = useQuery(
|
||||
api.liveChat.listMachineMessages,
|
||||
machineToken && ticketId
|
||||
? { machineToken, ticketId, limit: options?.limit }
|
||||
: "skip"
|
||||
)
|
||||
|
||||
return {
|
||||
messages: (result as MachineMessagesResult | undefined)?.messages ?? [],
|
||||
hasSession: (result as MachineMessagesResult | undefined)?.hasSession ?? false,
|
||||
unreadCount: (result as MachineMessagesResult | undefined)?.unreadCount ?? 0,
|
||||
isLoading: result === undefined && !!machineToken && !!ticketId,
|
||||
hasToken: !!machineToken,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para verificar updates (polling leve)
|
||||
* Usado como fallback ou para verificar status rapidamente
|
||||
*/
|
||||
export function useMachineUpdates() {
|
||||
const machineToken = useMachineToken()
|
||||
|
||||
const result = useQuery(
|
||||
api.liveChat.checkMachineUpdates,
|
||||
machineToken ? { machineToken } : "skip"
|
||||
)
|
||||
|
||||
return {
|
||||
hasActiveSessions: (result as MachineUpdatesResult | undefined)?.hasActiveSessions ?? false,
|
||||
sessions: (result as MachineUpdatesResult | undefined)?.sessions ?? [],
|
||||
totalUnread: (result as MachineUpdatesResult | undefined)?.totalUnread ?? 0,
|
||||
isLoading: result === undefined && !!machineToken,
|
||||
hasToken: !!machineToken,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para enviar mensagem
|
||||
*/
|
||||
export function usePostMachineMessage() {
|
||||
const machineToken = useMachineToken()
|
||||
const postMessage = useMutation(api.liveChat.postMachineMessage)
|
||||
|
||||
return async (args: {
|
||||
ticketId: Id<"tickets">
|
||||
body: string
|
||||
attachments?: Array<{
|
||||
storageId: Id<"_storage">
|
||||
name: string
|
||||
size?: number
|
||||
type?: string
|
||||
}>
|
||||
}) => {
|
||||
if (!machineToken) {
|
||||
throw new Error("Token da maquina nao disponivel")
|
||||
}
|
||||
|
||||
return postMessage({
|
||||
machineToken,
|
||||
ticketId: args.ticketId,
|
||||
body: args.body,
|
||||
attachments: args.attachments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para marcar mensagens como lidas
|
||||
*/
|
||||
export function useMarkMachineMessagesRead() {
|
||||
const machineToken = useMachineToken()
|
||||
const markRead = useMutation(api.liveChat.markMachineMessagesRead)
|
||||
|
||||
return async (args: {
|
||||
ticketId: Id<"tickets">
|
||||
messageIds: Id<"ticketChatMessages">[]
|
||||
}) => {
|
||||
if (!machineToken) {
|
||||
throw new Error("Token da maquina nao disponivel")
|
||||
}
|
||||
|
||||
return markRead({
|
||||
machineToken,
|
||||
ticketId: args.ticketId,
|
||||
messageIds: args.messageIds,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para gerar URL de upload
|
||||
*/
|
||||
export function useGenerateMachineUploadUrl() {
|
||||
const machineToken = useMachineToken()
|
||||
const generateUrl = useAction(api.liveChat.generateMachineUploadUrl)
|
||||
|
||||
return async (args: {
|
||||
fileName: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
}) => {
|
||||
if (!machineToken) {
|
||||
throw new Error("Token da maquina nao disponivel")
|
||||
}
|
||||
|
||||
return generateUrl({
|
||||
machineToken,
|
||||
fileName: args.fileName,
|
||||
fileType: args.fileType,
|
||||
fileSize: args.fileSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
121
apps/desktop/src/convex/_generated/api.d.ts
vendored
Normal file
121
apps/desktop/src/convex/_generated/api.d.ts
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as alerts from "../alerts.js";
|
||||
import type * as automations from "../automations.js";
|
||||
import type * as bootstrap from "../bootstrap.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as categorySlas from "../categorySlas.js";
|
||||
import type * as checklistTemplates from "../checklistTemplates.js";
|
||||
import type * as commentTemplates from "../commentTemplates.js";
|
||||
import type * as companies from "../companies.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as dashboards from "../dashboards.js";
|
||||
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
|
||||
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
|
||||
import type * as deviceFields from "../deviceFields.js";
|
||||
import type * as devices from "../devices.js";
|
||||
import type * as emprestimos from "../emprestimos.js";
|
||||
import type * as fields from "../fields.js";
|
||||
import type * as files from "../files.js";
|
||||
import type * as incidents from "../incidents.js";
|
||||
import type * as invites from "../invites.js";
|
||||
import type * as liveChat from "../liveChat.js";
|
||||
import type * as machines from "../machines.js";
|
||||
import type * as metrics from "../metrics.js";
|
||||
import type * as migrations from "../migrations.js";
|
||||
import type * as ops from "../ops.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as rbac from "../rbac.js";
|
||||
import type * as reports from "../reports.js";
|
||||
import type * as revision from "../revision.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as slas from "../slas.js";
|
||||
import type * as teams from "../teams.js";
|
||||
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||
import type * as ticketFormTemplates from "../ticketFormTemplates.js";
|
||||
import type * as ticketNotifications from "../ticketNotifications.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as usbPolicy from "../usbPolicy.js";
|
||||
import type * as users from "../users.js";
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
alerts: typeof alerts;
|
||||
automations: typeof automations;
|
||||
bootstrap: typeof bootstrap;
|
||||
categories: typeof categories;
|
||||
categorySlas: typeof categorySlas;
|
||||
checklistTemplates: typeof checklistTemplates;
|
||||
commentTemplates: typeof commentTemplates;
|
||||
companies: typeof companies;
|
||||
crons: typeof crons;
|
||||
dashboards: typeof dashboards;
|
||||
deviceExportTemplates: typeof deviceExportTemplates;
|
||||
deviceFieldDefaults: typeof deviceFieldDefaults;
|
||||
deviceFields: typeof deviceFields;
|
||||
devices: typeof devices;
|
||||
emprestimos: typeof emprestimos;
|
||||
fields: typeof fields;
|
||||
files: typeof files;
|
||||
incidents: typeof incidents;
|
||||
invites: typeof invites;
|
||||
liveChat: typeof liveChat;
|
||||
machines: typeof machines;
|
||||
metrics: typeof metrics;
|
||||
migrations: typeof migrations;
|
||||
ops: typeof ops;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
reports: typeof reports;
|
||||
revision: typeof revision;
|
||||
seed: typeof seed;
|
||||
slas: typeof slas;
|
||||
teams: typeof teams;
|
||||
ticketFormSettings: typeof ticketFormSettings;
|
||||
ticketFormTemplates: typeof ticketFormTemplates;
|
||||
ticketNotifications: typeof ticketNotifications;
|
||||
tickets: typeof tickets;
|
||||
usbPolicy: typeof usbPolicy;
|
||||
users: typeof users;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's public API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's internal API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = internal.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApi,
|
||||
FunctionReference<any, "internal">
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
23
apps/desktop/src/convex/_generated/api.js
Normal file
23
apps/desktop/src/convex/_generated/api.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Generated `api` utility.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi, componentsGeneric } from "convex/server";
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
*
|
||||
* Usage:
|
||||
* ```js
|
||||
* const myFunctionReference = api.myModule.myFunction;
|
||||
* ```
|
||||
*/
|
||||
export const api = anyApi;
|
||||
export const internal = anyApi;
|
||||
export const components = componentsGeneric();
|
||||
60
apps/desktop/src/convex/_generated/dataModel.d.ts
vendored
Normal file
60
apps/desktop/src/convex/_generated/dataModel.d.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Generated data model types.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
DataModelFromSchemaDefinition,
|
||||
DocumentByName,
|
||||
TableNamesInDataModel,
|
||||
SystemTableNames,
|
||||
} from "convex/server";
|
||||
import type { GenericId } from "convex/values";
|
||||
import schema from "../schema.js";
|
||||
|
||||
/**
|
||||
* The names of all of your Convex tables.
|
||||
*/
|
||||
export type TableNames = TableNamesInDataModel<DataModel>;
|
||||
|
||||
/**
|
||||
* The type of a document stored in Convex.
|
||||
*
|
||||
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||
*/
|
||||
export type Doc<TableName extends TableNames> = DocumentByName<
|
||||
DataModel,
|
||||
TableName
|
||||
>;
|
||||
|
||||
/**
|
||||
* An identifier for a document in Convex.
|
||||
*
|
||||
* Convex documents are uniquely identified by their `Id`, which is accessible
|
||||
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
|
||||
*
|
||||
* Documents can be loaded using `db.get(id)` in query and mutation functions.
|
||||
*
|
||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||
* strings when type checking.
|
||||
*
|
||||
* @typeParam TableName - A string literal type of the table name (like "users").
|
||||
*/
|
||||
export type Id<TableName extends TableNames | SystemTableNames> =
|
||||
GenericId<TableName>;
|
||||
|
||||
/**
|
||||
* A type describing your Convex data model.
|
||||
*
|
||||
* This type includes information about what tables you have, the type of
|
||||
* documents stored in those tables, and the indexes defined on them.
|
||||
*
|
||||
* This type is used to parameterize methods like `queryGeneric` and
|
||||
* `mutationGeneric` to make them type-safe.
|
||||
*/
|
||||
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
|
||||
143
apps/desktop/src/convex/_generated/server.d.ts
vendored
Normal file
143
apps/desktop/src/convex/_generated/server.d.ts
vendored
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
ActionBuilder,
|
||||
HttpActionBuilder,
|
||||
MutationBuilder,
|
||||
QueryBuilder,
|
||||
GenericActionCtx,
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const query: QueryBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
*
|
||||
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||
* code and code with side-effects, like calling third-party services.
|
||||
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||
*
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const action: ActionBuilder<DataModel, "public">;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
*
|
||||
* The wrapped function will be used to respond to HTTP requests received
|
||||
* by a Convex deployment if the requests matches the path and method where
|
||||
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||
* and a Fetch API `Request` object as its second.
|
||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||
*/
|
||||
export declare const httpAction: HttpActionBuilder;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex query functions.
|
||||
*
|
||||
* The query context is passed as the first argument to any Convex query
|
||||
* function run on the server.
|
||||
*
|
||||
* This differs from the {@link MutationCtx} because all of the services are
|
||||
* read-only.
|
||||
*/
|
||||
export type QueryCtx = GenericQueryCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex mutation functions.
|
||||
*
|
||||
* The mutation context is passed as the first argument to any Convex mutation
|
||||
* function run on the server.
|
||||
*/
|
||||
export type MutationCtx = GenericMutationCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* A set of services for use within Convex action functions.
|
||||
*
|
||||
* The action context is passed as the first argument to any Convex action
|
||||
* function run on the server.
|
||||
*/
|
||||
export type ActionCtx = GenericActionCtx<DataModel>;
|
||||
|
||||
/**
|
||||
* An interface to read from the database within Convex query functions.
|
||||
*
|
||||
* The two entry points are {@link DatabaseReader.get}, which fetches a single
|
||||
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
|
||||
* building a query.
|
||||
*/
|
||||
export type DatabaseReader = GenericDatabaseReader<DataModel>;
|
||||
|
||||
/**
|
||||
* An interface to read from and write to the database within Convex mutation
|
||||
* functions.
|
||||
*
|
||||
* Convex guarantees that all writes within a single mutation are
|
||||
* executed atomically, so you never have to worry about partial writes leaving
|
||||
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
|
||||
* for the guarantees Convex provides your functions.
|
||||
*/
|
||||
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
|
||||
93
apps/desktop/src/convex/_generated/server.js
Normal file
93
apps/desktop/src/convex/_generated/server.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/* eslint-disable */
|
||||
/**
|
||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||
*
|
||||
* THIS CODE IS AUTOMATICALLY GENERATED.
|
||||
*
|
||||
* To regenerate, run `npx convex dev`.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import {
|
||||
actionGeneric,
|
||||
httpActionGeneric,
|
||||
queryGeneric,
|
||||
mutationGeneric,
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
} from "convex/server";
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to read your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const query = queryGeneric;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalQuery = internalQueryGeneric;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
*
|
||||
* This function will be allowed to modify your Convex database and will be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const mutation = mutationGeneric;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
|
||||
*
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalMutation = internalMutationGeneric;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
*
|
||||
* An action is a function which can execute any JavaScript code, including non-deterministic
|
||||
* code and code with side-effects, like calling third-party services.
|
||||
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
|
||||
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
|
||||
*
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const action = actionGeneric;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export const internalAction = internalActionGeneric;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
*
|
||||
* The wrapped function will be used to respond to HTTP requests received
|
||||
* by a Convex deployment if the requests matches the path and method where
|
||||
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
|
||||
*
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument
|
||||
* and a Fetch API `Request` object as its second.
|
||||
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
|
||||
*/
|
||||
export const httpAction = httpActionGeneric;
|
||||
|
|
@ -19,7 +19,13 @@
|
|||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Paths */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@convex/_generated/*": ["./src/convex/_generated/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
|
|
@ -7,6 +8,13 @@ const host = process.env.TAURI_DEV_HOST;
|
|||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
// Usar arquivos _generated locais para evitar problemas de type-check
|
||||
"@convex/_generated": resolve(__dirname, "./src/convex/_generated"),
|
||||
},
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
|
|
|
|||
55
bun.lock
55
bun.lock
|
|
@ -115,6 +115,7 @@
|
|||
"@tauri-apps/plugin-process": "^2",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"convex": "^1.31.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -2335,6 +2336,8 @@
|
|||
|
||||
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"appsdesktop/convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="],
|
||||
|
||||
"appsdesktop/lucide-react": ["lucide-react@0.544.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
|
||||
|
||||
"appsdesktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||
|
|
@ -2469,6 +2472,8 @@
|
|||
|
||||
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"appsdesktop/convex/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"conf/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
|
@ -2600,5 +2605,55 @@
|
|||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
|
||||
|
||||
"appsdesktop/convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue