Spaces:
Sleeping
Sleeping
| <html lang="it"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Clorofilla</title> | |
| <style> | |
| :root { | |
| --green: #2e7d32; | |
| --light-green: #a5d6a7; | |
| --bg: #f1f8f1; | |
| --card: #ffffff; | |
| --text: #1b1b1b; | |
| --muted: #666; | |
| --radius: 12px; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: "Segoe UI", sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| padding: 2rem 1rem; | |
| } | |
| header { | |
| text-align: center; | |
| margin-bottom: 2rem; | |
| } | |
| header h1 { | |
| color: var(--green); | |
| font-size: 2rem; | |
| } | |
| header p { color: var(--muted); margin-top: .4rem; } | |
| .tabs { | |
| display: flex; | |
| justify-content: center; | |
| gap: 1rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| .tab-btn { | |
| padding: .55rem 1.6rem; | |
| border: 2px solid var(--green); | |
| border-radius: 999px; | |
| background: transparent; | |
| color: var(--green); | |
| font-size: .95rem; | |
| cursor: pointer; | |
| transition: background .2s, color .2s; | |
| } | |
| .tab-btn.active, .tab-btn:hover { | |
| background: var(--green); | |
| color: #fff; | |
| } | |
| .panel { display: none; } | |
| .panel.active { display: block; } | |
| .card { | |
| background: var(--card); | |
| border-radius: var(--radius); | |
| padding: 1.8rem; | |
| max-width: 720px; | |
| margin: 0 auto; | |
| box-shadow: 0 2px 12px rgba(0,0,0,.08); | |
| } | |
| label { display: block; font-weight: 600; margin-bottom: .5rem; } | |
| input[type="file"], | |
| input[type="text"], | |
| input[type="number"], | |
| select { | |
| width: 100%; | |
| padding: .55rem .8rem; | |
| border: 1.5px solid #ccc; | |
| border-radius: 8px; | |
| font-size: .95rem; | |
| margin-bottom: 1rem; | |
| background: #fafafa; | |
| } | |
| input:focus, select:focus { | |
| outline: none; | |
| border-color: var(--green); | |
| } | |
| .row { display: flex; gap: 1rem; } | |
| .row > * { flex: 1; } | |
| button.submit { | |
| width: 100%; | |
| padding: .7rem; | |
| background: var(--green); | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 1rem; | |
| cursor: pointer; | |
| transition: background .2s; | |
| } | |
| button.submit:hover { background: #1b5e20; } | |
| button.submit:disabled { background: #aaa; cursor: not-allowed; } | |
| /* Preview immagine caricata */ | |
| #preview-wrap { | |
| margin-bottom: 1rem; | |
| display: none; | |
| text-align: center; | |
| } | |
| #preview-wrap img { | |
| max-height: 200px; | |
| border-radius: 8px; | |
| box-shadow: 0 1px 6px rgba(0,0,0,.15); | |
| } | |
| /* Risultati ricerca immagine */ | |
| .result-list { margin-top: 1.5rem; } | |
| .result-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: .6rem .9rem; | |
| border-radius: 8px; | |
| margin-bottom: .5rem; | |
| background: var(--bg); | |
| } | |
| .result-item .species { | |
| font-weight: 600; | |
| color: #1e5f26; | |
| text-decoration: underline; | |
| cursor: pointer; | |
| } | |
| .result-item .score { color: var(--green); font-weight: 700; } | |
| .bar-wrap { flex: 1; margin: 0 1rem; height: 8px; background: #ddd; border-radius: 4px; } | |
| .bar { height: 100%; background: var(--light-green); border-radius: 4px; transition: width .5s; } | |
| /* Info pianta */ | |
| .plant-header { | |
| display: flex; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| margin-bottom: 1.2rem; | |
| } | |
| .plant-header img { | |
| height: 160px; | |
| border-radius: 8px; | |
| object-fit: cover; | |
| box-shadow: 0 1px 6px rgba(0,0,0,.15); | |
| flex-shrink: 0; | |
| } | |
| .plant-text p { line-height: 1.6; color: #333; margin-top: .5rem; } | |
| .info-section-title { | |
| margin-top: 1.4rem; | |
| margin-bottom: .7rem; | |
| color: #1e5f26; | |
| font-size: 1rem; | |
| font-weight: 700; | |
| } | |
| .profile-table-wrap { | |
| margin-top: 1rem; | |
| overflow-x: auto; | |
| border: 1px solid #d8ead9; | |
| border-radius: 10px; | |
| background: #fcfffc; | |
| } | |
| .profile-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| min-width: 560px; | |
| } | |
| .profile-table th, | |
| .profile-table td { | |
| padding: .7rem .85rem; | |
| text-align: left; | |
| vertical-align: top; | |
| border-bottom: 1px solid #e6f1e7; | |
| } | |
| .profile-table th { | |
| width: 220px; | |
| color: #245f2d; | |
| background: #f4faf4; | |
| font-weight: 700; | |
| } | |
| .profile-table tr:last-child th, | |
| .profile-table tr:last-child td { | |
| border-bottom: none; | |
| } | |
| .profile-note { | |
| margin-top: 1rem; | |
| padding: .8rem 1rem; | |
| background: #f7fbf7; | |
| border: 1px solid #d8ead9; | |
| border-radius: 10px; | |
| color: #35563a; | |
| font-size: .92rem; | |
| } | |
| .more-btn { | |
| margin-top: .8rem; | |
| padding: .45rem .9rem; | |
| border: 1px solid var(--green); | |
| background: #fff; | |
| color: var(--green); | |
| border-radius: 999px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| } | |
| .more-btn:hover { background: #f3faf3; } | |
| .more-text { | |
| margin-top: .8rem; | |
| white-space: pre-wrap; | |
| line-height: 1.6; | |
| color: #2b2b2b; | |
| } | |
| .markdown-content h1, | |
| .markdown-content h2, | |
| .markdown-content h3 { | |
| color: #1e5f26; | |
| margin: .6rem 0; | |
| } | |
| .markdown-content p { | |
| margin: .55rem 0; | |
| line-height: 1.65; | |
| } | |
| .markdown-content ul, | |
| .markdown-content ol { | |
| margin: .5rem 0 .5rem 1.2rem; | |
| } | |
| .markdown-content code { | |
| background: #eef6ef; | |
| padding: .08rem .35rem; | |
| border-radius: 4px; | |
| font-size: .9em; | |
| } | |
| .plant-title { font-size: 1.4rem; font-weight: 700; color: var(--green); } | |
| .chat-link-btn { | |
| display: inline-block; | |
| margin-top: 1rem; | |
| margin-left: .7rem; | |
| color: #fff; | |
| background: var(--green); | |
| border: 1px solid var(--green); | |
| border-radius: 999px; | |
| padding: .35rem .8rem; | |
| font-size: .88rem; | |
| cursor: pointer; | |
| text-decoration: none; | |
| } | |
| .chat-link-btn:hover { background: #1b5e20; } | |
| textarea { | |
| width: 100%; | |
| min-height: 96px; | |
| padding: .55rem .8rem; | |
| border: 1.5px solid #ccc; | |
| border-radius: 8px; | |
| font-size: .95rem; | |
| margin-bottom: 1rem; | |
| background: #fafafa; | |
| resize: vertical; | |
| font-family: inherit; | |
| } | |
| textarea:focus { | |
| outline: none; | |
| border-color: var(--green); | |
| } | |
| .chat-response { | |
| margin-top: 1rem; | |
| white-space: pre-wrap; | |
| line-height: 1.6; | |
| background: var(--bg); | |
| border-radius: 10px; | |
| padding: 1rem; | |
| border: 1px solid #d8ead9; | |
| } | |
| .chat-meta { | |
| color: var(--muted); | |
| font-size: .86rem; | |
| margin-top: .7rem; | |
| } | |
| .gallery { | |
| display: flex; | |
| gap: .6rem; | |
| flex-wrap: wrap; | |
| margin-top: 1rem; | |
| } | |
| .gallery img { | |
| height: 130px; | |
| border-radius: 8px; | |
| object-fit: cover; | |
| cursor: pointer; | |
| transition: transform .2s; | |
| } | |
| .gallery img:hover { transform: scale(1.04); } | |
| .spinner { | |
| text-align: center; | |
| padding: 2rem; | |
| color: var(--muted); | |
| font-size: .95rem; | |
| } | |
| .error-box { | |
| background: #ffebee; | |
| color: #c62828; | |
| border-radius: 8px; | |
| padding: .8rem 1rem; | |
| margin-top: 1rem; | |
| font-size: .9rem; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>🌿 Clorofilla</h1> | |
| <p>Riconosci piante e scopri informazioni botaniche</p> | |
| </header> | |
| <div class="tabs"> | |
| <button class="tab-btn active" data-tab="search" onclick="switchTab('search')">Ricerca per immagine</button> | |
| <button class="tab-btn" data-tab="info" onclick="switchTab('info')">Info su una pianta</button> | |
| <button class="tab-btn" data-tab="chat" onclick="switchTab('chat')">Chatbot cura</button> | |
| </div> | |
| <!-- ── TAB 1: Ricerca per immagine ── --> | |
| <div id="panel-search" class="panel active"> | |
| <div class="card"> | |
| <label for="img-input">Carica un'immagine</label> | |
| <input type="file" id="img-input" accept="image/*" onchange="previewImage()" /> | |
| <div id="preview-wrap"> | |
| <img id="preview-img" src="" alt="anteprima" /> | |
| </div> | |
| <div class="row"> | |
| <div> | |
| <label for="k-input">Numero di risultati (k)</label> | |
| <input type="number" id="k-input" value="5" min="1" max="50" /> | |
| </div> | |
| </div> | |
| <button class="submit" id="search-btn" onclick="doSearch()">Cerca specie simili</button> | |
| <div id="search-result"></div> | |
| </div> | |
| </div> | |
| <!-- ── TAB 2: Info pianta ── --> | |
| <div id="panel-info" class="panel"> | |
| <div class="card"> | |
| <label for="plant-name">Nome della pianta</label> | |
| <input type="text" id="plant-name" placeholder="es. Rosa canina" onkeydown="if(event.key==='Enter') doPlantInfo()" /> | |
| <div class="row"> | |
| <div> | |
| <label for="lang-select">Lingua Wikipedia</label> | |
| <select id="lang-select"> | |
| <option value="it">Italiano</option> | |
| <option value="en">English</option> | |
| <option value="fr">Français</option> | |
| <option value="de">Deutsch</option> | |
| <option value="es">Español</option> | |
| </select> | |
| </div> | |
| </div> | |
| <button class="submit" id="info-btn" onclick="doPlantInfo()">Cerca informazioni</button> | |
| <div id="info-result"></div> | |
| </div> | |
| </div> | |
| <!-- ── TAB 3: Chatbot cura piante ── --> | |
| <div id="panel-chat" class="panel"> | |
| <div class="card"> | |
| <label for="chat-plant-name">Nome della pianta</label> | |
| <input type="text" id="chat-plant-name" placeholder="es. Rosa canina" /> | |
| <label for="chat-question">Domanda</label> | |
| <textarea id="chat-question" placeholder="es. Quanta acqua devo dare in estate?"></textarea> | |
| <div class="row"> | |
| <div> | |
| <label for="chat-lang-select">Lingua contesto Wikipedia</label> | |
| <select id="chat-lang-select"> | |
| <option value="it">Italiano</option> | |
| <option value="en">English</option> | |
| <option value="fr">Français</option> | |
| <option value="de">Deutsch</option> | |
| <option value="es">Español</option> | |
| </select> | |
| </div> | |
| </div> | |
| <button class="submit" id="chat-btn" onclick="askPlantCareChat()">Chiedi al chatbot</button> | |
| <div id="chat-result"></div> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/dompurify@3.1.7/dist/purify.min.js"></script> | |
| <script> | |
| function switchTab(tab) { | |
| document.querySelectorAll('.tab-btn').forEach((b) => { | |
| b.classList.toggle('active', b.dataset.tab === tab); | |
| }); | |
| document.getElementById('panel-search').classList.toggle('active', tab === 'search'); | |
| document.getElementById('panel-info').classList.toggle('active', tab === 'info'); | |
| document.getElementById('panel-chat').classList.toggle('active', tab === 'chat'); | |
| } | |
| function previewImage() { | |
| const file = document.getElementById('img-input').files[0]; | |
| if (!file) return; | |
| const wrap = document.getElementById('preview-wrap'); | |
| const img = document.getElementById('preview-img'); | |
| img.src = URL.createObjectURL(file); | |
| wrap.style.display = 'block'; | |
| } | |
| async function doSearch() { | |
| const fileInput = document.getElementById('img-input'); | |
| const k = parseInt(document.getElementById('k-input').value) || 5; | |
| const out = document.getElementById('search-result'); | |
| const btn = document.getElementById('search-btn'); | |
| if (!fileInput.files.length) { | |
| out.innerHTML = '<div class="error-box">Seleziona un\'immagine prima di cercare.</div>'; | |
| return; | |
| } | |
| btn.disabled = true; | |
| out.innerHTML = '<div class="spinner">⏳ Ricerca in corso…</div>'; | |
| const formData = new FormData(); | |
| formData.append('file', fileInput.files[0]); | |
| try { | |
| const resp = await fetch(`/search?k=${k}`, { method: 'POST', body: formData }); | |
| if (!resp.ok) { | |
| const err = await resp.json(); | |
| if (resp.status === 503) { | |
| let extra = ''; | |
| try { | |
| const stResp = await fetch('/search/status'); | |
| if (stResp.ok) { | |
| const st = await stResp.json(); | |
| const mods = st.modules || {}; | |
| const lines = Object.keys(mods).map(k => `${k}: ${mods[k]}`); | |
| extra = `<br><br><b>Diagnostica backend:</b><br>${lines.map(escHtml).join('<br>')}`; | |
| } | |
| } catch (_) { | |
| // niente: mantieni almeno il messaggio principale | |
| } | |
| out.innerHTML = `<div class="error-box">${escHtml(err.detail || 'Backend immagini non disponibile.')}${extra}<br><br>Serve allowlist IT per librerie native Torch/FAISS nella cartella .venv.</div>`; | |
| return; | |
| } | |
| out.innerHTML = `<div class="error-box">${err.detail || 'Errore sconosciuto'}</div>`; | |
| return; | |
| } | |
| const data = await resp.json(); | |
| renderSearchResults(data.results, out); | |
| } catch (e) { | |
| out.innerHTML = `<div class="error-box">Errore di rete: ${e.message}</div>`; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| function renderSearchResults(results, container) { | |
| if (!results.length) { | |
| container.innerHTML = '<div class="error-box">Nessun risultato trovato.</div>'; | |
| return; | |
| } | |
| const maxScore = Math.max(...results.map(r => r.score)); | |
| const items = results.map(r => { | |
| const pct = maxScore > 0 ? (r.score / maxScore * 100).toFixed(1) : 0; | |
| return ` | |
| <div class="result-item"> | |
| <span class="species" onclick='openPlantInfoFromResult(${JSON.stringify(r.species)})' title="Apri informazioni su questa specie">${escHtml(r.species)}</span> | |
| <div class="bar-wrap"><div class="bar" style="width:${pct}%"></div></div> | |
| <span class="score">${r.score.toFixed(4)}</span> | |
| </div>`; | |
| }).join(''); | |
| container.innerHTML = `<div class="result-list">${items}</div>`; | |
| } | |
| function openPlantInfoFromResult(species) { | |
| document.getElementById('plant-name').value = species; | |
| switchTab('info'); | |
| doPlantInfo(); | |
| } | |
| function openChatFromPlant(plantName) { | |
| document.getElementById('chat-plant-name').value = plantName || ''; | |
| if (!document.getElementById('chat-question').value.trim()) { | |
| document.getElementById('chat-question').value = 'Come posso prendermi cura di questa pianta?'; | |
| } | |
| const infoLang = document.getElementById('lang-select').value; | |
| if (infoLang) { | |
| document.getElementById('chat-lang-select').value = infoLang; | |
| } | |
| switchTab('chat'); | |
| } | |
| async function doPlantInfo() { | |
| const name = document.getElementById('plant-name').value.trim(); | |
| const lang = document.getElementById('lang-select').value; | |
| const out = document.getElementById('info-result'); | |
| const btn = document.getElementById('info-btn'); | |
| if (!name) { | |
| out.innerHTML = '<div class="error-box">Inserisci il nome di una pianta.</div>'; | |
| return; | |
| } | |
| btn.disabled = true; | |
| out.innerHTML = '<div class="spinner">⏳ Recupero informazioni…</div>'; | |
| try { | |
| const encodedName = encodeURIComponent(name); | |
| const [resp, profileResp] = await Promise.all([ | |
| fetch(`/plant/${encodedName}?lang=${lang}`), | |
| fetch(`/plant/${encodedName}/profile`) | |
| ]); | |
| if (!resp.ok) { | |
| const err = await resp.json(); | |
| out.innerHTML = `<div class="error-box">${err.detail || 'Errore sconosciuto'}</div>`; | |
| return; | |
| } | |
| const data = await resp.json(); | |
| let profile = null; | |
| if (profileResp.ok) { | |
| profile = await profileResp.json(); | |
| } else if (profileResp.status !== 404) { | |
| const err = await profileResp.json().catch(() => ({})); | |
| out.innerHTML = `<div class="error-box">${escHtml(err.detail || 'Errore nel recupero profilo dal database.')}</div>`; | |
| return; | |
| } | |
| renderPlantInfo(data, profile, out); | |
| } catch (e) { | |
| out.innerHTML = `<div class="error-box">Errore di rete: ${e.message}</div>`; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| function renderPlantInfo(data, profile, container) { | |
| const imgs = []; | |
| const seen = new Set(); | |
| // Priorita al campo immagini esplicito del backend | |
| const apiImages = Array.isArray(data.images) ? data.images : []; | |
| for (const src of apiImages) { | |
| const s = String(src || '').trim(); | |
| if (s && !seen.has(s)) { | |
| seen.add(s); | |
| imgs.push(s); | |
| } | |
| } | |
| // Fallback: estrai immagini da HTML o markdown dentro data.markdown | |
| const markdownRaw = String(data.markdown || ''); | |
| const htmlImgRegex = /<img[^>]+src=["']([^"']+)["']/gi; | |
| let m; | |
| while ((m = htmlImgRegex.exec(markdownRaw)) !== null) { | |
| const s = String(m[1] || '').trim(); | |
| if (s && !seen.has(s)) { | |
| seen.add(s); | |
| imgs.push(s); | |
| } | |
| } | |
| const mdImgRegex = /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; | |
| while ((m = mdImgRegex.exec(markdownRaw)) !== null) { | |
| const s = String(m[1] || '').trim(); | |
| if (s && !seen.has(s)) { | |
| seen.add(s); | |
| imgs.push(s); | |
| } | |
| } | |
| // Estrai testo puro (rimuovi tag HTML dal markdown) | |
| const textOnly = (data.summary || markdownRaw | |
| .replace(/<[^>]+>/g, '') | |
| .replace(/^#+ .+\n?/m, '') | |
| .replace(/---[\s\S]*$/, '') | |
| .trim()); | |
| const markdownMain = markdownRaw | |
| .replace(/<img[^>]*>/g, '') | |
| .replace(/\n---[\s\S]*$/, '') | |
| .trim(); | |
| const moreText = (data.extended_text || '').trim(); | |
| const moreId = `more-text-${Date.now()}-${Math.floor(Math.random() * 1000)}`; | |
| const coverImage = imgs.length | |
| ? `<img src="${escHtml(imgs[0])}" alt="${escHtml(data.title)}" onerror="this.style.display='none'" />` | |
| : ''; | |
| const galleryImages = imgs.slice(1); | |
| const gallery = galleryImages.length | |
| ? `<div class="gallery">${galleryImages.map(s => `<img src="${escHtml(s)}" alt="${escHtml(data.title)}" onerror="this.style.display='none'" />`).join('')}</div>` | |
| : ''; | |
| const profileSection = renderPlantProfile(profile); | |
| container.innerHTML = ` | |
| <div style="margin-top:1.5rem"> | |
| <div class="plant-header"> | |
| ${coverImage} | |
| <div class="plant-title">${escHtml(data.title)}</div> | |
| </div> | |
| <div class="plant-text markdown-content">${renderMarkdown(markdownMain || textOnly)}</div> | |
| ${profileSection} | |
| ${gallery} | |
| ${moreText ? `<button type="button" class="more-btn" onclick="toggleMore('${moreId}', this)">More</button> | |
| <div id="${moreId}" class="more-text markdown-content" style="display:none;">${renderMarkdown(moreText)}</div>` : ''} | |
| <button type="button" class="chat-link-btn" onclick='openChatFromPlant(${JSON.stringify(data.title)})'>💬 Apri chatbot</button> | |
| </div>`; | |
| } | |
| function renderPlantProfile(profile) { | |
| if (!profile) { | |
| return '<div class="profile-note">Nessun dato strutturato disponibile in plants.db per questa specie.</div>'; | |
| } | |
| const rows = [ | |
| ['Specie', profile.species_name], | |
| ['Presente in RAG', profile.indexed ? 'Sì' : 'No'], | |
| ['Annaffiatura (giorni)', formatProfileValue(profile.annaffiatura_gg)], | |
| ['Momento annaffiatura', formatProfileValue(profile.annaffiatura_time)], | |
| ['Luce', formatProfileValue(profile.luce)], | |
| ['Temperatura', formatProfileValue(profile.temperatura)], | |
| ['Umidità', formatProfileValue(profile.umidita)], | |
| ['Altezza media', formatProfileValue(profile.altezza_media)], | |
| ['Pulizia', formatProfileValue(profile.pulizia)], | |
| ['Terriccio', formatProfileValue(profile.terriccio)], | |
| ['Concimazione', formatProfileValue(profile.concimazione)], | |
| ['Prevenzione', formatProfileValue(profile.prevenzione)], | |
| ['Ultimo aggiornamento', formatProfileValue(profile.updated_at)] | |
| ]; | |
| return ` | |
| <div class="info-section-title">Scheda strutturata dal database</div> | |
| <div class="profile-table-wrap"> | |
| <table class="profile-table"> | |
| <tbody> | |
| ${rows.map(([label, value]) => `<tr><th>${escHtml(label)}</th><td>${escHtml(value)}</td></tr>`).join('')} | |
| </tbody> | |
| </table> | |
| </div>`; | |
| } | |
| function formatProfileValue(value) { | |
| if (value === null || value === undefined) { | |
| return '—'; | |
| } | |
| const text = String(value).trim(); | |
| return text || '—'; | |
| } | |
| async function askPlantCareChat() { | |
| const plantName = document.getElementById('chat-plant-name').value.trim(); | |
| const question = document.getElementById('chat-question').value.trim(); | |
| const lang = document.getElementById('chat-lang-select').value; | |
| const out = document.getElementById('chat-result'); | |
| const btn = document.getElementById('chat-btn'); | |
| if (!plantName || !question) { | |
| out.innerHTML = '<div class="error-box">Inserisci nome pianta e domanda.</div>'; | |
| return; | |
| } | |
| btn.disabled = true; | |
| out.innerHTML = '<div class="spinner">⏳ Il chatbot sta rispondendo…</div>'; | |
| try { | |
| const resp = await fetch('/chat/plant-care', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ plant_name: plantName, question, lang }) | |
| }); | |
| const data = await resp.json(); | |
| if (!resp.ok) { | |
| out.innerHTML = `<div class="error-box">${escHtml(data.detail || 'Errore sconosciuto')}</div>`; | |
| return; | |
| } | |
| out.innerHTML = ` | |
| <div class="chat-response markdown-content">${renderMarkdown(data.answer || '')}</div> | |
| <div class="chat-meta">Fonte: <a href="${escHtml(data.source || '#')}" target="_blank" rel="noopener">Wikipedia</a> · Modello: ${escHtml(data.model || '')}</div> | |
| `; | |
| } catch (e) { | |
| out.innerHTML = `<div class="error-box">Errore di rete: ${escHtml(e.message)}</div>`; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| function toggleMore(id, btn) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const isHidden = el.style.display === 'none' || !el.style.display; | |
| el.style.display = isHidden ? 'block' : 'none'; | |
| btn.textContent = isHidden ? 'Less' : 'More'; | |
| } | |
| function escHtml(str) { | |
| return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| function renderMarkdown(md) { | |
| const text = String(md || '').trim(); | |
| if (!text) return ''; | |
| if (window.marked) { | |
| const rawHtml = window.marked.parse(text, { | |
| gfm: true, | |
| breaks: true, | |
| }); | |
| if (window.DOMPurify) { | |
| return window.DOMPurify.sanitize(rawHtml); | |
| } | |
| return rawHtml; | |
| } | |
| return basicMarkdownToHtml(text); | |
| } | |
| function basicMarkdownToHtml(md) { | |
| const src = String(md || '').replace(/\r\n/g, '\n'); | |
| const lines = src.split('\n'); | |
| const out = []; | |
| let inUl = false; | |
| let inOl = false; | |
| const closeLists = () => { | |
| if (inUl) { | |
| out.push('</ul>'); | |
| inUl = false; | |
| } | |
| if (inOl) { | |
| out.push('</ol>'); | |
| inOl = false; | |
| } | |
| }; | |
| const inline = (value) => { | |
| let s = escHtml(value); | |
| s = s.replace(/`([^`]+)`/g, '<code>$1</code>'); | |
| s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); | |
| s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>'); | |
| s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); | |
| return s; | |
| }; | |
| for (const rawLine of lines) { | |
| const line = rawLine.trim(); | |
| if (!line) { | |
| closeLists(); | |
| continue; | |
| } | |
| if (line.startsWith('### ')) { | |
| closeLists(); | |
| out.push(`<h3>${inline(line.slice(4))}</h3>`); | |
| continue; | |
| } | |
| if (line.startsWith('## ')) { | |
| closeLists(); | |
| out.push(`<h2>${inline(line.slice(3))}</h2>`); | |
| continue; | |
| } | |
| if (line.startsWith('# ')) { | |
| closeLists(); | |
| out.push(`<h1>${inline(line.slice(2))}</h1>`); | |
| continue; | |
| } | |
| if (/^[-*]\s+/.test(line)) { | |
| if (inOl) { | |
| out.push('</ol>'); | |
| inOl = false; | |
| } | |
| if (!inUl) { | |
| out.push('<ul>'); | |
| inUl = true; | |
| } | |
| out.push(`<li>${inline(line.replace(/^[-*]\s+/, ''))}</li>`); | |
| continue; | |
| } | |
| if (/^\d+\.\s+/.test(line)) { | |
| if (inUl) { | |
| out.push('</ul>'); | |
| inUl = false; | |
| } | |
| if (!inOl) { | |
| out.push('<ol>'); | |
| inOl = true; | |
| } | |
| out.push(`<li>${inline(line.replace(/^\d+\.\s+/, ''))}</li>`); | |
| continue; | |
| } | |
| closeLists(); | |
| out.push(`<p>${inline(line)}</p>`); | |
| } | |
| closeLists(); | |
| return out.join('\n'); | |
| } | |
| </script> | |
| </body> | |
| </html> | |