Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <meta name="color-scheme" content="dark" /> | |
| <title>AI Article Summarizer Β· Qwen + Kokoro</title> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root{ | |
| --bg-0:#0b0f17; | |
| --bg-1:#0f1624; | |
| --bg-2:#121a2b; | |
| --glass: rgba(255,255,255,.04); | |
| --muted: #9aa4bf; | |
| --text: #e7ecf8; | |
| --accent-1:#6d6aff; | |
| --accent-2:#7b5cff; | |
| --accent-3:#00d4ff; | |
| --ok:#21d19f; | |
| --warn:#ffb84d; | |
| --err:#ff6b6b; | |
| --ring: 0 0 0 1px rgba(255,255,255,.07), 0 0 0 6px rgba(124, 58, 237, .12); | |
| --shadow: 0 20px 60px rgba(0,0,0,.45), 0 8px 20px rgba(0,0,0,.35); | |
| --radius-xl:22px; | |
| --radius-lg:16px; | |
| --radius-md:12px; | |
| --radius-sm:10px; | |
| --grad: conic-gradient(from 220deg at 50% 50%, var(--accent-1), var(--accent-2), var(--accent-3), var(--accent-1)); | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%} | |
| body{ | |
| margin:0; | |
| font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; | |
| color:var(--text); | |
| background: | |
| radial-gradient(1200px 600px at -10% -10%, rgba(109,106,255,.20), transparent 50%), | |
| radial-gradient(900px 500px at 120% -10%, rgba(0,212,255,.16), transparent 55%), | |
| radial-gradient(1200px 900px at 50% 120%, rgba(123,92,255,.18), transparent 60%), | |
| linear-gradient(180deg, var(--bg-0), var(--bg-1) 50%, var(--bg-2)); | |
| overflow-y:auto; | |
| } | |
| /* Top progress bar */ | |
| .bar{ | |
| position:fixed; inset:0 0 auto 0; height:3px; z-index:9999; | |
| background: linear-gradient(90deg, var(--accent-3), var(--accent-2), var(--accent-1)); | |
| background-size:200% 100%; | |
| transform:scaleX(0); transform-origin:left; | |
| box-shadow:0 0 18px rgba(0,212,255,.45); | |
| transition:transform .2s ease-out; | |
| animation:bar-move 2.2s linear infinite; | |
| } | |
| @keyframes bar-move{0%{background-position:0 0}100%{background-position:200% 0}} | |
| .wrap{ | |
| max-width:1080px; margin:72px auto; padding:0 24px; | |
| } | |
| .hero{ | |
| display:flex; flex-direction:column; align-items:center; gap:14px; margin-bottom:28px; text-align:center; | |
| } | |
| .hero-badge{ | |
| display:inline-flex; align-items:center; gap:10px; padding:8px 12px; border-radius:999px; | |
| background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)); | |
| border:1px solid rgba(255,255,255,.08); | |
| backdrop-filter: blur(8px); | |
| box-shadow: var(--shadow); | |
| } | |
| .dot{width:8px;height:8px;border-radius:50%; background:var(--warn); box-shadow:0 0 0 6px rgba(255,184,77,.14)} | |
| .dot.ready{background:var(--ok); box-shadow:0 0 0 6px rgba(33,209,159,.14)} | |
| .hero h1{font-size: clamp(28px, 5vw, 44px); margin:0; font-weight:800; letter-spacing:-.02em; line-height:1.05} | |
| .grad-text{ | |
| background: linear-gradient(92deg, #f0f3ff, #bfc8ff 30%, #9ad8ff 60%, #c2b5ff 90%); | |
| -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; | |
| } | |
| .hero p{margin:0; color:var(--muted); font-size:15.5px} | |
| .panel{ | |
| position:relative; | |
| background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03)); | |
| border:1px solid rgba(255,255,255,.08); | |
| border-radius: var(--radius-xl); | |
| padding:24px; | |
| box-shadow: var(--shadow); | |
| overflow:hidden; | |
| } | |
| .panel::before{ | |
| content:""; | |
| position:absolute; inset:-1px; | |
| border-radius:inherit; | |
| padding:1px; | |
| background:linear-gradient(180deg, rgba(175,134,255,.35) 0%, rgba(0,212,255,.18) 100%); | |
| -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); | |
| -webkit-mask-composite:xor; mask-composite: exclude; | |
| pointer-events:none; | |
| } | |
| .form-grid{display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center} | |
| .input{ | |
| width:100%; | |
| background:rgba(0,0,0,.35); | |
| border:1px solid rgba(255,255,255,.12); | |
| border-radius:var(--radius-lg); | |
| padding:14px 16px; | |
| color:var(--text); | |
| font-size:15.5px; | |
| outline:none; | |
| transition:border .2s ease, box-shadow .2s ease, background .2s ease; | |
| } | |
| .input::placeholder{color:#7f8aad} | |
| .input:focus{border-color:rgba(0,212,255,.55); box-shadow: var(--ring)} | |
| .btn{ | |
| position:relative; | |
| display:inline-flex; align-items:center; justify-content:center; gap:10px; | |
| padding:14px 18px; | |
| border-radius:var(--radius-lg); | |
| border:1px solid rgba(255,255,255,.12); | |
| color:#0b0f17; font-weight:700; letter-spacing:.02em; | |
| background: linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%); | |
| box-shadow: 0 10px 30px rgba(0,212,255,.35), inset 0 1px 0 rgba(255,255,255,.15); | |
| cursor:pointer; user-select:none; | |
| transition: transform .08s ease, filter .15s ease, box-shadow .2s ease, opacity .2s ease; | |
| } | |
| .btn:hover{transform: translateY(-1px)} | |
| .btn:active{transform: translateY(0)} | |
| .btn:disabled{opacity:.55; cursor:not-allowed; filter:grayscale(.2)} | |
| .row{display:flex; flex-wrap:wrap; gap:12px; align-items:center; margin-top:14px} | |
| /* Switch */ | |
| .switch{ | |
| display:inline-flex; align-items:center; gap:12px; cursor:pointer; user-select:none; | |
| padding:10px 12px; border-radius:999px; background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08); | |
| } | |
| .switch .track{ | |
| width:44px; height:24px; background:rgba(255,255,255,.12); border-radius:999px; position:relative; transition: background .2s ease; | |
| } | |
| .switch .thumb{ | |
| width:18px; height:18px; border-radius:50%; background:white; position:absolute; top:3px; left:3px; | |
| box-shadow:0 4px 16px rgba(0,0,0,.45); | |
| transition:left .18s ease, background .2s ease, transform .18s ease; | |
| } | |
| .switch input{display:none} | |
| .switch input:checked + .track{background:linear-gradient(90deg, #00d4ff, #7b5cff)} | |
| .switch input:checked + .track .thumb{left:23px; background:#0b0f17; transform:scale(1.05)} | |
| /* Collapsible voice panel */ | |
| .collapse{ | |
| overflow:hidden; max-height:0; opacity:0; transform: translateY(-4px); | |
| transition:max-height .35s ease, opacity .25s ease, transform .25s ease; | |
| } | |
| .collapse.open{max-height:520px; opacity:1; transform:none} | |
| .voices{ | |
| display:grid; gap:12px; margin-top:12px; | |
| grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); | |
| } | |
| .voice{ | |
| position:relative; padding:14px; border-radius:var(--radius-md); | |
| background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08); | |
| transition: transform .12s ease, box-shadow .2s ease, border .2s ease, background .2s ease; | |
| cursor:pointer; | |
| } | |
| .voice:hover{transform: translateY(-2px); box-shadow: var(--shadow); border-color: rgba(0,212,255,.25)} | |
| .voice.selected{background:linear-gradient(180deg, rgba(0,212,255,.08), rgba(123,92,255,.08)); border-color: rgba(123,92,255,.55)} | |
| .voice .name{font-weight:700; letter-spacing:.01em} | |
| .voice .meta{color:var(--muted); font-size:12.5px; margin-top:6px; display:flex; gap:10px; align-items:center} | |
| .voice .badge{ | |
| font-size:11px; padding:3px 8px; border-radius:999px; border:1px solid rgba(255,255,255,.14); | |
| background:rgba(255,255,255,.05); | |
| } | |
| /* Results */ | |
| .results{margin-top:18px} | |
| .chips{display:flex; flex-wrap:wrap; gap:10px} | |
| .chip{ | |
| font-size:12.5px; color:#cdd6f6; | |
| padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03); | |
| } | |
| .toolbar{ | |
| display:flex; gap:10px; flex-wrap:wrap; margin-top:12px | |
| } | |
| .tbtn{ | |
| display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:10px; | |
| background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.1); color:var(--text); | |
| cursor:pointer; font-size:13px; transition: background .15s ease, transform .08s ease; | |
| } | |
| .tbtn:hover{background:rgba(255,255,255,.08)} | |
| .tbtn:active{transform: translateY(1px)} | |
| .summary{ | |
| margin-top:14px; | |
| background:rgba(0,0,0,.35); | |
| border:1px solid rgba(255,255,255,.1); | |
| border-radius:var(--radius-lg); | |
| padding:18px; | |
| line-height:1.7; | |
| font-size:15.5px; | |
| white-space:pre-wrap; | |
| min-height:120px; | |
| } | |
| /* Skeleton */ | |
| .skeleton{ | |
| position:relative; overflow:hidden; background:rgba(255,255,255,.06); border-radius:10px; | |
| } | |
| .skeleton::after{ | |
| content:""; position:absolute; inset:0; | |
| background:linear-gradient(100deg, transparent, rgba(255,255,255,.10), transparent); | |
| transform:translateX(-100%); animation:shine 1.2s infinite; | |
| } | |
| @keyframes shine{to{transform:translateX(100%)}} | |
| /* Messages */ | |
| .msg{ | |
| margin-top:14px; padding:12px 14px; border-radius:12px; border:1px solid rgba(255,255,255,.08); | |
| display:none; font-size:14px; | |
| } | |
| .msg.err{display:block; color:#ffd8d8; background:rgba(255,107,107,.08)} | |
| .msg.ok{display:block; color:#d9fff4; background:rgba(33,209,159,.08)} | |
| /* Audio card */ | |
| .audio{ | |
| margin-top:14px; padding:16px; | |
| background:rgba(255,255,255,.03); | |
| border:1px solid rgba(255,255,255,.08); border-radius:var(--radius-lg); | |
| } | |
| audio{width:100%; height:40px; outline:none} | |
| /* Footer note */ | |
| .foot{margin-top:14px; text-align:center; color:#7f8aad; font-size:12.5px} | |
| @media (max-width:720px){ | |
| .form-grid{grid-template-columns: 1fr} | |
| .btn{width:100%} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bar" id="bar"></div> | |
| <div class="wrap"> | |
| <header class="hero"> | |
| <div class="hero-badge" id="statusBadge"> | |
| <span class="dot" id="statusDot"></span> | |
| <span id="statusText">Loading AI modelsβ¦</span> | |
| </div> | |
| <h1><span class="grad-text">AI Article Summarizer</span></h1> | |
| <p>Qwen3-0.6B summarization Β· Kokoro neural TTS Β· smooth, private, fast</p> | |
| </header> | |
| <section class="panel"> | |
| <form id="summarizerForm" autocomplete="on"> | |
| <div class="form-grid"> | |
| <input id="articleUrl" class="input" type="url" inputmode="url" | |
| placeholder="Paste an article URL (https://β¦)" required /> | |
| <button id="submitBtn" class="btn" type="submit"> | |
| β¨ Summarize | |
| </button> | |
| </div> | |
| <div class="row"> | |
| <label class="switch" title="Generate audio with Kokoro TTS"> | |
| <input id="generateAudio" type="checkbox" /> | |
| <span class="track"><span class="thumb"></span></span> | |
| <span>π΅ Text-to-Speech</span> | |
| </label> | |
| <span class="chip">Models: Qwen3-0.6B Β· Kokoro</span> | |
| <span class="chip">On-device processing</span> | |
| </div> | |
| <div id="voiceSection" class="collapse" aria-hidden="true"> | |
| <div class="voices" id="voiceGrid"> | |
| <!-- Injected --> | |
| </div> | |
| </div> | |
| </form> | |
| <!-- Loading skeleton --> | |
| <div id="loadingSection" style="display:none; margin-top:18px"> | |
| <div class="skeleton" style="height:18px; width:42%; margin-bottom:10px"></div> | |
| <div class="skeleton" style="height:14px; width:90%; margin-bottom:8px"></div> | |
| <div class="skeleton" style="height:14px; width:86%; margin-bottom:8px"></div> | |
| <div class="skeleton" style="height:14px; width:88%; margin-bottom:8px"></div> | |
| <div class="skeleton" style="height:14px; width:60%; margin-bottom:8px"></div> | |
| </div> | |
| <!-- Results --> | |
| <div id="resultSection" class="results" style="display:none"> | |
| <div class="chips" id="stats"></div> | |
| <div class="toolbar"> | |
| <button class="tbtn" id="copyBtn" type="button">π Copy summary</button> | |
| <a class="tbtn" id="downloadAudioBtn" href="#" download style="display:none">β¬οΈ Download audio</a> | |
| </div> | |
| <div id="summaryContent" class="summary"></div> | |
| <div id="audioSection" class="audio" style="display:none"> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px"> | |
| <strong>π§ Audio Playback</strong> | |
| <span id="duration" style="color:var(--muted); font-size:12.5px"></span> | |
| </div> | |
| <audio id="audioPlayer" controls preload="none"></audio> | |
| </div> | |
| </div> | |
| <div id="errorMessage" class="msg err"></div> | |
| <div id="successMessage" class="msg ok"></div> | |
| </section> | |
| <p class="foot">Tip: turn on TTS and pick a voice you like. Weβll remember your last choice.</p> | |
| </div> | |
| <script> | |
| // ---------------- State ---------------- | |
| let modelsReady = false; | |
| let selectedVoice = localStorage.getItem("voiceId") || "af_heart"; | |
| const bar = document.getElementById("bar"); | |
| // --------------- Utilities -------------- | |
| const $ = (sel) => document.querySelector(sel); | |
| function showBar(active) { | |
| bar.style.transform = active ? "scaleX(1)" : "scaleX(0)"; | |
| } | |
| function setStatus(ready, error){ | |
| const dot = $("#statusDot"); | |
| const text = $("#statusText"); | |
| const badge = $("#statusBadge"); | |
| if (error){ | |
| dot.classList.remove("ready"); | |
| text.textContent = "Model error: " + error; | |
| badge.style.borderColor = "rgba(255,107,107,.45)"; | |
| return; | |
| } | |
| if (ready){ | |
| dot.classList.add("ready"); | |
| text.textContent = "Models ready"; | |
| } else { | |
| dot.classList.remove("ready"); | |
| text.textContent = "Loading AI modelsβ¦"; | |
| } | |
| } | |
| function chip(text){ const span = document.createElement("span"); span.className="chip"; span.textContent=text; return span; } | |
| function fmt(x){ return new Intl.NumberFormat().format(x); } | |
| // ------------- Model status poll --------- | |
| async function checkModelStatus(){ | |
| try{ | |
| const res = await fetch("/status"); | |
| const s = await res.json(); | |
| modelsReady = !!s.loaded; | |
| setStatus(modelsReady, s.error || null); | |
| if (!modelsReady && !s.error) setTimeout(checkModelStatus, 1500); | |
| if (modelsReady) { await loadVoices(); } | |
| }catch(e){ | |
| setTimeout(checkModelStatus, 2000); | |
| } | |
| } | |
| // ------------- Voice loading ------------- | |
| async function loadVoices(){ | |
| try{ | |
| const res = await fetch("/voices"); | |
| const voices = await res.json(); | |
| const grid = $("#voiceGrid"); | |
| grid.innerHTML = ""; | |
| voices.forEach(v=>{ | |
| const el = document.createElement("div"); | |
| el.className = "voice" + (v.id === selectedVoice ? " selected":""); | |
| el.dataset.voice = v.id; | |
| el.innerHTML = ` | |
| <div class="name">${v.name}</div> | |
| <div class="meta"> | |
| <span class="badge">Grade ${v.grade}</span> | |
| <span>${v.description || ""}</span> | |
| </div>`; | |
| el.addEventListener("click", ()=>{ | |
| document.querySelectorAll(".voice").forEach(x=>x.classList.remove("selected")); | |
| el.classList.add("selected"); | |
| selectedVoice = v.id; | |
| localStorage.setItem("voiceId", selectedVoice); | |
| }); | |
| grid.appendChild(el); | |
| }); | |
| }catch(e){ | |
| // ignore | |
| } | |
| } | |
| // ------------- Collapsible voices -------- | |
| const generateAudio = $("#generateAudio"); | |
| const voiceSection = $("#voiceSection"); | |
| function toggleVoices(open){ | |
| voiceSection.classList.toggle("open", !!open); | |
| voiceSection.setAttribute("aria-hidden", open ? "false" : "true"); | |
| } | |
| generateAudio.addEventListener("change", e=> toggleVoices(e.target.checked)); | |
| toggleVoices(generateAudio.checked); // on load | |
| // ------------- Form submit ---------------- | |
| const form = $("#summarizerForm"); | |
| const loading = $("#loadingSection"); | |
| const result = $("#resultSection"); | |
| const errorBox = $("#errorMessage"); | |
| const okBox = $("#successMessage"); | |
| const submitBtn = $("#submitBtn"); | |
| const urlInput = $("#articleUrl"); | |
| form.addEventListener("submit", async (e)=>{ | |
| e.preventDefault(); | |
| errorBox.style.display="none"; okBox.style.display="none"; | |
| if (!modelsReady){ | |
| errorBox.textContent = "Please wait for the AI models to finish loading."; | |
| errorBox.style.display = "block"; | |
| return; | |
| } | |
| const url = urlInput.value.trim(); | |
| if (!url){ return; } | |
| submitBtn.disabled = true; | |
| showBar(true); | |
| loading.style.display = "block"; | |
| result.style.display = "none"; | |
| try{ | |
| const res = await fetch("/process", { | |
| method: "POST", | |
| headers: {"Content-Type":"application/json"}, | |
| body: JSON.stringify({ | |
| url, | |
| generate_audio: generateAudio.checked, | |
| voice: selectedVoice | |
| }) | |
| }); | |
| const data = await res.json(); | |
| loading.style.display = "none"; | |
| submitBtn.disabled = false; | |
| showBar(false); | |
| if (!data.success){ | |
| errorBox.textContent = data.error || "Something went wrong."; | |
| errorBox.style.display = "block"; | |
| return; | |
| } | |
| renderResult(data); | |
| okBox.textContent = "Done!"; | |
| okBox.style.display = "block"; | |
| setTimeout(()=> okBox.style.display="none", 1800); | |
| }catch(err){ | |
| loading.style.display="none"; | |
| submitBtn.disabled=false; | |
| showBar(false); | |
| errorBox.textContent = "Network error: " + (err?.message || err); | |
| errorBox.style.display = "block"; | |
| } | |
| }); | |
| // ------------- Render results ------------- | |
| const stats = $("#stats"); | |
| const summaryEl = $("#summaryContent"); | |
| const audioWrap = $("#audioSection"); | |
| const audioEl = $("#audioPlayer"); | |
| const dlBtn = $("#downloadAudioBtn"); | |
| const durationLabel = $("#duration"); | |
| const copyBtn = $("#copyBtn"); | |
| function renderResult(r){ | |
| // Stats | |
| stats.innerHTML = ""; | |
| stats.appendChild(chip(`π ${fmt(r.article_length)} β ${fmt(r.summary_length)} chars`)); | |
| stats.appendChild(chip(`π ${r.compression_ratio}% compression`)); | |
| stats.appendChild(chip(`π ${r.timestamp}`)); | |
| // Summary | |
| summaryEl.textContent = r.summary || ""; | |
| result.style.display = "block"; | |
| // Audio | |
| if (r.audio_file){ | |
| audioEl.src = r.audio_file; | |
| audioWrap.style.display = "block"; | |
| durationLabel.textContent = `${r.audio_duration}s`; | |
| dlBtn.style.display = "inline-flex"; | |
| dlBtn.href = r.audio_file; | |
| dlBtn.download = r.audio_file.split("/").pop() || "summary.wav"; | |
| } else { | |
| audioWrap.style.display = "none"; | |
| dlBtn.style.display = "none"; | |
| } | |
| } | |
| // Copy summary | |
| copyBtn.addEventListener("click", async ()=>{ | |
| try{ | |
| await navigator.clipboard.writeText(summaryEl.textContent || ""); | |
| copyBtn.textContent = "β Copied"; | |
| setTimeout(()=> copyBtn.textContent = "π Copy summary", 900); | |
| }catch(e){ | |
| // ignore | |
| } | |
| }); | |
| // ------------- Quality of life ------------- | |
| // Paste on Cmd/Ctrl+V if input empty | |
| window.addEventListener("paste", (e)=>{ | |
| if(document.activeElement !== urlInput && !urlInput.value){ | |
| const t = (e.clipboardData || window.clipboardData).getData("text"); | |
| if (t?.startsWith("http")){ urlInput.value = t; } | |
| } | |
| }); | |
| // Init | |
| document.addEventListener("DOMContentLoaded", ()=>{ | |
| checkModelStatus(); | |
| // Restore voice toggle state hint | |
| if (localStorage.getItem("voiceId")) selectedVoice = localStorage.getItem("voiceId"); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |