diff --git a/docs/configuration.md b/docs/configuration.md index b1be83e..9c0d8fb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,7 +34,7 @@ keys): | `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | Bedrock / Transcribe / Transcribe-Medical. | | `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_DEPLOYMENT_NAME`, `AZURE_OPENAI_API_VERSION` | Azure OpenAI. | | `GOOGLE_VERTEX_PROJECT`, `GOOGLE_VERTEX_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI + Gemini (STT/TTS). | -| `LITELLM_API_BASE`, `LITELLM_API_KEY` | Self-hosted LiteLLM proxy. | +| `LITELLM_API_BASE`, `LITELLM_API_KEY` | OpenAI-compatible AI gateway (Bifrost, LiteLLM, or similar). | ### Speech-to-text @@ -160,3 +160,45 @@ The Admin Panel (`/admin` route, admin-only) exposes everything above plus: provider. - Prompt editor: live-edit any `PROMPTS.*` key. - Test SMTP / test STT / test TTS. + +## Switching AI gateways + +The `LITELLM_API_BASE` and `LITELLM_API_KEY` variables work with any +OpenAI-compatible gateway — LiteLLM, Bifrost, or other proxies. + +### Migration steps + +1. **Set the base URL** — `LITELLM_API_BASE` should include `/v1` if the + gateway serves on that path (e.g., `https://gateway.example.com/v1`). + The application normalizes double `/v1` paths internally for TTS, STT, + and embedding endpoints. + +2. **Set the API key** — `LITELLM_API_KEY` accepts any key format the + gateway issues (virtual keys, bearer tokens, etc.). + +3. **Update model names** — Different gateways use different naming + conventions. Bifrost requires `provider/model` format + (e.g., `openrouter/vendor-model-sonnet-4.6`), while LiteLLM uses aliases + (e.g., `openrouter-vendor-model-sonnet-4.6`). Update model names in: + - Admin Panel → Models (chat models) + - Admin Panel → Settings → `stt.model` (speech-to-text) + - Admin Panel → Settings → `tts.model` (text-to-speech) + - `LITELLM_TTS_MODEL` env var (if set) + +4. **Embedding model** — Set via Admin Panel → Settings → + `embeddings.model`. The embedding vector column is `VECTOR(768)`, so + any model producing 768 dimensions works without re-embedding + (e.g., `vertex/text-embedding-005`). Switching to a model with + different dimensions requires altering the column and re-embedding all + content. + +5. **Restart the container** — `docker compose up -d --force-recreate` to + pick up `.env` changes (a plain `restart` does not re-read `.env`). + +### Verified gateways + +| Gateway | Model format | Notes | +|---|---|---| +| Bifrost | `provider/model` | Virtual keys, semantic caching, MCP gateway | +| LiteLLM | Custom aliases | Requires PostgreSQL + Redis | +| Any OpenAI-compatible | Varies | Must serve `/v1/chat/completions`, `/v1/audio/speech`, `/v1/audio/transcriptions`, `/v1/embeddings` | diff --git a/grafana-dashboard.json b/grafana-dashboard.json new file mode 100644 index 0000000..4d135c3 --- /dev/null +++ b/grafana-dashboard.json @@ -0,0 +1,58 @@ +{ + "dashboard": { + "title": "PedScribe Overview", + "tags": ["pedscribe"], + "timezone": "browser", + "refresh": "30s", + "time": {"from": "now-24h", "to": "now"}, + "panels": [ + { + "id": 1, "title": "Audit Events", "type": "timeseries", + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 0}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "count_over_time({app=\"pedscribe\"} [5m])", "legendFormat": "{{action}}"}], + "fieldConfig": {"defaults": {"custom": {"drawStyle": "bars", "fillOpacity": 30}}} + }, + { + "id": 2, "title": "By Category", "type": "piechart", + "gridPos": {"h": 8, "w": 8, "x": 0, "y": 8}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "sum by (category) (count_over_time({app=\"pedscribe\"} [24h]))"}] + }, + { + "id": 3, "title": "By Action", "type": "piechart", + "gridPos": {"h": 8, "w": 8, "x": 8, "y": 8}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "sum by (action) (count_over_time({app=\"pedscribe\"} [24h]))"}] + }, + { + "id": 4, "title": "Success vs Failure", "type": "piechart", + "gridPos": {"h": 8, "w": 8, "x": 16, "y": 8}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "sum by (status) (count_over_time({app=\"pedscribe\"} [24h]))"}] + }, + { + "id": 5, "title": "Auth Events", "type": "logs", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "{app=\"pedscribe\", category=\"auth\"}"}], + "options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true} + }, + { + "id": 6, "title": "Clinical Events", "type": "logs", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 16}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "{app=\"pedscribe\", category=\"clinical\"}"}], + "options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true} + }, + { + "id": 7, "title": "All Logs", "type": "logs", + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 24}, + "datasource": {"type": "loki"}, + "targets": [{"expr": "{app=\"pedscribe\"}"}], + "options": {"showTime": true, "sortOrder": "Descending", "enableLogDetails": true} + } + ] + }, + "overwrite": true +} diff --git a/package.json b/package.json index 98789e8..8933487 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "express-rate-limit": "^7.4.0", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.8.0", "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.7.0", "nodemailer": "^8.0.5", diff --git a/public/components/calculators.html b/public/components/calculators.html index 9f2df9c..8823e4b 100644 --- a/public/components/calculators.html +++ b/public/components/calculators.html @@ -15,6 +15,7 @@ + @@ -636,3 +637,39 @@ + + +
diff --git a/public/js/admin.js b/public/js/admin.js index e621bd0..7b60e69 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1088,9 +1088,12 @@ } container.innerHTML = 'Found ' + data.count + ' voices (provider: ' + esc(data.provider) + ')
' + items.slice(0, 100).map(function(v) { + var isModel = (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1; + var setType = isModel ? 'model' : 'voice'; + var badge = isModel ? 'MODEL' : 'VOICE'; return 'Enter GA and birth weight.
'; + return; + } + + var gaDecimal = weeks + days / 7; + var gaClass = classifyGA(weeks, days); + var bwClass = classifyBirthWeight(weight); + + // Calculate percentile using Fenton LMS + var lms = interpolateLMS(fentonLMS[sex], gaDecimal); + var z = calcZ(weight, lms.L, lms.M, lms.S); + var percentile = zToPercentile(z); + var wtClass = classifyWeight(percentile); + var expectedWeight = Math.round(lms.M); + + var html = ''; + + // GA Classification + html += 'Fenton TR, Kim JH. A systematic review and meta-analysis to revise the Fenton growth chart for preterm infants. BMC Pediatrics 2013;13:59. GA classification per ACOG/AAP definitions.
'; + + resultDiv.innerHTML = html; + } +})(); diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 3bf82b8..5796434 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -8,6 +8,7 @@ var db = require('../db/database'); var { authMiddleware, adminMiddleware } = require('../middleware/auth'); var PROMPTS = require('../utils/prompts'); var logger = require('../utils/logger'); +var { gatewayUrl } = require('../utils/errors'); router.use(authMiddleware); @@ -378,7 +379,7 @@ router.post('/config/models/test', async function(req, res) { var start = Date.now(); var result = await callAI( [{ role: 'user', content: 'Reply with only the word: OK' }], - { model: modelId, maxTokens: 20, temperature: 0 } + { model: modelId, maxTokens: 20, temperature: 0, skipAllowlistCheck: true } ); res.json({ success: true, @@ -473,25 +474,44 @@ router.get('/config/tts/discover', async function(req, res) { } if (provider === 'litellm' && process.env.LITELLM_API_BASE) { - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + // base URL handled by gatewayUrl() helper var lHeaders = {}; if (process.env.LITELLM_API_KEY) lHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var lResp = await axios.get(base + '/v1/models', { headers: lHeaders, timeout: 10000 }); + var lResp = await axios.get(gatewayUrl('/models'), { headers: lHeaders, timeout: 10000 }); if (lResp.data && lResp.data.data) { lResp.data.data.forEach(function(m) { var mid = (m.id || '').toLowerCase(); - if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1) { - discovered.push({ id: m.id, name: m.id, source: 'litellm-api' }); + if (mid.indexOf('tts') !== -1 || mid.indexOf('speech') !== -1 || mid.indexOf('voice') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('playai') !== -1) { + discovered.push({ id: m.id, name: m.id, source: 'gateway-api' }); } }); } } catch (e) { logger.warn('LiteLLM TTS model list failed: ' + e.message); } - ['alloy','echo','fable','onyx','nova','shimmer'].forEach(function(v) { - if (!discovered.find(function(d) { return d.id === v; })) { - discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'builtin' }); - } + + // OpenAI voices + ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral'].forEach(function(v) { + discovered.push({ id: v, name: v + ' (OpenAI voice)', source: 'openai-voices' }); }); + + // Vertex Gemini TTS voices + ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr'].forEach(function(v) { + discovered.push({ id: v, name: v + ' (Vertex voice)', source: 'vertex-voices' }); + }); + + // ElevenLabs voices — fetch from API if key available + if (process.env.ELEVENLABS_API_KEY) { + try { + var elResp = await axios.get('https://api.elevenlabs.io/v1/voices', { + headers: { 'xi-api-key': process.env.ELEVENLABS_API_KEY }, timeout: 10000 + }); + if (elResp.data && elResp.data.voices) { + elResp.data.voices.forEach(function(v) { + discovered.push({ id: v.voice_id, name: v.name + ' (ElevenLabs)', source: 'elevenlabs-api' }); + }); + } + } catch (e) { logger.warn('ElevenLabs voice fetch failed: ' + e.message); } + } } if (provider === 'elevenlabs' && process.env.ELEVENLABS_API_KEY) { @@ -540,13 +560,23 @@ router.post('/config/tts/test', async function(req, res) { buffer = await synthesizeWithGoogleTTS(text, usedVoice); } else if (provider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); - var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1'; - var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') ? rawModel : 'openai/' + rawModel; - usedVoice = voice || process.env.LITELLM_TTS_VOICE || 'alloy'; - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + var db = require('../db/database'); + var adminModel = await db.getSetting('tts.model') || ''; + var adminVoice = await db.getSetting('tts.voice') || ''; + if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } + var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; + var resolvedVoice = voice || adminVoice || ''; + if (!adminModel && resolvedVoice) { + if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; + else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; + } + var rawModel = adminModel || process.env.LITELLM_TTS_MODEL || 'tts-1'; + var ttsModel = rawModel.indexOf('/') !== -1 || rawModel.indexOf('-tts-') !== -1 || rawModel.startsWith('openai-') || rawModel.startsWith('elevenlabs-') || rawModel.startsWith('gemini-') || rawModel.startsWith('vertex-') ? rawModel : 'openai/' + rawModel; + usedVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || 'alloy'; + // base URL handled by gatewayUrl() helper var ttsHeaders = { 'Content-Type': 'application/json' }; if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var ttsResp = await axios.post(base + '/v1/audio/speech', + var ttsResp = await axios.post(gatewayUrl('/audio/speech'), { model: ttsModel, voice: usedVoice, input: text }, { headers: ttsHeaders, responseType: 'arraybuffer', timeout: 60000 } ); @@ -665,20 +695,20 @@ router.get('/config/stt/discover', async function(req, res) { } if (provider === 'litellm' && process.env.LITELLM_API_BASE) { - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + // base URL handled by gatewayUrl() helper var sHeaders = {}; if (process.env.LITELLM_API_KEY) sHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var sResp = await axios.get(base + '/v1/models', { headers: sHeaders, timeout: 10000 }); + var sResp = await axios.get(gatewayUrl('/models'), { headers: sHeaders, timeout: 10000 }); if (sResp.data && sResp.data.data) { sResp.data.data.forEach(function(m) { var mid = (m.id || '').toLowerCase(); - if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1) { - discovered.push({ id: m.id, name: m.id, source: 'litellm-api' }); + if (mid.indexOf('whisper') !== -1 || mid.indexOf('gemini') !== -1 || mid.indexOf('audio') !== -1 || mid.indexOf('transcri') !== -1 || mid.indexOf('scribe') !== -1 || mid.indexOf('nova') !== -1 || mid.indexOf('deepgram') !== -1 || mid.indexOf('eleven') !== -1 || mid.indexOf('chirp') !== -1) { + discovered.push({ id: m.id, name: m.id, source: 'gateway-api' }); } }); } - } catch(e) { logger.warn('LiteLLM STT discovery failed: ' + e.message); } + } catch(e) { logger.warn('STT discovery failed: ' + e.message); } } if (provider === 'local') { @@ -735,19 +765,38 @@ router.post('/config/stt/test', async function(req, res) { text = await transcribeWithLocal(audioBuffer, mime); } else if (provider === 'litellm') { if (!process.env.LITELLM_API_BASE) return res.json({ success: false, error: 'LITELLM_API_BASE not set' }); - var sttModel = process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; - var base = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); - var headers = { 'Content-Type': 'application/json' }; - if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; - var sttResp = await axios.post(base + '/v1/chat/completions', { - model: sttModel, - messages: [{ role: 'user', content: [ - { type: 'input_audio', input_audio: { data: audioBase64, format: mime.split('/')[1] || 'webm' } }, - { type: 'text', text: 'Transcribe this audio. Output the spoken words only.' } - ]}] - }, { headers: headers, timeout: 60000 }); - if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) { - text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || ''; + var db = require('../db/database'); + var adminSttModel = await db.getSetting('stt.model') || ''; + var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; + // base URL handled by gatewayUrl() helper + + var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); + + if (isTranscriptionModel) { + var ext = mime.split('/')[1] || 'webm'; + var file = new File([audioBuffer], 'audio.' + ext, { type: mime }); + var form = new FormData(); + form.append('file', file); + form.append('model', sttModel); + var fetchHeaders = {}; + if (process.env.LITELLM_API_KEY) fetchHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; + var sttResp = await fetch(gatewayUrl('/audio/transcriptions'), { + method: 'POST', headers: fetchHeaders, body: form + }).then(function(r) { return r.json().then(function(d) { return { data: d }; }); }); + text = sttResp.data && sttResp.data.text ? sttResp.data.text : ''; + } else { + var headers = { 'Content-Type': 'application/json' }; + if (process.env.LITELLM_API_KEY) headers['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; + var sttResp = await axios.post(gatewayUrl('/chat/completions'), { + model: sttModel, + messages: [{ role: 'user', content: [ + { type: 'input_audio', input_audio: { data: audioBase64, format: mime.split('/')[1] || 'webm' } }, + { type: 'text', text: 'Transcribe this audio. Output the spoken words only.' } + ]}] + }, { headers: headers, timeout: 60000 }); + if (sttResp.data && sttResp.data.choices && sttResp.data.choices[0]) { + text = (sttResp.data.choices[0].message && sttResp.data.choices[0].message.content) || ''; + } } } else if (provider === 'openai') { if (!whisperClient) return res.json({ success: false, error: 'Whisper not configured' }); @@ -821,16 +870,16 @@ router.get('/config/embeddings/discover', async function(req, res) { ]; if (provider === 'litellm') { - var eBase = process.env.LITELLM_API_BASE.replace(/\/+$/, ''); + // base URL handled by gatewayUrl() helper var eHeaders = {}; if (process.env.LITELLM_API_KEY) eHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY; try { - var eResp = await axios.get(eBase + '/v1/models', { headers: eHeaders, timeout: 10000 }); + var eResp = await axios.get(gatewayUrl('/models'), { headers: eHeaders, timeout: 10000 }); if (eResp.data && eResp.data.data) { eResp.data.data.forEach(function(m) { if ((m.id || '').toLowerCase().indexOf('embed') !== -1) { var dims = m.id.indexOf('3-large') !== -1 ? 3072 : m.id.indexOf('3-small') !== -1 ? 1536 : m.id.indexOf('005') !== -1 ? 768 : '?'; - discovered.push({ id: m.id, name: m.id, dims: dims, source: 'litellm-api' }); + discovered.push({ id: m.id, name: m.id, dims: dims, source: 'gateway-api' }); } }); } @@ -880,6 +929,8 @@ router.post('/config/embeddings/test', async function(req, res) { try { var text = (req.body.text || 'Pediatric patient with fever').substring(0, 500); var { generateEmbedding, DEFAULT_MODEL } = require('../utils/embeddings'); + var db = require('../db/database'); + var dbModel = await db.getSetting('embeddings.model') || ''; var start = Date.now(); var vector = await generateEmbedding(text); var dims = Array.isArray(vector) ? vector.length : 0; @@ -888,7 +939,7 @@ router.post('/config/embeddings/test', async function(req, res) { success: true, dimensions: dims, sample: sample, - model: process.env.EMBEDDING_MODEL || DEFAULT_MODEL, + model: dbModel || process.env.EMBEDDING_MODEL || DEFAULT_MODEL, duration: Date.now() - start }); } catch (e) { diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js index 6ddd698..2da2ef1 100644 --- a/src/routes/learningAI.js +++ b/src/routes/learningAI.js @@ -10,6 +10,7 @@ var path = require('path'); var { callAI } = require('../utils/ai'); var { authMiddleware, moderatorMiddleware } = require('../middleware/auth'); var db = require('../db/database'); +var cryptoUtil = require('../utils/crypto'); router.use(authMiddleware); router.use(moderatorMiddleware); @@ -28,12 +29,16 @@ var upload = multer({ 'text/markdown', 'text/html', 'text/csv', - 'application/json' + 'application/json', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text', + 'application/epub+zip' ]; - if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json)$/i)) { + if (allowed.includes(file.mimetype) || file.originalname.match(/\.(pdf|txt|md|html|htm|csv|json|docx|pptx|odt|epub)$/i)) { cb(null, true); } else { - cb(new Error('File type not allowed. Only PDF, TXT, MD, HTML, CSV, JSON are supported.')); + cb(new Error('File type not allowed. Supported: PDF, DOCX, PPTX, ODT, EPUB, TXT, MD, HTML, CSV, JSON.')); } } }); @@ -64,6 +69,65 @@ async function extractText(buffer, mimetype, filename) { return buffer.toString('utf8'); } + // DOCX + if (mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || ext === 'docx') { + try { + var mammoth = require('mammoth'); + var result = await mammoth.extractRawText({ buffer: buffer }); + return result.value || ''; + } catch (e) { + throw new Error('Could not parse DOCX: ' + e.message); + } + } + + // PPTX — extract text from slide XML inside the zip + if (mimetype === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || ext === 'pptx') { + try { + var JSZip = require('jszip'); + var zip = await JSZip.loadAsync(buffer); + var slides = Object.keys(zip.files).filter(function(f) { return /^ppt\/slides\/slide\d+\.xml$/.test(f); }).sort(); + var texts = []; + for (var i = 0; i < slides.length; i++) { + var xml = await zip.files[slides[i]].async('string'); + var slideText = xml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + if (slideText) texts.push('--- Slide ' + (i + 1) + ' ---\n' + slideText); + } + return texts.join('\n\n'); + } catch (e) { + throw new Error('Could not parse PPTX: ' + e.message); + } + } + + // ODT — extract text from content.xml inside the zip + if (mimetype === 'application/vnd.oasis.opendocument.text' || ext === 'odt') { + try { + var JSZip = require('jszip'); + var zip = await JSZip.loadAsync(buffer); + var contentXml = await zip.files['content.xml'].async('string'); + return contentXml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + } catch (e) { + throw new Error('Could not parse ODT: ' + e.message); + } + } + + // EPUB — extract text from XHTML chapters inside the zip + if (mimetype === 'application/epub+zip' || ext === 'epub') { + try { + var JSZip = require('jszip'); + var zip = await JSZip.loadAsync(buffer); + var chapters = Object.keys(zip.files).filter(function(f) { return /\.(xhtml|html|htm)$/i.test(f) && !zip.files[f].dir; }).sort(); + var texts = []; + for (var i = 0; i < chapters.length; i++) { + var html = await zip.files[chapters[i]].async('string'); + var chapterText = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + if (chapterText) texts.push(chapterText); + } + return texts.join('\n\n'); + } catch (e) { + throw new Error('Could not parse EPUB: ' + e.message); + } + } + // Fallback — try utf8 return buffer.toString('utf8'); } @@ -215,9 +279,12 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) if (!user || !user.nextcloud_url) { return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings first.' }); } + var ncPassword; + try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); } + catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); } var fileUrl = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + webdavPath; var response = await axios.get(fileUrl, { - auth: { username: user.nextcloud_user, password: user.nextcloud_token }, + auth: { username: user.nextcloud_user, password: ncPassword }, responseType: 'arraybuffer', timeout: 30000 }); @@ -372,6 +439,10 @@ router.get('/webdav-browse', async function(req, res) { return res.status(400).json({ error: 'Nextcloud not connected' }); } + var ncPassword; + try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); } + catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); } + var browsePath = req.query.path || user.webdav_learning_path || user.nextcloud_folder || '/'; // Ensure it starts with / if (!browsePath.startsWith('/')) browsePath = '/' + browsePath; @@ -381,7 +452,7 @@ router.get('/webdav-browse', async function(req, res) { var davResponse = await axios({ method: 'PROPFIND', url: davUrl, - auth: { username: user.nextcloud_user, password: user.nextcloud_token }, + auth: { username: user.nextcloud_user, password: ncPassword }, headers: { Depth: '1', 'Content-Type': 'application/xml' }, data: `