Vineeth Sai commited on
Commit
501847e
Β·
0 Parent(s):

Initial deploy to HF Spaces (Docker)

Browse files
Files changed (14) hide show
  1. ## GitHub Copilot Chat.md +33 -0
  2. .dockerignore +10 -0
  3. .gitignore +21 -0
  4. Dockerfile +44 -0
  5. README.md +268 -0
  6. app.py +617 -0
  7. main.py +286 -0
  8. model_test.py +52 -0
  9. requirements.txt +16 -0
  10. setup_web_app.sh +59 -0
  11. start.sh +17 -0
  12. summarize_qwen.py +143 -0
  13. templates/index.html +528 -0
  14. templates/index0.html +551 -0
## GitHub Copilot Chat.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## GitHub Copilot Chat
2
+
3
+ - Extension Version: 0.22.4 (prod)
4
+ - VS Code: vscode/1.95.3
5
+ - OS: Mac
6
+
7
+ ## Network
8
+
9
+ User Settings:
10
+ ```json
11
+ "github.copilot.advanced": {
12
+ "debug.useElectronFetcher": true,
13
+ "debug.useNodeFetcher": false
14
+ }
15
+ ```
16
+
17
+ Connecting to https://api.github.com:
18
+ - DNS ipv4 Lookup: 140.82.116.5 (35 ms)
19
+ - DNS ipv6 Lookup: 64:ff9b::8c52:7405 (17 ms)
20
+ - Electron Fetcher (configured): HTTP 200 (125 ms)
21
+ - Node Fetcher: HTTP 200 (86 ms)
22
+ - Helix Fetcher: HTTP 200 (307 ms)
23
+
24
+ Connecting to https://api.individual.githubcopilot.com/_ping:
25
+ - DNS ipv4 Lookup: 140.82.114.22 (18 ms)
26
+ - DNS ipv6 Lookup: 64:ff9b::8c52:7216 (18 ms)
27
+ - Electron Fetcher (configured): HTTP 200 (233 ms)
28
+ - Node Fetcher: HTTP 200 (256 ms)
29
+ - Helix Fetcher: HTTP 200 (253 ms)
30
+
31
+ ## Documentation
32
+
33
+ In corporate networks: [Troubleshooting firewall settings for GitHub Copilot](https://docs.github.com/en/copilot/troubleshooting-github-copilot/troubleshooting-firewall-settings-for-github-copilot).
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ *.log
7
+ .cache/
8
+ .huggingface/
9
+ .git/
10
+ .gitignore
.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cat > .gitignore << 'EOF'
2
+ # Python
3
+ venv/
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+ *.egg-info/
9
+ .cache/
10
+
11
+ # macOS
12
+ .DS_Store
13
+
14
+ # Audio & generated artifacts
15
+ *.wav
16
+ static/audio/*
17
+ static/summaries/*
18
+
19
+ # Git / tooling
20
+ .git/
21
+ EOF
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Small, compatible base image
2
+ FROM python:3.10-slim
3
+
4
+ ENV PYTHONDONTWRITEBYTECODE=1 \
5
+ PYTHONUNBUFFERED=1 \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ HF_HOME=/cache/hf \
8
+ TRANSFORMERS_CACHE=/cache/hf \
9
+ TORCH_HOME=/cache/torch \
10
+ PORT=7860 \
11
+ RUNNING_GUNICORN=1 \
12
+ # Optional: set 1 to allow proxy fallback for stubborn sites (non-paywalled).
13
+ ALLOW_PROXY_FALLBACK=0
14
+
15
+ # System deps: espeak-ng for Kokoro phonemizer, sndfile for soundfile, ffmpeg optional
16
+ RUN apt-get update && apt-get install -y --no-install-recommends \
17
+ espeak-ng ffmpeg libsndfile1 git build-essential \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ WORKDIR /app
21
+
22
+ # Install Python deps first to leverage Docker layer caching
23
+ COPY requirements.txt .
24
+ RUN pip install --upgrade pip && pip install -r requirements.txt
25
+
26
+ # (Optional but useful) Preload models during build so first start is snappy
27
+ # If this step times out in your Space, just comment it out.
28
+ RUN python - <<'PY'
29
+ from transformers import AutoModelForCausalLM, AutoTokenizer
30
+ print("Downloading Qwen/Qwen3-0.6B…")
31
+ AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
32
+ AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B", torch_dtype="auto", device_map="auto")
33
+ print("Priming Kokoro…")
34
+ from kokoro import KPipeline
35
+ KPipeline(lang_code="a")
36
+ print("Preload done.")
37
+ PY
38
+
39
+ # Copy the app
40
+ COPY . .
41
+
42
+ # Flask will read PORT from env (default 7860). We already handle this in app.py.
43
+ EXPOSE 7860
44
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ€– AI Article Summarizer with Text-to-Speech
2
+
3
+ A beautiful web application that scrapes articles from URLs, generates concise summaries using Qwen3-0.6B, and optionally converts them to speech using Kokoro TTS.
4
+
5
+ ## ✨ Features
6
+
7
+ - **🌐 Web Scraping**: Extract clean article text from any URL
8
+ - **πŸ€– AI Summarization**: Generate concise summaries using Qwen3-0.6B
9
+ - **🎡 Text-to-Speech**: Convert summaries to natural speech with Kokoro TTS
10
+ - **🎭 Multiple Voices**: Choose from 8 different voice options
11
+ - **πŸ“± Responsive Design**: Works on desktop and mobile devices
12
+ - **⚑ Real-time Processing**: Live status updates and progress indicators
13
+
14
+ ## πŸš€ Quick Start
15
+
16
+ ### 1. Clone/Download Files
17
+
18
+ Create a new directory and save these files:
19
+ - `app.py` - Flask web application
20
+ - `templates/index.html` - Web interface
21
+ - `setup_web_app.sh` - Setup script
22
+
23
+ ### 2. Run Setup Script
24
+
25
+ ```bash
26
+ # Make setup script executable
27
+ chmod +x setup_web_app.sh
28
+
29
+ # Run setup (installs everything automatically)
30
+ ./setup_web_app.sh
31
+ ```
32
+
33
+ ### 3. Start the Web Application
34
+
35
+ ```bash
36
+ # Activate virtual environment
37
+ source venv/bin/activate
38
+
39
+ # Run the Flask app
40
+ python app.py
41
+ ```
42
+
43
+ ### 4. Open in Browser
44
+
45
+ Navigate to: **http://localhost:5000**
46
+
47
+ ## πŸ“‹ Manual Setup (Alternative)
48
+
49
+ If you prefer manual setup:
50
+
51
+ ### Prerequisites
52
+
53
+ - Python 3.8+
54
+ - macOS: `brew install espeak`
55
+ - Linux: `sudo apt-get install espeak-ng`
56
+
57
+ ### Installation
58
+
59
+ ```bash
60
+ # Create virtual environment
61
+ python3 -m venv venv
62
+ source venv/bin/activate
63
+
64
+ # Install packages
65
+ pip install Flask torch transformers trafilatura soundfile kokoro librosa
66
+
67
+ # Create directories
68
+ mkdir -p templates static/audio static/summaries
69
+ ```
70
+
71
+ ## 🎭 Available Voices
72
+
73
+ | Voice | Type | Quality | Description |
74
+ |-------|------|---------|-------------|
75
+ | af_heart ❀️ | Female | A | Warm, best quality (default) |
76
+ | af_bella πŸ”₯ | Female | A- | Energetic, high quality |
77
+ | af_nicole 🎧 | Female | B- | Professional tone |
78
+ | am_michael | Male | C+ | Clear male voice |
79
+ | am_fenrir | Male | C+ | Strong male voice |
80
+ | af_sarah | Female | C+ | Gentle female voice |
81
+ | bf_emma πŸ‡¬πŸ‡§ | Female | B- | British accent |
82
+ | bm_george πŸ‡¬πŸ‡§ | Male | C | British male accent |
83
+
84
+ ## πŸ–₯️ Web Interface Features
85
+
86
+ ### Main Interface
87
+ - Clean, modern design with gradient background
88
+ - Real-time model loading status
89
+ - URL input with validation
90
+ - Optional text-to-speech toggle
91
+ - Voice selection with quality indicators
92
+
93
+ ### Processing
94
+ - Live progress indicators
95
+ - Error handling with user-friendly messages
96
+ - Summary statistics (compression ratio, word count)
97
+ - Timestamp tracking
98
+
99
+ ### Results
100
+ - Beautiful summary display with syntax highlighting
101
+ - Integrated audio player for TTS output
102
+ - Downloadable audio files
103
+ - Responsive design for mobile devices
104
+
105
+ ## πŸ“Š Technical Details
106
+
107
+ ### AI Models Used
108
+ - **Qwen3-0.6B**: 600M parameter language model for summarization
109
+ - **Kokoro TTS**: 82M parameter text-to-speech model
110
+ - **Trafilatura**: Web scraping and content extraction
111
+
112
+ ### Performance
113
+ - **Model Loading**: ~1-2 minutes on first startup
114
+ - **Summarization**: ~2-5 seconds per article
115
+ - **TTS Generation**: ~1-3 seconds for typical summaries
116
+ - **Memory Usage**: ~2-4GB RAM (depending on hardware)
117
+
118
+ ### File Structure
119
+ ```
120
+ your-project/
121
+ β”œβ”€β”€ app.py # Flask web application
122
+ β”œβ”€β”€ templates/
123
+ β”‚ └── index.html # Web interface
124
+ β”œβ”€β”€ static/
125
+ β”‚ β”œβ”€β”€ audio/ # Generated audio files
126
+ β”‚ └── summaries/ # Cached summaries (optional)
127
+ β”œβ”€β”€ venv/ # Virtual environment
128
+ └── requirements.txt # Python dependencies
129
+ ```
130
+
131
+ ## πŸ”§ Configuration
132
+
133
+ ### Environment Variables (Optional)
134
+ ```bash
135
+ export FLASK_ENV=development # For debugging
136
+ export FLASK_PORT=5000 # Custom port
137
+ ```
138
+
139
+ ### Model Configuration
140
+ - Models are loaded automatically on startup
141
+ - First run downloads ~1.2GB of model files
142
+ - Models are cached locally for faster subsequent starts
143
+
144
+ ## πŸ› Troubleshooting
145
+
146
+ ### Common Issues
147
+
148
+ **Models not loading**
149
+ ```bash
150
+ # Check internet connection and disk space
151
+ df -h
152
+ ping huggingface.co
153
+ ```
154
+
155
+ **espeak not found**
156
+ ```bash
157
+ # macOS
158
+ brew install espeak
159
+
160
+ # Linux
161
+ sudo apt-get install espeak-ng
162
+ ```
163
+
164
+ **Permission errors**
165
+ ```bash
166
+ # Make sure virtual environment is activated
167
+ source venv/bin/activate
168
+ which python # Should show venv path
169
+ ```
170
+
171
+ **Port already in use**
172
+ ```bash
173
+ # Kill process using port 5000
174
+ lsof -ti:5000 | xargs kill -9
175
+
176
+ # Or use different port
177
+ python app.py --port 5001
178
+ ```
179
+
180
+ ### Performance Optimization
181
+
182
+ **For faster startup:**
183
+ - Keep models in memory between requests
184
+ - Use SSD storage for model cache
185
+ - Ensure sufficient RAM (4GB+ recommended)
186
+
187
+ **For better quality:**
188
+ - Use `af_heart` or `af_bella` voices
189
+ - Keep summaries under 500 words for best TTS quality
190
+ - Use clean, well-formatted article URLs
191
+
192
+ ## πŸ“± Usage Tips
193
+
194
+ 1. **Best Article Sources**: News sites, blogs, Wikipedia work well
195
+ 2. **URL Format**: Use full URLs (https://example.com/article)
196
+ 3. **Summary Length**: Typically 100-300 words from longer articles
197
+ 4. **Audio Quality**: Higher-grade voices sound more natural
198
+ 5. **Mobile Use**: Interface is fully responsive
199
+
200
+ ## πŸ”’ Privacy & Security
201
+
202
+ - No article content is stored permanently
203
+ - Audio files are generated locally
204
+ - No data is sent to external services (except model downloads)
205
+ - All processing happens on your machine
206
+
207
+ ## πŸš€ Advanced Usage
208
+
209
+ ### API Endpoints
210
+
211
+ The web app exposes these endpoints:
212
+
213
+ - `GET /` - Main interface
214
+ - `GET /status` - Model loading status
215
+ - `GET /voices` - Available voice list
216
+ - `POST /process` - Process article (JSON)
217
+
218
+ ### Example API Usage
219
+ ```javascript
220
+ fetch('/process', {
221
+ method: 'POST',
222
+ headers: {'Content-Type': 'application/json'},
223
+ body: JSON.stringify({
224
+ url: 'https://example.com/article',
225
+ generate_audio: true,
226
+ voice: 'af_heart'
227
+ })
228
+ });
229
+ ```
230
+
231
+ ## πŸ“ˆ Future Enhancements
232
+
233
+ Possible improvements:
234
+ - Multiple language support
235
+ - Custom voice training
236
+ - Batch processing
237
+ - Summary length control
238
+ - Export options (PDF, EPUB)
239
+ - Integration with read-later apps
240
+
241
+ ## 🀝 Contributing
242
+
243
+ Feel free to:
244
+ - Report bugs
245
+ - Suggest features
246
+ - Submit improvements
247
+ - Share cool use cases
248
+
249
+ ## πŸ“„ License
250
+
251
+ This project uses:
252
+ - Qwen3-0.6B (Apache 2.0)
253
+ - Kokoro TTS (Apache 2.0)
254
+ - Flask (BSD)
255
+ - Other open-source libraries
256
+
257
+ ## πŸ™ Acknowledgments
258
+
259
+ - **Qwen Team** for the summarization model
260
+ - **hexgrad** for Kokoro TTS
261
+ - **Trafilatura** for web scraping
262
+ - **Flask** for the web framework
263
+
264
+ ---
265
+
266
+ **Enjoy your AI-powered article summarizer!** πŸŽ‰
267
+
268
+ For support or questions, check the troubleshooting section or open an issue.
app.py ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
263
+ """
264
+
265
+ from flask import Flask, render_template, request, jsonify
266
+ import os
267
+ import time
268
+ 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)
282
+ logger = logging.getLogger("summarizer")
283
+
284
+ # ---------------- Flask ----------------
285
+ app = Flask(__name__)
286
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me")
287
+
288
+ # ---------------- Globals ----------------
289
+ qwen_model = None
290
+ qwen_tokenizer = None
291
+ kokoro_pipeline = None
292
+
293
+ model_loading_status = {"loaded": False, "error": None}
294
+ _load_lock = threading.Lock()
295
+ _loaded_once = False # idempotence guard across threads
296
+
297
+ # Voice whitelist
298
+ ALLOWED_VOICES = {
299
+ "af_heart", "af_bella", "af_nicole", "am_michael",
300
+ "am_fenrir", "af_sarah", "bf_emma", "bm_george"
301
+ }
302
+
303
+ # HTTP headers to look like a real browser for sites that block bots
304
+ BROWSER_HEADERS = {
305
+ "User-Agent": (
306
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) AppleWebKit/537.36 "
307
+ "(KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
308
+ ),
309
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
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
+ # ---------------- Helpers ----------------
318
+ def _get_device():
319
+ # Works for both CPU/GPU; safer than qwen_model.device
320
+ return next(qwen_model.parameters()).device
321
+
322
+ def _safe_trim_to_tokens(text: str, tokenizer, max_tokens: int) -> str:
323
+ ids = tokenizer.encode(text, add_special_tokens=False)
324
+ if len(ids) <= max_tokens:
325
+ return text
326
+ ids = ids[:max_tokens]
327
+ return tokenizer.decode(ids, skip_special_tokens=True)
328
+
329
+ # Remove any leaked <think>…</think> (with optional attributes) or similar tags
330
+ _THINK_BLOCK_RE = re.compile(
331
+ r"<\s*(think|reasoning|thought)\b[^>]*>.*?<\s*/\s*\1\s*>",
332
+ re.IGNORECASE | re.DOTALL,
333
+ )
334
+ _THINK_TAGS_RE = re.compile(r"</?\s*(think|reasoning|thought)\b[^>]*>", re.IGNORECASE)
335
+
336
+ def _strip_reasoning(text: str) -> str:
337
+ cleaned = _THINK_BLOCK_RE.sub("", text) # remove full blocks
338
+ cleaned = _THINK_TAGS_RE.sub("", cleaned) # remove any stray tags
339
+ # optionally collapse leftover triple-backtick blocks that only had think text
340
+ cleaned = re.sub(r"```(?:\w+)?\s*```", "", cleaned)
341
+ return cleaned.strip()
342
+
343
+ def _normalize_url_for_proxy(u: str) -> str:
344
+ # r.jina.ai expects 'http://<host>/<path>' after it; unify scheme-less
345
+ u2 = u.replace("https://", "").replace("http://", "")
346
+ return f"https://r.jina.ai/http://{u2}"
347
+
348
+ # ---------------- Model Load ----------------
349
+ def load_models():
350
+ """Load Qwen and Kokoro models on startup (idempotent)."""
351
+ global qwen_model, qwen_tokenizer, kokoro_pipeline, model_loading_status, _loaded_once
352
+ with _load_lock:
353
+ if _loaded_once:
354
+ return
355
+ try:
356
+ logger.info("Loading Qwen3-0.6B…")
357
+ model_name = "Qwen/Qwen3-0.6B"
358
+
359
+ qwen_tokenizer = AutoTokenizer.from_pretrained(model_name)
360
+ qwen_model = AutoModelForCausalLM.from_pretrained(
361
+ model_name,
362
+ torch_dtype="auto",
363
+ device_map="auto", # CPU or GPU automatically
364
+ )
365
+ qwen_model.eval() # inference mode
366
+
367
+ logger.info("Loading Kokoro TTS…")
368
+ kokoro_pipeline = KPipeline(lang_code="a")
369
+
370
+ model_loading_status["loaded"] = True
371
+ model_loading_status["error"] = None
372
+ _loaded_once = True
373
+ logger.info("βœ… Models ready")
374
+ except Exception as e:
375
+ err = f"{type(e).__name__}: {e}"
376
+ model_loading_status["loaded"] = False
377
+ model_loading_status["error"] = err
378
+ logger.exception("Failed to load models: %s", err)
379
+
380
+ # ---------------- Core Logic ----------------
381
+ def scrape_article_text(url: str) -> tuple[str | None, str | None]:
382
+ """
383
+ Try to fetch & extract article text.
384
+ Strategy:
385
+ 1) Trafilatura.fetch_url (vanilla)
386
+ 2) requests.get with browser headers + trafilatura.extract
387
+ 3) (optional) Proxy fallback if ALLOW_PROXY_FALLBACK=1
388
+ Returns (content, error)
389
+ """
390
+ try:
391
+ # --- 1) Direct fetch via Trafilatura ---
392
+ downloaded = trafilatura.fetch_url(url)
393
+ if downloaded:
394
+ text = trafilatura.extract(downloaded, include_comments=False, include_tables=False)
395
+ if text:
396
+ return text, None
397
+
398
+ # --- 2) Raw requests + Trafilatura extract ---
399
+ try:
400
+ r = requests.get(url, headers=BROWSER_HEADERS, timeout=15)
401
+ if r.status_code == 200 and r.text:
402
+ text = trafilatura.extract(r.text, include_comments=False, include_tables=False, url=url)
403
+ if text:
404
+ return text, None
405
+ elif r.status_code == 403:
406
+ logger.info("Site returned 403; considering proxy fallback (if enabled).")
407
+ except requests.RequestException as e:
408
+ logger.info("requests.get failed: %s", e)
409
+
410
+ # --- 3) Optional proxy fallback (off by default) ---
411
+ if os.environ.get("ALLOW_PROXY_FALLBACK", "0") == "1":
412
+ proxy_url = _normalize_url_for_proxy(url)
413
+ try:
414
+ pr = requests.get(proxy_url, headers=BROWSER_HEADERS, timeout=15)
415
+ if pr.status_code == 200 and pr.text:
416
+ extracted = trafilatura.extract(pr.text) or pr.text
417
+ if extracted and extracted.strip():
418
+ return extracted.strip(), None
419
+ except requests.RequestException as e:
420
+ logger.info("Proxy fallback failed: %s", e)
421
+
422
+ return None, (
423
+ "Failed to download the article content (site may block automated fetches). "
424
+ "Try another URL, paste the text manually, or set ALLOW_PROXY_FALLBACK=1."
425
+ )
426
+
427
+ except Exception as e:
428
+ return None, f"Error scraping article: {e}"
429
+
430
+ def summarize_with_qwen(text: str) -> tuple[str | None, str | None]:
431
+ """Generate summary and return (summary, error)."""
432
+ try:
433
+ # Budget input tokens based on max context; fallback to 4096
434
+ try:
435
+ max_ctx = int(getattr(qwen_model.config, "max_position_embeddings", 4096))
436
+ except Exception:
437
+ max_ctx = 4096
438
+ # Leave room for prompt + output tokens
439
+ max_input_tokens = max(512, max_ctx - 1024)
440
+
441
+ prompt_hdr = (
442
+ "Please provide a concise and clear summary of the following article. "
443
+ "Focus on the main points, key findings, and conclusions. "
444
+ "Keep it easy to understand for someone who hasn't read the original.\n\nARTICLE:\n"
445
+ )
446
+
447
+ # Trim article to safe length
448
+ article_trimmed = _safe_trim_to_tokens(text, qwen_tokenizer, max_input_tokens)
449
+ user_content = prompt_hdr + article_trimmed
450
+
451
+ messages = [
452
+ {
453
+ "role": "system",
454
+ "content": (
455
+ "You are a helpful assistant. Return ONLY the final summary as plain text. "
456
+ "Do not include analysis, steps, or <think> tags."
457
+ ),
458
+ },
459
+ {"role": "user", "content": user_content}, # <-- important: pass the TRIMMED content
460
+ ]
461
+
462
+ # Build the chat prompt text (disable thinking if supported)
463
+ try:
464
+ text_input = qwen_tokenizer.apply_chat_template(
465
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
466
+ )
467
+ except TypeError:
468
+ text_input = qwen_tokenizer.apply_chat_template(
469
+ messages, tokenize=False, add_generation_prompt=True
470
+ )
471
+
472
+ device = _get_device()
473
+ model_inputs = qwen_tokenizer([text_input], return_tensors="pt").to(device)
474
+
475
+ with torch.inference_mode():
476
+ generated_ids = qwen_model.generate(
477
+ **model_inputs,
478
+ max_new_tokens=512,
479
+ temperature=0.7,
480
+ top_p=0.8,
481
+ top_k=20,
482
+ do_sample=True,
483
+ )
484
+
485
+ output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
486
+ summary = qwen_tokenizer.decode(output_ids, skip_special_tokens=True).strip()
487
+ summary = _strip_reasoning(summary) # <-- remove any leaked <think>…</think>
488
+ return summary, None
489
+ except Exception as e:
490
+ return None, f"Error generating summary: {e}"
491
+
492
+ def generate_speech(summary: str, voice: str) -> tuple[str | None, str | None, float]:
493
+ """Generate speech and return (filename, error, duration_seconds)."""
494
+ try:
495
+ if voice not in ALLOWED_VOICES:
496
+ voice = "af_heart"
497
+ generator = kokoro_pipeline(summary, voice=voice)
498
+
499
+ audio_chunks = []
500
+ total_duration = 0.0
501
+
502
+ for _, _, audio in generator:
503
+ audio_chunks.append(audio)
504
+ total_duration += len(audio) / 24000.0
505
+
506
+ if not audio_chunks:
507
+ return None, "No audio generated.", 0.0
508
+
509
+ combined = audio_chunks[0] if len(audio_chunks) == 1 else torch.cat(audio_chunks, dim=0)
510
+
511
+ ts = int(time.time())
512
+ filename = f"summary_{ts}.wav"
513
+ filepath = os.path.join("static", "audio", filename)
514
+ sf.write(filepath, combined.numpy(), 24000)
515
+
516
+ return filename, None, total_duration
517
+ except Exception as e:
518
+ return None, f"Error generating speech: {e}", 0.0
519
+
520
+ # ---------------- Routes ----------------
521
+ @app.route("/")
522
+ def index():
523
+ return render_template("index.html")
524
+
525
+ @app.route("/status")
526
+ def status():
527
+ return jsonify(model_loading_status)
528
+
529
+ @app.route("/process", methods=["POST"])
530
+ def process_article():
531
+ if not model_loading_status["loaded"]:
532
+ return jsonify({"success": False, "error": "Models not loaded yet. Please wait."})
533
+
534
+ data = request.get_json(force=True, silent=True) or {}
535
+ url = (data.get("url") or "").strip()
536
+ generate_audio = bool(data.get("generate_audio", False))
537
+ voice = (data.get("voice") or "af_heart").strip()
538
+
539
+ if not url:
540
+ return jsonify({"success": False, "error": "Please provide a valid URL."})
541
+
542
+ # 1) Scrape
543
+ article_content, scrape_error = scrape_article_text(url)
544
+ if scrape_error:
545
+ return jsonify({"success": False, "error": scrape_error})
546
+
547
+ # 2) Summarize
548
+ summary, summary_error = summarize_with_qwen(article_content)
549
+ if summary_error:
550
+ return jsonify({"success": False, "error": summary_error})
551
+
552
+ resp = {
553
+ "success": True,
554
+ "summary": summary,
555
+ "article_length": len(article_content or ""),
556
+ "summary_length": len(summary or ""),
557
+ "compression_ratio": round(len(summary) / max(len(article_content), 1) * 100, 1),
558
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
559
+ }
560
+
561
+ # 3) TTS
562
+ if generate_audio:
563
+ audio_filename, audio_error, duration = generate_speech(summary, voice)
564
+ if audio_error:
565
+ resp["audio_error"] = audio_error
566
+ else:
567
+ resp["audio_file"] = f"/static/audio/{audio_filename}"
568
+ resp["audio_duration"] = round(duration, 2)
569
+
570
+ return jsonify(resp)
571
+
572
+ @app.route("/voices")
573
+ def get_voices():
574
+ voices = [
575
+ {"id": "af_heart", "name": "Female - Heart", "grade": "A", "description": "❀️ Warm female voice (best quality)"},
576
+ {"id": "af_bella", "name": "Female - Bella", "grade": "A-", "description": "πŸ”₯ Energetic female voice"},
577
+ {"id": "af_nicole", "name": "Female - Nicole", "grade": "B-", "description": "🎧 Professional female voice"},
578
+ {"id": "am_michael", "name": "Male - Michael", "grade": "C+", "description": "Clear male voice"},
579
+ {"id": "am_fenrir", "name": "Male - Fenrir", "grade": "C+", "description": "Strong male voice"},
580
+ {"id": "af_sarah", "name": "Female - Sarah", "grade": "C+", "description": "Gentle female voice"},
581
+ {"id": "bf_emma", "name": "British Female - Emma", "grade": "B-", "description": "πŸ‡¬πŸ‡§ British accent"},
582
+ {"id": "bm_george", "name": "British Male - George", "grade": "C", "description": "πŸ‡¬πŸ‡§ British male voice"},
583
+ ]
584
+ return jsonify(voices)
585
+
586
+ # Kick off model loading when running under Gunicorn/containers
587
+ if os.environ.get("RUNNING_GUNICORN", "0") == "1":
588
+ threading.Thread(target=load_models, daemon=True).start()
589
+
590
+ # ---------------- Dev entrypoint ----------------
591
+ if __name__ == "__main__":
592
+ import argparse
593
+ parser = argparse.ArgumentParser(description="AI Article Summarizer Web App")
594
+ parser.add_argument("--port", type=int, default=5001, help="Port to run the server on (default: 5001)")
595
+ parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
596
+ args = parser.parse_args()
597
+
598
+ # Load models in background thread
599
+ threading.Thread(target=load_models, daemon=True).start()
600
+
601
+ # Respect platform env PORT when present
602
+ port = int(os.environ.get("PORT", args.port))
603
+
604
+ print("πŸš€ Starting Article Summarizer Web App…")
605
+ print("πŸ“š Models are loading in the background…")
606
+ print(f"🌐 Open http://localhost:{port} in your browser")
607
+
608
+ try:
609
+ app.run(debug=True, host=args.host, port=port)
610
+ except OSError as e:
611
+ if "Address already in use" in str(e):
612
+ print(f"❌ Port {port} is already in use!")
613
+ print("πŸ’‘ Try a different port:")
614
+ print(f" python app.py --port {port + 1}")
615
+ print("πŸ“± Or disable AirPlay Receiver in System Settings β†’ General β†’ AirDrop & Handoff")
616
+ else:
617
+ raise
main.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # #!/usr/bin/env python3
2
+ # """
3
+ # Article Summarizer with Text-to-Speech
4
+ # Scrapes articles, summarizes with Qwen3-0.6B, and reads aloud with Kokoro TTS
5
+ # """
6
+
7
+ # import sys
8
+ # import torch
9
+ # import trafilatura
10
+ # import soundfile as sf
11
+ # import time
12
+ # from transformers import AutoModelForCausalLM, AutoTokenizer
13
+ # from kokoro import KPipeline
14
+
15
+ # # --- Part 1: Web Scraping Function ---
16
+
17
+ # def scrape_article_text(url: str) -> str | None:
18
+ # """
19
+ # Downloads a webpage and extracts the main article text, removing ads,
20
+ # menus, and other boilerplate.
21
+
22
+ # Args:
23
+ # url: The URL of the article to scrape.
24
+
25
+ # Returns:
26
+ # The cleaned article text as a string, or None if it fails.
27
+ # """
28
+ # print(f"🌐 Scraping article from: {url}")
29
+ # # fetch_url downloads the content of the URL
30
+ # downloaded = trafilatura.fetch_url(url)
31
+
32
+ # if downloaded is None:
33
+ # print("❌ Error: Failed to download the article content.")
34
+ # return None
35
+
36
+ # # extract the main text, ignoring comments and tables for a cleaner summary
37
+ # article_text = trafilatura.extract(downloaded, include_comments=False, include_tables=False)
38
+
39
+ # if article_text:
40
+ # print("βœ… Successfully extracted article text.")
41
+ # return article_text
42
+ # else:
43
+ # print("❌ Error: Could not find main article text on the page.")
44
+ # return None
45
+
46
+ # # --- Part 2: Summarization Function ---
47
+
48
+ # def summarize_with_qwen(text: str, model, tokenizer) -> str:
49
+ # """
50
+ # Generates a summary for the given text using the Qwen3-0.6B model.
51
+
52
+ # Args:
53
+ # text: The article text to summarize.
54
+ # model: The pre-loaded transformer model.
55
+ # tokenizer: The pre-loaded tokenizer.
56
+
57
+ # Returns:
58
+ # The generated summary as a string.
59
+ # """
60
+ # print("πŸ€– Summarizing text with Qwen3-0.6B...")
61
+
62
+ # # 1. Create a detailed prompt for the summarization task
63
+ # prompt = f"""
64
+ # Please provide a concise and clear summary of the following article.
65
+ # Focus on the main points, key findings, and conclusions. The summary should be
66
+ # easy to understand for someone who has not read the original text.
67
+
68
+ # ARTICLE:
69
+ # {text}
70
+ # """
71
+
72
+ # messages = [{"role": "user", "content": prompt}]
73
+
74
+ # # 2. Apply the chat template. We set `enable_thinking=False` for direct summarization.
75
+ # # This is more efficient than the default reasoning mode for this task.
76
+ # text_input = tokenizer.apply_chat_template(
77
+ # messages,
78
+ # tokenize=False,
79
+ # add_generation_prompt=True,
80
+ # enable_thinking=False
81
+ # )
82
+
83
+ # # 3. Tokenize the formatted prompt and move it to the correct device (CPU or MPS on Mac)
84
+ # model_inputs = tokenizer([text_input], return_tensors="pt").to(model.device)
85
+
86
+ # # 4. Generate the summary using parameters recommended for non-thinking mode
87
+ # generated_ids = model.generate(
88
+ # **model_inputs,
89
+ # max_new_tokens=512, # Limit summary length
90
+ # temperature=0.7,
91
+ # top_p=0.8,
92
+ # top_k=20
93
+ # )
94
+
95
+ # # 5. Slice the output to remove the input prompt, leaving only the generated response
96
+ # output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
97
+
98
+ # # 6. Decode the token IDs back into a readable string
99
+ # summary = tokenizer.decode(output_ids, skip_special_tokens=True).strip()
100
+
101
+ # print("βœ… Summary generated successfully.")
102
+ # return summary
103
+
104
+ # # --- Part 3: Text-to-Speech Function ---
105
+
106
+ # def speak_summary_with_kokoro(summary: str, voice: str = "af_heart") -> str:
107
+ # """
108
+ # Converts the summary text to speech using Kokoro TTS and saves as audio file.
109
+
110
+ # Args:
111
+ # summary: The text summary to convert to speech.
112
+ # voice: The voice to use (default: "af_heart").
113
+
114
+ # Returns:
115
+ # The filename of the generated audio file.
116
+ # """
117
+ # print("🎡 Converting summary to speech with Kokoro TTS...")
118
+
119
+ # try:
120
+ # # Initialize Kokoro TTS pipeline
121
+ # pipeline = KPipeline(lang_code='a') # 'a' for English
122
+
123
+ # # Generate speech
124
+ # generator = pipeline(summary, voice=voice)
125
+
126
+ # # Process audio chunks
127
+ # audio_chunks = []
128
+ # total_duration = 0
129
+
130
+ # for i, (gs, ps, audio) in enumerate(generator):
131
+ # audio_chunks.append(audio)
132
+ # chunk_duration = len(audio) / 24000
133
+ # total_duration += chunk_duration
134
+ # print(f" πŸ“Š Generated chunk {i+1}: {chunk_duration:.2f}s")
135
+
136
+ # # Combine all audio chunks
137
+ # if len(audio_chunks) > 1:
138
+ # combined_audio = torch.cat(audio_chunks, dim=0)
139
+ # else:
140
+ # combined_audio = audio_chunks[0]
141
+
142
+ # # Generate filename with timestamp
143
+ # timestamp = int(time.time())
144
+ # filename = f"summary_audio_{timestamp}.wav"
145
+
146
+ # # Save audio file
147
+ # sf.write(filename, combined_audio.numpy(), 24000)
148
+
149
+ # print(f"βœ… Audio generated successfully!")
150
+ # print(f"πŸ’Ύ Saved as: {filename}")
151
+ # print(f"⏱️ Duration: {total_duration:.2f} seconds")
152
+ # print(f"🎭 Voice used: {voice}")
153
+
154
+ # return filename
155
+
156
+ # except Exception as e:
157
+ # print(f"❌ Error generating speech: {e}")
158
+ # return None
159
+
160
+ # # --- Part 4: Voice Selection Function ---
161
+
162
+ # def select_voice() -> str:
163
+ # """
164
+ # Allows user to select from available voices or use default.
165
+
166
+ # Returns:
167
+ # Selected voice name.
168
+ # """
169
+ # available_voices = {
170
+ # '1': ('af_heart', 'Female - Heart (Grade A, default) ❀️'),
171
+ # '2': ('af_bella', 'Female - Bella (Grade A-) πŸ”₯'),
172
+ # '3': ('af_nicole', 'Female - Nicole (Grade B-) 🎧'),
173
+ # '4': ('am_michael', 'Male - Michael (Grade C+)'),
174
+ # '5': ('am_fenrir', 'Male - Fenrir (Grade C+)'),
175
+ # '6': ('af_sarah', 'Female - Sarah (Grade C+)'),
176
+ # '7': ('bf_emma', 'British Female - Emma (Grade B-)'),
177
+ # '8': ('bm_george', 'British Male - George (Grade C)')
178
+ # }
179
+
180
+ # print("\n🎭 Available voices (sorted by quality):")
181
+ # for key, (voice_id, description) in available_voices.items():
182
+ # print(f" {key}. {description}")
183
+
184
+ # print(" Enter: Use default voice (af_heart)")
185
+
186
+ # choice = input("\nSelect voice (1-8 or Enter): ").strip()
187
+
188
+ # if choice in available_voices:
189
+ # selected_voice, description = available_voices[choice]
190
+ # print(f"🎡 Selected: {description}")
191
+ # return selected_voice
192
+ # else:
193
+ # print("🎡 Using default voice: Female - Heart")
194
+ # return 'af_heart'
195
+
196
+ # # --- Main Execution Block ---
197
+
198
+ # if __name__ == "__main__":
199
+ # print("πŸš€ Article Summarizer with Text-to-Speech")
200
+ # print("=" * 50)
201
+
202
+ # # Check if a URL was provided as a command-line argument
203
+ # if len(sys.argv) < 2:
204
+ # print("Usage: python qwen_kokoro_summarizer.py <URL_OF_ARTICLE>")
205
+ # print("Example: python qwen_kokoro_summarizer.py https://example.com/article")
206
+ # sys.exit(1)
207
+
208
+ # article_url = sys.argv[1]
209
+
210
+ # # --- Load Qwen Model and Tokenizer ---
211
+ # print("\nπŸ“š Setting up the Qwen3-0.6B model...")
212
+ # print("Note: The first run will download the model (~1.2 GB). Please be patient.")
213
+
214
+ # model_name = "Qwen/Qwen3-0.6B"
215
+
216
+ # try:
217
+ # tokenizer = AutoTokenizer.from_pretrained(model_name)
218
+ # model = AutoModelForCausalLM.from_pretrained(
219
+ # model_name,
220
+ # torch_dtype="auto", # Automatically selects precision (e.g., float16)
221
+ # device_map="auto" # Automatically uses MPS (Mac GPU) if available
222
+ # )
223
+ # except Exception as e:
224
+ # print(f"❌ Failed to load the Qwen model. Error: {e}")
225
+ # print("Please ensure you have a stable internet connection and sufficient disk space.")
226
+ # sys.exit(1)
227
+
228
+ # # Inform the user which device is being used
229
+ # device = next(model.parameters()).device
230
+ # print(f"βœ… Qwen model loaded successfully on device: {str(device).upper()}")
231
+ # if "mps" in str(device):
232
+ # print(" (Running on Apple Silicon GPU)")
233
+
234
+ # # --- Run the Complete Process ---
235
+
236
+ # # Step 1: Scrape the article
237
+ # print(f"\nπŸ“° Step 1: Scraping article")
238
+ # article_content = scrape_article_text(article_url)
239
+
240
+ # if not article_content:
241
+ # print("❌ Failed to scrape article. Exiting.")
242
+ # sys.exit(1)
243
+
244
+ # # Step 2: Summarize the content
245
+ # print(f"\nπŸ€– Step 2: Generating summary")
246
+ # summary = summarize_with_qwen(article_content, model, tokenizer)
247
+
248
+ # # Step 3: Display the summary
249
+ # print("\n" + "="*60)
250
+ # print("✨ GENERATED SUMMARY ✨")
251
+ # print("="*60)
252
+ # print(summary)
253
+ # print("="*60)
254
+
255
+ # # Step 4: Ask if user wants TTS
256
+ # print(f"\n🎡 Step 3: Text-to-Speech")
257
+ # tts_choice = input("Would you like to hear the summary read aloud? (y/N): ").strip().lower()
258
+
259
+ # if tts_choice in ['y', 'yes']:
260
+ # # Let user select voice
261
+ # selected_voice = select_voice()
262
+
263
+ # # Generate speech
264
+ # audio_filename = speak_summary_with_kokoro(summary, voice=selected_voice)
265
+
266
+ # if audio_filename:
267
+ # print(f"\n🎧 Audio saved as: {audio_filename}")
268
+ # print("πŸ”Š You can now play this file to hear the summary!")
269
+
270
+ # # Optional: Try to play the audio automatically (macOS)
271
+ # try:
272
+ # import subprocess
273
+ # print("🎢 Attempting to play audio automatically...")
274
+ # subprocess.run(['afplay', audio_filename], check=True)
275
+ # print("βœ… Audio playback completed!")
276
+ # except (subprocess.CalledProcessError, FileNotFoundError):
277
+ # print("ℹ️ Auto-play not available. Please play the file manually.")
278
+ # else:
279
+ # print("❌ Failed to generate audio.")
280
+ # else:
281
+ # print("πŸ‘ Summary completed without audio generation.")
282
+
283
+ # print(f"\nπŸŽ‰ Process completed successfully!")
284
+ # print(f"πŸ“ Summary length: {len(summary)} characters")
285
+ # print(f"πŸ“Š Original article length: {len(article_content)} characters")
286
+ # print(f"πŸ“‰ Compression ratio: {len(summary)/len(article_content)*100:.1f}%")
model_test.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Simple Kokoro TTS Test Script
4
+ Run this after installing dependencies
5
+ """
6
+
7
+ from kokoro import KPipeline
8
+ import soundfile as sf
9
+ import time
10
+
11
+ def main():
12
+ print("🎡 Starting Kokoro TTS test...")
13
+
14
+ # Initialize the model
15
+ print("πŸ“¦ Loading model...")
16
+ start_time = time.time()
17
+ pipeline = KPipeline(lang_code='a') # 'a' for English
18
+ load_time = time.time() - start_time
19
+ print(f"βœ… Model loaded in {load_time:.2f} seconds")
20
+
21
+ # Test text
22
+ text = "Hello! This is a test of Kokoro text-to-speech. The model sounds quite natural!"
23
+
24
+ # Generate speech
25
+ print("πŸ—£οΈ Generating speech...")
26
+ gen_start = time.time()
27
+ generator = pipeline(text, voice='af_heart')
28
+
29
+ # Process and save audio
30
+ for i, (gs, ps, audio) in enumerate(generator):
31
+ gen_time = time.time() - gen_start
32
+ duration = len(audio) / 24000 # seconds
33
+
34
+ # Save audio file
35
+ filename = f"kokoro_test_output_{i}.wav"
36
+ sf.write(filename, audio, 24000)
37
+
38
+ print(f"βœ… Generated {duration:.2f}s of audio in {gen_time:.2f}s")
39
+ print(f"πŸ’Ύ Saved as: {filename}")
40
+ print(f"⚑ Real-time factor: {gen_time/duration:.2f}x")
41
+
42
+ print("πŸŽ‰ Test completed successfully!")
43
+ print("🎧 Play the generated .wav file to hear the result")
44
+
45
+ if __name__ == "__main__":
46
+ try:
47
+ main()
48
+ except Exception as e:
49
+ print(f"❌ Error: {e}")
50
+ print("\nπŸ”§ Make sure you've installed all dependencies:")
51
+ print(" brew install espeak")
52
+ print(" pip install torch torchaudio kokoro>=0.9.2 soundfile")
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # === Runtime ===
2
+ Flask==2.3.3
3
+ gunicorn==21.2.0
4
+
5
+ # === ML / TTS ===
6
+ transformers>=4.41.0
7
+ accelerate>=0.31.0
8
+ safetensors>=0.4.3
9
+ torch>=2.2.0
10
+ trafilatura>=2.0.0
11
+ soundfile>=0.12.1
12
+ kokoro>=0.9.4
13
+ numpy>=1.24.0
14
+ scipy>=1.10.0
15
+ librosa>=0.10.0.post2
16
+ huggingface-hub>=0.23.0
setup_web_app.sh ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "πŸš€ Setting up AI Article Summarizer Web App"
4
+ echo "============================================="
5
+
6
+ # Create project structure
7
+ echo "πŸ“ Creating project structure..."
8
+ mkdir -p templates static/audio static/summaries
9
+
10
+ # Create requirements.txt
11
+ echo "πŸ“ Creating requirements.txt..."
12
+ cat > requirements.txt << EOF
13
+ Flask==2.3.3
14
+ torch>=2.0.0
15
+ transformers>=4.30.0
16
+ trafilatura>=1.6.0
17
+ soundfile>=0.12.1
18
+ kokoro>=0.9.2
19
+ librosa>=0.10.0
20
+ numpy>=1.24.0
21
+ scipy>=1.10.0
22
+ EOF
23
+
24
+ # Check if virtual environment exists
25
+ if [ ! -d "venv" ]; then
26
+ echo "🐍 Creating virtual environment..."
27
+ python3 -m venv venv
28
+ fi
29
+
30
+ echo "πŸ”„ Activating virtual environment..."
31
+ source venv/bin/activate
32
+
33
+ echo "πŸ“¦ Installing Python packages..."
34
+ pip install --upgrade pip
35
+ pip install -r requirements.txt
36
+
37
+ # Install system dependencies (macOS)
38
+ if [[ "$OSTYPE" == "darwin"* ]]; then
39
+ echo "🍎 Installing espeak for macOS..."
40
+ if ! command -v brew &> /dev/null; then
41
+ echo "❌ Homebrew not found. Please install Homebrew first:"
42
+ echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
43
+ exit 1
44
+ fi
45
+ brew install espeak
46
+ elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
47
+ echo "🐧 Installing espeak for Linux..."
48
+ sudo apt-get update && sudo apt-get install -y espeak-ng
49
+ fi
50
+
51
+ echo "βœ… Setup complete!"
52
+ echo ""
53
+ echo "🌟 To run the web application:"
54
+ echo " 1. Activate virtual environment: source venv/bin/activate"
55
+ echo " 2. Run the app: python app.py"
56
+ echo " 3. Open http://localhost:5000 in your browser"
57
+ echo ""
58
+ echo "πŸ“ Note: The first run will download AI models (~1.2GB)"
59
+ echo "⏱️ Model loading may take 1-2 minutes on first startup"
start.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Ensure HF cache lives on a writable path (persistent if your platform supports volumes)
5
+ export HF_HOME=${HF_HOME:-/root/.cache/huggingface}
6
+ export TRANSFORMERS_CACHE=${TRANSFORMERS_CACHE:-$HF_HOME/transformers}
7
+ export RUNNING_GUNICORN=1
8
+ export PYTHONUNBUFFERED=1
9
+ export TOKENIZERS_PARALLELISM=false
10
+ export WEB_CONCURRENCY=1 # Keep single worker so you don't load models multiple times
11
+
12
+ # Start gunicorn (threaded so /process stays responsive)
13
+ exec gunicorn --bind 0.0.0.0:${PORT:-8080} \
14
+ --workers ${WEB_CONCURRENCY} \
15
+ --threads 4 \
16
+ --timeout 0 \
17
+ app:app
summarize_qwen.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import torch
3
+ import trafilatura
4
+ from transformers import AutoModelForCausalLM, AutoTokenizer
5
+
6
+ # --- Part 1: Web Scraping Function ---
7
+
8
+ def scrape_article_text(url: str) -> str | None:
9
+ """
10
+ Downloads a webpage and extracts the main article text, removing ads,
11
+ menus, and other boilerplate.
12
+
13
+ Args:
14
+ url: The URL of the article to scrape.
15
+
16
+ Returns:
17
+ The cleaned article text as a string, or None if it fails.
18
+ """
19
+ print(f" Scraping article from: {url}")
20
+ # fetch_url downloads the content of the URL
21
+ downloaded = trafilatura.fetch_url(url)
22
+
23
+ if downloaded is None:
24
+ print("❌ Error: Failed to download the article content.")
25
+ return None
26
+
27
+ # extract the main text, ignoring comments and tables for a cleaner summary
28
+ article_text = trafilatura.extract(downloaded, include_comments=False, include_tables=False)
29
+
30
+ if article_text:
31
+ print("βœ… Successfully extracted article text.")
32
+ return article_text
33
+ else:
34
+ print("❌ Error: Could not find main article text on the page.")
35
+ return None
36
+
37
+ # --- Part 2: Summarization Function ---
38
+
39
+ def summarize_with_qwen(text: str, model, tokenizer) -> str:
40
+ """
41
+ Generates a summary for the given text using the Qwen3-0.6B model.
42
+
43
+ Args:
44
+ text: The article text to summarize.
45
+ model: The pre-loaded transformer model.
46
+ tokenizer: The pre-loaded tokenizer.
47
+
48
+ Returns:
49
+ The generated summary as a string.
50
+ """
51
+ print(" Summarizing text with Qwen3-0.6B...")
52
+
53
+ # 1. Create a detailed prompt for the summarization task
54
+ prompt = f"""
55
+ Please provide a concise and clear summary of the following article.
56
+ Focus on the main points, key findings, and conclusions. The summary should be
57
+ easy to understand for someone who has not read the original text.
58
+
59
+ ARTICLE:
60
+ {text}
61
+ """
62
+
63
+ messages = [{"role": "user", "content": prompt}]
64
+
65
+ # 2. Apply the chat template. We set `enable_thinking=False` for direct summarization.
66
+ # This is more efficient than the default reasoning mode for this task.
67
+ text_input = tokenizer.apply_chat_template(
68
+ messages,
69
+ tokenize=False,
70
+ add_generation_prompt=True,
71
+ enable_thinking=False
72
+ )
73
+
74
+ # 3. Tokenize the formatted prompt and move it to the correct device (CPU or MPS on Mac)
75
+ model_inputs = tokenizer([text_input], return_tensors="pt").to(model.device)
76
+
77
+ # 4. Generate the summary using parameters recommended for non-thinking mode
78
+ generated_ids = model.generate(
79
+ **model_inputs,
80
+ max_new_tokens=512, # Limit summary length
81
+ temperature=0.7,
82
+ top_p=0.8,
83
+ top_k=20
84
+ )
85
+
86
+ # 5. Slice the output to remove the input prompt, leaving only the generated response
87
+ output_ids = generated_ids[0][len(model_inputs.input_ids[0]):]
88
+
89
+ # 6. Decode the token IDs back into a readable string
90
+ summary = tokenizer.decode(output_ids, skip_special_tokens=True).strip()
91
+
92
+ print("βœ… Summary generated successfully.")
93
+ return summary
94
+
95
+
96
+ # --- Main Execution Block ---
97
+
98
+ if __name__ == "__main__":
99
+ # Check if a URL was provided as a command-line argument
100
+ if len(sys.argv) < 2:
101
+ print("Usage: python summarize_qwen.py <URL_OF_ARTICLE>")
102
+ sys.exit(1)
103
+
104
+ article_url = sys.argv[1]
105
+
106
+ # --- Load Model and Tokenizer ---
107
+ print("Setting up the Qwen3-0.6B model...")
108
+ print("Note: The first run will download the model (~1.2 GB). Please be patient.")
109
+
110
+ model_name = "Qwen/Qwen3-0.6B"
111
+
112
+ try:
113
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
114
+ model = AutoModelForCausalLM.from_pretrained(
115
+ model_name,
116
+ torch_dtype="auto", # Automatically selects precision (e.g., float16)
117
+ device_map="auto" # Automatically uses MPS (Mac GPU) if available
118
+ )
119
+ except Exception as e:
120
+ print(f"❌ Failed to load the model. Error: {e}")
121
+ print("Please ensure you have a stable internet connection and sufficient disk space.")
122
+ sys.exit(1)
123
+
124
+ # Inform the user which device is being used
125
+ device = next(model.parameters()).device
126
+ print(f"βœ… Model loaded successfully on device: {str(device).upper()}")
127
+ if "mps" in str(device):
128
+ print(" (Running on Apple Silicon GPU)")
129
+
130
+ # --- Run the Process ---
131
+ # Step 1: Scrape the article
132
+ article_content = scrape_article_text(article_url)
133
+
134
+ if article_content:
135
+ # Step 2: Summarize the content
136
+ final_summary = summarize_with_qwen(article_content, model, tokenizer)
137
+
138
+ # Step 3: Print the final result
139
+ print("\n" + "="*50)
140
+ print("✨ Article Summary ✨")
141
+ print("="*50)
142
+ print(final_summary)
143
+ print("="*50 + "\n")
templates/index.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>
templates/index0.html ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.0">
6
+ <title>AI Article Summarizer with Text-to-Speech</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ min-height: 100vh;
18
+ padding: 20px;
19
+ }
20
+
21
+ .container {
22
+ max-width: 1200px;
23
+ margin: 0 auto;
24
+ background: rgba(255, 255, 255, 0.95);
25
+ border-radius: 20px;
26
+ padding: 40px;
27
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
28
+ backdrop-filter: blur(10px);
29
+ }
30
+
31
+ .header {
32
+ text-align: center;
33
+ margin-bottom: 40px;
34
+ }
35
+
36
+ .header h1 {
37
+ font-size: 2.5rem;
38
+ color: #2d3748;
39
+ margin-bottom: 10px;
40
+ background: linear-gradient(135deg, #667eea, #764ba2);
41
+ -webkit-background-clip: text;
42
+ -webkit-text-fill-color: transparent;
43
+ background-clip: text;
44
+ }
45
+
46
+ .header p {
47
+ color: #718096;
48
+ font-size: 1.1rem;
49
+ }
50
+
51
+ .status-indicator {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ padding: 8px 16px;
55
+ border-radius: 25px;
56
+ font-size: 0.9rem;
57
+ font-weight: 600;
58
+ margin: 20px auto;
59
+ }
60
+
61
+ .status-loading {
62
+ background: #fed7d7;
63
+ color: #c53030;
64
+ }
65
+
66
+ .status-ready {
67
+ background: #c6f6d5;
68
+ color: #38a169;
69
+ }
70
+
71
+ .form-section {
72
+ background: #f7fafc;
73
+ padding: 30px;
74
+ border-radius: 15px;
75
+ margin-bottom: 30px;
76
+ border: 1px solid #e2e8f0;
77
+ }
78
+
79
+ .form-group {
80
+ margin-bottom: 20px;
81
+ }
82
+
83
+ .form-group label {
84
+ display: block;
85
+ margin-bottom: 8px;
86
+ font-weight: 600;
87
+ color: #2d3748;
88
+ }
89
+
90
+ .form-group input[type="url"] {
91
+ width: 100%;
92
+ padding: 12px 16px;
93
+ border: 2px solid #e2e8f0;
94
+ border-radius: 8px;
95
+ font-size: 1rem;
96
+ transition: border-color 0.3s ease;
97
+ }
98
+
99
+ .form-group input[type="url"]:focus {
100
+ outline: none;
101
+ border-color: #667eea;
102
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
103
+ }
104
+
105
+ .checkbox-group {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 10px;
109
+ margin-bottom: 15px;
110
+ }
111
+
112
+ .checkbox-group input[type="checkbox"] {
113
+ width: 18px;
114
+ height: 18px;
115
+ accent-color: #667eea;
116
+ }
117
+
118
+ .voice-selector {
119
+ display: none;
120
+ margin-top: 15px;
121
+ padding: 15px;
122
+ background: white;
123
+ border-radius: 8px;
124
+ border: 1px solid #e2e8f0;
125
+ }
126
+
127
+ .voice-grid {
128
+ display: grid;
129
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
130
+ gap: 10px;
131
+ margin-top: 10px;
132
+ }
133
+
134
+ .voice-option {
135
+ padding: 10px;
136
+ border: 2px solid #e2e8f0;
137
+ border-radius: 8px;
138
+ cursor: pointer;
139
+ transition: all 0.3s ease;
140
+ text-align: center;
141
+ }
142
+
143
+ .voice-option:hover {
144
+ border-color: #667eea;
145
+ background: #f7fafc;
146
+ }
147
+
148
+ .voice-option.selected {
149
+ border-color: #667eea;
150
+ background: #ebf4ff;
151
+ }
152
+
153
+ .voice-option .name {
154
+ font-weight: 600;
155
+ color: #2d3748;
156
+ }
157
+
158
+ .voice-option .grade {
159
+ font-size: 0.8rem;
160
+ color: #718096;
161
+ }
162
+
163
+ .voice-option .description {
164
+ font-size: 0.85rem;
165
+ color: #4a5568;
166
+ margin-top: 2px;
167
+ }
168
+
169
+ .submit-btn {
170
+ width: 100%;
171
+ padding: 15px;
172
+ background: linear-gradient(135deg, #667eea, #764ba2);
173
+ color: white;
174
+ border: none;
175
+ border-radius: 8px;
176
+ font-size: 1.1rem;
177
+ font-weight: 600;
178
+ cursor: pointer;
179
+ transition: transform 0.2s ease;
180
+ disabled: opacity 0.6;
181
+ }
182
+
183
+ .submit-btn:hover:not(:disabled) {
184
+ transform: translateY(-2px);
185
+ box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
186
+ }
187
+
188
+ .submit-btn:disabled {
189
+ opacity: 0.6;
190
+ cursor: not-allowed;
191
+ transform: none;
192
+ }
193
+
194
+ .loading {
195
+ display: none;
196
+ text-align: center;
197
+ padding: 30px;
198
+ }
199
+
200
+ .spinner {
201
+ display: inline-block;
202
+ width: 40px;
203
+ height: 40px;
204
+ border: 4px solid #f3f3f3;
205
+ border-top: 4px solid #667eea;
206
+ border-radius: 50%;
207
+ animation: spin 1s linear infinite;
208
+ }
209
+
210
+ @keyframes spin {
211
+ 0% { transform: rotate(0deg); }
212
+ 100% { transform: rotate(360deg); }
213
+ }
214
+
215
+ .result-section {
216
+ display: none;
217
+ background: #f7fafc;
218
+ padding: 30px;
219
+ border-radius: 15px;
220
+ margin-top: 30px;
221
+ border: 1px solid #e2e8f0;
222
+ }
223
+
224
+ .result-header {
225
+ display: flex;
226
+ justify-content: space-between;
227
+ align-items: center;
228
+ margin-bottom: 20px;
229
+ flex-wrap: wrap;
230
+ gap: 10px;
231
+ }
232
+
233
+ .result-title {
234
+ font-size: 1.5rem;
235
+ font-weight: 700;
236
+ color: #2d3748;
237
+ }
238
+
239
+ .stats {
240
+ display: flex;
241
+ gap: 20px;
242
+ font-size: 0.9rem;
243
+ color: #718096;
244
+ flex-wrap: wrap;
245
+ }
246
+
247
+ .summary-content {
248
+ background: white;
249
+ padding: 25px;
250
+ border-radius: 12px;
251
+ line-height: 1.6;
252
+ color: #2d3748;
253
+ font-size: 1.05rem;
254
+ border-left: 4px solid #667eea;
255
+ margin-bottom: 20px;
256
+ }
257
+
258
+ .audio-section {
259
+ display: none;
260
+ background: white;
261
+ padding: 20px;
262
+ border-radius: 12px;
263
+ text-align: center;
264
+ }
265
+
266
+ .audio-player {
267
+ width: 100%;
268
+ max-width: 500px;
269
+ margin: 15px auto;
270
+ }
271
+
272
+ .error-message {
273
+ background: #fed7d7;
274
+ color: #c53030;
275
+ padding: 15px;
276
+ border-radius: 8px;
277
+ margin: 20px 0;
278
+ border-left: 4px solid #c53030;
279
+ }
280
+
281
+ .success-message {
282
+ background: #c6f6d5;
283
+ color: #38a169;
284
+ padding: 15px;
285
+ border-radius: 8px;
286
+ margin: 20px 0;
287
+ border-left: 4px solid #38a169;
288
+ }
289
+
290
+ @media (max-width: 768px) {
291
+ .container {
292
+ padding: 20px;
293
+ margin: 10px;
294
+ }
295
+
296
+ .header h1 {
297
+ font-size: 2rem;
298
+ }
299
+
300
+ .voice-grid {
301
+ grid-template-columns: 1fr;
302
+ }
303
+
304
+ .result-header {
305
+ flex-direction: column;
306
+ align-items: flex-start;
307
+ }
308
+
309
+ .stats {
310
+ justify-content: space-between;
311
+ width: 100%;
312
+ }
313
+ }
314
+ </style>
315
+ </head>
316
+ <body>
317
+ <div class="container">
318
+ <div class="header">
319
+ <h1>πŸ€– AI Article Summarizer</h1>
320
+ <p>Powered by Qwen3-0.6B and Kokoro TTS</p>
321
+ <div id="modelStatus" class="status-indicator status-loading">
322
+ πŸ”„ Loading AI models...
323
+ </div>
324
+ </div>
325
+
326
+ <form id="summarizerForm" class="form-section">
327
+ <div class="form-group">
328
+ <label for="articleUrl">πŸ“° Article URL</label>
329
+ <input
330
+ type="url"
331
+ id="articleUrl"
332
+ name="articleUrl"
333
+ placeholder="https://example.com/article"
334
+ required
335
+ >
336
+ </div>
337
+
338
+ <div class="checkbox-group">
339
+ <input type="checkbox" id="generateAudio" name="generateAudio">
340
+ <label for="generateAudio">🎡 Generate text-to-speech audio</label>
341
+ </div>
342
+
343
+ <div id="voiceSelector" class="voice-selector">
344
+ <label>🎭 Select Voice</label>
345
+ <div id="voiceGrid" class="voice-grid">
346
+ <!-- Voices will be loaded here -->
347
+ </div>
348
+ </div>
349
+
350
+ <button type="submit" id="submitBtn" class="submit-btn">
351
+ ✨ Summarize Article
352
+ </button>
353
+ </form>
354
+
355
+ <div id="loadingSection" class="loading">
356
+ <div class="spinner"></div>
357
+ <p>Processing your article...</p>
358
+ </div>
359
+
360
+ <div id="resultSection" class="result-section">
361
+ <div class="result-header">
362
+ <h2 class="result-title">πŸ“‹ Summary</h2>
363
+ <div id="stats" class="stats">
364
+ <!-- Stats will be inserted here -->
365
+ </div>
366
+ </div>
367
+
368
+ <div id="summaryContent" class="summary-content">
369
+ <!-- Summary will be inserted here -->
370
+ </div>
371
+
372
+ <div id="audioSection" class="audio-section">
373
+ <h3>🎧 Audio Playback</h3>
374
+ <p>Listen to your summary:</p>
375
+ <audio id="audioPlayer" class="audio-player" controls>
376
+ Your browser does not support the audio element.
377
+ </audio>
378
+ </div>
379
+ </div>
380
+
381
+ <div id="errorMessage" class="error-message" style="display: none;">
382
+ <!-- Error messages will be shown here -->
383
+ </div>
384
+ </div>
385
+
386
+ <script>
387
+ let selectedVoice = 'af_heart';
388
+ let modelsReady = false;
389
+
390
+ // Check model status on page load
391
+ async function checkModelStatus() {
392
+ try {
393
+ const response = await fetch('/status');
394
+ const status = await response.json();
395
+
396
+ const statusEl = document.getElementById('modelStatus');
397
+
398
+ if (status.loaded) {
399
+ statusEl.textContent = 'βœ… AI models ready!';
400
+ statusEl.className = 'status-indicator status-ready';
401
+ modelsReady = true;
402
+ loadVoices();
403
+ } else if (status.error) {
404
+ statusEl.textContent = `❌ Error loading models: ${status.error}`;
405
+ statusEl.className = 'status-indicator status-loading';
406
+ } else {
407
+ statusEl.textContent = 'πŸ”„ Loading AI models...';
408
+ setTimeout(checkModelStatus, 2000);
409
+ }
410
+ } catch (error) {
411
+ console.error('Error checking status:', error);
412
+ setTimeout(checkModelStatus, 5000);
413
+ }
414
+ }
415
+
416
+ // Load available voices
417
+ async function loadVoices() {
418
+ try {
419
+ const response = await fetch('/voices');
420
+ const voices = await response.json();
421
+
422
+ const voiceGrid = document.getElementById('voiceGrid');
423
+ voiceGrid.innerHTML = '';
424
+
425
+ voices.forEach((voice, index) => {
426
+ const voiceEl = document.createElement('div');
427
+ voiceEl.className = `voice-option${index === 0 ? ' selected' : ''}`;
428
+ voiceEl.dataset.voice = voice.id;
429
+
430
+ voiceEl.innerHTML = `
431
+ <div class="name">${voice.name}</div>
432
+ <div class="grade">Grade: ${voice.grade}</div>
433
+ <div class="description">${voice.description}</div>
434
+ `;
435
+
436
+ voiceEl.addEventListener('click', () => selectVoice(voice.id, voiceEl));
437
+ voiceGrid.appendChild(voiceEl);
438
+ });
439
+ } catch (error) {
440
+ console.error('Error loading voices:', error);
441
+ }
442
+ }
443
+
444
+ // Select voice
445
+ function selectVoice(voiceId, element) {
446
+ document.querySelectorAll('.voice-option').forEach(el => {
447
+ el.classList.remove('selected');
448
+ });
449
+ element.classList.add('selected');
450
+ selectedVoice = voiceId;
451
+ }
452
+
453
+ // Toggle voice selector
454
+ document.getElementById('generateAudio').addEventListener('change', function() {
455
+ const voiceSelector = document.getElementById('voiceSelector');
456
+ voiceSelector.style.display = this.checked ? 'block' : 'none';
457
+ });
458
+
459
+ // Handle form submission
460
+ document.getElementById('summarizerForm').addEventListener('submit', async function(e) {
461
+ e.preventDefault();
462
+
463
+ if (!modelsReady) {
464
+ showError('Please wait for the AI models to finish loading.');
465
+ return;
466
+ }
467
+
468
+ const url = document.getElementById('articleUrl').value;
469
+ const generateAudio = document.getElementById('generateAudio').checked;
470
+
471
+ // Show loading
472
+ document.getElementById('loadingSection').style.display = 'block';
473
+ document.getElementById('resultSection').style.display = 'none';
474
+ document.getElementById('errorMessage').style.display = 'none';
475
+ document.getElementById('submitBtn').disabled = true;
476
+
477
+ try {
478
+ const response = await fetch('/process', {
479
+ method: 'POST',
480
+ headers: {
481
+ 'Content-Type': 'application/json',
482
+ },
483
+ body: JSON.stringify({
484
+ url: url,
485
+ generate_audio: generateAudio,
486
+ voice: selectedVoice
487
+ })
488
+ });
489
+
490
+ const result = await response.json();
491
+
492
+ // Hide loading
493
+ document.getElementById('loadingSection').style.display = 'none';
494
+ document.getElementById('submitBtn').disabled = false;
495
+
496
+ if (result.success) {
497
+ showResult(result);
498
+ } else {
499
+ showError(result.error);
500
+ }
501
+
502
+ } catch (error) {
503
+ document.getElementById('loadingSection').style.display = 'none';
504
+ document.getElementById('submitBtn').disabled = false;
505
+ showError(`Network error: ${error.message}`);
506
+ }
507
+ });
508
+
509
+ // Show results
510
+ function showResult(result) {
511
+ document.getElementById('summaryContent').textContent = result.summary;
512
+
513
+ // Update stats
514
+ const stats = document.getElementById('stats');
515
+ stats.innerHTML = `
516
+ <span>πŸ“Š ${result.article_length} β†’ ${result.summary_length} chars</span>
517
+ <span>πŸ“‰ ${result.compression_ratio}% compression</span>
518
+ <span>πŸ•’ ${result.timestamp}</span>
519
+ `;
520
+
521
+ // Show/hide audio section
522
+ const audioSection = document.getElementById('audioSection');
523
+ if (result.audio_file) {
524
+ const audioPlayer = document.getElementById('audioPlayer');
525
+ audioPlayer.src = result.audio_file;
526
+ audioSection.style.display = 'block';
527
+
528
+ // Add duration info
529
+ const durationInfo = audioSection.querySelector('p');
530
+ durationInfo.textContent = `Listen to your summary (${result.audio_duration}s):`;
531
+ } else {
532
+ audioSection.style.display = 'none';
533
+ }
534
+
535
+ document.getElementById('resultSection').style.display = 'block';
536
+ }
537
+
538
+ // Show error
539
+ function showError(message) {
540
+ const errorEl = document.getElementById('errorMessage');
541
+ errorEl.textContent = message;
542
+ errorEl.style.display = 'block';
543
+ }
544
+
545
+ // Initialize
546
+ document.addEventListener('DOMContentLoaded', function() {
547
+ checkModelStatus();
548
+ });
549
+ </script>
550
+ </body>
551
+ </html>