article-summarizer / works.html
Vineeth Sai
UI: uniform background + add Paste Text mode; API: accept text & strip <think>
05db4f1
<!DOCTYPE html>
<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>