feat: add AI summarization with multi-provider support and Groq TTS integration

This commit is contained in:
Sunny 2026-01-19 23:32:13 +00:00
parent ad59f3c1ed
commit 5f87d66cc4
22 changed files with 10946 additions and 29 deletions

9307
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,10 +10,14 @@
"test": "playwright test"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.15",
"@ai-sdk/groq": "^3.0.10",
"@ai-sdk/openai": "^3.0.12",
"@headlessui/react": "^2.2.9",
"@types/howler": "^2.2.12",
"@types/uuid": "^10.0.0",
"@vercel/analytics": "^1.6.1",
"ai": "^6.0.39",
"cmpstr": "^3.0.4",
"compromise": "^14.14.4",
"core-js": "^3.47.0",

View file

@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@ai-sdk/anthropic':
specifier: ^3.0.15
version: 3.0.15(zod@3.25.76)
'@ai-sdk/groq':
specifier: ^3.0.10
version: 3.0.10(zod@3.25.76)
'@ai-sdk/openai':
specifier: ^3.0.12
version: 3.0.12(zod@3.25.76)
'@headlessui/react':
specifier: ^2.2.9
version: 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@ -19,7 +28,10 @@ importers:
version: 10.0.0
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(next@15.5.9(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
version: 1.6.1(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
ai:
specifier: ^6.0.39
version: 6.0.39(zod@3.25.76)
cmpstr:
specifier: ^3.0.4
version: 3.0.4
@ -46,10 +58,10 @@ importers:
version: 11.2.4
next:
specifier: ^15.5.9
version: 15.5.9(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
openai:
specifier: ^4.104.0
version: 4.104.0
version: 4.104.0(zod@3.25.76)
pdfjs-dist:
specifier: 4.8.69
version: 4.8.69
@ -123,6 +135,40 @@ importers:
packages:
'@ai-sdk/anthropic@3.0.15':
resolution: {integrity: sha512-FCNy6pABPe5Qb1VPbdLLIi/XkQN2g/fKUcl1GcXxIU3Ofr+vOND8cyZfH20cMODR523FSGfwswJoJic8skr8qg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/gateway@3.0.16':
resolution: {integrity: sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/groq@3.0.10':
resolution: {integrity: sha512-kwIsEoDyKaHGv6p/vYUQBJixiSB/tuHFigsxU8HGKXoP9C/ERckyfs5ze/oVmj/amtC99K8T5DmbHubltOhMqg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/openai@3.0.12':
resolution: {integrity: sha512-zqLWEKuaKnjXhu7xCw1jgz/+yTbd3F7EtgU4T2Q8BAo8OJC5wZv14l+kwM7Jai7M1/2Y2T/zBkrfiIu+7NsvfQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@4.0.8':
resolution: {integrity: sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@3.0.4':
resolution: {integrity: sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@ -445,6 +491,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
engines: {node: '>=18'}
@ -502,6 +552,9 @@ packages:
'@rushstack/eslint-patch@1.15.0':
resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@ -765,6 +818,10 @@ packages:
vue-router:
optional: true
'@vercel/oidc@3.1.0':
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
engines: {node: '>= 20'}
'@xmldom/xmldom@0.7.13':
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
engines: {node: '>=10.0.0'}
@ -788,6 +845,12 @@ packages:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ai@6.0.39:
resolution: {integrity: sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@ -1300,6 +1363,10 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@ -1684,6 +1751,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@ -2709,11 +2779,50 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
snapshots:
'@ai-sdk/anthropic@3.0.15(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/gateway@3.0.16(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
'@vercel/oidc': 3.1.0
zod: 3.25.76
'@ai-sdk/groq@3.0.10(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/openai@3.0.12(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@4.0.8(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 3.0.4
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider@3.0.4':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@babel/runtime@7.28.4': {}
@ -2988,6 +3097,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.0': {}
'@playwright/test@1.57.0':
dependencies:
playwright: 1.57.0
@ -3051,6 +3162,8 @@ snapshots:
'@rushstack/eslint-patch@1.15.0': {}
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@ -3286,11 +3399,13 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vercel/analytics@1.6.1(next@15.5.9(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
'@vercel/analytics@1.6.1(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
optionalDependencies:
next: 15.5.9(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
'@vercel/oidc@3.1.0': {}
'@xmldom/xmldom@0.7.13': {}
abort-controller@3.0.0:
@ -3307,6 +3422,14 @@ snapshots:
dependencies:
humanize-ms: 1.2.1
ai@6.0.39(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 3.0.16(zod@3.25.76)
'@ai-sdk/provider': 3.0.4
'@ai-sdk/provider-utils': 4.0.8(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@ -4004,6 +4127,8 @@ snapshots:
event-target-shim@5.0.1: {}
eventsource-parser@3.0.6: {}
expand-template@2.0.3:
optional: true
@ -4407,6 +4532,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@ -4880,7 +5007,7 @@ snapshots:
next-tick@1.1.0: {}
next@15.5.9(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 15.5.9
'@swc/helpers': 0.5.15
@ -4898,6 +5025,7 @@ snapshots:
'@next/swc-linux-x64-musl': 15.5.7
'@next/swc-win32-arm64-msvc': 15.5.7
'@next/swc-win32-x64-msvc': 15.5.7
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.57.0
sharp: 0.34.5
transitivePeerDependencies:
@ -4969,7 +5097,7 @@ snapshots:
wrappy: 1.0.2
optional: true
openai@4.104.0:
openai@4.104.0(zod@3.25.76):
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
@ -4978,6 +5106,8 @@ snapshots:
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
zod: 3.25.76
transitivePeerDependencies:
- encoding
@ -5869,4 +5999,6 @@ snapshots:
yocto-queue@0.1.0: {}
zod@3.25.76: {}
zwitch@2.0.4: {}

208
scripts/OPENREADER_SETUP.md Normal file
View file

@ -0,0 +1,208 @@
# OpenReader WebUI Setup Guide
Document reader with high-quality text-to-speech using Groq's Orpheus TTS.
**URL:** https://reader.sunnymodi.com
## Architecture
```
┌─────────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ OpenReader WebUI │────▶│ Groq TTS Proxy │────▶│ Groq API │
│ (Docker :3003) │ │ (Python :8880) │ │ (Orpheus) │
└─────────────────────┘ └──────────────────┘ └─────────────────┘
┌─────────────────────┐
│ nginx (SSL :443) │
│ reader.sunnymodi.com
└─────────────────────┘
```
## Components
| Component | Location | Port | Purpose |
|-----------|----------|------|---------|
| OpenReader WebUI | Docker container | 3003 | PDF/EPUB reader UI |
| Groq TTS Proxy | `/home/ec2-user/groq-tts-proxy.py` | 8880 | Adds `/voices` endpoint, proxies to Groq |
| nginx | `/etc/nginx/conf.d/openreader.conf` | 443 | SSL termination, reverse proxy |
## Files
```
/home/ec2-user/
├── openreader-webui.sh # Startup script (run this to start everything)
├── groq-tts-proxy.py # Python proxy server for Groq TTS
├── ENTERPRISE_AI_MAIN/.env # Contains GROQ_API_KEY
└── OPENREADER_SETUP.md # This file
```
## Quick Start
### Start Services
```bash
/home/ec2-user/openreader-webui.sh
```
### Check Status
```bash
# Check if proxy is running
curl http://localhost:8880/v1/audio/voices
# Check if container is running
docker ps | grep openreader
# Check proxy logs
cat /tmp/groq-proxy.log
# Check container logs
docker logs openreader-webui --tail 50
```
### Stop Services
```bash
# Stop container
docker stop openreader-webui
# Stop proxy
pkill -f groq-tts-proxy.py
```
## Configuration
### Environment Variable
The `GROQ_API_KEY` is stored in `/home/ec2-user/ENTERPRISE_AI_MAIN/.env`:
```
GROQ_API_KEY=gsk_xxxxx
```
### Available Voices
Groq Orpheus supports 6 voices:
- **Male:** troy, austin, daniel
- **Female:** autumn, diana, hannah
### TTS Model
- Model: `canopylabs/orpheus-v1-english`
- Output: WAV audio, 24kHz, 16-bit mono
## nginx Configuration
File: `/etc/nginx/conf.d/openreader.conf`
```nginx
server {
server_name reader.sunnymodi.com;
location / {
proxy_pass http://localhost:3003;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/reader.sunnymodi.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/reader.sunnymodi.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
```
## Groq TTS Proxy
The proxy (`groq-tts-proxy.py`) serves two purposes:
1. **Provides `/v1/audio/voices` endpoint** - OpenReader needs this to populate the voice dropdown, but Groq doesn't have this endpoint
2. **Proxies TTS requests** - Forwards `/v1/audio/speech` requests to Groq with correct model name and response format
### Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Health check |
| `/v1/audio/voices` | GET | Returns available voices |
| `/v1/audio/speech` | POST | Generates speech audio |
### Request Example
```bash
curl -X POST http://localhost:8880/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"input": "Hello world", "voice": "troy"}' \
-o output.wav
```
## Docker Container
### Image
`ghcr.io/richardr1126/openreader-webui:latest`
### Volume
Documents are stored in Docker volume `openreader_docstore` mounted at `/app/docstore`
### Environment Variables
| Variable | Value | Description |
|----------|-------|-------------|
| `API_KEY` | `none` | Not needed (proxy handles auth) |
| `API_BASE` | `http://host.docker.internal:8880/v1` | Points to local proxy |
## Troubleshooting
### Voices not showing in dropdown
```bash
# Check proxy is running
curl http://localhost:8880/v1/audio/voices
# If not running, restart
/home/ec2-user/openreader-webui.sh
```
### TTS not working / Server not responding
```bash
# Test proxy directly
curl -X POST http://localhost:8880/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"input": "test", "voice": "troy"}' \
-o /tmp/test.wav
# Check if valid audio
file /tmp/test.wav
# Should show: RIFF (little-endian) data, WAVE audio...
```
### Document not loading (stuck on spinner)
The document may have been deleted when the container was recreated. Re-upload the file.
### Port 8880 already in use
```bash
lsof -ti:8880 | xargs -r kill -9
```
### SSL certificate renewal
Certbot auto-renews. Manual renewal:
```bash
sudo certbot renew
```
## Supported File Types
- PDF
- EPUB
- TXT
- MD (Markdown)
- DOCX
## Limitations
- Groq Orpheus has a **200 character limit** per TTS request
- OpenReader handles chunking automatically
- Only English voice model available via Groq (Arabic Saudi dialect also available)
## Links
- [OpenReader WebUI GitHub](https://github.com/richardr1126/OpenReader-WebUI)
- [Groq Orpheus TTS Docs](https://console.groq.com/docs/text-to-speech/orpheus)
- [Groq Console](https://console.groq.com)

34
scripts/README.md Normal file
View file

@ -0,0 +1,34 @@
# OpenReader WebUI Scripts
Scripts for running OpenReader WebUI with Groq Orpheus TTS.
## Files
- `openreader-webui.sh` - Main startup script
- `groq-tts-proxy.py` - FastAPI proxy that adds `/v1/audio/voices` endpoint for Groq
- `.env` - Environment variables (contains `GROQ_API_KEY`)
## Usage
```bash
./openreader-webui.sh
```
## URL
https://reader.sunnymodi.com
## Architecture
```
OpenReader WebUI (Docker :3003)
Groq TTS Proxy (localhost:8880)
Groq API (canopylabs/orpheus-v1-english)
```
## Available Voices
- troy, austin, daniel (male)
- autumn, diana, hannah (female)

52
scripts/groq-tts-proxy.py Normal file
View file

@ -0,0 +1,52 @@
"""Groq TTS Proxy - OpenAI-compatible TTS server using Groq Orpheus API"""
from fastapi import FastAPI, Request, Response, HTTPException
import httpx
import os
app = FastAPI(title="Groq TTS Proxy")
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
GROQ_BASE = "https://api.groq.com/openai/v1"
VOICES = ["troy", "austin", "daniel", "autumn", "diana", "hannah"]
@app.get("/")
async def root():
return {"status": "ok", "service": "Groq TTS Proxy"}
@app.get("/v1/audio/voices")
@app.get("/audio/voices")
@app.get("/v1/voices")
@app.get("/voices")
async def get_voices():
return {"voices": VOICES}
@app.post("/v1/audio/speech")
@app.post("/audio/speech")
async def speech(request: Request):
body = await request.json()
model = body.get("model", "")
if not model.startswith("canopylabs/"):
body["model"] = "canopylabs/orpheus-v1-english"
if body.get("voice") not in VOICES:
body["voice"] = "troy"
# Groq requires response_format
if "response_format" not in body:
body["response_format"] = "wav"
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
f"{GROQ_BASE}/audio/speech",
json=body,
headers={"Authorization": f"Bearer {GROQ_API_KEY}"}
)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
return Response(content=resp.content, media_type="audio/wav")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8880)

41
scripts/openreader-webui.sh Executable file
View file

@ -0,0 +1,41 @@
#!/bin/bash
# OpenReader WebUI - TTS Document Reader
# URL: https://reader.sunnymodi.com
# Uses Groq Orpheus TTS via local proxy
set -e
# Load GROQ_API_KEY
source /home/ec2-user/OpenReader-WebUI/scripts/.env
export GROQ_API_KEY
# Stop existing services
lsof -ti:8880 | xargs -r kill -9 2>/dev/null || true
docker stop openreader-webui 2>/dev/null || true
docker rm openreader-webui 2>/dev/null || true
sleep 1
# Start Groq TTS proxy (adds /voices endpoint for OpenReader)
nohup python3 /home/ec2-user/OpenReader-WebUI/scripts/groq-tts-proxy.py > /tmp/groq-proxy.log 2>&1 &
sleep 2
# Verify proxy is running
if ! curl -s http://localhost:8880/ > /dev/null; then
echo "ERROR: Groq TTS proxy failed to start"
exit 1
fi
# Run OpenReader WebUI
docker run --name openreader-webui \
--restart unless-stopped \
--add-host=host.docker.internal:host-gateway \
-e API_KEY=none \
-e API_BASE=http://host.docker.internal:8880/v1 \
-p 3003:3003 \
-v openreader_docstore:/app/docstore \
-d \
ghcr.io/richardr1126/openreader-webui:latest
echo "OpenReader WebUI started at https://reader.sunnymodi.com"
echo "Groq TTS proxy running on port 8880"
echo "Available voices: troy, austin, daniel, autumn, diana, hannah"

View file

@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createGroq } from '@ai-sdk/groq';
import type { SummarizeRequest, SummarizeResponse, SummarizeError } from '@/types/summary';
export const runtime = 'nodejs';
const SYSTEM_PROMPTS: Record<string, string> = {
current_page: `You are a helpful assistant that summarizes text content.
Provide a clear, concise summary of the current page content provided.
Focus on the main ideas and key points. Keep the summary focused and readable.
Do not include any preamble like "Here is a summary" - just provide the summary directly.`,
select_page: `You are a helpful assistant that summarizes text content.
Provide a clear, concise summary of the selected page content provided.
Focus on the main ideas and key points. Keep the summary focused and readable.
Do not include any preamble like "Here is a summary" - just provide the summary directly.`,
whole_book: `You are a helpful assistant that summarizes documents.
Provide a comprehensive summary of the entire document content provided.
Structure your summary with key themes, main arguments, and important conclusions.
For longer texts, organize the summary into logical sections.
Do not include any preamble like "Here is a summary" - just provide the summary directly.`,
};
export async function POST(req: NextRequest) {
try {
// Get configuration from headers
const provider = req.headers.get('x-summary-provider') || 'openai';
const baseUrl = req.headers.get('x-summary-base-url') || '';
const modelId = req.headers.get('x-summary-model') || 'gpt-4o-mini';
// Get API key from headers or environment variables based on provider
let apiKey = req.headers.get('x-summary-api-key') || '';
if (!apiKey) {
switch (provider) {
case 'groq':
apiKey = process.env.GROQ_API_KEY || '';
break;
case 'anthropic':
apiKey = process.env.ANTHROPIC_API_KEY || '';
break;
case 'openrouter':
apiKey = process.env.OPENROUTER_API_KEY || '';
break;
case 'openai':
default:
apiKey = process.env.OPENAI_API_KEY || process.env.SUMMARY_API_KEY || process.env.API_KEY || '';
break;
}
}
const body = (await req.json()) as SummarizeRequest;
const { text, mode, maxLength } = body;
console.log('Received summarize request:', { provider, modelId, mode, textLength: text?.length });
if (!text) {
const errorBody: SummarizeError = {
code: 'MISSING_TEXT',
message: 'No text provided for summarization',
};
return NextResponse.json(errorBody, { status: 400 });
}
if (!apiKey) {
const errorBody: SummarizeError = {
code: 'MISSING_API_KEY',
message: 'No API key configured for summarization. Please configure your API key in Settings.',
};
return NextResponse.json(errorBody, { status: 400 });
}
const systemPrompt = SYSTEM_PROMPTS[mode] || SYSTEM_PROMPTS.current_page;
const userPrompt = maxLength
? `Please summarize the following text in approximately ${maxLength} words:\n\n${text}`
: `Please summarize the following text:\n\n${text}`;
let model;
switch (provider) {
case 'anthropic': {
const anthropic = createAnthropic({
apiKey,
baseURL: baseUrl || undefined,
});
model = anthropic(modelId);
break;
}
case 'groq': {
const groq = createGroq({
apiKey,
baseURL: baseUrl || undefined,
});
model = groq(modelId);
break;
}
case 'openrouter': {
const openrouter = createOpenAI({
apiKey,
baseURL: baseUrl || 'https://openrouter.ai/api/v1',
});
model = openrouter(modelId);
break;
}
case 'custom-openai': {
if (!baseUrl) {
const errorBody: SummarizeError = {
code: 'MISSING_BASE_URL',
message: 'Custom provider requires a base URL',
};
return NextResponse.json(errorBody, { status: 400 });
}
const customOpenAI = createOpenAI({
apiKey: apiKey || 'not-needed',
baseURL: baseUrl,
});
model = customOpenAI(modelId);
break;
}
case 'openai':
default: {
const openai = createOpenAI({
apiKey,
baseURL: baseUrl || undefined,
});
model = openai(modelId);
break;
}
}
const result = await generateText({
model,
system: systemPrompt,
prompt: userPrompt,
});
const response: SummarizeResponse = {
summary: result.text,
provider,
model: modelId,
tokensUsed: result.usage?.totalTokens,
};
return NextResponse.json(response);
} catch (error) {
console.error('Error generating summary:', error);
const errorBody: SummarizeError = {
code: 'SUMMARIZE_FAILED',
message: error instanceof Error ? error.message : 'Failed to generate summary',
details: process.env.NODE_ENV !== 'production' ? String(error) : undefined,
};
return NextResponse.json(errorBody, { status: 500 });
}
}

View file

@ -101,9 +101,40 @@ function makeCacheKey(input: {
export async function POST(req: NextRequest) {
try {
// Get API credentials from headers or fall back to environment variables
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
// Get API key and base URL based on provider
let apiKey = req.headers.get('x-openai-key') || '';
let baseUrl = req.headers.get('x-openai-base-url') || '';
if (!apiKey) {
switch (provider) {
case 'groq':
apiKey = process.env.GROQ_API_KEY || '';
break;
case 'openai':
apiKey = process.env.OPENAI_API_KEY || process.env.API_KEY || 'none';
break;
default:
apiKey = process.env.API_KEY || 'none';
break;
}
}
if (!baseUrl) {
switch (provider) {
case 'groq':
baseUrl = 'https://api.groq.com/openai/v1';
break;
case 'deepinfra':
baseUrl = process.env.API_BASE || 'https://api.deepinfra.com/v1/openai';
break;
default:
baseUrl = process.env.API_BASE || undefined as unknown as string;
break;
}
}
const body = (await req.json()) as TTSRequestPayload;
const { text, voice, speed, format, model: req_model, instructions } = body;
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
@ -115,14 +146,28 @@ export async function POST(req: NextRequest) {
};
return NextResponse.json(errorBody, { status: 400 });
}
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
// Initialize OpenAI client with abort signal (OpenAI/deepinfra)
// Set default model based on provider
let rawModel = req_model;
if (!rawModel) {
switch (provider) {
case 'deepinfra':
rawModel = 'hexgrad/Kokoro-82M';
break;
case 'groq':
rawModel = 'canopylabs/orpheus-v1-english';
break;
default:
rawModel = 'gpt-4o-mini-tts';
break;
}
}
const model: SpeechCreateParams['model'] = rawModel as SpeechCreateParams['model'];
// Initialize OpenAI client (works with OpenAI-compatible APIs)
const openai = new OpenAI({
apiKey: openApiKey,
baseURL: openApiBaseUrl,
apiKey: apiKey,
baseURL: baseUrl || undefined,
});
const normalizedVoice = (
@ -131,12 +176,15 @@ export async function POST(req: NextRequest) {
: voice
) as SpeechCreateParams['voice'];
// Groq Orpheus models only support WAV format
const actualFormat = provider === 'groq' ? 'wav' : format;
const createParams: ExtendedSpeechParams = {
model: model,
voice: normalizedVoice,
input: text,
speed: speed,
response_format: format,
response_format: actualFormat,
};
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
@ -144,7 +192,7 @@ export async function POST(req: NextRequest) {
}
// Compute cache key and check LRU before making provider call
const contentType = 'audio/mpeg';
const contentType = actualFormat === 'wav' ? 'audio/wav' : 'audio/mpeg';
// Preserve voice string as-is for cache key (no weight stripping)
const voiceForKey = typeof createParams.voice === 'string'

View file

@ -19,6 +19,10 @@ const ORPHEUS_VOICES = ['tara', 'leah', 'jess', 'leo', 'dan', 'mia', 'zac'];
const SESAME_VOICES = ['conversational_a', 'conversational_b', 'read_speech_a', 'read_speech_b', 'read_speech_c', 'read_speech_d', 'none'];
// Groq Orpheus TTS voices
const GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
const GROQ_ORPHEUS_ARABIC_VOICES = ['fahad', 'sultan', 'lulwa', 'noura'];
function getDefaultVoices(provider: string, model: string): string[] {
// For OpenAI provider
if (provider === 'openai') {
@ -58,7 +62,15 @@ function getDefaultVoices(provider: string, model: string): string[] {
// Default Deepinfra voices
return CUSTOM_OPENAI_VOICES;
}
// For Groq provider
if (provider === 'groq') {
if (model === 'canopylabs/orpheus-arabic-saudi') {
return GROQ_ORPHEUS_ARABIC_VOICES;
}
return GROQ_ORPHEUS_ENGLISH_VOICES;
}
// Default fallback
return OPENAI_VOICES;
}
@ -103,6 +115,11 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}
// For Groq provider, use default voices
if (provider === 'groq') {
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}
// For Deepinfra provider with specific models that need API fetching
if (provider === 'deepinfra') {
const needsApiFetch = model === 'ResembleAI/chatterbox' ||

View file

@ -15,17 +15,21 @@ import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { SummarizeButton } from '@/components/SummarizeButton';
import { SummarizeModal } from '@/components/SummarizeModal';
import type { SummarizeMode } from '@/types/summary';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
export default function EPUBPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter, bookRef, renditionRef, extractPageText, currDocPages } = useEPUB();
const { stop } = useTTS();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
@ -102,6 +106,42 @@ export default function EPUBPage() {
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
}, [regenerateEPUBChapter]);
const handleExtractTextForSummary = useCallback(async (mode: SummarizeMode): Promise<string> => {
const book = bookRef.current;
const rendition = renditionRef.current;
if (!book || !rendition) {
throw new Error('EPUB document not loaded');
}
if (mode === 'whole_book') {
// Extract text from all spine sections
const promises: Promise<string>[] = [];
const spine = book.spine;
spine.each((item: { href?: string }) => {
const url = item.href || '';
if (!url) return;
const promise = book.load(url)
.then((section) => (section as Document))
.then((section) => section.body?.textContent?.trim() || '')
.catch((err) => {
console.warn('Failed to extract text from section:', url, err);
return '';
});
promises.push(promise);
});
const textParts = await Promise.all(promises);
return textParts.filter(text => text).join('\n\n');
} else {
// Extract current page text
return extractPageText(book, rendition, false);
}
}, [bookRef, renditionRef, extractPageText]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -146,6 +186,7 @@ export default function EPUBPage() {
min={0}
max={100}
/>
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!bookRef.current} />
{isDev && (
<button
onClick={() => setIsAudiobookModalOpen(true)}
@ -187,6 +228,14 @@ export default function EPUBPage() {
onRegenerateChapter={handleRegenerateChapter}
/>
)}
<SummarizeModal
isOpen={isSummarizeModalOpen}
setIsOpen={setIsSummarizeModalOpen}
docId={id as string}
docType="epub"
totalPages={currDocPages}
onExtractText={handleExtractTextForSummary}
/>
<TTSPlayer />
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>

View file

@ -12,14 +12,18 @@ import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
import { SummarizeButton } from '@/components/SummarizeButton';
import { SummarizeModal } from '@/components/SummarizeModal';
import type { SummarizeMode } from '@/types/summary';
export default function HTMLPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocText } = useHTML();
const { stop } = useTTS();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
const [maxPadPx, setMaxPadPx] = useState<number>(0);
@ -69,6 +73,15 @@ export default function HTMLPage() {
return () => window.removeEventListener('resize', compute);
}, []);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleExtractTextForSummary = useCallback(async (_mode: SummarizeMode): Promise<string> => {
if (!currDocText) {
throw new Error('Document text not loaded');
}
// For HTML documents, we always return the full text (no page concept)
return currDocText;
}, [currDocText]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -113,6 +126,7 @@ export default function HTMLPage() {
min={0}
max={100}
/>
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!currDocText} />
<button
onClick={() => setIsSettingsOpen(true)}
className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"
@ -134,6 +148,13 @@ export default function HTMLPage() {
</div>
)}
</div>
<SummarizeModal
isOpen={isSummarizeModalOpen}
setIsOpen={setIsSummarizeModalOpen}
docId={id as string}
docType="html"
onExtractText={handleExtractTextForSummary}
/>
<TTSPlayer />
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>

View file

@ -14,6 +14,10 @@ import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import TTSPlayer from '@/components/player/TTSPlayer';
import { SummarizeButton } from '@/components/SummarizeButton';
import { SummarizeModal } from '@/components/SummarizeModal';
import { extractTextFromPDF } from '@/lib/pdf';
import type { SummarizeMode } from '@/types/summary';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -28,13 +32,14 @@ const PDFViewer = dynamic(
export default function PDFViewerPage() {
const { id } = useParams();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter, pdfDocument } = usePDF();
const { stop } = useTTS();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
const [containerHeight, setContainerHeight] = useState<string>('auto');
const loadDocument = useCallback(async () => {
@ -96,6 +101,28 @@ export default function PDFViewerPage() {
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
}, [regeneratePDFChapter]);
const handleExtractTextForSummary = useCallback(async (mode: SummarizeMode, pageNumber?: number): Promise<string> => {
if (!pdfDocument) {
throw new Error('PDF document not loaded');
}
const margins = { header: 0.07, footer: 0.07, left: 0.07, right: 0.07 };
if (mode === 'whole_book') {
const textParts: string[] = [];
for (let page = 1; page <= pdfDocument.numPages; page++) {
const pageText = await extractTextFromPDF(pdfDocument, page, margins);
if (pageText) {
textParts.push(pageText);
}
}
return textParts.join('\n\n');
} else {
const targetPage = pageNumber ?? currDocPage ?? 1;
return extractTextFromPDF(pdfDocument, targetPage, margins);
}
}, [pdfDocument, currDocPage]);
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
@ -134,6 +161,7 @@ export default function PDFViewerPage() {
right={
<div className="flex items-center gap-2">
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!pdfDocument} />
{isDev && (
<button
onClick={() => setIsAudiobookModalOpen(true)}
@ -173,6 +201,15 @@ export default function PDFViewerPage() {
onRegenerateChapter={handleRegenerateChapter}
/>
)}
<SummarizeModal
isOpen={isSummarizeModalOpen}
setIsOpen={setIsSummarizeModalOpen}
docId={id as string}
docType="pdf"
currentPage={currDocPage}
totalPages={currDocPages}
onExtractText={handleExtractTextForSummary}
/>
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>

View file

@ -41,7 +41,7 @@ export function SettingsModal() {
const [isOpen, setIsOpen] = useState(false);
const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig();
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl } = useConfig();
const { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
const [localApiKey, setLocalApiKey] = useState(apiKey);
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
@ -49,6 +49,11 @@ export function SettingsModal() {
const [modelValue, setModelValue] = useState(ttsModel);
const [customModelInput, setCustomModelInput] = useState('');
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
// Summary settings state
const [localSummaryProvider, setLocalSummaryProvider] = useState(summaryProvider);
const [localSummaryModel, setLocalSummaryModel] = useState(summaryModel);
const [localSummaryApiKey, setLocalSummaryApiKey] = useState(summaryApiKey);
const [localSummaryBaseUrl, setLocalSummaryBaseUrl] = useState(summaryBaseUrl);
const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showProgress, setShowProgress] = useState(false);
@ -63,6 +68,7 @@ export function SettingsModal() {
const ttsProviders = useMemo(() => [
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
{ id: 'deepinfra', name: 'Deepinfra' },
{ id: 'groq', name: 'Groq' },
{ id: 'openai', name: 'OpenAI' }
], []);
@ -97,6 +103,11 @@ export function SettingsModal() {
{ id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' },
{ id: 'custom', name: 'Other' }
];
case 'groq':
return [
{ id: 'canopylabs/orpheus-v1-english', name: 'Orpheus English' },
{ id: 'canopylabs/orpheus-arabic-saudi', name: 'Orpheus Arabic (Saudi)' },
];
default:
return [
{ id: 'tts-1', name: 'TTS-1' }
@ -106,6 +117,59 @@ export function SettingsModal() {
const supportsCustom = useMemo(() => localTTSProvider !== 'openai', [localTTSProvider]);
// Summary provider and model options
const summaryProviders = useMemo(() => [
{ id: 'openai', name: 'OpenAI' },
{ id: 'anthropic', name: 'Anthropic' },
{ id: 'groq', name: 'Groq' },
{ id: 'openrouter', name: 'OpenRouter' },
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
], []);
const summaryModels = useMemo(() => {
switch (localSummaryProvider) {
case 'openai':
return [
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'gpt-4o', name: 'GPT-4o' },
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo' },
];
case 'anthropic':
return [
{ id: 'claude-3-5-haiku-latest', name: 'Claude 3.5 Haiku' },
{ id: 'claude-3-5-sonnet-latest', name: 'Claude 3.5 Sonnet' },
{ id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' },
];
case 'groq':
return [
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B' },
{ id: 'meta-llama/llama-4-maverick-17b-128e-instruct', name: 'Llama 4 Maverick 17B' },
{ id: 'meta-llama/llama-4-scout-17b-16e-instruct', name: 'Llama 4 Scout 17B' },
{ id: 'qwen/qwen3-32b', name: 'Qwen3 32B' },
{ id: 'openai/gpt-oss-120b', name: 'GPT-OSS 120B' },
{ id: 'moonshotai/kimi-k2-instruct', name: 'Kimi K2' },
{ id: 'llama-3.1-8b-instant', name: 'Llama 3.1 8B Instant' },
{ id: 'groq/compound', name: 'Compound' },
];
case 'openrouter':
return [
{ id: 'google/gemini-2.0-flash-001', name: 'Gemini 2.0 Flash' },
{ id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B' },
{ id: 'anthropic/claude-3.5-haiku', name: 'Claude 3.5 Haiku' },
{ id: 'openai/gpt-4o-mini', name: 'GPT-4o Mini' },
{ id: 'mistralai/mistral-small-24b-instruct-2501', name: 'Mistral Small 24B' },
];
case 'custom-openai':
return [
{ id: 'custom', name: 'Custom Model' },
];
default:
return [
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini' },
];
}
}, [localSummaryProvider]);
const selectedModelId = useMemo(
() => {
const isPreset = ttsModels.some(m => m.id === modelValue);
@ -137,7 +201,11 @@ export function SettingsModal() {
setLocalTTSProvider(ttsProvider);
setModelValue(ttsModel);
setLocalTTSInstructions(ttsInstructions);
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]);
setLocalSummaryProvider(summaryProvider);
setLocalSummaryModel(summaryModel);
setLocalSummaryApiKey(summaryApiKey);
setLocalSummaryBaseUrl(summaryBaseUrl);
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl]);
// Detect if current model is custom (not in presets) and mirror it in the input field
useEffect(() => {
@ -250,11 +318,16 @@ export function SettingsModal() {
} else {
setCustomModelInput('');
}
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]);
setLocalSummaryProvider(summaryProvider);
setLocalSummaryModel(summaryModel);
setLocalSummaryApiKey(summaryApiKey);
setLocalSummaryBaseUrl(summaryBaseUrl);
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels, summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl]);
const tabs = [
{ name: 'API', icon: '🔑' },
{ name: 'Appearance', icon: '✨' },
{ name: 'AI Summary', icon: '✨' },
{ name: 'Appearance', icon: '🎨' },
{ name: 'Documents', icon: '📄' }
];
@ -551,6 +624,212 @@ export function SettingsModal() {
</div>
</TabPanel>
{/* AI Summary Tab */}
<TabPanel className="space-y-2.5">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">AI Provider</label>
<Listbox
value={summaryProviders.find(p => p.id === localSummaryProvider) || summaryProviders[0]}
onChange={(provider) => {
setLocalSummaryProvider(provider.id);
// Set default model for each provider
switch (provider.id) {
case 'openai':
setLocalSummaryModel('gpt-4o-mini');
break;
case 'anthropic':
setLocalSummaryModel('claude-3-5-haiku-latest');
break;
case 'groq':
setLocalSummaryModel('llama-3.3-70b-versatile');
break;
case 'openrouter':
setLocalSummaryModel('google/gemini-2.0-flash-001');
break;
case 'custom-openai':
setLocalSummaryModel('');
break;
}
}}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">
{summaryProviders.find(p => p.id === localSummaryProvider)?.name || 'Select Provider'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
{summaryProviders.map((provider) => (
<ListboxOption
key={provider.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={provider}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
API Key
{localSummaryApiKey ? (
<span className="ml-2 text-xs text-accent">(Set)</span>
) : (
<span className="ml-2 text-xs text-muted">
(Uses {localSummaryProvider === 'groq' ? 'GROQ_API_KEY' :
localSummaryProvider === 'anthropic' ? 'ANTHROPIC_API_KEY' :
localSummaryProvider === 'openrouter' ? 'OPENROUTER_API_KEY' :
localSummaryProvider === 'custom-openai' ? 'optional' : 'OPENAI_API_KEY'} env)
</span>
)}
</label>
<Input
type="password"
value={localSummaryApiKey}
onChange={(e) => setLocalSummaryApiKey(e.target.value)}
placeholder={localSummaryProvider === 'custom-openai' ? "Optional for local models" : "Leave empty to use env variable"}
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Model</label>
{localSummaryProvider === 'custom-openai' ? (
<Input
type="text"
value={localSummaryModel}
onChange={(e) => setLocalSummaryModel(e.target.value)}
placeholder="Enter model name (e.g., llama3)"
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
) : (
<Listbox
value={summaryModels.find(m => m.id === localSummaryModel) || summaryModels[0]}
onChange={(model) => setLocalSummaryModel(model.id)}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">
{summaryModels.find(m => m.id === localSummaryModel)?.name || 'Select Model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
{summaryModels.map((model) => (
<ListboxOption
key={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={model}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{model.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
)}
</div>
{localSummaryProvider === 'custom-openai' && (
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">
Base URL
{localSummaryBaseUrl && <span className="ml-2 text-xs text-accent">(Set)</span>}
</label>
<Input
type="text"
value={localSummaryBaseUrl}
onChange={(e) => setLocalSummaryBaseUrl(e.target.value)}
placeholder="http://localhost:11434/v1"
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
)}
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={() => {
setLocalSummaryProvider('groq');
setLocalSummaryModel('llama-3.3-70b-versatile');
setLocalSummaryApiKey('');
setLocalSummaryBaseUrl('');
}}
>
Reset
</Button>
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
onClick={async () => {
await updateConfigKey('summaryProvider', localSummaryProvider);
await updateConfigKey('summaryModel', localSummaryModel);
await updateConfigKey('summaryApiKey', localSummaryApiKey);
await updateConfigKey('summaryBaseUrl', localSummaryBaseUrl);
setIsOpen(false);
}}
>
Save
</Button>
</div>
</TabPanel>
<TabPanel className="space-y-4 pb-3">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Theme</label>

View file

@ -0,0 +1,22 @@
'use client';
import { SummarizeIcon } from '@/components/icons/Icons';
interface SummarizeButtonProps {
onClick: () => void;
disabled?: boolean;
}
export function SummarizeButton({ onClick, disabled }: SummarizeButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Summarize document"
title="AI Summarize"
>
<SummarizeIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
</button>
);
}

View file

@ -0,0 +1,327 @@
'use client';
import { Fragment, useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Button,
Input,
RadioGroup,
Radio,
Label,
} from '@headlessui/react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
import { useConfig } from '@/contexts/ConfigContext';
import { generateSummary } from '@/lib/summarize';
import { saveSummary, getSummary } from '@/lib/dexie';
import type { SummarizeMode, SummaryRow } from '@/types/summary';
interface SummarizeModalProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
docId: string;
docType: 'pdf' | 'epub' | 'html';
currentPage?: number;
totalPages?: number;
onExtractText: (mode: SummarizeMode, pageNumber?: number) => Promise<string>;
}
const MODE_OPTIONS: { value: SummarizeMode; label: string; description: string }[] = [
{ value: 'current_page', label: 'Current Page', description: 'Summarize the page you are viewing' },
{ value: 'select_page', label: 'Select Page', description: 'Choose a specific page to summarize' },
{ value: 'whole_book', label: 'Whole Document', description: 'Summarize the entire document' },
];
export function SummarizeModal({
isOpen,
setIsOpen,
docId,
docType,
currentPage = 1,
totalPages,
onExtractText,
}: SummarizeModalProps) {
const { summaryProvider, summaryModel, summaryApiKey, summaryBaseUrl } = useConfig();
const [mode, setMode] = useState<SummarizeMode>('current_page');
const [selectedPage, setSelectedPage] = useState<number>(currentPage);
const [summary, setSummary] = useState<string>('');
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [savedSummary, setSavedSummary] = useState<SummaryRow | null>(null);
// Update selected page when current page changes
useEffect(() => {
setSelectedPage(currentPage);
}, [currentPage]);
// Check for existing saved summary when mode or page changes
const checkSavedSummary = useCallback(async () => {
if (!isOpen) return;
const pageNumber = mode === 'whole_book' ? null : (mode === 'current_page' ? currentPage : selectedPage);
const existing = await getSummary(docId, docType, pageNumber);
if (existing) {
setSavedSummary(existing);
setSummary(existing.summary);
} else {
setSavedSummary(null);
setSummary('');
}
}, [isOpen, mode, docId, docType, currentPage, selectedPage]);
useEffect(() => {
checkSavedSummary();
}, [checkSavedSummary]);
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setError(null);
setCopied(false);
}
}, [isOpen]);
const handleGenerate = async () => {
setIsGenerating(true);
setError(null);
setSummary('');
setSavedSummary(null);
try {
const pageNumber = mode === 'whole_book' ? undefined : (mode === 'current_page' ? currentPage : selectedPage);
const text = await onExtractText(mode, pageNumber);
if (!text || text.trim().length === 0) {
throw new Error('No text could be extracted from the document');
}
const result = await generateSummary(text, mode, {
provider: summaryProvider,
apiKey: summaryApiKey,
baseUrl: summaryBaseUrl,
model: summaryModel,
});
setSummary(result.summary);
// Save to IndexedDB
await saveSummary({
docId,
docType,
scope: mode === 'whole_book' ? 'book' : 'page',
pageNumber: mode === 'whole_book' ? null : (pageNumber ?? null),
summary: result.summary,
provider: result.provider,
model: result.model,
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Refresh saved summary state
await checkSavedSummary();
} catch (err) {
console.error('Error generating summary:', err);
setError(err instanceof Error ? err.message : 'Failed to generate summary');
} finally {
setIsGenerating(false);
}
};
const handleCopy = async () => {
if (!summary) return;
try {
await navigator.clipboard.writeText(summary);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Always allow - server will use env vars as fallback if no API key configured
const isConfigured = true;
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-lg transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
AI Summarize
</DialogTitle>
{!isConfigured && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<p className="text-sm text-yellow-600 dark:text-yellow-400">
Please configure your AI provider settings in Settings to use summarization.
</p>
</div>
)}
<div className="space-y-4">
{/* Mode selector */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">
Summarize Mode
</label>
<RadioGroup value={mode} onChange={setMode} className="space-y-2">
{MODE_OPTIONS.map((option) => (
<Radio
key={option.value}
value={option.value}
className={({ checked }) =>
`relative flex cursor-pointer rounded-lg px-4 py-3 border transition-all ${
checked
? 'bg-accent/10 border-accent'
: 'bg-background border-offbase hover:border-accent/50'
}`
}
>
{({ checked }) => (
<div className="flex w-full items-center justify-between">
<div>
<Label as="p" className={`font-medium ${checked ? 'text-accent' : 'text-foreground'}`}>
{option.label}
</Label>
<p className="text-xs text-muted">{option.description}</p>
</div>
{checked && (
<div className="shrink-0 text-accent">
<CheckIcon className="h-5 w-5" />
</div>
)}
</div>
)}
</Radio>
))}
</RadioGroup>
</div>
{/* Page selector for select_page mode */}
{mode === 'select_page' && totalPages && (
<div>
<label className="block text-sm font-medium text-foreground mb-1">
Page Number
</label>
<Input
type="number"
min={1}
max={totalPages}
value={selectedPage}
onChange={(e) => setSelectedPage(Math.max(1, Math.min(totalPages, parseInt(e.target.value) || 1)))}
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="text-xs text-muted mt-1">
Enter a page number between 1 and {totalPages}
</p>
</div>
)}
{/* Generate button */}
<Button
onClick={handleGenerate}
disabled={isGenerating || !isConfigured}
className="w-full inline-flex justify-center items-center rounded-lg bg-accent px-4 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 disabled:cursor-not-allowed transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
{isGenerating ? (
<>
<LoadingSpinner />
<span className="ml-2">Generating...</span>
</>
) : savedSummary ? (
'Regenerate Summary'
) : (
'Generate Summary'
)}
</Button>
{/* Error display */}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Summary display */}
{summary && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-foreground">
Summary
{savedSummary && (
<span className="ml-2 text-xs text-muted">
(saved {new Date(savedSummary.updatedAt).toLocaleDateString()})
</span>
)}
</label>
<Button
onClick={handleCopy}
className="inline-flex items-center gap-1 text-xs text-muted hover:text-accent transition-colors"
>
{copied ? (
<>
<CheckIcon className="w-4 h-4" />
Copied
</>
) : (
<>
<CopyIcon className="w-4 h-4" />
Copy
</>
)}
</Button>
</div>
<div className="max-h-64 overflow-y-auto p-3 bg-background rounded-lg border border-offbase">
<p className="text-sm text-foreground whitespace-pre-wrap">{summary}</p>
</div>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={() => setIsOpen(false)}
>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -510,3 +510,19 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function SummarizeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path fillRule="evenodd" d="M9 4.5a.75.75 0 01.721.544l.813 2.846a3.75 3.75 0 002.576 2.576l2.846.813a.75.75 0 010 1.442l-2.846.813a3.75 3.75 0 00-2.576 2.576l-.813 2.846a.75.75 0 01-1.442 0l-.813-2.846a3.75 3.75 0 00-2.576-2.576l-2.846-.813a.75.75 0 010-1.442l2.846-.813A3.75 3.75 0 007.466 7.89l.813-2.846A.75.75 0 019 4.5zM18 1.5a.75.75 0 01.728.568l.258 1.036c.236.94.97 1.674 1.91 1.91l1.036.258a.75.75 0 010 1.456l-1.036.258c-.94.236-1.674.97-1.91 1.91l-.258 1.036a.75.75 0 01-1.456 0l-.258-1.036a2.625 2.625 0 00-1.91-1.91l-1.036-.258a.75.75 0 010-1.456l1.036-.258a2.625 2.625 0 001.91-1.91l.258-1.036A.75.75 0 0118 1.5zM16.5 15a.75.75 0 01.712.513l.394 1.183c.15.447.5.799.948.948l1.183.395a.75.75 0 010 1.422l-1.183.395c-.447.15-.799.5-.948.948l-.395 1.183a.75.75 0 01-1.422 0l-.395-1.183a1.5 1.5 0 00-.948-.948l-1.183-.395a.75.75 0 010-1.422l1.183-.395c.447-.15.799-.5.948-.948l.395-1.183A.75.75 0 0116.5 15z" clipRule="evenodd" />
</svg>
);
}

View file

@ -35,6 +35,11 @@ interface ConfigContextType {
pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
epubWordHighlightEnabled: boolean;
// Summary/AI settings
summaryProvider: string;
summaryModel: string;
summaryApiKey: string;
summaryBaseUrl: string;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@ -108,6 +113,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
summaryProvider,
summaryModel,
summaryApiKey,
summaryBaseUrl,
} = config || APP_CONFIG_DEFAULTS;
/**
@ -207,7 +216,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled
epubWordHighlightEnabled,
summaryProvider,
summaryModel,
summaryApiKey,
summaryBaseUrl,
}}>
{children}
</ConfigContext.Provider>

View file

@ -1,10 +1,11 @@
import Dexie, { type EntityTable } from 'dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
import type { SummaryRow } from '@/types/summary';
const DB_NAME = 'openreader-db';
// Managed via Dexie (version bumped from the original manual IndexedDB)
const DB_VERSION = 5;
const DB_VERSION = 6;
const PDF_TABLE = 'pdf-documents' as const;
const EPUB_TABLE = 'epub-documents' as const;
@ -12,6 +13,7 @@ const HTML_TABLE = 'html-documents' as const;
const CONFIG_TABLE = 'config' as const;
const APP_CONFIG_TABLE = 'app-config' as const;
const LAST_LOCATION_TABLE = 'last-locations' as const;
const SUMMARIES_TABLE = 'summaries' as const;
export interface LastLocationRow {
docId: string;
@ -30,6 +32,7 @@ type OpenReaderDB = Dexie & {
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
[SUMMARIES_TABLE]: EntityTable<SummaryRow, 'id'>;
};
export const db = new Dexie(DB_NAME) as OpenReaderDB;
@ -152,14 +155,16 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
return config;
}
// Version 5: introduce app-config and last-locations tables, migrate scattered config keys,
// and drop the legacy config table in a single upgrade step.
// Version 6: add summaries table for AI-generated document summaries.
// Previous version 5 introduced app-config and last-locations tables, migrated scattered config keys,
// and dropped the legacy config table.
db.version(DB_VERSION).stores({
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId',
[APP_CONFIG_TABLE]: 'id',
[LAST_LOCATION_TABLE]: 'docId',
[SUMMARIES_TABLE]: 'id, docId, [docId+pageNumber]',
// `null` here means: drop the old 'config' table after upgrade runs,
// but Dexie still lets us read it inside the upgrade transaction.
[CONFIG_TABLE]: null,
@ -555,3 +560,55 @@ export async function loadDocumentsFromServer(
return { lastSync: Date.now() };
}
// Summary helpers (for AI-generated document summaries)
export async function saveSummary(summary: Omit<SummaryRow, 'id'>): Promise<string> {
return withDB(async () => {
const id = `${summary.docId}-${summary.scope}-${summary.pageNumber ?? 'all'}`;
const now = Date.now();
const existing = await db[SUMMARIES_TABLE].get(id);
const row: SummaryRow = {
...summary,
id,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await db[SUMMARIES_TABLE].put(row);
console.log('Saved summary:', id);
return id;
});
}
export async function getSummary(
docId: string,
docType: 'pdf' | 'epub' | 'html',
pageNumber?: number | null
): Promise<SummaryRow | null> {
return withDB(async () => {
const scope = pageNumber != null ? 'page' : 'book';
const id = `${docId}-${scope}-${pageNumber ?? 'all'}`;
const row = await db[SUMMARIES_TABLE].get(id);
return row ?? null;
});
}
export async function getSummariesForDocument(docId: string): Promise<SummaryRow[]> {
return withDB(async () => {
return db[SUMMARIES_TABLE].where('docId').equals(docId).toArray();
});
}
export async function deleteSummary(id: string): Promise<void> {
await withDB(async () => {
await db[SUMMARIES_TABLE].delete(id);
console.log('Deleted summary:', id);
});
}
export async function deleteSummariesForDocument(docId: string): Promise<void> {
await withDB(async () => {
await db[SUMMARIES_TABLE].where('docId').equals(docId).delete();
console.log('Deleted all summaries for document:', docId);
});
}

51
src/lib/summarize.ts Normal file
View file

@ -0,0 +1,51 @@
import type { SummarizeMode, SummarizeResponse, SummarizeError } from '@/types/summary';
export interface SummarizeOptions {
provider: string;
apiKey: string;
baseUrl: string;
model: string;
}
export async function generateSummary(
text: string,
mode: SummarizeMode,
options: SummarizeOptions,
maxLength?: number
): Promise<SummarizeResponse> {
// Build headers, only include API key if explicitly set (otherwise server uses env vars)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-summary-provider': options.provider,
'x-summary-model': options.model,
};
// Only send API key if explicitly configured (empty = use server env var)
if (options.apiKey) {
headers['x-summary-api-key'] = options.apiKey;
}
// Only send base URL if configured
if (options.baseUrl) {
headers['x-summary-base-url'] = options.baseUrl;
}
const response = await fetch('/api/summarize', {
method: 'POST',
headers,
body: JSON.stringify({
text,
mode,
maxLength,
}),
});
const data = await response.json();
if (!response.ok) {
const error = data as SummarizeError;
throw new Error(error.message || 'Failed to generate summary');
}
return data as SummarizeResponse;
}

View file

@ -30,6 +30,11 @@ export interface AppConfigValues {
epubWordHighlightEnabled: boolean;
firstVisit: boolean;
documentListState: DocumentListState;
// Summary/AI settings
summaryProvider: string;
summaryModel: string;
summaryApiKey: string;
summaryBaseUrl: string;
}
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
@ -63,6 +68,11 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
showHint: true,
viewMode: 'grid',
},
// Summary/AI defaults
summaryProvider: 'groq',
summaryModel: 'llama-3.3-70b-versatile',
summaryApiKey: '',
summaryBaseUrl: '',
};
export interface AppConfigRow extends AppConfigValues {

34
src/types/summary.ts Normal file
View file

@ -0,0 +1,34 @@
export type SummarizeMode = 'current_page' | 'select_page' | 'whole_book';
export type SummaryProvider = 'openai' | 'anthropic' | 'custom-openai';
export interface SummaryRow {
id: string; // `${docId}-${scope}-${pageNumber ?? 'all'}`
docId: string;
docType: 'pdf' | 'epub' | 'html';
scope: 'page' | 'book';
pageNumber: number | null;
summary: string;
provider: string;
model: string;
createdAt: number;
updatedAt: number;
}
export interface SummarizeRequest {
text: string;
mode: SummarizeMode;
maxLength?: number;
}
export interface SummarizeResponse {
summary: string;
provider: string;
model: string;
tokensUsed?: number;
}
export interface SummarizeError {
code: string;
message: string;
details?: string;
}