feat: add AI summarization with multi-provider support and Groq TTS integration
This commit is contained in:
parent
ad59f3c1ed
commit
5f87d66cc4
22 changed files with 10946 additions and 29 deletions
9307
package-lock.json
generated
Normal file
9307
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
146
pnpm-lock.yaml
146
pnpm-lock.yaml
|
|
@ -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
208
scripts/OPENREADER_SETUP.md
Normal 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
34
scripts/README.md
Normal 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
52
scripts/groq-tts-proxy.py
Normal 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
41
scripts/openreader-webui.sh
Executable 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"
|
||||
158
src/app/api/summarize/route.ts
Normal file
158
src/app/api/summarize/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
22
src/components/SummarizeButton.tsx
Normal file
22
src/components/SummarizeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
src/components/SummarizeModal.tsx
Normal file
327
src/components/SummarizeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
51
src/lib/summarize.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
34
src/types/summary.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue