Vineeth Sai commited on
Commit
05db4f1
·
1 Parent(s): f05c22e

UI: uniform background + add Paste Text mode; API: accept text & strip <think>

Browse files
Files changed (5) hide show
  1. app.py +31 -277
  2. templates/index.html +156 -206
  3. test.py +385 -0
  4. works.html +528 -0
  5. works.py +372 -0
app.py CHANGED
@@ -1,262 +1,3 @@
1
- # #!/usr/bin/env python3
2
- # """
3
- # Flask Web Application for Article Summarizer with TTS
4
- # """
5
-
6
- # from flask import Flask, render_template, request, jsonify, send_file, url_for
7
- # import os
8
- # import sys
9
- # import torch
10
- # import trafilatura
11
- # import soundfile as sf
12
- # import time
13
- # import threading
14
- # from datetime import datetime
15
- # from transformers import AutoModelForCausalLM, AutoTokenizer
16
- # from kokoro import KPipeline
17
- # import logging
18
-
19
- # # Configure logging
20
- # logging.basicConfig(level=logging.INFO)
21
- # logger = logging.getLogger(__name__)
22
-
23
- # app = Flask(__name__)
24
- # app.config['SECRET_KEY'] = 'your-secret-key-here'
25
-
26
- # # Global variables to store models (load once, use many times)
27
- # qwen_model = None
28
- # qwen_tokenizer = None
29
- # kokoro_pipeline = None
30
- # model_loading_status = {"loaded": False, "error": None}
31
-
32
- # # Create directories for generated files
33
- # os.makedirs("static/audio", exist_ok=True)
34
- # os.makedirs("static/summaries", exist_ok=True)
35
-
36
- # def load_models():
37
- # """Load Qwen and Kokoro models on startup"""
38
- # global qwen_model, qwen_tokenizer, kokoro_pipeline, model_loading_status
39
-
40
- # try:
41
- # logger.info("Loading Qwen3-0.6B model...")
42
- # model_name = "Qwen/Qwen3-0.6B"
43
-
44
- # qwen_tokenizer = AutoTokenizer.from_pretrained(model_name)
45
- # qwen_model = AutoModelForCausalLM.from_pretrained(
46
- # model_name,
47
- # torch_dtype="auto",
48
- # device_map="auto"
49
- # )
50
-
51
- # logger.info("Loading Kokoro TTS model...")
52
- # kokoro_pipeline = KPipeline(lang_code='a')
53
-
54
- # model_loading_status["loaded"] = True
55
- # logger.info("All models loaded successfully!")
56
-
57
- # except Exception as e:
58
- # model_loading_status["error"] = str(e)
59
- # logger.error(f"Failed to load models: {e}")
60
-
61
- # def scrape_article_text(url: str) -> tuple[str, str]:
62
- # """
63
- # Scrape article and return (content, error_message)
64
- # """
65
- # try:
66
- # downloaded = trafilatura.fetch_url(url)
67
- # if downloaded is None:
68
- # return None, "Failed to download the article content."
69
-
70
- # article_text = trafilatura.extract(downloaded, include_comments=False, include_tables=False)
71
- # if article_text:
72
- # return article_text, None
73
- # else:
74
- # return None, "Could not find main article text on the page."
75
-
76
- # except Exception as e:
77
- # return None, f"Error scraping article: {str(e)}"
78
-
79
- # def summarize_with_qwen(text: str) -> tuple[str, str]:
80
- # """
81
- # Generate summary and return (summary, error_message)
82
- # """
83
- # try:
84
- # prompt = f"""
85
- # Please provide a concise and clear summary of the following article.
86
- # Focus on the main points, key findings, and conclusions. The summary should be
87
- # easy to understand for someone who has not read the original text.
88
-
89
- # ARTICLE:
90
- # {text}
91
- # """
92
-
93
- # messages = [{"role": "user", "content": prompt}]
94
-
95
- # text_input = qwen_tokenizer.apply_chat_template(
96
- # messages,
97
- # tokenize=False,
98
- # add_generation_prompt=True,
99
- # enable_thinking=False
100
- # )
101
-
102
- # model_inputs = qwen_tokenizer([text_input], return_tensors="pt").to(qwen_model.device)
103
-
104
- # generated_ids = qwen_model.generate(
105
- # **model_inputs,
106
- # max_new_tokens=512,
107
- # temperature=0.7,
108
- # top_p=0.8,
109
- # top_k=20
110
- # )
111
-
112
- # output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
113
- # summary = qwen_tokenizer.decode(output_ids, skip_special_tokens=True).strip()
114
-
115
- # return summary, None
116
-
117
- # except Exception as e:
118
- # return None, f"Error generating summary: {str(e)}"
119
-
120
- # def generate_speech(summary: str, voice: str) -> tuple[str, str, float]:
121
- # """
122
- # Generate speech and return (filename, error_message, duration)
123
- # """
124
- # try:
125
- # generator = kokoro_pipeline(summary, voice=voice)
126
-
127
- # audio_chunks = []
128
- # total_duration = 0
129
-
130
- # for i, (gs, ps, audio) in enumerate(generator):
131
- # audio_chunks.append(audio)
132
- # total_duration += len(audio) / 24000
133
-
134
- # if len(audio_chunks) > 1:
135
- # combined_audio = torch.cat(audio_chunks, dim=0)
136
- # else:
137
- # combined_audio = audio_chunks[0]
138
-
139
- # # Generate unique filename
140
- # timestamp = int(time.time())
141
- # filename = f"summary_{timestamp}.wav"
142
- # filepath = os.path.join("static", "audio", filename)
143
-
144
- # sf.write(filepath, combined_audio.numpy(), 24000)
145
-
146
- # return filename, None, total_duration
147
-
148
- # except Exception as e:
149
- # return None, f"Error generating speech: {str(e)}", 0
150
-
151
- # @app.route('/')
152
- # def index():
153
- # """Main page"""
154
- # return render_template('index.html')
155
-
156
- # @app.route('/status')
157
- # def status():
158
- # """Check if models are loaded"""
159
- # return jsonify(model_loading_status)
160
-
161
- # @app.route('/process', methods=['POST'])
162
- # def process_article():
163
- # """Process article URL - scrape, summarize, and optionally generate speech"""
164
-
165
- # if not model_loading_status["loaded"]:
166
- # return jsonify({
167
- # "success": False,
168
- # "error": "Models not loaded yet. Please wait."
169
- # })
170
-
171
- # data = request.get_json()
172
- # url = data.get('url', '').strip()
173
- # generate_audio = data.get('generate_audio', False)
174
- # voice = data.get('voice', 'af_heart')
175
-
176
- # if not url:
177
- # return jsonify({"success": False, "error": "Please provide a valid URL."})
178
-
179
- # # Step 1: Scrape article
180
- # article_content, scrape_error = scrape_article_text(url)
181
- # if scrape_error:
182
- # return jsonify({"success": False, "error": scrape_error})
183
-
184
- # # Step 2: Generate summary
185
- # summary, summary_error = summarize_with_qwen(article_content)
186
- # if summary_error:
187
- # return jsonify({"success": False, "error": summary_error})
188
-
189
- # # Prepare response
190
- # response_data = {
191
- # "success": True,
192
- # "summary": summary,
193
- # "article_length": len(article_content),
194
- # "summary_length": len(summary),
195
- # "compression_ratio": round(len(summary) / len(article_content) * 100, 1),
196
- # "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
197
- # }
198
-
199
- # # Step 3: Generate speech if requested
200
- # if generate_audio:
201
- # audio_filename, audio_error, duration = generate_speech(summary, voice)
202
- # if audio_error:
203
- # response_data["audio_error"] = audio_error
204
- # else:
205
- # response_data["audio_file"] = f"/static/audio/{audio_filename}"
206
- # response_data["audio_duration"] = round(duration, 2)
207
-
208
- # return jsonify(response_data)
209
-
210
- # @app.route('/voices')
211
- # def get_voices():
212
- # """Get available voice options"""
213
- # voices = [
214
- # {"id": "af_heart", "name": "Female - Heart", "grade": "A", "description": "❤️ Warm female voice (best quality)"},
215
- # {"id": "af_bella", "name": "Female - Bella", "grade": "A-", "description": "🔥 Energetic female voice"},
216
- # {"id": "af_nicole", "name": "Female - Nicole", "grade": "B-", "description": "🎧 Professional female voice"},
217
- # {"id": "am_michael", "name": "Male - Michael", "grade": "C+", "description": "Clear male voice"},
218
- # {"id": "am_fenrir", "name": "Male - Fenrir", "grade": "C+", "description": "Strong male voice"},
219
- # {"id": "af_sarah", "name": "Female - Sarah", "grade": "C+", "description": "Gentle female voice"},
220
- # {"id": "bf_emma", "name": "British Female - Emma", "grade": "B-", "description": "🇬🇧 British accent"},
221
- # {"id": "bm_george", "name": "British Male - George", "grade": "C", "description": "🇬🇧 British male voice"}
222
- # ]
223
- # return jsonify(voices)
224
- # # Kick off model loading when running under Gunicorn/containers
225
- # if os.environ.get("RUNNING_GUNICORN", "0") == "1":
226
- # threading.Thread(target=load_models, daemon=True).start()
227
-
228
-
229
- # if __name__ == '__main__':
230
- # import argparse
231
-
232
- # # Parse command line arguments
233
- # parser = argparse.ArgumentParser(description='AI Article Summarizer Web App')
234
- # parser.add_argument('--port', type=int, default=5001, help='Port to run the server on (default: 5001)')
235
- # parser.add_argument('--host', type=str, default='0.0.0.0', help='Host to bind to (default: 0.0.0.0)')
236
- # args = parser.parse_args()
237
-
238
- # # Load models in background thread
239
- # threading.Thread(target=load_models, daemon=True).start()
240
-
241
- # # Run Flask app
242
- # print("🚀 Starting Article Summarizer Web App...")
243
- # print("📚 Models are loading in the background...")
244
- # print(f"🌐 Open http://localhost:{args.port} in your browser")
245
-
246
- # try:
247
- # app.run(debug=True, host=args.host, port=args.port)
248
- # except OSError as e:
249
- # if "Address already in use" in str(e):
250
- # print(f"❌ Port {args.port} is already in use!")
251
- # print("💡 Try a different port:")
252
- # print(f" python app.py --port {args.port + 1}")
253
- # print("📱 Or disable AirPlay Receiver in System Preferences → General → AirDrop & Handoff")
254
- # else:
255
- # raise
256
-
257
-
258
-
259
-
260
  #!/usr/bin/env python3
261
  """
262
  Flask Web Application for Article Summarizer with TTS
@@ -269,13 +10,14 @@ import threading
269
  import logging
270
  from datetime import datetime
271
  import re
 
272
 
273
  import torch
274
  import trafilatura
275
  import soundfile as sf
 
276
  from transformers import AutoModelForCausalLM, AutoTokenizer
277
  from kokoro import KPipeline
278
- import requests # ensure requests>=2.32.0 in requirements.txt
279
 
280
  # ---------------- Logging ----------------
281
  logging.basicConfig(level=logging.INFO)
@@ -310,12 +52,7 @@ BROWSER_HEADERS = {
310
  "Accept-Language": "en-US,en;q=0.9",
311
  }
312
 
313
- # Create output dirs
314
- # os.makedirs("static/audio", exist_ok=True)
315
- # os.makedirs("static/summaries", exist_ok=True)
316
-
317
  # Create output dirs (robust, relative to this file)
318
- from pathlib import Path
319
  BASE_DIR = Path(__file__).parent.resolve()
320
  STATIC_DIR = BASE_DIR / "static"
321
  AUDIO_DIR = STATIC_DIR / "audio"
@@ -327,7 +64,6 @@ for p in (AUDIO_DIR, SUMM_DIR):
327
  except PermissionError:
328
  logger.warning("No permission to create %s (will rely on image pre-created dirs).", p)
329
 
330
-
331
  # ---------------- Helpers ----------------
332
  def _get_device():
333
  # Works for both CPU/GPU; safer than qwen_model.device
@@ -350,8 +86,7 @@ _THINK_TAGS_RE = re.compile(r"</?\s*(think|reasoning|thought)\b[^>]*>", re.IGNOR
350
  def _strip_reasoning(text: str) -> str:
351
  cleaned = _THINK_BLOCK_RE.sub("", text) # remove full blocks
352
  cleaned = _THINK_TAGS_RE.sub("", cleaned) # remove any stray tags
353
- # optionally collapse leftover triple-backtick blocks that only had think text
354
- cleaned = re.sub(r"```(?:\w+)?\s*```", "", cleaned)
355
  return cleaned.strip()
356
 
357
  def _normalize_url_for_proxy(u: str) -> str:
@@ -359,6 +94,17 @@ def _normalize_url_for_proxy(u: str) -> str:
359
  u2 = u.replace("https://", "").replace("http://", "")
360
  return f"https://r.jina.ai/http://{u2}"
361
 
 
 
 
 
 
 
 
 
 
 
 
362
  # ---------------- Model Load ----------------
363
  def load_models():
364
  """Load Qwen and Kokoro models on startup (idempotent)."""
@@ -427,7 +173,7 @@ def scrape_article_text(url: str) -> tuple[str | None, str | None]:
427
  try:
428
  pr = requests.get(proxy_url, headers=BROWSER_HEADERS, timeout=15)
429
  if pr.status_code == 200 and pr.text:
430
- extracted = trafilatura.extract(pr.text) or pr.text
431
  if extracted and extracted.strip():
432
  return extracted.strip(), None
433
  except requests.RequestException as e:
@@ -470,7 +216,7 @@ def summarize_with_qwen(text: str) -> tuple[str | None, str | None]:
470
  "Do not include analysis, steps, or <think> tags."
471
  ),
472
  },
473
- {"role": "user", "content": user_content}, # <-- important: pass the TRIMMED content
474
  ]
475
 
476
  # Build the chat prompt text (disable thinking if supported)
@@ -524,8 +270,8 @@ def generate_speech(summary: str, voice: str) -> tuple[str | None, str | None, f
524
 
525
  ts = int(time.time())
526
  filename = f"summary_{ts}.wav"
527
- filepath = os.path.join("static", "audio", filename)
528
- sf.write(filepath, combined.numpy(), 24000)
529
 
530
  return filename, None, total_duration
531
  except Exception as e:
@@ -546,15 +292,23 @@ def process_article():
546
  return jsonify({"success": False, "error": "Models not loaded yet. Please wait."})
547
 
548
  data = request.get_json(force=True, silent=True) or {}
 
 
 
549
  url = (data.get("url") or "").strip()
550
  generate_audio = bool(data.get("generate_audio", False))
551
  voice = (data.get("voice") or "af_heart").strip()
552
 
553
- if not url:
554
- return jsonify({"success": False, "error": "Please provide a valid URL."})
 
 
 
 
 
 
 
555
 
556
- # 1) Scrape
557
- article_content, scrape_error = scrape_article_text(url)
558
  if scrape_error:
559
  return jsonify({"success": False, "error": scrape_error})
560
 
@@ -612,7 +366,7 @@ if __name__ == "__main__":
612
  # Load models in background thread
613
  threading.Thread(target=load_models, daemon=True).start()
614
 
615
- # Respect platform env PORT when present
616
  port = int(os.environ.get("PORT", args.port))
617
 
618
  print("🚀 Starting Article Summarizer Web App…")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
  """
3
  Flask Web Application for Article Summarizer with TTS
 
10
  import logging
11
  from datetime import datetime
12
  import re
13
+ from pathlib import Path
14
 
15
  import torch
16
  import trafilatura
17
  import soundfile as sf
18
+ import requests
19
  from transformers import AutoModelForCausalLM, AutoTokenizer
20
  from kokoro import KPipeline
 
21
 
22
  # ---------------- Logging ----------------
23
  logging.basicConfig(level=logging.INFO)
 
52
  "Accept-Language": "en-US,en;q=0.9",
53
  }
54
 
 
 
 
 
55
  # Create output dirs (robust, relative to this file)
 
56
  BASE_DIR = Path(__file__).parent.resolve()
57
  STATIC_DIR = BASE_DIR / "static"
58
  AUDIO_DIR = STATIC_DIR / "audio"
 
64
  except PermissionError:
65
  logger.warning("No permission to create %s (will rely on image pre-created dirs).", p)
66
 
 
67
  # ---------------- Helpers ----------------
68
  def _get_device():
69
  # Works for both CPU/GPU; safer than qwen_model.device
 
86
  def _strip_reasoning(text: str) -> str:
87
  cleaned = _THINK_BLOCK_RE.sub("", text) # remove full blocks
88
  cleaned = _THINK_TAGS_RE.sub("", cleaned) # remove any stray tags
89
+ cleaned = re.sub(r"```(?:\w+)?\s*```", "", cleaned) # collapse empty fenced blocks
 
90
  return cleaned.strip()
91
 
92
  def _normalize_url_for_proxy(u: str) -> str:
 
94
  u2 = u.replace("https://", "").replace("http://", "")
95
  return f"https://r.jina.ai/http://{u2}"
96
 
97
+ def _maybe_extract_from_html(pasted: str) -> str:
98
+ """If the pasted text looks like HTML, try to extract the main text via trafilatura."""
99
+ looks_html = bool(re.search(r"</?(html|div|p|article|section|span|body|h1|h2)\b", pasted, re.I))
100
+ if not looks_html:
101
+ return pasted
102
+ try:
103
+ extracted = trafilatura.extract(pasted, include_comments=False, include_tables=False) or ""
104
+ return extracted.strip() or pasted
105
+ except Exception:
106
+ return pasted
107
+
108
  # ---------------- Model Load ----------------
109
  def load_models():
110
  """Load Qwen and Kokoro models on startup (idempotent)."""
 
173
  try:
174
  pr = requests.get(proxy_url, headers=BROWSER_HEADERS, timeout=15)
175
  if pr.status_code == 200 and pr.text:
176
+ extracted = trafilatura.extract(pr.text, include_comments=False, include_tables=False) or pr.text
177
  if extracted and extracted.strip():
178
  return extracted.strip(), None
179
  except requests.RequestException as e:
 
216
  "Do not include analysis, steps, or <think> tags."
217
  ),
218
  },
219
+ {"role": "user", "content": user_content},
220
  ]
221
 
222
  # Build the chat prompt text (disable thinking if supported)
 
270
 
271
  ts = int(time.time())
272
  filename = f"summary_{ts}.wav"
273
+ filepath = AUDIO_DIR / filename
274
+ sf.write(str(filepath), combined.numpy(), 24000)
275
 
276
  return filename, None, total_duration
277
  except Exception as e:
 
292
  return jsonify({"success": False, "error": "Models not loaded yet. Please wait."})
293
 
294
  data = request.get_json(force=True, silent=True) or {}
295
+
296
+ # New: accept raw pasted text
297
+ pasted_text = (data.get("text") or "").strip()
298
  url = (data.get("url") or "").strip()
299
  generate_audio = bool(data.get("generate_audio", False))
300
  voice = (data.get("voice") or "af_heart").strip()
301
 
302
+ if not pasted_text and not url:
303
+ return jsonify({"success": False, "error": "Please paste text or provide a valid URL."})
304
+
305
+ # 1) Resolve content: prefer pasted text if provided
306
+ if pasted_text:
307
+ article_content = _maybe_extract_from_html(pasted_text)
308
+ scrape_error = None
309
+ else:
310
+ article_content, scrape_error = scrape_article_text(url)
311
 
 
 
312
  if scrape_error:
313
  return jsonify({"success": False, "error": scrape_error})
314
 
 
366
  # Load models in background thread
367
  threading.Thread(target=load_models, daemon=True).start()
368
 
369
+ # Respect platform env PORT when present (HF Spaces: 7860)
370
  port = int(os.environ.get("PORT", args.port))
371
 
372
  print("🚀 Starting Article Summarizer Web App…")
templates/index.html CHANGED
@@ -9,232 +9,133 @@
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
  <style>
11
  :root{
12
- --bg-0:#0b0f17;
13
- --bg-1:#0f1624;
14
- --bg-2:#121a2b;
15
  --glass: rgba(255,255,255,.04);
16
- --muted: #9aa4bf;
17
- --text: #e7ecf8;
18
- --accent-1:#6d6aff;
19
- --accent-2:#7b5cff;
20
- --accent-3:#00d4ff;
21
- --ok:#21d19f;
22
- --warn:#ffb84d;
23
- --err:#ff6b6b;
24
  --ring: 0 0 0 1px rgba(255,255,255,.07), 0 0 0 6px rgba(124, 58, 237, .12);
25
  --shadow: 0 20px 60px rgba(0,0,0,.45), 0 8px 20px rgba(0,0,0,.35);
26
- --radius-xl:22px;
27
- --radius-lg:16px;
28
- --radius-md:12px;
29
- --radius-sm:10px;
30
- --grad: conic-gradient(from 220deg at 50% 50%, var(--accent-1), var(--accent-2), var(--accent-3), var(--accent-1));
31
  }
32
  *{box-sizing:border-box}
33
  html,body{height:100%}
34
  body{
35
  margin:0;
36
- font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
37
  color:var(--text);
 
38
  background:
39
- radial-gradient(1200px 600px at -10% -10%, rgba(109,106,255,.20), transparent 50%),
40
- radial-gradient(900px 500px at 120% -10%, rgba(0,212,255,.16), transparent 55%),
41
- radial-gradient(1200px 900px at 50% 120%, rgba(123,92,255,.18), transparent 60%),
42
- linear-gradient(180deg, var(--bg-0), var(--bg-1) 50%, var(--bg-2));
 
43
  overflow-y:auto;
44
  }
45
 
46
- /* Top progress bar */
47
- .bar{
48
- position:fixed; inset:0 0 auto 0; height:3px; z-index:9999;
49
  background: linear-gradient(90deg, var(--accent-3), var(--accent-2), var(--accent-1));
50
- background-size:200% 100%;
51
- transform:scaleX(0); transform-origin:left;
52
- box-shadow:0 0 18px rgba(0,212,255,.45);
53
- transition:transform .2s ease-out;
54
  animation:bar-move 2.2s linear infinite;
55
  }
56
  @keyframes bar-move{0%{background-position:0 0}100%{background-position:200% 0}}
57
-
58
- .wrap{
59
- max-width:1080px; margin:72px auto; padding:0 24px;
60
- }
61
- .hero{
62
- display:flex; flex-direction:column; align-items:center; gap:14px; margin-bottom:28px; text-align:center;
63
- }
64
- .hero-badge{
65
- display:inline-flex; align-items:center; gap:10px; padding:8px 12px; border-radius:999px;
66
  background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
67
- border:1px solid rgba(255,255,255,.08);
68
- backdrop-filter: blur(8px);
69
- box-shadow: var(--shadow);
70
- }
71
  .dot{width:8px;height:8px;border-radius:50%; background:var(--warn); box-shadow:0 0 0 6px rgba(255,184,77,.14)}
72
  .dot.ready{background:var(--ok); box-shadow:0 0 0 6px rgba(33,209,159,.14)}
73
  .hero h1{font-size: clamp(28px, 5vw, 44px); margin:0; font-weight:800; letter-spacing:-.02em; line-height:1.05}
74
- .grad-text{
75
- background: linear-gradient(92deg, #f0f3ff, #bfc8ff 30%, #9ad8ff 60%, #c2b5ff 90%);
76
- -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
77
- }
78
  .hero p{margin:0; color:var(--muted); font-size:15.5px}
79
 
 
80
  .panel{
81
- position:relative;
82
- background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));
83
- border:1px solid rgba(255,255,255,.08);
84
- border-radius: var(--radius-xl);
85
- padding:24px;
86
- box-shadow: var(--shadow);
87
- overflow:hidden;
88
  }
89
- .panel::before{
90
- content:"";
91
- position:absolute; inset:-1px;
92
- border-radius:inherit;
93
- padding:1px;
94
  background:linear-gradient(180deg, rgba(175,134,255,.35) 0%, rgba(0,212,255,.18) 100%);
95
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
96
- -webkit-mask-composite:xor; mask-composite: exclude;
97
- pointer-events:none;
98
- }
99
-
100
- .form-grid{display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center}
101
- .input{
102
- width:100%;
103
- background:rgba(0,0,0,.35);
104
- border:1px solid rgba(255,255,255,.12);
105
- border-radius:var(--radius-lg);
106
- padding:14px 16px;
107
- color:var(--text);
108
- font-size:15.5px;
109
- outline:none;
110
  transition:border .2s ease, box-shadow .2s ease, background .2s ease;
111
  }
112
- .input::placeholder{color:#7f8aad}
113
- .input:focus{border-color:rgba(0,212,255,.55); box-shadow: var(--ring)}
114
-
115
- .btn{
116
- position:relative;
117
- display:inline-flex; align-items:center; justify-content:center; gap:10px;
118
- padding:14px 18px;
119
- border-radius:var(--radius-lg);
120
- border:1px solid rgba(255,255,255,.12);
121
  color:#0b0f17; font-weight:700; letter-spacing:.02em;
122
  background: linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%);
123
  box-shadow: 0 10px 30px rgba(0,212,255,.35), inset 0 1px 0 rgba(255,255,255,.15);
124
- cursor:pointer; user-select:none;
125
- transition: transform .08s ease, filter .15s ease, box-shadow .2s ease, opacity .2s ease;
126
- }
127
  .btn:hover{transform: translateY(-1px)}
128
  .btn:active{transform: translateY(0)}
129
  .btn:disabled{opacity:.55; cursor:not-allowed; filter:grayscale(.2)}
130
 
131
  .row{display:flex; flex-wrap:wrap; gap:12px; align-items:center; margin-top:14px}
132
-
133
- /* Switch */
134
- .switch{
135
- display:inline-flex; align-items:center; gap:12px; cursor:pointer; user-select:none;
136
- padding:10px 12px; border-radius:999px; background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08);
137
- }
138
- .switch .track{
139
- width:44px; height:24px; background:rgba(255,255,255,.12); border-radius:999px; position:relative; transition: background .2s ease;
140
- }
141
- .switch .thumb{
142
- width:18px; height:18px; border-radius:50%; background:white; position:absolute; top:3px; left:3px;
143
- box-shadow:0 4px 16px rgba(0,0,0,.45);
144
- transition:left .18s ease, background .2s ease, transform .18s ease;
145
- }
146
  .switch input{display:none}
147
  .switch input:checked + .track{background:linear-gradient(90deg, #00d4ff, #7b5cff)}
148
  .switch input:checked + .track .thumb{left:23px; background:#0b0f17; transform:scale(1.05)}
149
 
150
- /* Collapsible voice panel */
151
- .collapse{
152
- overflow:hidden; max-height:0; opacity:0; transform: translateY(-4px);
153
- transition:max-height .35s ease, opacity .25s ease, transform .25s ease;
154
- }
155
  .collapse.open{max-height:520px; opacity:1; transform:none}
156
 
157
- .voices{
158
- display:grid; gap:12px; margin-top:12px;
159
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
160
- }
161
- .voice{
162
- position:relative; padding:14px; border-radius:var(--radius-md);
163
- background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08);
164
- transition: transform .12s ease, box-shadow .2s ease, border .2s ease, background .2s ease;
165
- cursor:pointer;
166
- }
167
  .voice:hover{transform: translateY(-2px); box-shadow: var(--shadow); border-color: rgba(0,212,255,.25)}
168
  .voice.selected{background:linear-gradient(180deg, rgba(0,212,255,.08), rgba(123,92,255,.08)); border-color: rgba(123,92,255,.55)}
169
  .voice .name{font-weight:700; letter-spacing:.01em}
170
  .voice .meta{color:var(--muted); font-size:12.5px; margin-top:6px; display:flex; gap:10px; align-items:center}
171
- .voice .badge{
172
- font-size:11px; padding:3px 8px; border-radius:999px; border:1px solid rgba(255,255,255,.14);
173
- background:rgba(255,255,255,.05);
174
- }
175
 
176
- /* Results */
177
  .results{margin-top:18px}
178
  .chips{display:flex; flex-wrap:wrap; gap:10px}
179
- .chip{
180
- font-size:12.5px; color:#cdd6f6;
181
- padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03);
182
- }
183
- .toolbar{
184
- display:flex; gap:10px; flex-wrap:wrap; margin-top:12px
185
- }
186
- .tbtn{
187
- display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:10px;
188
- background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.1); color:var(--text);
189
- cursor:pointer; font-size:13px; transition: background .15s ease, transform .08s ease;
190
- }
191
- .tbtn:hover{background:rgba(255,255,255,.08)}
192
- .tbtn:active{transform: translateY(1px)}
193
-
194
- .summary{
195
- margin-top:14px;
196
- background:rgba(0,0,0,.35);
197
- border:1px solid rgba(255,255,255,.1);
198
- border-radius:var(--radius-lg);
199
- padding:18px;
200
- line-height:1.7;
201
- font-size:15.5px;
202
- white-space:pre-wrap;
203
- min-height:120px;
204
- }
205
-
206
- /* Skeleton */
207
- .skeleton{
208
- position:relative; overflow:hidden; background:rgba(255,255,255,.06); border-radius:10px;
209
- }
210
- .skeleton::after{
211
- content:""; position:absolute; inset:0;
212
- background:linear-gradient(100deg, transparent, rgba(255,255,255,.10), transparent);
213
- transform:translateX(-100%); animation:shine 1.2s infinite;
214
- }
215
  @keyframes shine{to{transform:translateX(100%)}}
216
 
217
- /* Messages */
218
- .msg{
219
- margin-top:14px; padding:12px 14px; border-radius:12px; border:1px solid rgba(255,255,255,.08);
220
- display:none; font-size:14px;
221
- }
222
  .msg.err{display:block; color:#ffd8d8; background:rgba(255,107,107,.08)}
223
  .msg.ok{display:block; color:#d9fff4; background:rgba(33,209,159,.08)}
224
-
225
- /* Audio card */
226
- .audio{
227
- margin-top:14px; padding:16px;
228
- background:rgba(255,255,255,.03);
229
- border:1px solid rgba(255,255,255,.08); border-radius:var(--radius-lg);
230
- }
231
  audio{width:100%; height:40px; outline:none}
232
-
233
- /* Footer note */
234
  .foot{margin-top:14px; text-align:center; color:#7f8aad; font-size:12.5px}
235
 
236
  @media (max-width:720px){
237
- .form-grid{grid-template-columns: 1fr}
238
  .btn{width:100%}
239
  }
240
  </style>
@@ -253,13 +154,29 @@
253
  </header>
254
 
255
  <section class="panel">
 
 
 
 
 
 
 
 
256
  <form id="summarizerForm" autocomplete="on">
257
- <div class="form-grid">
 
258
  <input id="articleUrl" class="input" type="url" inputmode="url"
259
- placeholder="Paste an article URL (https://…)" required />
260
- <button id="submitBtn" class="btn" type="submit">
261
- ✨ Summarize
262
- </button>
 
 
 
 
 
 
 
263
  </div>
264
 
265
  <div class="row">
@@ -274,9 +191,7 @@
274
  </div>
275
 
276
  <div id="voiceSection" class="collapse" aria-hidden="true">
277
- <div class="voices" id="voiceGrid">
278
- <!-- Injected -->
279
- </div>
280
  </div>
281
  </form>
282
 
@@ -321,12 +236,11 @@
321
  let modelsReady = false;
322
  let selectedVoice = localStorage.getItem("voiceId") || "af_heart";
323
  const bar = document.getElementById("bar");
 
324
 
325
  // --------------- Utilities --------------
326
  const $ = (sel) => document.querySelector(sel);
327
- function showBar(active) {
328
- bar.style.transform = active ? "scaleX(1)" : "scaleX(0)";
329
- }
330
  function setStatus(ready, error){
331
  const dot = $("#statusDot");
332
  const text = $("#statusText");
@@ -337,13 +251,8 @@
337
  badge.style.borderColor = "rgba(255,107,107,.45)";
338
  return;
339
  }
340
- if (ready){
341
- dot.classList.add("ready");
342
- text.textContent = "Models ready";
343
- } else {
344
- dot.classList.remove("ready");
345
- text.textContent = "Loading AI models…";
346
- }
347
  }
348
  function chip(text){ const span = document.createElement("span"); span.className="chip"; span.textContent=text; return span; }
349
  function fmt(x){ return new Intl.NumberFormat().format(x); }
@@ -387,9 +296,7 @@
387
  });
388
  grid.appendChild(el);
389
  });
390
- }catch(e){
391
- // ignore
392
- }
393
  }
394
 
395
  // ------------- Collapsible voices --------
@@ -402,6 +309,33 @@
402
  generateAudio.addEventListener("change", e=> toggleVoices(e.target.checked));
403
  toggleVoices(generateAudio.checked); // on load
404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  // ------------- Form submit ----------------
406
  const form = $("#summarizerForm");
407
  const loading = $("#loadingSection");
@@ -409,7 +343,7 @@
409
  const errorBox = $("#errorMessage");
410
  const okBox = $("#successMessage");
411
  const submitBtn = $("#submitBtn");
412
- const urlInput = $("#articleUrl");
413
 
414
  form.addEventListener("submit", async (e)=>{
415
  e.preventDefault();
@@ -420,10 +354,29 @@
420
  errorBox.style.display = "block";
421
  return;
422
  }
423
- const url = urlInput.value.trim();
424
- if (!url){ return; }
425
 
426
- submitBtn.disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  showBar(true);
428
  loading.style.display = "block";
429
  result.style.display = "none";
@@ -433,7 +386,7 @@
433
  method: "POST",
434
  headers: {"Content-Type":"application/json"},
435
  body: JSON.stringify({
436
- url,
437
  generate_audio: generateAudio.checked,
438
  voice: selectedVoice
439
  })
@@ -441,7 +394,8 @@
441
  const data = await res.json();
442
 
443
  loading.style.display = "none";
444
- submitBtn.disabled = false;
 
445
  showBar(false);
446
 
447
  if (!data.success){
@@ -456,7 +410,8 @@
456
 
457
  }catch(err){
458
  loading.style.display="none";
459
- submitBtn.disabled=false;
 
460
  showBar(false);
461
  errorBox.textContent = "Network error: " + (err?.message || err);
462
  errorBox.style.display = "block";
@@ -473,17 +428,14 @@
473
  const copyBtn = $("#copyBtn");
474
 
475
  function renderResult(r){
476
- // Stats
477
  stats.innerHTML = "";
478
  stats.appendChild(chip(`📄 ${fmt(r.article_length)} → ${fmt(r.summary_length)} chars`));
479
  stats.appendChild(chip(`📉 ${r.compression_ratio}% compression`));
480
  stats.appendChild(chip(`🕒 ${r.timestamp}`));
481
 
482
- // Summary
483
  summaryEl.textContent = r.summary || "";
484
  result.style.display = "block";
485
 
486
- // Audio
487
  if (r.audio_file){
488
  audioEl.src = r.audio_file;
489
  audioWrap.style.display = "block";
@@ -503,15 +455,12 @@
503
  await navigator.clipboard.writeText(summaryEl.textContent || "");
504
  copyBtn.textContent = "✅ Copied";
505
  setTimeout(()=> copyBtn.textContent = "📋 Copy summary", 900);
506
- }catch(e){
507
- // ignore
508
- }
509
  });
510
 
511
- // ------------- Quality of life -------------
512
- // Paste on Cmd/Ctrl+V if input empty
513
  window.addEventListener("paste", (e)=>{
514
- if(document.activeElement !== urlInput && !urlInput.value){
515
  const t = (e.clipboardData || window.clipboardData).getData("text");
516
  if (t?.startsWith("http")){ urlInput.value = t; }
517
  }
@@ -520,8 +469,9 @@
520
  // Init
521
  document.addEventListener("DOMContentLoaded", ()=>{
522
  checkModelStatus();
523
- // Restore voice toggle state hint
524
  if (localStorage.getItem("voiceId")) selectedVoice = localStorage.getItem("voiceId");
 
 
525
  });
526
  </script>
527
  </body>
 
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
  <style>
11
  :root{
12
+ --bg-0:#0b0f17; --bg-1:#0f1624; --bg-2:#121a2b;
 
 
13
  --glass: rgba(255,255,255,.04);
14
+ --muted: #9aa4bf; --text: #e7ecf8;
15
+ --accent-1:#6d6aff; --accent-2:#7b5cff; --accent-3:#00d4ff;
16
+ --ok:#21d19f; --warn:#ffb84d; --err:#ff6b6b;
 
 
 
 
 
17
  --ring: 0 0 0 1px rgba(255,255,255,.07), 0 0 0 6px rgba(124, 58, 237, .12);
18
  --shadow: 0 20px 60px rgba(0,0,0,.45), 0 8px 20px rgba(0,0,0,.35);
19
+ --radius-xl:22px; --radius-lg:16px; --radius-md:12px; --radius-sm:10px;
 
 
 
 
20
  }
21
  *{box-sizing:border-box}
22
  html,body{height:100%}
23
  body{
24
  margin:0;
25
+ font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial;
26
  color:var(--text);
27
+ /* smoothed background (no mid-page band) */
28
  background:
29
+ radial-gradient(1200px 600px at -10% -10%, rgba(109,106,255,.18), transparent 52%),
30
+ radial-gradient(900px 500px at 120% -10%, rgba(0,212,255,.14), transparent 56%),
31
+ radial-gradient(1200px 900px at 50% 120%, rgba(123,92,255,.14), transparent 62%),
32
+ linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 38%, var(--bg-2) 100%);
33
+ background-attachment: fixed, fixed, fixed, fixed;
34
  overflow-y:auto;
35
  }
36
 
37
+ .bar{position:fixed; inset:0 0 auto 0; height:3px; z-index:9999;
 
 
38
  background: linear-gradient(90deg, var(--accent-3), var(--accent-2), var(--accent-1));
39
+ background-size:200% 100%; transform:scaleX(0); transform-origin:left;
40
+ box-shadow:0 0 18px rgba(0,212,255,.45); transition:transform .2s ease-out;
 
 
41
  animation:bar-move 2.2s linear infinite;
42
  }
43
  @keyframes bar-move{0%{background-position:0 0}100%{background-position:200% 0}}
44
+ .wrap{max-width:1080px; margin:72px auto; padding:0 24px}
45
+ .hero{display:flex; flex-direction:column; align-items:center; gap:14px; margin-bottom:28px; text-align:center}
46
+ .hero-badge{display:inline-flex; align-items:center; gap:10px; padding:8px 12px; border-radius:999px;
 
 
 
 
 
 
47
  background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
48
+ border:1px solid rgba(255,255,255,.08); backdrop-filter: blur(8px); box-shadow: var(--shadow)}
 
 
 
49
  .dot{width:8px;height:8px;border-radius:50%; background:var(--warn); box-shadow:0 0 0 6px rgba(255,184,77,.14)}
50
  .dot.ready{background:var(--ok); box-shadow:0 0 0 6px rgba(33,209,159,.14)}
51
  .hero h1{font-size: clamp(28px, 5vw, 44px); margin:0; font-weight:800; letter-spacing:-.02em; line-height:1.05}
52
+ .grad-text{background: linear-gradient(92deg, #f0f3ff, #bfc8ff 30%, #9ad8ff 60%, #c2b5ff 90%);
53
+ -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent}
 
 
54
  .hero p{margin:0; color:var(--muted); font-size:15.5px}
55
 
56
+ /* uniform glass surface on the card */
57
  .panel{
58
+ position:relative; background:rgba(255,255,255,.04);
59
+ border:1px solid rgba(255,255,255,.08); border-radius: var(--radius-xl);
60
+ padding:24px; box-shadow: var(--shadow); overflow:hidden
 
 
 
 
61
  }
62
+ .panel::before{content:""; position:absolute; inset:-1px; border-radius:inherit; padding:1px;
 
 
 
 
63
  background:linear-gradient(180deg, rgba(175,134,255,.35) 0%, rgba(0,212,255,.18) 100%);
64
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
65
+ -webkit-mask-composite:xor; mask-composite: exclude; pointer-events:none; opacity:.85}
66
+
67
+ .seg{display:inline-flex; padding:6px; background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.09);
68
+ border-radius:999px; gap:6px}
69
+ .seg button{border:0; border-radius:999px; padding:10px 14px; color:var(--text);
70
+ background:transparent; cursor:pointer; font-weight:700; font-size:14px}
71
+ .seg button.active{background:linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%); color:#0b0f17}
72
+
73
+ .grid{display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center}
74
+ .input, .textarea{
75
+ width:100%; background:rgba(0,0,0,.35); border:1px solid rgba(255,255,255,.12);
76
+ border-radius:16px; padding:14px 16px; color:var(--text); font-size:15.5px; outline:none;
 
 
77
  transition:border .2s ease, box-shadow .2s ease, background .2s ease;
78
  }
79
+ .input::placeholder, .textarea::placeholder{color:#7f8aad}
80
+ .input:focus, .textarea:focus{border-color:rgba(0,212,255,.55); box-shadow: var(--ring)}
81
+ .textarea{min-height:160px; resize:vertical}
82
+ .hint{color:var(--muted); font-size:12.5px; margin-top:6px}
83
+
84
+ .btn{position:relative; display:inline-flex; align-items:center; justify-content:center; gap:10px;
85
+ padding:14px 18px; border-radius:16px; border:1px solid rgba(255,255,255,.12);
 
 
86
  color:#0b0f17; font-weight:700; letter-spacing:.02em;
87
  background: linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%);
88
  box-shadow: 0 10px 30px rgba(0,212,255,.35), inset 0 1px 0 rgba(255,255,255,.15);
89
+ cursor:pointer; user-select:none; transition: transform .08s ease, filter .15s ease, box-shadow .2s ease, opacity .2s ease}
 
 
90
  .btn:hover{transform: translateY(-1px)}
91
  .btn:active{transform: translateY(0)}
92
  .btn:disabled{opacity:.55; cursor:not-allowed; filter:grayscale(.2)}
93
 
94
  .row{display:flex; flex-wrap:wrap; gap:12px; align-items:center; margin-top:14px}
95
+ .switch{display:inline-flex; align-items:center; gap:12px; cursor:pointer; user-select:none;
96
+ padding:10px 12px; border-radius:999px; background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08)}
97
+ .switch .track{width:44px; height:24px; background:rgba(255,255,255,.12); border-radius:999px; position:relative; transition: background .2s ease}
98
+ .switch .thumb{width:18px; height:18px; border-radius:50%; background:white; position:absolute; top:3px; left:3px;
99
+ box-shadow:0 4px 16px rgba(0,0,0,.45); transition:left .18s ease, background .2s ease, transform .18s ease}
 
 
 
 
 
 
 
 
 
100
  .switch input{display:none}
101
  .switch input:checked + .track{background:linear-gradient(90deg, #00d4ff, #7b5cff)}
102
  .switch input:checked + .track .thumb{left:23px; background:#0b0f17; transform:scale(1.05)}
103
 
104
+ .collapse{overflow:hidden; max-height:0; opacity:0; transform: translateY(-4px); transition:max-height .35s ease, opacity .25s ease, transform .25s ease}
 
 
 
 
105
  .collapse.open{max-height:520px; opacity:1; transform:none}
106
 
107
+ .voices{display:grid; gap:12px; margin-top:12px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr))}
108
+ .voice{position:relative; padding:14px; border-radius:12px; background:rgba(255,255,255,.03);
109
+ border:1px solid rgba(255,255,255,.08); transition: transform .12s ease, box-shadow .2s ease, border .2s ease, background .2s ease; cursor:pointer}
 
 
 
 
 
 
 
110
  .voice:hover{transform: translateY(-2px); box-shadow: var(--shadow); border-color: rgba(0,212,255,.25)}
111
  .voice.selected{background:linear-gradient(180deg, rgba(0,212,255,.08), rgba(123,92,255,.08)); border-color: rgba(123,92,255,.55)}
112
  .voice .name{font-weight:700; letter-spacing:.01em}
113
  .voice .meta{color:var(--muted); font-size:12.5px; margin-top:6px; display:flex; gap:10px; align-items:center}
114
+ .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)}
 
 
 
115
 
 
116
  .results{margin-top:18px}
117
  .chips{display:flex; flex-wrap:wrap; gap:10px}
118
+ .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)}
119
+ .toolbar{display:flex; gap:10px; flex-wrap:wrap; margin-top:12px}
120
+ .tbtn{display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:10px; background:rgba(255,255,255,.04);
121
+ border:1px solid rgba(255,255,255,.1); color:var(--text); cursor:pointer; font-size:13px; transition: background .15s ease, transform .08s ease}
122
+ .tbtn:hover{background:rgba(255,255,255,.08)} .tbtn:active{transform: translateY(1px)}
123
+
124
+ .summary{margin-top:14px; background:rgba(0,0,0,.35); border:1px solid rgba(255,255,255,.1); border-radius:16px; padding:18px; line-height:1.7; font-size:15.5px; white-space:pre-wrap; min-height:120px}
125
+ .skeleton{position:relative; overflow:hidden; background:rgba(255,255,255,.06); border-radius:10px}
126
+ .skeleton::after{content:""; position:absolute; inset:0; background:linear-gradient(100deg, transparent, rgba(255,255,255,.10), transparent);
127
+ transform:translateX(-100%); animation:shine 1.2s infinite}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  @keyframes shine{to{transform:translateX(100%)}}
129
 
130
+ .msg{margin-top:14px; padding:12px 14px; border-radius:12px; border:1px solid rgba(255,255,255,.08); display:none; font-size:14px}
 
 
 
 
131
  .msg.err{display:block; color:#ffd8d8; background:rgba(255,107,107,.08)}
132
  .msg.ok{display:block; color:#d9fff4; background:rgba(33,209,159,.08)}
133
+ .audio{margin-top:14px; padding:16px; background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08); border-radius:16px}
 
 
 
 
 
 
134
  audio{width:100%; height:40px; outline:none}
 
 
135
  .foot{margin-top:14px; text-align:center; color:#7f8aad; font-size:12.5px}
136
 
137
  @media (max-width:720px){
138
+ .grid{grid-template-columns:1fr}
139
  .btn{width:100%}
140
  }
141
  </style>
 
154
  </header>
155
 
156
  <section class="panel">
157
+ <!-- Mode switch -->
158
+ <div class="row" style="justify-content:center; margin-bottom:12px">
159
+ <div class="seg" role="tablist" aria-label="Input mode">
160
+ <button id="modeUrlBtn" class="active" role="tab" aria-selected="true">URL</button>
161
+ <button id="modeTextBtn" role="tab" aria-selected="false">Paste Text</button>
162
+ </div>
163
+ </div>
164
+
165
  <form id="summarizerForm" autocomplete="on">
166
+ <!-- URL mode -->
167
+ <div id="urlMode" class="grid">
168
  <input id="articleUrl" class="input" type="url" inputmode="url"
169
+ placeholder="Paste an article URL (https://…)" />
170
+ <button id="submitBtn" class="btn" type="submit">✨ Summarize</button>
171
+ </div>
172
+
173
+ <!-- Text mode -->
174
+ <div id="textMode" style="display:none; margin-top:12px">
175
+ <textarea id="articleText" class="textarea" placeholder="Paste the article text here…"></textarea>
176
+ <div class="hint"><span id="charCount">0</span> characters</div>
177
+ <div style="margin-top:12px">
178
+ <button id="submitBtnText" class="btn" type="submit">✨ Summarize Text</button>
179
+ </div>
180
  </div>
181
 
182
  <div class="row">
 
191
  </div>
192
 
193
  <div id="voiceSection" class="collapse" aria-hidden="true">
194
+ <div class="voices" id="voiceGrid"></div>
 
 
195
  </div>
196
  </form>
197
 
 
236
  let modelsReady = false;
237
  let selectedVoice = localStorage.getItem("voiceId") || "af_heart";
238
  const bar = document.getElementById("bar");
239
+ let inputMode = "url"; // "url" | "text"
240
 
241
  // --------------- Utilities --------------
242
  const $ = (sel) => document.querySelector(sel);
243
+ function showBar(active) { bar.style.transform = active ? "scaleX(1)" : "scaleX(0)"; }
 
 
244
  function setStatus(ready, error){
245
  const dot = $("#statusDot");
246
  const text = $("#statusText");
 
251
  badge.style.borderColor = "rgba(255,107,107,.45)";
252
  return;
253
  }
254
+ if (ready){ dot.classList.add("ready"); text.textContent = "Models ready"; }
255
+ else { dot.classList.remove("ready"); text.textContent = "Loading AI models…"; }
 
 
 
 
 
256
  }
257
  function chip(text){ const span = document.createElement("span"); span.className="chip"; span.textContent=text; return span; }
258
  function fmt(x){ return new Intl.NumberFormat().format(x); }
 
296
  });
297
  grid.appendChild(el);
298
  });
299
+ }catch(e){ /* ignore */ }
 
 
300
  }
301
 
302
  // ------------- Collapsible voices --------
 
309
  generateAudio.addEventListener("change", e=> toggleVoices(e.target.checked));
310
  toggleVoices(generateAudio.checked); // on load
311
 
312
+ // ------------- Mode switching ------------
313
+ const urlMode = $("#urlMode");
314
+ const textMode = $("#textMode");
315
+ const modeUrlBtn = $("#modeUrlBtn");
316
+ const modeTextBtn = $("#modeTextBtn");
317
+ const urlInput = $("#articleUrl");
318
+ const textArea = $("#articleText");
319
+ const charCount = $("#charCount");
320
+
321
+ function setMode(m){
322
+ inputMode = m;
323
+ if (m === "url"){
324
+ urlMode.style.display = "grid";
325
+ textMode.style.display = "none";
326
+ modeUrlBtn.classList.add("active");
327
+ modeTextBtn.classList.remove("active");
328
+ } else {
329
+ urlMode.style.display = "none";
330
+ textMode.style.display = "block";
331
+ modeTextBtn.classList.add("active");
332
+ modeUrlBtn.classList.remove("active");
333
+ }
334
+ }
335
+ modeUrlBtn.addEventListener("click", ()=> setMode("url"));
336
+ modeTextBtn.addEventListener("click", ()=> setMode("text"));
337
+ textArea.addEventListener("input", ()=> { charCount.textContent = (textArea.value || "").length; });
338
+
339
  // ------------- Form submit ----------------
340
  const form = $("#summarizerForm");
341
  const loading = $("#loadingSection");
 
343
  const errorBox = $("#errorMessage");
344
  const okBox = $("#successMessage");
345
  const submitBtn = $("#submitBtn");
346
+ const submitBtnText = $("#submitBtnText");
347
 
348
  form.addEventListener("submit", async (e)=>{
349
  e.preventDefault();
 
354
  errorBox.style.display = "block";
355
  return;
356
  }
 
 
357
 
358
+ const url = (urlInput.value || "").trim();
359
+ const text = (textArea.value || "").trim();
360
+
361
+ if (!text && !url){
362
+ errorBox.textContent = "Please paste text or provide a valid URL.";
363
+ errorBox.style.display = "block";
364
+ return;
365
+ }
366
+
367
+ if (inputMode === "url" && !url){
368
+ errorBox.textContent = "Please provide a valid URL or switch to Paste Text.";
369
+ errorBox.style.display = "block";
370
+ return;
371
+ }
372
+ if (inputMode === "text" && !text){
373
+ errorBox.textContent = "Please paste the article text or switch to URL.";
374
+ errorBox.style.display = "block";
375
+ return;
376
+ }
377
+
378
+ if (submitBtn) submitBtn.disabled = true;
379
+ if (submitBtnText) submitBtnText.disabled = true;
380
  showBar(true);
381
  loading.style.display = "block";
382
  result.style.display = "none";
 
386
  method: "POST",
387
  headers: {"Content-Type":"application/json"},
388
  body: JSON.stringify({
389
+ url, text,
390
  generate_audio: generateAudio.checked,
391
  voice: selectedVoice
392
  })
 
394
  const data = await res.json();
395
 
396
  loading.style.display = "none";
397
+ if (submitBtn) submitBtn.disabled = false;
398
+ if (submitBtnText) submitBtnText.disabled = false;
399
  showBar(false);
400
 
401
  if (!data.success){
 
410
 
411
  }catch(err){
412
  loading.style.display="none";
413
+ if (submitBtn) submitBtn.disabled = false;
414
+ if (submitBtnText) submitBtnText.disabled = false;
415
  showBar(false);
416
  errorBox.textContent = "Network error: " + (err?.message || err);
417
  errorBox.style.display = "block";
 
428
  const copyBtn = $("#copyBtn");
429
 
430
  function renderResult(r){
 
431
  stats.innerHTML = "";
432
  stats.appendChild(chip(`📄 ${fmt(r.article_length)} → ${fmt(r.summary_length)} chars`));
433
  stats.appendChild(chip(`📉 ${r.compression_ratio}% compression`));
434
  stats.appendChild(chip(`🕒 ${r.timestamp}`));
435
 
 
436
  summaryEl.textContent = r.summary || "";
437
  result.style.display = "block";
438
 
 
439
  if (r.audio_file){
440
  audioEl.src = r.audio_file;
441
  audioWrap.style.display = "block";
 
455
  await navigator.clipboard.writeText(summaryEl.textContent || "");
456
  copyBtn.textContent = "✅ Copied";
457
  setTimeout(()=> copyBtn.textContent = "📋 Copy summary", 900);
458
+ }catch(e){ /* ignore */ }
 
 
459
  });
460
 
461
+ // ------------- QoL -------------
 
462
  window.addEventListener("paste", (e)=>{
463
+ if (inputMode === "url" && document.activeElement !== urlInput && !urlInput.value){
464
  const t = (e.clipboardData || window.clipboardData).getData("text");
465
  if (t?.startsWith("http")){ urlInput.value = t; }
466
  }
 
469
  // Init
470
  document.addEventListener("DOMContentLoaded", ()=>{
471
  checkModelStatus();
 
472
  if (localStorage.getItem("voiceId")) selectedVoice = localStorage.getItem("voiceId");
473
+ setMode("url"); // default
474
+ charCount.textContent = "0";
475
  });
476
  </script>
477
  </body>
test.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Flask Web Application for Article Summarizer with TTS
4
+ """
5
+
6
+ from flask import Flask, render_template, request, jsonify
7
+ import os
8
+ import time
9
+ import threading
10
+ import logging
11
+ from datetime import datetime
12
+ import re
13
+ from pathlib import Path
14
+
15
+ import torch
16
+ import trafilatura
17
+ import soundfile as sf
18
+ import requests
19
+ from transformers import AutoModelForCausalLM, AutoTokenizer
20
+ from kokoro import KPipeline
21
+
22
+ # ---------------- Logging ----------------
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger("summarizer")
25
+
26
+ # ---------------- Flask ----------------
27
+ app = Flask(__name__)
28
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me")
29
+
30
+ # ---------------- Globals ----------------
31
+ qwen_model = None
32
+ qwen_tokenizer = None
33
+ kokoro_pipeline = None
34
+
35
+ model_loading_status = {"loaded": False, "error": None}
36
+ _load_lock = threading.Lock()
37
+ _loaded_once = False # idempotence guard across threads
38
+
39
+ # Voice whitelist
40
+ ALLOWED_VOICES = {
41
+ "af_heart", "af_bella", "af_nicole", "am_michael",
42
+ "am_fenrir", "af_sarah", "bf_emma", "bm_george"
43
+ }
44
+
45
+ # HTTP headers to look like a real browser for sites that block bots
46
+ BROWSER_HEADERS = {
47
+ "User-Agent": (
48
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 "
49
+ "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
50
+ ),
51
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
52
+ "Accept-Language": "en-US,en;q=0.9",
53
+ }
54
+
55
+ # Create output dirs (robust, relative to this file)
56
+ BASE_DIR = Path(__file__).parent.resolve()
57
+ STATIC_DIR = BASE_DIR / "static"
58
+ AUDIO_DIR = STATIC_DIR / "audio"
59
+ SUMM_DIR = STATIC_DIR / "summaries"
60
+
61
+ for p in (AUDIO_DIR, SUMM_DIR):
62
+ try:
63
+ p.mkdir(parents=True, exist_ok=True)
64
+ except PermissionError:
65
+ logger.warning("No permission to create %s (will rely on image pre-created dirs).", p)
66
+
67
+ # ---------------- Helpers ----------------
68
+ def _get_device():
69
+ # Works for both CPU/GPU; safer than qwen_model.device
70
+ return next(qwen_model.parameters()).device
71
+
72
+ def _safe_trim_to_tokens(text: str, tokenizer, max_tokens: int) -> str:
73
+ ids = tokenizer.encode(text, add_special_tokens=False)
74
+ if len(ids) <= max_tokens:
75
+ return text
76
+ ids = ids[:max_tokens]
77
+ return tokenizer.decode(ids, skip_special_tokens=True)
78
+
79
+ # Remove any leaked <think>…</think> (with optional attributes) or similar tags
80
+ _THINK_BLOCK_RE = re.compile(
81
+ r"<\s*(think|reasoning|thought)\b[^>]*>.*?<\s*/\s*\1\s*>",
82
+ re.IGNORECASE | re.DOTALL,
83
+ )
84
+ _THINK_TAGS_RE = re.compile(r"</?\s*(think|reasoning|thought)\b[^>]*>", re.IGNORECASE)
85
+
86
+ def _strip_reasoning(text: str) -> str:
87
+ cleaned = _THINK_BLOCK_RE.sub("", text) # remove full blocks
88
+ cleaned = _THINK_TAGS_RE.sub("", cleaned) # remove any stray tags
89
+ cleaned = re.sub(r"```(?:\w+)?\s*```", "", cleaned) # collapse empty fenced blocks
90
+ return cleaned.strip()
91
+
92
+ def _normalize_url_for_proxy(u: str) -> str:
93
+ # r.jina.ai expects 'http://<host>/<path>' after it; unify scheme-less
94
+ u2 = u.replace("https://", "").replace("http://", "")
95
+ return f"https://r.jina.ai/http://{u2}"
96
+
97
+ def _maybe_extract_from_html(pasted: str) -> str:
98
+ """If the pasted text looks like HTML, try to extract the main text via trafilatura."""
99
+ looks_html = bool(re.search(r"</?(html|div|p|article|section|span|body|h1|h2)\b", pasted, re.I))
100
+ if not looks_html:
101
+ return pasted
102
+ try:
103
+ extracted = trafilatura.extract(pasted, include_comments=False, include_tables=False) or ""
104
+ return extracted.strip() or pasted
105
+ except Exception:
106
+ return pasted
107
+
108
+ # ---------------- Model Load ----------------
109
+ def load_models():
110
+ """Load Qwen and Kokoro models on startup (idempotent)."""
111
+ global qwen_model, qwen_tokenizer, kokoro_pipeline, model_loading_status, _loaded_once
112
+ with _load_lock:
113
+ if _loaded_once:
114
+ return
115
+ try:
116
+ logger.info("Loading Qwen3-0.6B…")
117
+ model_name = "Qwen/Qwen3-0.6B"
118
+
119
+ qwen_tokenizer = AutoTokenizer.from_pretrained(model_name)
120
+ qwen_model = AutoModelForCausalLM.from_pretrained(
121
+ model_name,
122
+ torch_dtype="auto",
123
+ device_map="auto", # CPU or GPU automatically
124
+ )
125
+ qwen_model.eval() # inference mode
126
+
127
+ logger.info("Loading Kokoro TTS…")
128
+ kokoro_pipeline = KPipeline(lang_code="a")
129
+
130
+ model_loading_status["loaded"] = True
131
+ model_loading_status["error"] = None
132
+ _loaded_once = True
133
+ logger.info("✅ Models ready")
134
+ except Exception as e:
135
+ err = f"{type(e).__name__}: {e}"
136
+ model_loading_status["loaded"] = False
137
+ model_loading_status["error"] = err
138
+ logger.exception("Failed to load models: %s", err)
139
+
140
+ # ---------------- Core Logic ----------------
141
+ def scrape_article_text(url: str) -> tuple[str | None, str | None]:
142
+ """
143
+ Try to fetch & extract article text.
144
+ Strategy:
145
+ 1) Trafilatura.fetch_url (vanilla)
146
+ 2) requests.get with browser headers + trafilatura.extract
147
+ 3) (optional) Proxy fallback if ALLOW_PROXY_FALLBACK=1
148
+ Returns (content, error)
149
+ """
150
+ try:
151
+ # --- 1) Direct fetch via Trafilatura ---
152
+ downloaded = trafilatura.fetch_url(url)
153
+ if downloaded:
154
+ text = trafilatura.extract(downloaded, include_comments=False, include_tables=False)
155
+ if text:
156
+ return text, None
157
+
158
+ # --- 2) Raw requests + Trafilatura extract ---
159
+ try:
160
+ r = requests.get(url, headers=BROWSER_HEADERS, timeout=15)
161
+ if r.status_code == 200 and r.text:
162
+ text = trafilatura.extract(r.text, include_comments=False, include_tables=False, url=url)
163
+ if text:
164
+ return text, None
165
+ elif r.status_code == 403:
166
+ logger.info("Site returned 403; considering proxy fallback (if enabled).")
167
+ except requests.RequestException as e:
168
+ logger.info("requests.get failed: %s", e)
169
+
170
+ # --- 3) Optional proxy fallback (off by default) ---
171
+ if os.environ.get("ALLOW_PROXY_FALLBACK", "0") == "1":
172
+ proxy_url = _normalize_url_for_proxy(url)
173
+ try:
174
+ pr = requests.get(proxy_url, headers=BROWSER_HEADERS, timeout=15)
175
+ if pr.status_code == 200 and pr.text:
176
+ extracted = trafilatura.extract(pr.text, include_comments=False, include_tables=False) or pr.text
177
+ if extracted and extracted.strip():
178
+ return extracted.strip(), None
179
+ except requests.RequestException as e:
180
+ logger.info("Proxy fallback failed: %s", e)
181
+
182
+ return None, (
183
+ "Failed to download the article content (site may block automated fetches). "
184
+ "Try another URL, paste the text manually, or set ALLOW_PROXY_FALLBACK=1."
185
+ )
186
+
187
+ except Exception as e:
188
+ return None, f"Error scraping article: {e}"
189
+
190
+ def summarize_with_qwen(text: str) -> tuple[str | None, str | None]:
191
+ """Generate summary and return (summary, error)."""
192
+ try:
193
+ # Budget input tokens based on max context; fallback to 4096
194
+ try:
195
+ max_ctx = int(getattr(qwen_model.config, "max_position_embeddings", 4096))
196
+ except Exception:
197
+ max_ctx = 4096
198
+ # Leave room for prompt + output tokens
199
+ max_input_tokens = max(512, max_ctx - 1024)
200
+
201
+ prompt_hdr = (
202
+ "Please provide a concise and clear summary of the following article. "
203
+ "Focus on the main points, key findings, and conclusions. "
204
+ "Keep it easy to understand for someone who hasn't read the original.\n\nARTICLE:\n"
205
+ )
206
+
207
+ # Trim article to safe length
208
+ article_trimmed = _safe_trim_to_tokens(text, qwen_tokenizer, max_input_tokens)
209
+ user_content = prompt_hdr + article_trimmed
210
+
211
+ messages = [
212
+ {
213
+ "role": "system",
214
+ "content": (
215
+ "You are a helpful assistant. Return ONLY the final summary as plain text. "
216
+ "Do not include analysis, steps, or <think> tags."
217
+ ),
218
+ },
219
+ {"role": "user", "content": user_content},
220
+ ]
221
+
222
+ # Build the chat prompt text (disable thinking if supported)
223
+ try:
224
+ text_input = qwen_tokenizer.apply_chat_template(
225
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
226
+ )
227
+ except TypeError:
228
+ text_input = qwen_tokenizer.apply_chat_template(
229
+ messages, tokenize=False, add_generation_prompt=True
230
+ )
231
+
232
+ device = _get_device()
233
+ model_inputs = qwen_tokenizer([text_input], return_tensors="pt").to(device)
234
+
235
+ with torch.inference_mode():
236
+ generated_ids = qwen_model.generate(
237
+ **model_inputs,
238
+ max_new_tokens=512,
239
+ temperature=0.7,
240
+ top_p=0.8,
241
+ top_k=20,
242
+ do_sample=True,
243
+ )
244
+
245
+ output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
246
+ summary = qwen_tokenizer.decode(output_ids, skip_special_tokens=True).strip()
247
+ summary = _strip_reasoning(summary) # <-- remove any leaked <think>…</think>
248
+ return summary, None
249
+ except Exception as e:
250
+ return None, f"Error generating summary: {e}"
251
+
252
+ def generate_speech(summary: str, voice: str) -> tuple[str | None, str | None, float]:
253
+ """Generate speech and return (filename, error, duration_seconds)."""
254
+ try:
255
+ if voice not in ALLOWED_VOICES:
256
+ voice = "af_heart"
257
+ generator = kokoro_pipeline(summary, voice=voice)
258
+
259
+ audio_chunks = []
260
+ total_duration = 0.0
261
+
262
+ for _, _, audio in generator:
263
+ audio_chunks.append(audio)
264
+ total_duration += len(audio) / 24000.0
265
+
266
+ if not audio_chunks:
267
+ return None, "No audio generated.", 0.0
268
+
269
+ combined = audio_chunks[0] if len(audio_chunks) == 1 else torch.cat(audio_chunks, dim=0)
270
+
271
+ ts = int(time.time())
272
+ filename = f"summary_{ts}.wav"
273
+ filepath = AUDIO_DIR / filename
274
+ sf.write(str(filepath), combined.numpy(), 24000)
275
+
276
+ return filename, None, total_duration
277
+ except Exception as e:
278
+ return None, f"Error generating speech: {e}", 0.0
279
+
280
+ # ---------------- Routes ----------------
281
+ @app.route("/")
282
+ def index():
283
+ return render_template("index.html")
284
+
285
+ @app.route("/status")
286
+ def status():
287
+ return jsonify(model_loading_status)
288
+
289
+ @app.route("/process", methods=["POST"])
290
+ def process_article():
291
+ if not model_loading_status["loaded"]:
292
+ return jsonify({"success": False, "error": "Models not loaded yet. Please wait."})
293
+
294
+ data = request.get_json(force=True, silent=True) or {}
295
+
296
+ # New: accept raw pasted text
297
+ pasted_text = (data.get("text") or "").strip()
298
+ url = (data.get("url") or "").strip()
299
+ generate_audio = bool(data.get("generate_audio", False))
300
+ voice = (data.get("voice") or "af_heart").strip()
301
+
302
+ if not pasted_text and not url:
303
+ return jsonify({"success": False, "error": "Please paste text or provide a valid URL."})
304
+
305
+ # 1) Resolve content: prefer pasted text if provided
306
+ if pasted_text:
307
+ article_content = _maybe_extract_from_html(pasted_text)
308
+ scrape_error = None
309
+ else:
310
+ article_content, scrape_error = scrape_article_text(url)
311
+
312
+ if scrape_error:
313
+ return jsonify({"success": False, "error": scrape_error})
314
+
315
+ # 2) Summarize
316
+ summary, summary_error = summarize_with_qwen(article_content)
317
+ if summary_error:
318
+ return jsonify({"success": False, "error": summary_error})
319
+
320
+ resp = {
321
+ "success": True,
322
+ "summary": summary,
323
+ "article_length": len(article_content or ""),
324
+ "summary_length": len(summary or ""),
325
+ "compression_ratio": round(len(summary) / max(len(article_content), 1) * 100, 1),
326
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
327
+ }
328
+
329
+ # 3) TTS
330
+ if generate_audio:
331
+ audio_filename, audio_error, duration = generate_speech(summary, voice)
332
+ if audio_error:
333
+ resp["audio_error"] = audio_error
334
+ else:
335
+ resp["audio_file"] = f"/static/audio/{audio_filename}"
336
+ resp["audio_duration"] = round(duration, 2)
337
+
338
+ return jsonify(resp)
339
+
340
+ @app.route("/voices")
341
+ def get_voices():
342
+ voices = [
343
+ {"id": "af_heart", "name": "Female - Heart", "grade": "A", "description": "❤️ Warm female voice (best quality)"},
344
+ {"id": "af_bella", "name": "Female - Bella", "grade": "A-", "description": "🔥 Energetic female voice"},
345
+ {"id": "af_nicole", "name": "Female - Nicole", "grade": "B-", "description": "🎧 Professional female voice"},
346
+ {"id": "am_michael", "name": "Male - Michael", "grade": "C+", "description": "Clear male voice"},
347
+ {"id": "am_fenrir", "name": "Male - Fenrir", "grade": "C+", "description": "Strong male voice"},
348
+ {"id": "af_sarah", "name": "Female - Sarah", "grade": "C+", "description": "Gentle female voice"},
349
+ {"id": "bf_emma", "name": "British Female - Emma", "grade": "B-", "description": "🇬🇧 British accent"},
350
+ {"id": "bm_george", "name": "British Male - George", "grade": "C", "description": "🇬🇧 British male voice"},
351
+ ]
352
+ return jsonify(voices)
353
+
354
+ # Kick off model loading when running under Gunicorn/containers
355
+ if os.environ.get("RUNNING_GUNICORN", "0") == "1":
356
+ threading.Thread(target=load_models, daemon=True).start()
357
+
358
+ # ---------------- Dev entrypoint ----------------
359
+ if __name__ == "__main__":
360
+ import argparse
361
+ parser = argparse.ArgumentParser(description="AI Article Summarizer Web App")
362
+ parser.add_argument("--port", type=int, default=5001, help="Port to run the server on (default: 5001)")
363
+ parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
364
+ args = parser.parse_args()
365
+
366
+ # Load models in background thread
367
+ threading.Thread(target=load_models, daemon=True).start()
368
+
369
+ # Respect platform env PORT when present (HF Spaces: 7860)
370
+ port = int(os.environ.get("PORT", args.port))
371
+
372
+ print("🚀 Starting Article Summarizer Web App…")
373
+ print("📚 Models are loading in the background…")
374
+ print(f"🌐 Open http://localhost:{port} in your browser")
375
+
376
+ try:
377
+ app.run(debug=True, host=args.host, port=port)
378
+ except OSError as e:
379
+ if "Address already in use" in str(e):
380
+ print(f"❌ Port {port} is already in use!")
381
+ print("💡 Try a different port:")
382
+ print(f" python app.py --port {port + 1}")
383
+ print("📱 Or disable AirPlay Receiver in System Settings → General → AirDrop & Handoff")
384
+ else:
385
+ raise
works.html ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="dark" />
7
+ <title>AI Article Summarizer · Qwen + Kokoro</title>
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root{
12
+ --bg-0:#0b0f17;
13
+ --bg-1:#0f1624;
14
+ --bg-2:#121a2b;
15
+ --glass: rgba(255,255,255,.04);
16
+ --muted: #9aa4bf;
17
+ --text: #e7ecf8;
18
+ --accent-1:#6d6aff;
19
+ --accent-2:#7b5cff;
20
+ --accent-3:#00d4ff;
21
+ --ok:#21d19f;
22
+ --warn:#ffb84d;
23
+ --err:#ff6b6b;
24
+ --ring: 0 0 0 1px rgba(255,255,255,.07), 0 0 0 6px rgba(124, 58, 237, .12);
25
+ --shadow: 0 20px 60px rgba(0,0,0,.45), 0 8px 20px rgba(0,0,0,.35);
26
+ --radius-xl:22px;
27
+ --radius-lg:16px;
28
+ --radius-md:12px;
29
+ --radius-sm:10px;
30
+ --grad: conic-gradient(from 220deg at 50% 50%, var(--accent-1), var(--accent-2), var(--accent-3), var(--accent-1));
31
+ }
32
+ *{box-sizing:border-box}
33
+ html,body{height:100%}
34
+ body{
35
+ margin:0;
36
+ font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
37
+ color:var(--text);
38
+ background:
39
+ radial-gradient(1200px 600px at -10% -10%, rgba(109,106,255,.20), transparent 50%),
40
+ radial-gradient(900px 500px at 120% -10%, rgba(0,212,255,.16), transparent 55%),
41
+ radial-gradient(1200px 900px at 50% 120%, rgba(123,92,255,.18), transparent 60%),
42
+ linear-gradient(180deg, var(--bg-0), var(--bg-1) 50%, var(--bg-2));
43
+ overflow-y:auto;
44
+ }
45
+
46
+ /* Top progress bar */
47
+ .bar{
48
+ position:fixed; inset:0 0 auto 0; height:3px; z-index:9999;
49
+ background: linear-gradient(90deg, var(--accent-3), var(--accent-2), var(--accent-1));
50
+ background-size:200% 100%;
51
+ transform:scaleX(0); transform-origin:left;
52
+ box-shadow:0 0 18px rgba(0,212,255,.45);
53
+ transition:transform .2s ease-out;
54
+ animation:bar-move 2.2s linear infinite;
55
+ }
56
+ @keyframes bar-move{0%{background-position:0 0}100%{background-position:200% 0}}
57
+
58
+ .wrap{
59
+ max-width:1080px; margin:72px auto; padding:0 24px;
60
+ }
61
+ .hero{
62
+ display:flex; flex-direction:column; align-items:center; gap:14px; margin-bottom:28px; text-align:center;
63
+ }
64
+ .hero-badge{
65
+ display:inline-flex; align-items:center; gap:10px; padding:8px 12px; border-radius:999px;
66
+ background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
67
+ border:1px solid rgba(255,255,255,.08);
68
+ backdrop-filter: blur(8px);
69
+ box-shadow: var(--shadow);
70
+ }
71
+ .dot{width:8px;height:8px;border-radius:50%; background:var(--warn); box-shadow:0 0 0 6px rgba(255,184,77,.14)}
72
+ .dot.ready{background:var(--ok); box-shadow:0 0 0 6px rgba(33,209,159,.14)}
73
+ .hero h1{font-size: clamp(28px, 5vw, 44px); margin:0; font-weight:800; letter-spacing:-.02em; line-height:1.05}
74
+ .grad-text{
75
+ background: linear-gradient(92deg, #f0f3ff, #bfc8ff 30%, #9ad8ff 60%, #c2b5ff 90%);
76
+ -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
77
+ }
78
+ .hero p{margin:0; color:var(--muted); font-size:15.5px}
79
+
80
+ .panel{
81
+ position:relative;
82
+ background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03));
83
+ border:1px solid rgba(255,255,255,.08);
84
+ border-radius: var(--radius-xl);
85
+ padding:24px;
86
+ box-shadow: var(--shadow);
87
+ overflow:hidden;
88
+ }
89
+ .panel::before{
90
+ content:"";
91
+ position:absolute; inset:-1px;
92
+ border-radius:inherit;
93
+ padding:1px;
94
+ background:linear-gradient(180deg, rgba(175,134,255,.35) 0%, rgba(0,212,255,.18) 100%);
95
+ -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
96
+ -webkit-mask-composite:xor; mask-composite: exclude;
97
+ pointer-events:none;
98
+ }
99
+
100
+ .form-grid{display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center}
101
+ .input{
102
+ width:100%;
103
+ background:rgba(0,0,0,.35);
104
+ border:1px solid rgba(255,255,255,.12);
105
+ border-radius:var(--radius-lg);
106
+ padding:14px 16px;
107
+ color:var(--text);
108
+ font-size:15.5px;
109
+ outline:none;
110
+ transition:border .2s ease, box-shadow .2s ease, background .2s ease;
111
+ }
112
+ .input::placeholder{color:#7f8aad}
113
+ .input:focus{border-color:rgba(0,212,255,.55); box-shadow: var(--ring)}
114
+
115
+ .btn{
116
+ position:relative;
117
+ display:inline-flex; align-items:center; justify-content:center; gap:10px;
118
+ padding:14px 18px;
119
+ border-radius:var(--radius-lg);
120
+ border:1px solid rgba(255,255,255,.12);
121
+ color:#0b0f17; font-weight:700; letter-spacing:.02em;
122
+ background: linear-gradient(135deg, #7b5cff 0%, #00d4ff 100%);
123
+ box-shadow: 0 10px 30px rgba(0,212,255,.35), inset 0 1px 0 rgba(255,255,255,.15);
124
+ cursor:pointer; user-select:none;
125
+ transition: transform .08s ease, filter .15s ease, box-shadow .2s ease, opacity .2s ease;
126
+ }
127
+ .btn:hover{transform: translateY(-1px)}
128
+ .btn:active{transform: translateY(0)}
129
+ .btn:disabled{opacity:.55; cursor:not-allowed; filter:grayscale(.2)}
130
+
131
+ .row{display:flex; flex-wrap:wrap; gap:12px; align-items:center; margin-top:14px}
132
+
133
+ /* Switch */
134
+ .switch{
135
+ display:inline-flex; align-items:center; gap:12px; cursor:pointer; user-select:none;
136
+ padding:10px 12px; border-radius:999px; background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08);
137
+ }
138
+ .switch .track{
139
+ width:44px; height:24px; background:rgba(255,255,255,.12); border-radius:999px; position:relative; transition: background .2s ease;
140
+ }
141
+ .switch .thumb{
142
+ width:18px; height:18px; border-radius:50%; background:white; position:absolute; top:3px; left:3px;
143
+ box-shadow:0 4px 16px rgba(0,0,0,.45);
144
+ transition:left .18s ease, background .2s ease, transform .18s ease;
145
+ }
146
+ .switch input{display:none}
147
+ .switch input:checked + .track{background:linear-gradient(90deg, #00d4ff, #7b5cff)}
148
+ .switch input:checked + .track .thumb{left:23px; background:#0b0f17; transform:scale(1.05)}
149
+
150
+ /* Collapsible voice panel */
151
+ .collapse{
152
+ overflow:hidden; max-height:0; opacity:0; transform: translateY(-4px);
153
+ transition:max-height .35s ease, opacity .25s ease, transform .25s ease;
154
+ }
155
+ .collapse.open{max-height:520px; opacity:1; transform:none}
156
+
157
+ .voices{
158
+ display:grid; gap:12px; margin-top:12px;
159
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
160
+ }
161
+ .voice{
162
+ position:relative; padding:14px; border-radius:var(--radius-md);
163
+ background:rgba(255,255,255,.03); border:1px solid rgba(255,255,255,.08);
164
+ transition: transform .12s ease, box-shadow .2s ease, border .2s ease, background .2s ease;
165
+ cursor:pointer;
166
+ }
167
+ .voice:hover{transform: translateY(-2px); box-shadow: var(--shadow); border-color: rgba(0,212,255,.25)}
168
+ .voice.selected{background:linear-gradient(180deg, rgba(0,212,255,.08), rgba(123,92,255,.08)); border-color: rgba(123,92,255,.55)}
169
+ .voice .name{font-weight:700; letter-spacing:.01em}
170
+ .voice .meta{color:var(--muted); font-size:12.5px; margin-top:6px; display:flex; gap:10px; align-items:center}
171
+ .voice .badge{
172
+ font-size:11px; padding:3px 8px; border-radius:999px; border:1px solid rgba(255,255,255,.14);
173
+ background:rgba(255,255,255,.05);
174
+ }
175
+
176
+ /* Results */
177
+ .results{margin-top:18px}
178
+ .chips{display:flex; flex-wrap:wrap; gap:10px}
179
+ .chip{
180
+ font-size:12.5px; color:#cdd6f6;
181
+ padding:8px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03);
182
+ }
183
+ .toolbar{
184
+ display:flex; gap:10px; flex-wrap:wrap; margin-top:12px
185
+ }
186
+ .tbtn{
187
+ display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:10px;
188
+ background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.1); color:var(--text);
189
+ cursor:pointer; font-size:13px; transition: background .15s ease, transform .08s ease;
190
+ }
191
+ .tbtn:hover{background:rgba(255,255,255,.08)}
192
+ .tbtn:active{transform: translateY(1px)}
193
+
194
+ .summary{
195
+ margin-top:14px;
196
+ background:rgba(0,0,0,.35);
197
+ border:1px solid rgba(255,255,255,.1);
198
+ border-radius:var(--radius-lg);
199
+ padding:18px;
200
+ line-height:1.7;
201
+ font-size:15.5px;
202
+ white-space:pre-wrap;
203
+ min-height:120px;
204
+ }
205
+
206
+ /* Skeleton */
207
+ .skeleton{
208
+ position:relative; overflow:hidden; background:rgba(255,255,255,.06); border-radius:10px;
209
+ }
210
+ .skeleton::after{
211
+ content:""; position:absolute; inset:0;
212
+ background:linear-gradient(100deg, transparent, rgba(255,255,255,.10), transparent);
213
+ transform:translateX(-100%); animation:shine 1.2s infinite;
214
+ }
215
+ @keyframes shine{to{transform:translateX(100%)}}
216
+
217
+ /* Messages */
218
+ .msg{
219
+ margin-top:14px; padding:12px 14px; border-radius:12px; border:1px solid rgba(255,255,255,.08);
220
+ display:none; font-size:14px;
221
+ }
222
+ .msg.err{display:block; color:#ffd8d8; background:rgba(255,107,107,.08)}
223
+ .msg.ok{display:block; color:#d9fff4; background:rgba(33,209,159,.08)}
224
+
225
+ /* Audio card */
226
+ .audio{
227
+ margin-top:14px; padding:16px;
228
+ background:rgba(255,255,255,.03);
229
+ border:1px solid rgba(255,255,255,.08); border-radius:var(--radius-lg);
230
+ }
231
+ audio{width:100%; height:40px; outline:none}
232
+
233
+ /* Footer note */
234
+ .foot{margin-top:14px; text-align:center; color:#7f8aad; font-size:12.5px}
235
+
236
+ @media (max-width:720px){
237
+ .form-grid{grid-template-columns: 1fr}
238
+ .btn{width:100%}
239
+ }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div class="bar" id="bar"></div>
244
+
245
+ <div class="wrap">
246
+ <header class="hero">
247
+ <div class="hero-badge" id="statusBadge">
248
+ <span class="dot" id="statusDot"></span>
249
+ <span id="statusText">Loading AI models…</span>
250
+ </div>
251
+ <h1><span class="grad-text">AI Article Summarizer</span></h1>
252
+ <p>Qwen3-0.6B summarization · Kokoro neural TTS · smooth, private, fast</p>
253
+ </header>
254
+
255
+ <section class="panel">
256
+ <form id="summarizerForm" autocomplete="on">
257
+ <div class="form-grid">
258
+ <input id="articleUrl" class="input" type="url" inputmode="url"
259
+ placeholder="Paste an article URL (https://…)" required />
260
+ <button id="submitBtn" class="btn" type="submit">
261
+ ✨ Summarize
262
+ </button>
263
+ </div>
264
+
265
+ <div class="row">
266
+ <label class="switch" title="Generate audio with Kokoro TTS">
267
+ <input id="generateAudio" type="checkbox" />
268
+ <span class="track"><span class="thumb"></span></span>
269
+ <span>🎵 Text-to-Speech</span>
270
+ </label>
271
+
272
+ <span class="chip">Models: Qwen3-0.6B · Kokoro</span>
273
+ <span class="chip">On-device processing</span>
274
+ </div>
275
+
276
+ <div id="voiceSection" class="collapse" aria-hidden="true">
277
+ <div class="voices" id="voiceGrid">
278
+ <!-- Injected -->
279
+ </div>
280
+ </div>
281
+ </form>
282
+
283
+ <!-- Loading skeleton -->
284
+ <div id="loadingSection" style="display:none; margin-top:18px">
285
+ <div class="skeleton" style="height:18px; width:42%; margin-bottom:10px"></div>
286
+ <div class="skeleton" style="height:14px; width:90%; margin-bottom:8px"></div>
287
+ <div class="skeleton" style="height:14px; width:86%; margin-bottom:8px"></div>
288
+ <div class="skeleton" style="height:14px; width:88%; margin-bottom:8px"></div>
289
+ <div class="skeleton" style="height:14px; width:60%; margin-bottom:8px"></div>
290
+ </div>
291
+
292
+ <!-- Results -->
293
+ <div id="resultSection" class="results" style="display:none">
294
+ <div class="chips" id="stats"></div>
295
+
296
+ <div class="toolbar">
297
+ <button class="tbtn" id="copyBtn" type="button">📋 Copy summary</button>
298
+ <a class="tbtn" id="downloadAudioBtn" href="#" download style="display:none">⬇️ Download audio</a>
299
+ </div>
300
+
301
+ <div id="summaryContent" class="summary"></div>
302
+
303
+ <div id="audioSection" class="audio" style="display:none">
304
+ <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px">
305
+ <strong>🎧 Audio Playback</strong>
306
+ <span id="duration" style="color:var(--muted); font-size:12.5px"></span>
307
+ </div>
308
+ <audio id="audioPlayer" controls preload="none"></audio>
309
+ </div>
310
+ </div>
311
+
312
+ <div id="errorMessage" class="msg err"></div>
313
+ <div id="successMessage" class="msg ok"></div>
314
+ </section>
315
+
316
+ <p class="foot">Tip: turn on TTS and pick a voice you like. We’ll remember your last choice.</p>
317
+ </div>
318
+
319
+ <script>
320
+ // ---------------- State ----------------
321
+ let modelsReady = false;
322
+ let selectedVoice = localStorage.getItem("voiceId") || "af_heart";
323
+ const bar = document.getElementById("bar");
324
+
325
+ // --------------- Utilities --------------
326
+ const $ = (sel) => document.querySelector(sel);
327
+ function showBar(active) {
328
+ bar.style.transform = active ? "scaleX(1)" : "scaleX(0)";
329
+ }
330
+ function setStatus(ready, error){
331
+ const dot = $("#statusDot");
332
+ const text = $("#statusText");
333
+ const badge = $("#statusBadge");
334
+ if (error){
335
+ dot.classList.remove("ready");
336
+ text.textContent = "Model error: " + error;
337
+ badge.style.borderColor = "rgba(255,107,107,.45)";
338
+ return;
339
+ }
340
+ if (ready){
341
+ dot.classList.add("ready");
342
+ text.textContent = "Models ready";
343
+ } else {
344
+ dot.classList.remove("ready");
345
+ text.textContent = "Loading AI models…";
346
+ }
347
+ }
348
+ function chip(text){ const span = document.createElement("span"); span.className="chip"; span.textContent=text; return span; }
349
+ function fmt(x){ return new Intl.NumberFormat().format(x); }
350
+
351
+ // ------------- Model status poll ---------
352
+ async function checkModelStatus(){
353
+ try{
354
+ const res = await fetch("/status");
355
+ const s = await res.json();
356
+ modelsReady = !!s.loaded;
357
+ setStatus(modelsReady, s.error || null);
358
+ if (!modelsReady && !s.error) setTimeout(checkModelStatus, 1500);
359
+ if (modelsReady) { await loadVoices(); }
360
+ }catch(e){
361
+ setTimeout(checkModelStatus, 2000);
362
+ }
363
+ }
364
+
365
+ // ------------- Voice loading -------------
366
+ async function loadVoices(){
367
+ try{
368
+ const res = await fetch("/voices");
369
+ const voices = await res.json();
370
+ const grid = $("#voiceGrid");
371
+ grid.innerHTML = "";
372
+ voices.forEach(v=>{
373
+ const el = document.createElement("div");
374
+ el.className = "voice" + (v.id === selectedVoice ? " selected":"");
375
+ el.dataset.voice = v.id;
376
+ el.innerHTML = `
377
+ <div class="name">${v.name}</div>
378
+ <div class="meta">
379
+ <span class="badge">Grade ${v.grade}</span>
380
+ <span>${v.description || ""}</span>
381
+ </div>`;
382
+ el.addEventListener("click", ()=>{
383
+ document.querySelectorAll(".voice").forEach(x=>x.classList.remove("selected"));
384
+ el.classList.add("selected");
385
+ selectedVoice = v.id;
386
+ localStorage.setItem("voiceId", selectedVoice);
387
+ });
388
+ grid.appendChild(el);
389
+ });
390
+ }catch(e){
391
+ // ignore
392
+ }
393
+ }
394
+
395
+ // ------------- Collapsible voices --------
396
+ const generateAudio = $("#generateAudio");
397
+ const voiceSection = $("#voiceSection");
398
+ function toggleVoices(open){
399
+ voiceSection.classList.toggle("open", !!open);
400
+ voiceSection.setAttribute("aria-hidden", open ? "false" : "true");
401
+ }
402
+ generateAudio.addEventListener("change", e=> toggleVoices(e.target.checked));
403
+ toggleVoices(generateAudio.checked); // on load
404
+
405
+ // ------------- Form submit ----------------
406
+ const form = $("#summarizerForm");
407
+ const loading = $("#loadingSection");
408
+ const result = $("#resultSection");
409
+ const errorBox = $("#errorMessage");
410
+ const okBox = $("#successMessage");
411
+ const submitBtn = $("#submitBtn");
412
+ const urlInput = $("#articleUrl");
413
+
414
+ form.addEventListener("submit", async (e)=>{
415
+ e.preventDefault();
416
+ errorBox.style.display="none"; okBox.style.display="none";
417
+
418
+ if (!modelsReady){
419
+ errorBox.textContent = "Please wait for the AI models to finish loading.";
420
+ errorBox.style.display = "block";
421
+ return;
422
+ }
423
+ const url = urlInput.value.trim();
424
+ if (!url){ return; }
425
+
426
+ submitBtn.disabled = true;
427
+ showBar(true);
428
+ loading.style.display = "block";
429
+ result.style.display = "none";
430
+
431
+ try{
432
+ const res = await fetch("/process", {
433
+ method: "POST",
434
+ headers: {"Content-Type":"application/json"},
435
+ body: JSON.stringify({
436
+ url,
437
+ generate_audio: generateAudio.checked,
438
+ voice: selectedVoice
439
+ })
440
+ });
441
+ const data = await res.json();
442
+
443
+ loading.style.display = "none";
444
+ submitBtn.disabled = false;
445
+ showBar(false);
446
+
447
+ if (!data.success){
448
+ errorBox.textContent = data.error || "Something went wrong.";
449
+ errorBox.style.display = "block";
450
+ return;
451
+ }
452
+ renderResult(data);
453
+ okBox.textContent = "Done!";
454
+ okBox.style.display = "block";
455
+ setTimeout(()=> okBox.style.display="none", 1800);
456
+
457
+ }catch(err){
458
+ loading.style.display="none";
459
+ submitBtn.disabled=false;
460
+ showBar(false);
461
+ errorBox.textContent = "Network error: " + (err?.message || err);
462
+ errorBox.style.display = "block";
463
+ }
464
+ });
465
+
466
+ // ------------- Render results -------------
467
+ const stats = $("#stats");
468
+ const summaryEl = $("#summaryContent");
469
+ const audioWrap = $("#audioSection");
470
+ const audioEl = $("#audioPlayer");
471
+ const dlBtn = $("#downloadAudioBtn");
472
+ const durationLabel = $("#duration");
473
+ const copyBtn = $("#copyBtn");
474
+
475
+ function renderResult(r){
476
+ // Stats
477
+ stats.innerHTML = "";
478
+ stats.appendChild(chip(`📄 ${fmt(r.article_length)} → ${fmt(r.summary_length)} chars`));
479
+ stats.appendChild(chip(`📉 ${r.compression_ratio}% compression`));
480
+ stats.appendChild(chip(`🕒 ${r.timestamp}`));
481
+
482
+ // Summary
483
+ summaryEl.textContent = r.summary || "";
484
+ result.style.display = "block";
485
+
486
+ // Audio
487
+ if (r.audio_file){
488
+ audioEl.src = r.audio_file;
489
+ audioWrap.style.display = "block";
490
+ durationLabel.textContent = `${r.audio_duration}s`;
491
+ dlBtn.style.display = "inline-flex";
492
+ dlBtn.href = r.audio_file;
493
+ dlBtn.download = r.audio_file.split("/").pop() || "summary.wav";
494
+ } else {
495
+ audioWrap.style.display = "none";
496
+ dlBtn.style.display = "none";
497
+ }
498
+ }
499
+
500
+ // Copy summary
501
+ copyBtn.addEventListener("click", async ()=>{
502
+ try{
503
+ await navigator.clipboard.writeText(summaryEl.textContent || "");
504
+ copyBtn.textContent = "✅ Copied";
505
+ setTimeout(()=> copyBtn.textContent = "📋 Copy summary", 900);
506
+ }catch(e){
507
+ // ignore
508
+ }
509
+ });
510
+
511
+ // ------------- Quality of life -------------
512
+ // Paste on Cmd/Ctrl+V if input empty
513
+ window.addEventListener("paste", (e)=>{
514
+ if(document.activeElement !== urlInput && !urlInput.value){
515
+ const t = (e.clipboardData || window.clipboardData).getData("text");
516
+ if (t?.startsWith("http")){ urlInput.value = t; }
517
+ }
518
+ });
519
+
520
+ // Init
521
+ document.addEventListener("DOMContentLoaded", ()=>{
522
+ checkModelStatus();
523
+ // Restore voice toggle state hint
524
+ if (localStorage.getItem("voiceId")) selectedVoice = localStorage.getItem("voiceId");
525
+ });
526
+ </script>
527
+ </body>
528
+ </html>
works.py ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Flask Web Application for Article Summarizer with TTS
4
+ """
5
+
6
+ from flask import Flask, render_template, request, jsonify
7
+ import os
8
+ import time
9
+ import threading
10
+ import logging
11
+ from datetime import datetime
12
+ import re
13
+
14
+ import torch
15
+ import trafilatura
16
+ import soundfile as sf
17
+ from transformers import AutoModelForCausalLM, AutoTokenizer
18
+ from kokoro import KPipeline
19
+ import requests
20
+
21
+ # ---------------- Logging ----------------
22
+ logging.basicConfig(level=logging.INFO)
23
+ logger = logging.getLogger("summarizer")
24
+
25
+ # ---------------- Flask ----------------
26
+ app = Flask(__name__)
27
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me")
28
+
29
+ # ---------------- Globals ----------------
30
+ qwen_model = None
31
+ qwen_tokenizer = None
32
+ kokoro_pipeline = None
33
+
34
+ model_loading_status = {"loaded": False, "error": None}
35
+ _load_lock = threading.Lock()
36
+ _loaded_once = False # idempotence guard across threads
37
+
38
+ # Voice whitelist
39
+ ALLOWED_VOICES = {
40
+ "af_heart", "af_bella", "af_nicole", "am_michael",
41
+ "am_fenrir", "af_sarah", "bf_emma", "bm_george"
42
+ }
43
+
44
+ # HTTP headers to look like a real browser for sites that block bots
45
+ BROWSER_HEADERS = {
46
+ "User-Agent": (
47
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 "
48
+ "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
49
+ ),
50
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
51
+ "Accept-Language": "en-US,en;q=0.9",
52
+ }
53
+
54
+ # Create output dirs
55
+ # os.makedirs("static/audio", exist_ok=True)
56
+ # os.makedirs("static/summaries", exist_ok=True)
57
+
58
+ # Create output dirs (robust, relative to this file)
59
+ from pathlib import Path
60
+ BASE_DIR = Path(__file__).parent.resolve()
61
+ STATIC_DIR = BASE_DIR / "static"
62
+ AUDIO_DIR = STATIC_DIR / "audio"
63
+ SUMM_DIR = STATIC_DIR / "summaries"
64
+
65
+ for p in (AUDIO_DIR, SUMM_DIR):
66
+ try:
67
+ p.mkdir(parents=True, exist_ok=True)
68
+ except PermissionError:
69
+ logger.warning("No permission to create %s (will rely on image pre-created dirs).", p)
70
+
71
+
72
+ # ---------------- Helpers ----------------
73
+ def _get_device():
74
+ # Works for both CPU/GPU; safer than qwen_model.device
75
+ return next(qwen_model.parameters()).device
76
+
77
+ def _safe_trim_to_tokens(text: str, tokenizer, max_tokens: int) -> str:
78
+ ids = tokenizer.encode(text, add_special_tokens=False)
79
+ if len(ids) <= max_tokens:
80
+ return text
81
+ ids = ids[:max_tokens]
82
+ return tokenizer.decode(ids, skip_special_tokens=True)
83
+
84
+ # Remove any leaked <think>…</think> (with optional attributes) or similar tags
85
+ _THINK_BLOCK_RE = re.compile(
86
+ r"<\s*(think|reasoning|thought)\b[^>]*>.*?<\s*/\s*\1\s*>",
87
+ re.IGNORECASE | re.DOTALL,
88
+ )
89
+ _THINK_TAGS_RE = re.compile(r"</?\s*(think|reasoning|thought)\b[^>]*>", re.IGNORECASE)
90
+
91
+ def _strip_reasoning(text: str) -> str:
92
+ cleaned = _THINK_BLOCK_RE.sub("", text) # remove full blocks
93
+ cleaned = _THINK_TAGS_RE.sub("", cleaned) # remove any stray tags
94
+ # optionally collapse leftover triple-backtick blocks that only had think text
95
+ cleaned = re.sub(r"```(?:\w+)?\s*```", "", cleaned)
96
+ return cleaned.strip()
97
+
98
+ def _normalize_url_for_proxy(u: str) -> str:
99
+ # r.jina.ai expects 'http://<host>/<path>' after it; unify scheme-less
100
+ u2 = u.replace("https://", "").replace("http://", "")
101
+ return f"https://r.jina.ai/http://{u2}"
102
+
103
+ # ---------------- Model Load ----------------
104
+ def load_models():
105
+ """Load Qwen and Kokoro models on startup (idempotent)."""
106
+ global qwen_model, qwen_tokenizer, kokoro_pipeline, model_loading_status, _loaded_once
107
+ with _load_lock:
108
+ if _loaded_once:
109
+ return
110
+ try:
111
+ logger.info("Loading Qwen3-0.6B…")
112
+ model_name = "Qwen/Qwen3-0.6B"
113
+
114
+ qwen_tokenizer = AutoTokenizer.from_pretrained(model_name)
115
+ qwen_model = AutoModelForCausalLM.from_pretrained(
116
+ model_name,
117
+ torch_dtype="auto",
118
+ device_map="auto", # CPU or GPU automatically
119
+ )
120
+ qwen_model.eval() # inference mode
121
+
122
+ logger.info("Loading Kokoro TTS…")
123
+ kokoro_pipeline = KPipeline(lang_code="a")
124
+
125
+ model_loading_status["loaded"] = True
126
+ model_loading_status["error"] = None
127
+ _loaded_once = True
128
+ logger.info("✅ Models ready")
129
+ except Exception as e:
130
+ err = f"{type(e).__name__}: {e}"
131
+ model_loading_status["loaded"] = False
132
+ model_loading_status["error"] = err
133
+ logger.exception("Failed to load models: %s", err)
134
+
135
+ # ---------------- Core Logic ----------------
136
+ def scrape_article_text(url: str) -> tuple[str | None, str | None]:
137
+ """
138
+ Try to fetch & extract article text.
139
+ Strategy:
140
+ 1) Trafilatura.fetch_url (vanilla)
141
+ 2) requests.get with browser headers + trafilatura.extract
142
+ 3) (optional) Proxy fallback if ALLOW_PROXY_FALLBACK=1
143
+ Returns (content, error)
144
+ """
145
+ try:
146
+ # --- 1) Direct fetch via Trafilatura ---
147
+ downloaded = trafilatura.fetch_url(url)
148
+ if downloaded:
149
+ text = trafilatura.extract(downloaded, include_comments=False, include_tables=False)
150
+ if text:
151
+ return text, None
152
+
153
+ # --- 2) Raw requests + Trafilatura extract ---
154
+ try:
155
+ r = requests.get(url, headers=BROWSER_HEADERS, timeout=15)
156
+ if r.status_code == 200 and r.text:
157
+ text = trafilatura.extract(r.text, include_comments=False, include_tables=False, url=url)
158
+ if text:
159
+ return text, None
160
+ elif r.status_code == 403:
161
+ logger.info("Site returned 403; considering proxy fallback (if enabled).")
162
+ except requests.RequestException as e:
163
+ logger.info("requests.get failed: %s", e)
164
+
165
+ # --- 3) Optional proxy fallback (off by default) ---
166
+ if os.environ.get("ALLOW_PROXY_FALLBACK", "0") == "1":
167
+ proxy_url = _normalize_url_for_proxy(url)
168
+ try:
169
+ pr = requests.get(proxy_url, headers=BROWSER_HEADERS, timeout=15)
170
+ if pr.status_code == 200 and pr.text:
171
+ extracted = trafilatura.extract(pr.text) or pr.text
172
+ if extracted and extracted.strip():
173
+ return extracted.strip(), None
174
+ except requests.RequestException as e:
175
+ logger.info("Proxy fallback failed: %s", e)
176
+
177
+ return None, (
178
+ "Failed to download the article content (site may block automated fetches). "
179
+ "Try another URL, paste the text manually, or set ALLOW_PROXY_FALLBACK=1."
180
+ )
181
+
182
+ except Exception as e:
183
+ return None, f"Error scraping article: {e}"
184
+
185
+ def summarize_with_qwen(text: str) -> tuple[str | None, str | None]:
186
+ """Generate summary and return (summary, error)."""
187
+ try:
188
+ # Budget input tokens based on max context; fallback to 4096
189
+ try:
190
+ max_ctx = int(getattr(qwen_model.config, "max_position_embeddings", 4096))
191
+ except Exception:
192
+ max_ctx = 4096
193
+ # Leave room for prompt + output tokens
194
+ max_input_tokens = max(512, max_ctx - 1024)
195
+
196
+ prompt_hdr = (
197
+ "Please provide a concise and clear summary of the following article. "
198
+ "Focus on the main points, key findings, and conclusions. "
199
+ "Keep it easy to understand for someone who hasn't read the original.\n\nARTICLE:\n"
200
+ )
201
+
202
+ # Trim article to safe length
203
+ article_trimmed = _safe_trim_to_tokens(text, qwen_tokenizer, max_input_tokens)
204
+ user_content = prompt_hdr + article_trimmed
205
+
206
+ messages = [
207
+ {
208
+ "role": "system",
209
+ "content": (
210
+ "You are a helpful assistant. Return ONLY the final summary as plain text. "
211
+ "Do not include analysis, steps, or <think> tags."
212
+ ),
213
+ },
214
+ {"role": "user", "content": user_content}, # <-- important: pass the TRIMMED content
215
+ ]
216
+
217
+ # Build the chat prompt text (disable thinking if supported)
218
+ try:
219
+ text_input = qwen_tokenizer.apply_chat_template(
220
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
221
+ )
222
+ except TypeError:
223
+ text_input = qwen_tokenizer.apply_chat_template(
224
+ messages, tokenize=False, add_generation_prompt=True
225
+ )
226
+
227
+ device = _get_device()
228
+ model_inputs = qwen_tokenizer([text_input], return_tensors="pt").to(device)
229
+
230
+ with torch.inference_mode():
231
+ generated_ids = qwen_model.generate(
232
+ **model_inputs,
233
+ max_new_tokens=512,
234
+ temperature=0.7,
235
+ top_p=0.8,
236
+ top_k=20,
237
+ do_sample=True,
238
+ )
239
+
240
+ output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
241
+ summary = qwen_tokenizer.decode(output_ids, skip_special_tokens=True).strip()
242
+ summary = _strip_reasoning(summary) # <-- remove any leaked <think>…</think>
243
+ return summary, None
244
+ except Exception as e:
245
+ return None, f"Error generating summary: {e}"
246
+
247
+ def generate_speech(summary: str, voice: str) -> tuple[str | None, str | None, float]:
248
+ """Generate speech and return (filename, error, duration_seconds)."""
249
+ try:
250
+ if voice not in ALLOWED_VOICES:
251
+ voice = "af_heart"
252
+ generator = kokoro_pipeline(summary, voice=voice)
253
+
254
+ audio_chunks = []
255
+ total_duration = 0.0
256
+
257
+ for _, _, audio in generator:
258
+ audio_chunks.append(audio)
259
+ total_duration += len(audio) / 24000.0
260
+
261
+ if not audio_chunks:
262
+ return None, "No audio generated.", 0.0
263
+
264
+ combined = audio_chunks[0] if len(audio_chunks) == 1 else torch.cat(audio_chunks, dim=0)
265
+
266
+ ts = int(time.time())
267
+ filename = f"summary_{ts}.wav"
268
+ filepath = os.path.join("static", "audio", filename)
269
+ sf.write(filepath, combined.numpy(), 24000)
270
+
271
+ return filename, None, total_duration
272
+ except Exception as e:
273
+ return None, f"Error generating speech: {e}", 0.0
274
+
275
+ # ---------------- Routes ----------------
276
+ @app.route("/")
277
+ def index():
278
+ return render_template("index.html")
279
+
280
+ @app.route("/status")
281
+ def status():
282
+ return jsonify(model_loading_status)
283
+
284
+ @app.route("/process", methods=["POST"])
285
+ def process_article():
286
+ if not model_loading_status["loaded"]:
287
+ return jsonify({"success": False, "error": "Models not loaded yet. Please wait."})
288
+
289
+ data = request.get_json(force=True, silent=True) or {}
290
+ url = (data.get("url") or "").strip()
291
+ generate_audio = bool(data.get("generate_audio", False))
292
+ voice = (data.get("voice") or "af_heart").strip()
293
+
294
+ if not url:
295
+ return jsonify({"success": False, "error": "Please provide a valid URL."})
296
+
297
+ # 1) Scrape
298
+ article_content, scrape_error = scrape_article_text(url)
299
+ if scrape_error:
300
+ return jsonify({"success": False, "error": scrape_error})
301
+
302
+ # 2) Summarize
303
+ summary, summary_error = summarize_with_qwen(article_content)
304
+ if summary_error:
305
+ return jsonify({"success": False, "error": summary_error})
306
+
307
+ resp = {
308
+ "success": True,
309
+ "summary": summary,
310
+ "article_length": len(article_content or ""),
311
+ "summary_length": len(summary or ""),
312
+ "compression_ratio": round(len(summary) / max(len(article_content), 1) * 100, 1),
313
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
314
+ }
315
+
316
+ # 3) TTS
317
+ if generate_audio:
318
+ audio_filename, audio_error, duration = generate_speech(summary, voice)
319
+ if audio_error:
320
+ resp["audio_error"] = audio_error
321
+ else:
322
+ resp["audio_file"] = f"/static/audio/{audio_filename}"
323
+ resp["audio_duration"] = round(duration, 2)
324
+
325
+ return jsonify(resp)
326
+
327
+ @app.route("/voices")
328
+ def get_voices():
329
+ voices = [
330
+ {"id": "af_heart", "name": "Female - Heart", "grade": "A", "description": "❤️ Warm female voice (best quality)"},
331
+ {"id": "af_bella", "name": "Female - Bella", "grade": "A-", "description": "🔥 Energetic female voice"},
332
+ {"id": "af_nicole", "name": "Female - Nicole", "grade": "B-", "description": "🎧 Professional female voice"},
333
+ {"id": "am_michael", "name": "Male - Michael", "grade": "C+", "description": "Clear male voice"},
334
+ {"id": "am_fenrir", "name": "Male - Fenrir", "grade": "C+", "description": "Strong male voice"},
335
+ {"id": "af_sarah", "name": "Female - Sarah", "grade": "C+", "description": "Gentle female voice"},
336
+ {"id": "bf_emma", "name": "British Female - Emma", "grade": "B-", "description": "🇬🇧 British accent"},
337
+ {"id": "bm_george", "name": "British Male - George", "grade": "C", "description": "🇬🇧 British male voice"},
338
+ ]
339
+ return jsonify(voices)
340
+
341
+ # Kick off model loading when running under Gunicorn/containers
342
+ if os.environ.get("RUNNING_GUNICORN", "0") == "1":
343
+ threading.Thread(target=load_models, daemon=True).start()
344
+
345
+ # ---------------- Dev entrypoint ----------------
346
+ if __name__ == "__main__":
347
+ import argparse
348
+ parser = argparse.ArgumentParser(description="AI Article Summarizer Web App")
349
+ parser.add_argument("--port", type=int, default=5001, help="Port to run the server on (default: 5001)")
350
+ parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
351
+ args = parser.parse_args()
352
+
353
+ # Load models in background thread
354
+ threading.Thread(target=load_models, daemon=True).start()
355
+
356
+ # Respect platform env PORT when present
357
+ port = int(os.environ.get("PORT", args.port))
358
+
359
+ print("🚀 Starting Article Summarizer Web App…")
360
+ print("📚 Models are loading in the background…")
361
+ print(f"🌐 Open http://localhost:{port} in your browser")
362
+
363
+ try:
364
+ app.run(debug=True, host=args.host, port=port)
365
+ except OSError as e:
366
+ if "Address already in use" in str(e):
367
+ print(f"❌ Port {port} is already in use!")
368
+ print("💡 Try a different port:")
369
+ print(f" python app.py --port {port + 1}")
370
+ print("📱 Or disable AirPlay Receiver in System Settings → General → AirDrop & Handoff")
371
+ else:
372
+ raise