Files
sibilla/static/app.js
T
Sibilla 76bc667adf feat: v0.2.0 — API REST, TTS support, documentazione completa e UI mobile-first
✨ Nuove funzionalità:
- Nuovo endpoint API REST /api/v1/query (GET+POST) per consumo programmatico
- Campo speakable_text ottimizzato per sintesi vocale (Alexa, Google Home)
- Funzione strip_markdown() per conversione Markdown → plain text TTS
- Supporto CORS per accesso cross-origin
- UI responsive mobile-first con micro-interazioni e stati
- Nuovo pulsante 'Nuova ricerca' e metadata bar

📚 Documentazione completa:
- docs/ARCHITECTURE.md — architettura di sistema, stack, componenti
- docs/FLOW.md — diagrammi Mermaid della pipeline e flussi
- docs/API.md — documentazione endpoint REST con esempi curl
- docs/WIKI.md — FAQ, glossario, roadmap
- docs/DEPLOYMENT.md — guida deployment (Gunicorn, Nginx, Docker, systemd)
- docs/CONTRIBUTING.md — guida alla contribuzione

🔧 Migliorie tecniche:
- Pipeline di ricerca condivisa in _run_search_pipeline()
- Gestione errori Gemini migliorata con _clean_error()
- Sanitizzazione HTML con allowlist per prevenzione XSS
- .env.example completo con commenti

🧪 Test: 109/109 passati
2026-07-18 10:05:46 +00:00

160 lines
5.7 KiB
JavaScript

/**
* Sibilla — Frontend search interface
* Mobile-friendly, smooth UX
*/
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('search-form');
const queryInput = document.getElementById('query');
const submitBtn = document.getElementById('submit-btn');
const responseArea = document.getElementById('response-area');
const loading = document.getElementById('loading');
const answerDiv = document.getElementById('answer');
const sourcesDiv = document.getElementById('sources');
const metaBar = document.getElementById('meta-bar');
const newSearchBtn = document.getElementById('new-search-btn');
const btnText = submitBtn.querySelector('span').textContent;
let isSearching = false;
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (isSearching) return;
const query = queryInput.value.trim();
if (!query) return;
// Reset and show UI
isSearching = true;
responseArea.style.display = 'block';
loading.style.display = 'flex';
answerDiv.innerHTML = '';
sourcesDiv.innerHTML = '';
metaBar.innerHTML = '';
metaBar.style.display = 'none';
newSearchBtn.classList.remove('visible');
submitBtn.disabled = true;
submitBtn.querySelector('span').textContent = 'Consultando...';
// Scroll to loading indicator on mobile
loading.scrollIntoView({ behavior: 'smooth', block: 'start' });
try {
const res = await fetch('/api/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Errore sconosciuto');
}
const data = await res.json();
// Render answer — prefer server-rendered HTML, fall back to client-side markdown
if (data.html_answer) {
answerDiv.innerHTML = data.html_answer;
} else {
answerDiv.innerHTML = renderMarkdown(data.answer);
}
// Render metadata bar
if (data.search_engine || data.tokens_used) {
let metaHtml = '';
if (data.search_engine && data.search_engine !== 'none') {
metaHtml += `<span class="meta-badge engine">🔍 ${data.search_engine}</span>`;
}
if (data.model) {
metaHtml += `<span class="meta-badge">🧠 ${data.model}</span>`;
}
if (data.tokens_used) {
metaHtml += `<span class="meta-badge">📊 ~${data.tokens_used} token</span>`;
}
metaBar.innerHTML = metaHtml;
metaBar.style.display = 'flex';
}
// Render sources
if (data.sources && data.sources.length > 0) {
sourcesDiv.innerHTML = '<h3>📚 Fonti</h3>' +
data.sources.map(s =>
`<a href="${escapeHtml(s.url)}" target="_blank" rel="noopener" class="source-item">
<span class="source-index">${s.index}</span>
<span class="source-title">${escapeHtml(s.title)}</span>
<span class="source-url">${escapeHtml(s.url)}</span>
</a>`
).join('');
}
// Show "new search" button and scroll to results
newSearchBtn.classList.add('visible');
answerDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (err) {
answerDiv.innerHTML = `<div class="error-box">⚠️ ${escapeHtml(err.message)}</div>`;
answerDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
} finally {
loading.style.display = 'none';
submitBtn.disabled = false;
submitBtn.querySelector('span').textContent = btnText;
isSearching = false;
}
});
// "New search" button — scroll back to query input
newSearchBtn.addEventListener('click', () => {
queryInput.focus();
queryInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
// Allow Ctrl+Enter or Cmd+Enter to submit
queryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
form.dispatchEvent(new Event('submit'));
}
});
});
// ── Tiny Markdown renderer ─────────────────────────────────────
function renderMarkdown(text) {
if (!text) return '';
let html = escapeHtml(text);
// Headers
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold + italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Inline code
html = html.replace(/`(.+?)`/g, '<code>$1</code>');
// Links
html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Paragraphs (double newlines)
html = html.replace(/\n\n/g, '</p><p>');
html = '<p>' + html + '</p>';
html = html.replace(/<p>\s*<\/p>/g, '');
// Line breaks
html = html.replace(/\n/g, '<br>');
return html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}