Spaces:
Sleeping
Sleeping
Vineeth Sai commited on
Commit Β·
501847e
0
Parent(s):
Initial deploy to HF Spaces (Docker)
Browse files- ## GitHub Copilot Chat.md +33 -0
- .dockerignore +10 -0
- .gitignore +21 -0
- Dockerfile +44 -0
- README.md +268 -0
- app.py +617 -0
- main.py +286 -0
- model_test.py +52 -0
- requirements.txt +16 -0
- setup_web_app.sh +59 -0
- start.sh +17 -0
- summarize_qwen.py +143 -0
- templates/index.html +528 -0
- 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>
|