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"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/anthropic": "^3.0.15",
|
||||||
|
"@ai-sdk/groq": "^3.0.10",
|
||||||
|
"@ai-sdk/openai": "^3.0.12",
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@types/howler": "^2.2.12",
|
"@types/howler": "^2.2.12",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
|
"ai": "^6.0.39",
|
||||||
"cmpstr": "^3.0.4",
|
"cmpstr": "^3.0.4",
|
||||||
"compromise": "^14.14.4",
|
"compromise": "^14.14.4",
|
||||||
"core-js": "^3.47.0",
|
"core-js": "^3.47.0",
|
||||||
|
|
|
||||||
146
pnpm-lock.yaml
146
pnpm-lock.yaml
|
|
@ -8,6 +8,15 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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':
|
'@headlessui/react':
|
||||||
specifier: ^2.2.9
|
specifier: ^2.2.9
|
||||||
version: 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
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
|
version: 10.0.0
|
||||||
'@vercel/analytics':
|
'@vercel/analytics':
|
||||||
specifier: ^1.6.1
|
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:
|
cmpstr:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4
|
version: 3.0.4
|
||||||
|
|
@ -46,10 +58,10 @@ importers:
|
||||||
version: 11.2.4
|
version: 11.2.4
|
||||||
next:
|
next:
|
||||||
specifier: ^15.5.9
|
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:
|
openai:
|
||||||
specifier: ^4.104.0
|
specifier: ^4.104.0
|
||||||
version: 4.104.0
|
version: 4.104.0(zod@3.25.76)
|
||||||
pdfjs-dist:
|
pdfjs-dist:
|
||||||
specifier: 4.8.69
|
specifier: 4.8.69
|
||||||
version: 4.8.69
|
version: 4.8.69
|
||||||
|
|
@ -123,6 +135,40 @@ importers:
|
||||||
|
|
||||||
packages:
|
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':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -445,6 +491,10 @@ packages:
|
||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
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':
|
'@playwright/test@1.57.0':
|
||||||
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -502,6 +552,9 @@ packages:
|
||||||
'@rushstack/eslint-patch@1.15.0':
|
'@rushstack/eslint-patch@1.15.0':
|
||||||
resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==}
|
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':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||||
|
|
||||||
|
|
@ -765,6 +818,10 @@ packages:
|
||||||
vue-router:
|
vue-router:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vercel/oidc@3.1.0':
|
||||||
|
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.7.13':
|
'@xmldom/xmldom@0.7.13':
|
||||||
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
|
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
@ -788,6 +845,12 @@ packages:
|
||||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||||
engines: {node: '>= 8.0.0'}
|
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:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
|
|
@ -1300,6 +1363,10 @@ packages:
|
||||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
engines: {node: '>=6'}
|
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:
|
expand-template@2.0.3:
|
||||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -1684,6 +1751,9 @@ packages:
|
||||||
json-schema-traverse@0.4.1:
|
json-schema-traverse@0.4.1:
|
||||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
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:
|
json-stable-stringify-without-jsonify@1.0.1:
|
||||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||||
|
|
||||||
|
|
@ -2709,11 +2779,50 @@ packages:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zod@3.25.76:
|
||||||
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
zwitch@2.0.4:
|
zwitch@2.0.4:
|
||||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||||
|
|
||||||
snapshots:
|
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': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@babel/runtime@7.28.4': {}
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
@ -2988,6 +3097,8 @@ snapshots:
|
||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.0': {}
|
||||||
|
|
||||||
'@playwright/test@1.57.0':
|
'@playwright/test@1.57.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.57.0
|
playwright: 1.57.0
|
||||||
|
|
@ -3051,6 +3162,8 @@ snapshots:
|
||||||
|
|
||||||
'@rushstack/eslint-patch@1.15.0': {}
|
'@rushstack/eslint-patch@1.15.0': {}
|
||||||
|
|
||||||
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
@ -3286,11 +3399,13 @@ snapshots:
|
||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
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:
|
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
|
react: 19.2.3
|
||||||
|
|
||||||
|
'@vercel/oidc@3.1.0': {}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.7.13': {}
|
'@xmldom/xmldom@0.7.13': {}
|
||||||
|
|
||||||
abort-controller@3.0.0:
|
abort-controller@3.0.0:
|
||||||
|
|
@ -3307,6 +3422,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
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:
|
ajv@6.12.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|
@ -4004,6 +4127,8 @@ snapshots:
|
||||||
|
|
||||||
event-target-shim@5.0.1: {}
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
|
eventsource-parser@3.0.6: {}
|
||||||
|
|
||||||
expand-template@2.0.3:
|
expand-template@2.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -4407,6 +4532,8 @@ snapshots:
|
||||||
|
|
||||||
json-schema-traverse@0.4.1: {}
|
json-schema-traverse@0.4.1: {}
|
||||||
|
|
||||||
|
json-schema@0.4.0: {}
|
||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||||
|
|
||||||
json5@1.0.2:
|
json5@1.0.2:
|
||||||
|
|
@ -4880,7 +5007,7 @@ snapshots:
|
||||||
|
|
||||||
next-tick@1.1.0: {}
|
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:
|
dependencies:
|
||||||
'@next/env': 15.5.9
|
'@next/env': 15.5.9
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
|
|
@ -4898,6 +5025,7 @@ snapshots:
|
||||||
'@next/swc-linux-x64-musl': 15.5.7
|
'@next/swc-linux-x64-musl': 15.5.7
|
||||||
'@next/swc-win32-arm64-msvc': 15.5.7
|
'@next/swc-win32-arm64-msvc': 15.5.7
|
||||||
'@next/swc-win32-x64-msvc': 15.5.7
|
'@next/swc-win32-x64-msvc': 15.5.7
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
'@playwright/test': 1.57.0
|
'@playwright/test': 1.57.0
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|
@ -4969,7 +5097,7 @@ snapshots:
|
||||||
wrappy: 1.0.2
|
wrappy: 1.0.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
openai@4.104.0:
|
openai@4.104.0(zod@3.25.76):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 18.19.130
|
'@types/node': 18.19.130
|
||||||
'@types/node-fetch': 2.6.13
|
'@types/node-fetch': 2.6.13
|
||||||
|
|
@ -4978,6 +5106,8 @@ snapshots:
|
||||||
form-data-encoder: 1.7.2
|
form-data-encoder: 1.7.2
|
||||||
formdata-node: 4.4.1
|
formdata-node: 4.4.1
|
||||||
node-fetch: 2.7.0
|
node-fetch: 2.7.0
|
||||||
|
optionalDependencies:
|
||||||
|
zod: 3.25.76
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
|
|
@ -5869,4 +5999,6 @@ snapshots:
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zod@3.25.76: {}
|
||||||
|
|
||||||
zwitch@2.0.4: {}
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Get API credentials from headers or fall back to environment variables
|
// 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';
|
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 body = (await req.json()) as TTSRequestPayload;
|
||||||
const { text, voice, speed, format, model: req_model, instructions } = body;
|
const { text, voice, speed, format, model: req_model, instructions } = body;
|
||||||
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
|
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 });
|
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({
|
const openai = new OpenAI({
|
||||||
apiKey: openApiKey,
|
apiKey: apiKey,
|
||||||
baseURL: openApiBaseUrl,
|
baseURL: baseUrl || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizedVoice = (
|
const normalizedVoice = (
|
||||||
|
|
@ -131,12 +176,15 @@ export async function POST(req: NextRequest) {
|
||||||
: voice
|
: voice
|
||||||
) as SpeechCreateParams['voice'];
|
) as SpeechCreateParams['voice'];
|
||||||
|
|
||||||
|
// Groq Orpheus models only support WAV format
|
||||||
|
const actualFormat = provider === 'groq' ? 'wav' : format;
|
||||||
|
|
||||||
const createParams: ExtendedSpeechParams = {
|
const createParams: ExtendedSpeechParams = {
|
||||||
model: model,
|
model: model,
|
||||||
voice: normalizedVoice,
|
voice: normalizedVoice,
|
||||||
input: text,
|
input: text,
|
||||||
speed: speed,
|
speed: speed,
|
||||||
response_format: format,
|
response_format: actualFormat,
|
||||||
};
|
};
|
||||||
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||||
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
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
|
// 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)
|
// Preserve voice string as-is for cache key (no weight stripping)
|
||||||
const voiceForKey = typeof createParams.voice === 'string'
|
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'];
|
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[] {
|
function getDefaultVoices(provider: string, model: string): string[] {
|
||||||
// For OpenAI provider
|
// For OpenAI provider
|
||||||
if (provider === 'openai') {
|
if (provider === 'openai') {
|
||||||
|
|
@ -58,7 +62,15 @@ function getDefaultVoices(provider: string, model: string): string[] {
|
||||||
// Default Deepinfra voices
|
// Default Deepinfra voices
|
||||||
return CUSTOM_OPENAI_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
|
// Default fallback
|
||||||
return OPENAI_VOICES;
|
return OPENAI_VOICES;
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +115,11 @@ export async function GET(req: NextRequest) {
|
||||||
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
|
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
|
// For Deepinfra provider with specific models that need API fetching
|
||||||
if (provider === 'deepinfra') {
|
if (provider === 'deepinfra') {
|
||||||
const needsApiFetch = model === 'ResembleAI/chatterbox' ||
|
const needsApiFetch = model === 'ResembleAI/chatterbox' ||
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,21 @@ import { ZoomControl } from '@/components/ZoomControl';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { DownloadIcon } from '@/components/icons/Icons';
|
import { DownloadIcon } from '@/components/icons/Icons';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
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;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
export default function EPUBPage() {
|
export default function EPUBPage() {
|
||||||
const { id } = useParams();
|
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 { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
|
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
|
||||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width, 0 = max padding)
|
||||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
@ -102,6 +106,42 @@ export default function EPUBPage() {
|
||||||
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
}, [regenerateEPUBChapter]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -146,6 +186,7 @@ export default function EPUBPage() {
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
|
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!bookRef.current} />
|
||||||
{isDev && (
|
{isDev && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsAudiobookModalOpen(true)}
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
|
|
@ -187,6 +228,14 @@ export default function EPUBPage() {
|
||||||
onRegenerateChapter={handleRegenerateChapter}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SummarizeModal
|
||||||
|
isOpen={isSummarizeModalOpen}
|
||||||
|
setIsOpen={setIsSummarizeModalOpen}
|
||||||
|
docId={id as string}
|
||||||
|
docType="epub"
|
||||||
|
totalPages={currDocPages}
|
||||||
|
onExtractText={handleExtractTextForSummary}
|
||||||
|
/>
|
||||||
<TTSPlayer />
|
<TTSPlayer />
|
||||||
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,18 @@ import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
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() {
|
export default function HTMLPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML();
|
const { setCurrentDocument, currDocName, clearCurrDoc, currDocText } = useHTML();
|
||||||
const { stop } = useTTS();
|
const { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
|
||||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
const [padPct, setPadPct] = useState<number>(100); // 0..100 (100 = full width)
|
||||||
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
const [maxPadPx, setMaxPadPx] = useState<number>(0);
|
||||||
|
|
@ -69,6 +73,15 @@ export default function HTMLPage() {
|
||||||
return () => window.removeEventListener('resize', compute);
|
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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -113,6 +126,7 @@ export default function HTMLPage() {
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
|
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!currDocText} />
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<SummarizeModal
|
||||||
|
isOpen={isSummarizeModalOpen}
|
||||||
|
setIsOpen={setIsSummarizeModalOpen}
|
||||||
|
docId={id as string}
|
||||||
|
docType="html"
|
||||||
|
onExtractText={handleExtractTextForSummary}
|
||||||
|
/>
|
||||||
<TTSPlayer />
|
<TTSPlayer />
|
||||||
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import { ZoomControl } from '@/components/ZoomControl';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
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;
|
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() {
|
export default function PDFViewerPage() {
|
||||||
const { id } = useParams();
|
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 { stop } = useTTS();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
const [zoomLevel, setZoomLevel] = useState<number>(100);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
|
const [isSummarizeModalOpen, setIsSummarizeModalOpen] = useState(false);
|
||||||
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
const [containerHeight, setContainerHeight] = useState<string>('auto');
|
||||||
|
|
||||||
const loadDocument = useCallback(async () => {
|
const loadDocument = useCallback(async () => {
|
||||||
|
|
@ -96,6 +101,28 @@ export default function PDFViewerPage() {
|
||||||
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
}, [regeneratePDFChapter]);
|
}, [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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
|
|
@ -134,6 +161,7 @@ export default function PDFViewerPage() {
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
|
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
|
||||||
|
<SummarizeButton onClick={() => setIsSummarizeModalOpen(true)} disabled={!pdfDocument} />
|
||||||
{isDev && (
|
{isDev && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsAudiobookModalOpen(true)}
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
|
|
@ -173,6 +201,15 @@ export default function PDFViewerPage() {
|
||||||
onRegenerateChapter={handleRegenerateChapter}
|
onRegenerateChapter={handleRegenerateChapter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SummarizeModal
|
||||||
|
isOpen={isSummarizeModalOpen}
|
||||||
|
setIsOpen={setIsSummarizeModalOpen}
|
||||||
|
docId={id as string}
|
||||||
|
docType="pdf"
|
||||||
|
currentPage={currDocPage}
|
||||||
|
totalPages={currDocPages}
|
||||||
|
onExtractText={handleExtractTextForSummary}
|
||||||
|
/>
|
||||||
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
|
||||||
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function SettingsModal() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
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 { clearPDFs, clearEPUBs, clearHTML } = useDocuments();
|
||||||
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
const [localApiKey, setLocalApiKey] = useState(apiKey);
|
||||||
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl);
|
||||||
|
|
@ -49,6 +49,11 @@ export function SettingsModal() {
|
||||||
const [modelValue, setModelValue] = useState(ttsModel);
|
const [modelValue, setModelValue] = useState(ttsModel);
|
||||||
const [customModelInput, setCustomModelInput] = useState('');
|
const [customModelInput, setCustomModelInput] = useState('');
|
||||||
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
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 [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showProgress, setShowProgress] = useState(false);
|
const [showProgress, setShowProgress] = useState(false);
|
||||||
|
|
@ -63,6 +68,7 @@ export function SettingsModal() {
|
||||||
const ttsProviders = useMemo(() => [
|
const ttsProviders = useMemo(() => [
|
||||||
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
|
{ id: 'custom-openai', name: 'Custom OpenAI-Like' },
|
||||||
{ id: 'deepinfra', name: 'Deepinfra' },
|
{ id: 'deepinfra', name: 'Deepinfra' },
|
||||||
|
{ id: 'groq', name: 'Groq' },
|
||||||
{ id: 'openai', name: 'OpenAI' }
|
{ id: 'openai', name: 'OpenAI' }
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
|
|
@ -97,6 +103,11 @@ export function SettingsModal() {
|
||||||
{ id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' },
|
{ id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' },
|
||||||
{ id: 'custom', name: 'Other' }
|
{ 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:
|
default:
|
||||||
return [
|
return [
|
||||||
{ id: 'tts-1', name: 'TTS-1' }
|
{ id: 'tts-1', name: 'TTS-1' }
|
||||||
|
|
@ -106,6 +117,59 @@ export function SettingsModal() {
|
||||||
|
|
||||||
const supportsCustom = useMemo(() => localTTSProvider !== 'openai', [localTTSProvider]);
|
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 selectedModelId = useMemo(
|
||||||
() => {
|
() => {
|
||||||
const isPreset = ttsModels.some(m => m.id === modelValue);
|
const isPreset = ttsModels.some(m => m.id === modelValue);
|
||||||
|
|
@ -137,7 +201,11 @@ export function SettingsModal() {
|
||||||
setLocalTTSProvider(ttsProvider);
|
setLocalTTSProvider(ttsProvider);
|
||||||
setModelValue(ttsModel);
|
setModelValue(ttsModel);
|
||||||
setLocalTTSInstructions(ttsInstructions);
|
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
|
// Detect if current model is custom (not in presets) and mirror it in the input field
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -250,11 +318,16 @@ export function SettingsModal() {
|
||||||
} else {
|
} else {
|
||||||
setCustomModelInput('');
|
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 = [
|
const tabs = [
|
||||||
{ name: 'API', icon: '🔑' },
|
{ name: 'API', icon: '🔑' },
|
||||||
{ name: 'Appearance', icon: '✨' },
|
{ name: 'AI Summary', icon: '✨' },
|
||||||
|
{ name: 'Appearance', icon: '🎨' },
|
||||||
{ name: 'Documents', icon: '📄' }
|
{ name: 'Documents', icon: '📄' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -551,6 +624,212 @@ export function SettingsModal() {
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</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">
|
<TabPanel className="space-y-4 pb-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Theme</label>
|
<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>
|
</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;
|
pdfWordHighlightEnabled: boolean;
|
||||||
epubHighlightEnabled: boolean;
|
epubHighlightEnabled: boolean;
|
||||||
epubWordHighlightEnabled: boolean;
|
epubWordHighlightEnabled: boolean;
|
||||||
|
// Summary/AI settings
|
||||||
|
summaryProvider: string;
|
||||||
|
summaryModel: string;
|
||||||
|
summaryApiKey: string;
|
||||||
|
summaryBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
@ -108,6 +113,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
|
summaryProvider,
|
||||||
|
summaryModel,
|
||||||
|
summaryApiKey,
|
||||||
|
summaryBaseUrl,
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -207,7 +216,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
epubWordHighlightEnabled
|
epubWordHighlightEnabled,
|
||||||
|
summaryProvider,
|
||||||
|
summaryModel,
|
||||||
|
summaryApiKey,
|
||||||
|
summaryBaseUrl,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import Dexie, { type EntityTable } from 'dexie';
|
import Dexie, { type EntityTable } from 'dexie';
|
||||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
|
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
|
||||||
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
|
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState, SyncedDocument } from '@/types/documents';
|
||||||
|
import type { SummaryRow } from '@/types/summary';
|
||||||
|
|
||||||
const DB_NAME = 'openreader-db';
|
const DB_NAME = 'openreader-db';
|
||||||
// Managed via Dexie (version bumped from the original manual IndexedDB)
|
// 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 PDF_TABLE = 'pdf-documents' as const;
|
||||||
const EPUB_TABLE = 'epub-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 CONFIG_TABLE = 'config' as const;
|
||||||
const APP_CONFIG_TABLE = 'app-config' as const;
|
const APP_CONFIG_TABLE = 'app-config' as const;
|
||||||
const LAST_LOCATION_TABLE = 'last-locations' as const;
|
const LAST_LOCATION_TABLE = 'last-locations' as const;
|
||||||
|
const SUMMARIES_TABLE = 'summaries' as const;
|
||||||
|
|
||||||
export interface LastLocationRow {
|
export interface LastLocationRow {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
|
@ -30,6 +32,7 @@ type OpenReaderDB = Dexie & {
|
||||||
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
|
[CONFIG_TABLE]: EntityTable<ConfigRow, 'key'>;
|
||||||
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
|
[APP_CONFIG_TABLE]: EntityTable<AppConfigRow, 'id'>;
|
||||||
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
|
[LAST_LOCATION_TABLE]: EntityTable<LastLocationRow, 'docId'>;
|
||||||
|
[SUMMARIES_TABLE]: EntityTable<SummaryRow, 'id'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const db = new Dexie(DB_NAME) as OpenReaderDB;
|
export const db = new Dexie(DB_NAME) as OpenReaderDB;
|
||||||
|
|
@ -152,14 +155,16 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version 5: introduce app-config and last-locations tables, migrate scattered config keys,
|
// Version 6: add summaries table for AI-generated document summaries.
|
||||||
// and drop the legacy config table in a single upgrade step.
|
// 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({
|
db.version(DB_VERSION).stores({
|
||||||
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
|
[PDF_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
|
[EPUB_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId',
|
[HTML_TABLE]: 'id, type, name, lastModified, size, folderId',
|
||||||
[APP_CONFIG_TABLE]: 'id',
|
[APP_CONFIG_TABLE]: 'id',
|
||||||
[LAST_LOCATION_TABLE]: 'docId',
|
[LAST_LOCATION_TABLE]: 'docId',
|
||||||
|
[SUMMARIES_TABLE]: 'id, docId, [docId+pageNumber]',
|
||||||
// `null` here means: drop the old 'config' table after upgrade runs,
|
// `null` here means: drop the old 'config' table after upgrade runs,
|
||||||
// but Dexie still lets us read it inside the upgrade transaction.
|
// but Dexie still lets us read it inside the upgrade transaction.
|
||||||
[CONFIG_TABLE]: null,
|
[CONFIG_TABLE]: null,
|
||||||
|
|
@ -555,3 +560,55 @@ export async function loadDocumentsFromServer(
|
||||||
|
|
||||||
return { lastSync: Date.now() };
|
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;
|
epubWordHighlightEnabled: boolean;
|
||||||
firstVisit: boolean;
|
firstVisit: boolean;
|
||||||
documentListState: DocumentListState;
|
documentListState: DocumentListState;
|
||||||
|
// Summary/AI settings
|
||||||
|
summaryProvider: string;
|
||||||
|
summaryModel: string;
|
||||||
|
summaryApiKey: string;
|
||||||
|
summaryBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
|
|
@ -63,6 +68,11 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
showHint: true,
|
showHint: true,
|
||||||
viewMode: 'grid',
|
viewMode: 'grid',
|
||||||
},
|
},
|
||||||
|
// Summary/AI defaults
|
||||||
|
summaryProvider: 'groq',
|
||||||
|
summaryModel: 'llama-3.3-70b-versatile',
|
||||||
|
summaryApiKey: '',
|
||||||
|
summaryBaseUrl: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AppConfigRow extends AppConfigValues {
|
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