// kb-query frontend -- module 5 phase 4 (kb/phases/kb-m5-faza4.md §7). Vanilla JS, no // build step (plan §2 decision 4): fetch()s /search, renders results grouped by envelope_id. // // Threshold rule (plan §7, task spec): dist < 0.45 green, 0.45-0.55 yellow (still rendered with // a visual warning), > 0.55 never rendered as an individual result. If a query ends up with zero // renderable chunks (every hit > 0.55, or no hits at all), the whole group list is replaced by // one message instead -- "brak odpowiedzi w KB", carrying the best (lowest) dist seen so the // user can tell a near-miss from nothing at all. (function () { 'use strict'; const GREEN_MAX = 0.45; const YELLOW_MAX = 0.55; function buildSearchUrl(query, mode) { const params = new URLSearchParams({ q: query, mode: mode }); return '/search?' + params.toString(); } function distClass(dist) { if (dist < GREEN_MAX) return 'dist-green'; if (dist <= YELLOW_MAX) return 'dist-yellow'; return 'dist-red'; } // Flat /search results -> per-envelope groups, chunks sorted best-first within each group. // Insertion order follows first appearance, which already tracks best-group-first since the // API's own result list is globally dist-sorted (kb_retrieval.cascade_retrieve/flat_retrieve). function groupResults(results) { const groups = []; const byId = new Map(); for (const r of results) { let group = byId.get(r.envelope_id); if (!group) { group = { envelopeId: r.envelope_id, source: r.source, summary: r.summary, summaryTags: r.summary_tags || [], link: r.link, mailUiUrl: r.mail_ui_url, subject: r.subject, from: r.from, date: r.date, chunks: [], }; byId.set(r.envelope_id, group); groups.push(group); } group.chunks.push(r); } for (const group of groups) { group.chunks.sort((a, b) => a.dist - b.dist); } return groups; } function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text); } // Fallback for plain-http LAN access (no secure context -> Clipboard API unavailable). const el = document.createElement('textarea'); el.value = text; el.style.position = 'fixed'; el.style.opacity = '0'; document.body.appendChild(el); el.select(); try { document.execCommand('copy'); } finally { document.body.removeChild(el); } return Promise.resolve(); } function renderSolStatus(el, status) { el.textContent = 'SOLARIA: ' + (status === 'up' ? 'online' : 'offline (fallback embed)'); el.className = 'sol-status ' + (status === 'up' ? 'sol-up' : 'sol-down'); } function renderChunk(chunk) { const details = document.createElement('details'); details.className = 'chunk ' + distClass(chunk.dist); const summaryEl = document.createElement('summary'); summaryEl.textContent = 'Fragment ' + chunk.chunk_index + ' (dist ' + chunk.dist.toFixed(4) + ')'; const body = document.createElement('pre'); body.className = 'chunk-text'; body.textContent = chunk.text; details.appendChild(summaryEl); details.appendChild(body); return details; } function renderGroup(group) { const article = document.createElement('article'); article.className = 'result-group'; const header = document.createElement('h3'); header.textContent = group.summary || group.envelopeId; article.appendChild(header); if (group.summaryTags.length) { const tags = document.createElement('div'); tags.className = 'tags'; tags.textContent = group.summaryTags.join(', '); article.appendChild(tags); } const meta = document.createElement('div'); meta.className = 'source-meta'; if (group.source === 'paperless' && group.link) { const a = document.createElement('a'); a.href = group.link; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'Otwórz w Paperless'; meta.appendChild(a); } else if (group.source === 'gmail') { const info = document.createElement('span'); info.textContent = [group.subject, group.from, group.date].filter(Boolean).join(' — '); meta.appendChild(info); const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'Kopiuj Message-ID'; btn.addEventListener('click', function () { copyToClipboard(group.envelopeId).then(function () { btn.textContent = 'Skopiowano!'; setTimeout(function () { btn.textContent = 'Kopiuj Message-ID'; }, 1500); }); }); meta.appendChild(btn); } if (meta.childNodes.length) { article.appendChild(meta); } for (const chunk of group.chunks) { article.appendChild(renderChunk(chunk)); } return article; } function render(container, data) { container.innerHTML = ''; let bestDist = null; for (const r of data.results) { if (bestDist === null || r.dist < bestDist) bestDist = r.dist; } const groups = groupResults(data.results); let anyVisible = false; for (const group of groups) { group.chunks = group.chunks.filter(function (c) { return c.dist <= YELLOW_MAX; }); if (group.chunks.length) anyVisible = true; } if (!anyVisible) { const msg = document.createElement('p'); msg.className = 'no-answer'; msg.textContent = bestDist === null ? 'Brak odpowiedzi w KB dla tego zapytania.' : 'Brak odpowiedzi w KB dla tego zapytania (najlepszy dist: ' + bestDist.toFixed(4) + ').'; container.appendChild(msg); return; } for (const group of groups) { if (group.chunks.length) { container.appendChild(renderGroup(group)); } } } function init() { const form = document.getElementById('search-form'); const queryInput = document.getElementById('query'); const modeToggle = document.getElementById('mode-flat'); const results = document.getElementById('results'); const errorBox = document.getElementById('error'); const solStatusEl = document.getElementById('sol-status'); async function refreshSolStatus() { try { const resp = await fetch('/healthz'); const data = await resp.json(); renderSolStatus(solStatusEl, data.sol_status); } catch (err) { renderSolStatus(solStatusEl, 'down'); } } form.addEventListener('submit', async function (event) { event.preventDefault(); const query = queryInput.value.trim(); if (!query) return; const mode = modeToggle.checked ? 'flat' : 'cascade'; errorBox.hidden = true; results.innerHTML = '
Szukam…
'; try { const resp = await fetch(buildSearchUrl(query, mode)); if (!resp.ok) { const body = await resp.json().catch(function () { return {}; }); throw new Error(body.detail || ('Błąd wyszukiwania (' + resp.status + ')')); } const data = await resp.json(); render(results, data); renderSolStatus(solStatusEl, data.sol_status); } catch (err) { results.innerHTML = ''; errorBox.textContent = 'Wyszukiwanie chwilowo niedostępne: ' + err.message; errorBox.hidden = false; } }); refreshSolStatus(); } if (typeof module !== 'undefined' && module.exports) { module.exports = { buildSearchUrl, groupResults, distClass }; } else { document.addEventListener('DOMContentLoaded', init); } })();