// Composable para gestionar los resultados de búsqueda y las sesiones de búsqueda. import { computed, ref, watch } from 'vue' import { useWsMonitor } from './useWsMonitor' import { adaptSearchPayload } from '../services/resultAdapters' const sessions = ref([]) const activeSessionId = ref('') const pendingRequests = ref({}) const selectedCardIds = ref([]) let bridgeInitialized = false let sequence = 0 const processedLogIds = new Set() const uid = (prefix) => { sequence += 1 return `${prefix}_${Date.now()}_${sequence}` } const hashString = (value) => { const raw = String(value || '') let hash = 5381 for (let i = 0; i < raw.length; i += 1) { hash = ((hash << 5) + hash) + raw.charCodeAt(i) hash &= 0xffffffff } return `h${Math.abs(hash)}` } const getActiveSession = () => sessions.value.find((item) => item.id === activeSessionId.value) || null const activeSession = computed(() => getActiveSession()) const selectedSet = computed(() => new Set(selectedCardIds.value)) const findSessionById = (sessionId) => sessions.value.find((item) => item.id === sessionId) || null const findCardById = (cardId) => { for (const session of sessions.value) { const card = session.cardsById[cardId] if (card) { return { session, card } } } return { session: null, card: null } } const ensureSourceGroup = (session, sourceKey, sourceLabel) => { if (session.groupMap[sourceKey]) { return session.groupMap[sourceKey] } const group = { id: uid('group'), sourceKey, sourceLabel, status: 'ok', error: '', cards: [], } session.groupMap[sourceKey] = group session.groups.push(group) return group } const createSession = (rootRequestId, rootQuery, rootContext) => { const session = { id: uid('session'), createdAt: new Date().toISOString(), rootRequestId, rootQuery, rootContext, groups: [], groupMap: {}, cardsById: {}, } sessions.value.unshift(session) activeSessionId.value = session.id return session } const pushChildRequestPlaceholder = (parentCardId, requestId, query, context) => { if (!parentCardId) { return } const { card } = findCardById(parentCardId) if (!card) { return } card.childRequests.unshift({ requestId, query, context, status: 'loading', error: '', resultCardIds: [], createdAt: new Date().toISOString(), }) } // Actualiza el estado de una solicitud de búsqueda secundaria asociada a un card padre, incluyendo el estado, los IDs de las cards resultantes y cualquier error. const setChildRequestResult = (parentCardId, requestId, status, resultCardIds = [], error = '') => { if (!parentCardId) { return } const { card } = findCardById(parentCardId) if (!card) { return } const target = card.childRequests.find((item) => item.requestId === requestId) if (!target) { return } target.status = status target.error = error target.resultCardIds = resultCardIds } // Ejecuta una busqueda enviando la query y el context al backend a través del WebSocket const dispatchSearch = ({ query, context, parentCardId = '', sessionId = '' }) => { // Genera un ID único para la solicitud de búsqueda const requestId = uid('req') // Obtiene la función sendCommand del composable useWsMonitor para enviar comandos al backend const { sendCommand } = useWsMonitor() // Busca la sesión correspondiente al sessionId proporcionado, si no se encuentra, intenta buscarla a través del parentCardId. Si aún no se encuentra, crea una nueva sesión con la query y el context proporcionados. let targetSession = sessionId ? findSessionById(sessionId) : null if (!targetSession && parentCardId) { const found = findCardById(parentCardId) targetSession = found.session } // Si no se encuentra ninguna sesión existente, crea una nueva sesión con la query y el context proporcionados. if (!targetSession) { targetSession = createSession(requestId, query, context) } // Registra la solicitud de búsqueda pendiente en el objeto pendingRequests, asociando el requestId con la sesión, el parentCardId, la query y el context. pendingRequests.value[requestId] = { requestId, sessionId: targetSession.id, parentCardId, query, context, status: 'pending', error: '', createdAt: new Date().toISOString(), } // Si se proporciona un parentCardId, agrega un marcador de posición para la solicitud de búsqueda secundaria en el card correspondiente. pushChildRequestPlaceholder(parentCardId, requestId, query, context) // Envía el comando 'busqueda' al backend a través del WebSocket, incluyendo la query y el context en el payload. sendCommand('busqueda', { busqueda: { query, context, }, }) return { requestId, sessionId: targetSession.id } } const normalizeMessagePayload = (payload) => { if (!payload) { return null } if (typeof payload === 'string') { try { return JSON.parse(payload) } catch { return null } } if (typeof payload === 'object') { return payload } return null } const extractRequestId = (payload) => { return ( payload?.requestId || payload?.context?.requestId || payload?.meta?.requestId || payload?.source?.requestId || '' ) } const pickLatestRequest = () => { const entries = Object.values(pendingRequests.value) .filter((item) => item.status !== 'error') .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) return entries[0] || null } // Agrega nuevas cards a un grupo de resultados de búsqueda dentro de una sesión, asegurando que no se dupliquen y devolviendo los IDs de las cards agregadas. const addCardsToGroup = ({ session, group, cards, parentCardId, requestId }) => { const appendedIds = [] for (const card of cards) { const stableSeed = `${group.sourceKey}:${card.stableKey}` const stableId = `card_${hashString(stableSeed)}` if (session.cardsById[stableId]) { appendedIds.push(stableId) continue } const normalized = { id: stableId, stableKey: card.stableKey, sourceKey: group.sourceKey, sourceLabel: group.sourceLabel, title: card.title, fields: Array.isArray(card.fields) ? card.fields : [], raw: card.raw, actions: Array.isArray(card.actions) ? card.actions : [], parentCardId: parentCardId || '', createdByRequestId: requestId, childRequests: [], } session.cardsById[stableId] = normalized group.cards.push(stableId) appendedIds.push(stableId) } return appendedIds } const handleIncomingSearchPayload = (payload) => { const requestId = extractRequestId(payload) const pending = requestId ? pendingRequests.value[requestId] : pickLatestRequest() if (!pending) { return } const session = findSessionById(pending.sessionId) if (!session) { return } if (payload?.ok === false) { const normalized = adaptSearchPayload(payload) const group = ensureSourceGroup(session, normalized.sourceKey, normalized.sourceLabel) group.status = 'error' group.error = String( payload?.error?.message || payload?.error?.detail || payload?.message || 'Error en respuesta' ) // NOT_FOUND por fuente es esperado en búsquedas multi-base: no cancelar toda la solicitud. const errorCode = String(payload?.error?.code || '').toUpperCase() if (errorCode !== 'NOT_FOUND') { pending.error = group.error setChildRequestResult(pending.parentCardId, pending.requestId, 'error', [], pending.error) } return } const normalized = adaptSearchPayload(payload) const group = ensureSourceGroup(session, normalized.sourceKey, normalized.sourceLabel) const cardIds = addCardsToGroup({ session, group, cards: normalized.cards, parentCardId: pending.parentCardId, requestId: pending.requestId, }) pending.status = 'done' setChildRequestResult(pending.parentCardId, pending.requestId, 'done', cardIds) } // Inicializa el puente WebSocket para recibir los resultados de búsqueda en tiempo real const initWsBridge = () => { if (bridgeInitialized) { return } bridgeInitialized = true const { logs } = useWsMonitor() // Observa los cambios en los logs del WebSocket para procesar los mensajes entrantes watch( logs, (items) => { for (const item of items) { if (item.type !== 'incoming') { continue } if (processedLogIds.has(item.id)) { continue } processedLogIds.add(item.id) const payload = normalizeMessagePayload(item.payload) if (!payload) { continue } const isSearchPayload = Object.prototype.hasOwnProperty.call(payload, 'ok') && Object.prototype.hasOwnProperty.call(payload, 'source') && Object.prototype.hasOwnProperty.call(payload, 'data') if (isSearchPayload) { handleIncomingSearchPayload(payload) } } }, { deep: true } ) } const toggleCardSelection = (cardId) => { if (selectedSet.value.has(cardId)) { selectedCardIds.value = selectedCardIds.value.filter((id) => id !== cardId) } else { selectedCardIds.value = [...selectedCardIds.value, cardId] } } const isCardSelected = (cardId) => selectedSet.value.has(cardId) const runCardAction = ({ sessionId, parentCardId, action }) => { if (!action?.query || !action?.context) { return } dispatchSearch({ query: action.query, context: action.context, parentCardId, sessionId, }) } const selectedCards = computed(() => { const session = activeSession.value if (!session) { return [] } return selectedCardIds.value.map((id) => session.cardsById[id]).filter(Boolean) }) const exportSelectedAsJson = () => { const data = { generatedAt: new Date().toISOString(), sessionId: activeSessionId.value, cards: selectedCards.value, } const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `reporte_resultados_${Date.now()}.json` link.click() URL.revokeObjectURL(url) } const escapeHtml = (value) => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') const exportSelectedAsPdf = () => { if (!selectedCards.value.length) { return } const rows = selectedCards.value .map((card, index) => { const fields = card.fields .map((field) => `
  • ${escapeHtml(field.label)}: ${escapeHtml(field.value)}
  • `) .join('') return `

    ${index + 1}. ${escapeHtml(card.title)}

    Base: ${escapeHtml(card.sourceLabel || card.sourceKey)}

    ` }) .join('') const html = `Reporte

    Reporte de resultados

    Generado: ${escapeHtml(new Date().toLocaleString())}

    Total de cards: ${selectedCards.value.length}

    ${rows}` const win = window.open('', '_blank', 'noopener,noreferrer,width=1100,height=800') if (!win) { return } win.document.open() win.document.write(html) win.document.close() win.focus() win.print() } export const useSearchResultsStore = () => ({ sessions, activeSession, activeSessionId, pendingRequests, selectedCardIds, selectedCards, initWsBridge, dispatchSearch, toggleCardSelection, isCardSelected, runCardAction, exportSelectedAsJson, exportSelectedAsPdf, })