Add AI content generation to Learning Hub CMS
Backend (src/routes/learningAI.js): - POST /api/admin/learning/ai-generate * Accepts: topic text, uploaded file (PDF/TXT/MD/HTML), or Nextcloud WebDAV path * Extracts text from PDFs via pdf-parse; plain text read directly * Fetches WebDAV files using stored Nextcloud credentials * Prompts AI to return structured JSON: title, subject, body (HTML), questions[] * Strips code fences, falls back to regex JSON extraction if needed - POST /api/admin/learning/ai-refine — refine existing body with instructions - GET /api/admin/learning/webdav-browse — PROPFIND-based Nextcloud file browser - POST /api/admin/learning/webdav-path — save user's default browse path Frontend: - AI Generate button in CMS editor header (gradient purple→blue) - Panel with 3 source tabs: By Topic / Upload File / Nextcloud - Drag-and-drop file dropzone with DataTransfer API support - WebDAV file browser: navigate folders, select files, breadcrumb path - Options: question count, content type (article/quiz/pearl), model selector, instructions - Refine Current Body: prompt-driven AI rewrite of existing content - Generated content auto-fills title, subject, type, Tiptap body, quiz questions Settings: - Nextcloud section: "Learning Hub Default Browse Path" field (shown when connected) - Saves to webdav_learning_path column via /api/admin/learning/webdav-path DB: ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT
This commit is contained in:
parent
9280257e2a
commit
936ad7e8a0
10 changed files with 954 additions and 2 deletions
217
package-lock.json
generated
217
package-lock.json
generated
|
|
@ -27,6 +27,7 @@
|
|||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"openai": "^4.73.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0"
|
||||
|
|
@ -769,6 +770,190 @@
|
|||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
|
|
@ -3315,6 +3500,38 @@
|
|||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"openai": "^4.73.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0"
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@
|
|||
<div class="cms-editor-header">
|
||||
<button id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-arrow-left"></i> Back to List</button>
|
||||
<div class="cms-editor-actions">
|
||||
<button id="btn-lh-ai-open" class="btn-sm" style="background:linear-gradient(135deg,#7c3aed,#2563eb);color:white;border:none;"><i class="fas fa-wand-magic-sparkles"></i> AI Generate</button>
|
||||
<button id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);"><i class="fas fa-trash"></i> Delete</button>
|
||||
<select id="lh-cms-edit-published" class="cms-input-sm">
|
||||
<option value="false">Draft</option>
|
||||
|
|
@ -112,7 +113,96 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body editor (Quill rich text) -->
|
||||
<!-- AI Generate panel -->
|
||||
<div id="lh-ai-panel" class="lh-ai-panel hidden">
|
||||
<div class="lh-ai-panel-header">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="font-size:18px;">✨</span>
|
||||
<strong style="font-size:14px;color:var(--g800);">AI Content Generator</strong>
|
||||
</div>
|
||||
<button id="btn-lh-ai-close" class="btn-sm btn-ghost" style="padding:2px 8px;">✕ Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Source tabs -->
|
||||
<div class="lh-ai-tabs">
|
||||
<button class="lh-ai-tab active" data-aitab="topic"><i class="fas fa-lightbulb"></i> By Topic</button>
|
||||
<button class="lh-ai-tab" data-aitab="upload"><i class="fas fa-file-upload"></i> Upload File</button>
|
||||
<button class="lh-ai-tab" data-aitab="webdav" id="lh-ai-tab-webdav"><i class="fas fa-cloud"></i> Nextcloud</button>
|
||||
</div>
|
||||
|
||||
<!-- Topic tab -->
|
||||
<div class="lh-ai-tabpanel" id="lh-ai-tp-topic">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic</label>
|
||||
<input type="text" id="lh-ai-topic" class="cms-input-sm" style="width:100%;" placeholder="e.g., Febrile seizures in children, ADHD management, Asthma exacerbation">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload tab -->
|
||||
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-upload">
|
||||
<label class="lh-ai-dropzone" id="lh-ai-dropzone">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:28px;color:var(--blue);margin-bottom:8px;display:block;"></i>
|
||||
<span id="lh-ai-file-label">Drop a file here or click to browse</span>
|
||||
<small style="color:var(--g400);display:block;margin-top:4px;">PDF, TXT, MD, HTML — max 20 MB</small>
|
||||
<input type="file" id="lh-ai-file" accept=".pdf,.txt,.md,.html,.htm,.csv,.json" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV tab -->
|
||||
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-webdav">
|
||||
<div id="lh-ai-webdav-browser">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span id="lh-ai-webdav-path-label" style="font-size:12px;color:var(--g500);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">/</span>
|
||||
<button id="btn-lh-webdav-refresh" class="btn-sm btn-ghost" style="padding:3px 8px;font-size:12px;"><i class="fas fa-sync"></i></button>
|
||||
</div>
|
||||
<div id="lh-ai-webdav-list" style="max-height:200px;overflow-y:auto;border:1px solid var(--g200);border-radius:8px;background:white;">
|
||||
<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Click to browse your Nextcloud files</div>
|
||||
</div>
|
||||
<div id="lh-ai-webdav-selected" class="hidden" style="margin-top:8px;padding:8px 12px;background:var(--blue-light);border-radius:6px;font-size:13px;color:var(--blue);display:flex;align-items:center;gap:8px;">
|
||||
<i class="fas fa-file-check"></i>
|
||||
<span id="lh-ai-webdav-selected-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
||||
<button id="btn-lh-webdav-deselect" style="background:none;border:none;cursor:pointer;color:var(--blue);padding:0;font-size:14px;">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generation options -->
|
||||
<div class="lh-ai-options">
|
||||
<div class="lh-ai-opt-row">
|
||||
<div class="lh-ai-opt-field">
|
||||
<label>Questions to generate</label>
|
||||
<input type="number" id="lh-ai-q-count" value="5" min="0" max="20" class="cms-input-sm" style="width:80px;">
|
||||
</div>
|
||||
<div class="lh-ai-opt-field">
|
||||
<label>Content type</label>
|
||||
<select id="lh-ai-ctype" class="cms-input-sm">
|
||||
<option value="article">Article</option>
|
||||
<option value="quiz">Quiz</option>
|
||||
<option value="pearl">Clinical Pearl</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lh-ai-opt-field" style="flex:2;">
|
||||
<label>Model</label>
|
||||
<select id="lh-ai-model" class="cms-input-sm tab-model-select" style="width:100%;"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lh-ai-opt-field" style="width:100%;">
|
||||
<label>Special instructions (optional)</label>
|
||||
<input type="text" id="lh-ai-refinement" class="cms-input-sm" style="width:100%;" placeholder="e.g., Focus on ER management, make it suitable for residents, use case-based format">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button id="btn-lh-ai-generate" class="btn-sm btn-primary" style="flex:1;padding:10px;">
|
||||
<i class="fas fa-wand-magic-sparkles"></i> Generate Content
|
||||
</button>
|
||||
<button id="btn-lh-ai-refine-body" class="btn-sm btn-ghost" style="padding:10px;">
|
||||
<i class="fas fa-pen-to-square"></i> Refine Current Body
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body editor (Tiptap rich text) -->
|
||||
<div class="cms-body-section">
|
||||
<label class="cms-label">Body</label>
|
||||
<div id="lh-body-editor" class="cms-quill-body"></div>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@
|
|||
</div>
|
||||
<button id="btn-nc-connect" class="btn-sm btn-primary">Connect</button>
|
||||
<button id="btn-nc-disconnect" class="btn-sm btn-ghost hidden">Disconnect</button>
|
||||
<div id="nc-webdav-path-section" class="hidden" style="margin-top:14px;padding-top:14px;border-top:1px solid var(--g100);">
|
||||
<label style="display:block;font-size:12px;font-weight:600;color:var(--g600);margin-bottom:4px;">Learning Hub — Default Browse Path</label>
|
||||
<small style="display:block;color:var(--g500);font-size:12px;margin-bottom:6px;">Folder opened first when picking files for AI content generation (e.g. <code>/Medical-Resources</code>)</small>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="nc-webdav-path" placeholder="/Medical-Resources" style="flex:1;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<button id="btn-nc-save-path" class="btn-sm btn-primary">Save Path</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Templates / Memories -->
|
||||
|
|
|
|||
|
|
@ -709,3 +709,26 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
/* Multi-select quiz options */
|
||||
.lh-quiz-option input[type="checkbox"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;}
|
||||
.lh-quiz-multi-hint{font-size:12px;color:var(--g500);margin-bottom:10px;font-style:italic;}
|
||||
|
||||
/* ── AI Generate panel ────────────────────────────────────── */
|
||||
.lh-ai-panel{border:2px solid transparent;background:linear-gradient(white,white) padding-box,linear-gradient(135deg,#7c3aed,#2563eb) border-box;border-radius:12px;padding:20px;margin-bottom:16px;}
|
||||
.lh-ai-panel-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;}
|
||||
.lh-ai-tabs{display:flex;gap:4px;margin-bottom:14px;border-bottom:1px solid var(--g200);padding-bottom:10px;}
|
||||
.lh-ai-tab{padding:6px 14px;border:1px solid var(--g200);border-radius:8px;background:white;cursor:pointer;font-size:13px;font-weight:500;color:var(--g600);display:flex;align-items:center;gap:6px;transition:all 0.15s;}
|
||||
.lh-ai-tab:hover{border-color:var(--blue);color:var(--blue);}
|
||||
.lh-ai-tab.active{background:var(--blue-light);border-color:var(--blue);color:var(--blue);font-weight:600;}
|
||||
.lh-ai-tabpanel{margin-bottom:14px;}
|
||||
.lh-ai-dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:24px;border:2px dashed var(--g300);border-radius:10px;cursor:pointer;text-align:center;font-size:13px;color:var(--g600);transition:border-color 0.2s;background:var(--g50);}
|
||||
.lh-ai-dropzone:hover{border-color:var(--blue);background:var(--blue-light);}
|
||||
.lh-ai-options{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;padding:14px;background:var(--g50);border-radius:8px;}
|
||||
.lh-ai-opt-row{display:flex;gap:10px;flex-wrap:wrap;}
|
||||
.lh-ai-opt-field{display:flex;flex-direction:column;gap:3px;}
|
||||
.lh-ai-opt-field label{font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;}
|
||||
|
||||
/* WebDAV browser */
|
||||
.lh-webdav-item{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:13px;cursor:pointer;border-bottom:1px solid var(--g100);transition:background 0.1s;}
|
||||
.lh-webdav-item:last-child{border-bottom:none;}
|
||||
.lh-webdav-item:hover{background:var(--g50);}
|
||||
.lh-webdav-dir{color:var(--g700);font-weight:500;}
|
||||
.lh-webdav-file{color:var(--g600);}
|
||||
.lh-webdav-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
}
|
||||
// Load CMS when Content Manager tab is opened
|
||||
if (e.detail && e.detail.tab === 'cms') {
|
||||
if (!cmsLoaded) { loadCms(); cmsLoaded = true; }
|
||||
if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); }
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -65,6 +65,29 @@
|
|||
if (e.target.closest('#btn-lh-delete-content')) { deleteContent(); return; }
|
||||
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
|
||||
|
||||
// AI panel
|
||||
if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; }
|
||||
if (e.target.closest('#btn-lh-ai-close')) { closeAiPanel(); return; }
|
||||
if (e.target.closest('#btn-lh-ai-generate')) { runAiGenerate(); return; }
|
||||
if (e.target.closest('#btn-lh-ai-refine-body')) { runAiRefineBody(); return; }
|
||||
if (e.target.closest('#btn-lh-webdav-refresh')) { browseWebdav(_aiWebdavCurrentPath); return; }
|
||||
if (e.target.closest('#btn-lh-webdav-deselect')) { deselectWebdavFile(); return; }
|
||||
|
||||
// AI tabs
|
||||
var aiTab = e.target.closest('.lh-ai-tab');
|
||||
if (aiTab && aiTab.dataset.aitab) { switchAiTab(aiTab.dataset.aitab); return; }
|
||||
|
||||
// WebDAV list items
|
||||
var wdItem = e.target.closest('.lh-webdav-item');
|
||||
if (wdItem) {
|
||||
if (wdItem.dataset.isdir === '1') {
|
||||
browseWebdav(wdItem.dataset.path);
|
||||
} else {
|
||||
selectWebdavFile(wdItem.dataset.path, wdItem.dataset.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Rich text toggle for question or option
|
||||
var rtBtn = e.target.closest('.lh-richtext-btn');
|
||||
if (rtBtn) { toggleRichText(rtBtn); return; }
|
||||
|
|
@ -421,12 +444,279 @@
|
|||
// ADMIN CMS
|
||||
// ============================================================
|
||||
|
||||
function wireCmsFileInput() {
|
||||
var fileInput = document.getElementById('lh-ai-file');
|
||||
var fileLabel = document.getElementById('lh-ai-file-label');
|
||||
var dropzone = document.getElementById('lh-ai-dropzone');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function() {
|
||||
if (fileInput.files[0] && fileLabel) fileLabel.textContent = fileInput.files[0].name;
|
||||
});
|
||||
}
|
||||
if (dropzone) {
|
||||
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
|
||||
dropzone.addEventListener('dragleave', function() { dropzone.style.borderColor = ''; });
|
||||
dropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dropzone.style.borderColor = '';
|
||||
var files = e.dataTransfer.files;
|
||||
if (files[0] && fileInput && fileLabel) {
|
||||
// Transfer to file input via DataTransfer
|
||||
try {
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(files[0]);
|
||||
fileInput.files = dt.files;
|
||||
} catch(ex) {}
|
||||
fileLabel.textContent = files[0].name;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadCms() {
|
||||
loadCmsCategories();
|
||||
loadCmsContent();
|
||||
loadCmsStats();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AI GENERATE PANEL
|
||||
// ============================================================
|
||||
|
||||
var _aiWebdavCurrentPath = '/';
|
||||
var _aiWebdavSelectedPath = '';
|
||||
var _aiWebdavSelectedName = '';
|
||||
|
||||
function openAiPanel() {
|
||||
var panel = document.getElementById('lh-ai-panel');
|
||||
if (!panel) return;
|
||||
panel.classList.remove('hidden');
|
||||
// Populate model selector
|
||||
populateAiModelSelect();
|
||||
// Hide WebDAV tab if Nextcloud not configured
|
||||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var tab = document.getElementById('lh-ai-tab-webdav');
|
||||
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
|
||||
if (d.user && d.user.webdav_learning_path) {
|
||||
_aiWebdavCurrentPath = d.user.webdav_learning_path || '/';
|
||||
}
|
||||
});
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function closeAiPanel() {
|
||||
var panel = document.getElementById('lh-ai-panel');
|
||||
if (panel) panel.classList.add('hidden');
|
||||
}
|
||||
|
||||
function populateAiModelSelect() {
|
||||
var sel = document.getElementById('lh-ai-model');
|
||||
if (!sel || sel.options.length > 1) return;
|
||||
// Copy options from global model select
|
||||
var globalSel = document.getElementById('global-model-select');
|
||||
if (!globalSel) return;
|
||||
sel.innerHTML = globalSel.innerHTML;
|
||||
sel.value = globalSel.value;
|
||||
}
|
||||
|
||||
function switchAiTab(tabName) {
|
||||
document.querySelectorAll('.lh-ai-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.aitab === tabName); });
|
||||
document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
|
||||
var tp = document.getElementById('lh-ai-tp-' + tabName);
|
||||
if (tp) tp.classList.remove('hidden');
|
||||
if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
|
||||
browseWebdav(_aiWebdavCurrentPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebDAV browser ──────────────────────────────────────────
|
||||
function browseWebdav(path) {
|
||||
_aiWebdavCurrentPath = path || '/';
|
||||
var list = document.getElementById('lh-ai-webdav-list');
|
||||
var pathLabel = document.getElementById('lh-ai-webdav-path-label');
|
||||
if (list) list.innerHTML = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
if (pathLabel) pathLabel.textContent = _aiWebdavCurrentPath;
|
||||
|
||||
fetch('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(data.error) + '</div>'; return; }
|
||||
if (pathLabel) pathLabel.textContent = data.path;
|
||||
renderWebdavList(data);
|
||||
})
|
||||
.catch(function(err) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(err.message) + '</div>'; });
|
||||
}
|
||||
|
||||
function renderWebdavList(data) {
|
||||
var list = document.getElementById('lh-ai-webdav-list');
|
||||
if (!list) return;
|
||||
|
||||
var html = '';
|
||||
|
||||
// Parent nav
|
||||
if (data.path !== '/' && data.path !== data.parentPath) {
|
||||
html += '<div class="lh-webdav-item lh-webdav-dir" data-path="' + esc(data.parentPath) + '" data-isdir="1">' +
|
||||
'<i class="fas fa-arrow-left" style="color:var(--g400);"></i> <span>..</span></div>';
|
||||
}
|
||||
|
||||
if (data.items.length === 0 && html === '') {
|
||||
html = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Empty folder</div>';
|
||||
}
|
||||
|
||||
data.items.forEach(function(item) {
|
||||
var icon = item.isDir ? 'fa-folder' : getFileIcon(item.contentType, item.name);
|
||||
var iconColor = item.isDir ? 'var(--amber)' : 'var(--blue)';
|
||||
var sizeStr = (!item.isDir && item.size) ? ' <span style="color:var(--g400);font-size:11px;">(' + formatBytes(item.size) + ')</span>' : '';
|
||||
html += '<div class="lh-webdav-item' + (item.isDir ? ' lh-webdav-dir' : ' lh-webdav-file') + '"' +
|
||||
' data-path="' + esc(item.path) + '" data-name="' + esc(item.name) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' +
|
||||
'<i class="fas ' + icon + '" style="color:' + iconColor + ';width:16px;flex-shrink:0;"></i>' +
|
||||
'<span class="lh-webdav-name">' + esc(item.name) + '</span>' + sizeStr +
|
||||
'</div>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
function getFileIcon(mime, name) {
|
||||
if (!name) name = '';
|
||||
var ext = name.split('.').pop().toLowerCase();
|
||||
if (mime && mime.includes('pdf') || ext === 'pdf') return 'fa-file-pdf';
|
||||
if (['doc','docx'].includes(ext)) return 'fa-file-word';
|
||||
if (['txt','md'].includes(ext)) return 'fa-file-lines';
|
||||
if (['html','htm'].includes(ext)) return 'fa-file-code';
|
||||
return 'fa-file';
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024*1024) return (bytes/1024).toFixed(0) + ' KB';
|
||||
return (bytes/1024/1024).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function selectWebdavFile(path, name) {
|
||||
_aiWebdavSelectedPath = path;
|
||||
_aiWebdavSelectedName = name;
|
||||
var selDiv = document.getElementById('lh-ai-webdav-selected');
|
||||
var selName = document.getElementById('lh-ai-webdav-selected-name');
|
||||
if (selDiv) selDiv.classList.remove('hidden');
|
||||
if (selName) selName.textContent = name;
|
||||
}
|
||||
|
||||
function deselectWebdavFile() {
|
||||
_aiWebdavSelectedPath = '';
|
||||
_aiWebdavSelectedName = '';
|
||||
var selDiv = document.getElementById('lh-ai-webdav-selected');
|
||||
if (selDiv) selDiv.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Generate ────────────────────────────────────────────────
|
||||
function runAiGenerate() {
|
||||
var activeTab = document.querySelector('.lh-ai-tab.active');
|
||||
var tabName = activeTab ? activeTab.dataset.aitab : 'topic';
|
||||
var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
|
||||
var questionCount = document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5;
|
||||
var contentType = document.getElementById('lh-ai-ctype') ? document.getElementById('lh-ai-ctype').value : 'article';
|
||||
var refinement = document.getElementById('lh-ai-refinement') ? document.getElementById('lh-ai-refinement').value.trim() : '';
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('model', model);
|
||||
formData.append('questionCount', questionCount);
|
||||
formData.append('contentType', contentType);
|
||||
if (refinement) formData.append('refinement', refinement);
|
||||
|
||||
if (tabName === 'topic') {
|
||||
var topic = document.getElementById('lh-ai-topic') ? document.getElementById('lh-ai-topic').value.trim() : '';
|
||||
if (!topic) { showToast('Enter a topic', 'error'); return; }
|
||||
formData.append('topic', topic);
|
||||
} else if (tabName === 'upload') {
|
||||
var fileInput = document.getElementById('lh-ai-file');
|
||||
if (!fileInput || !fileInput.files[0]) { showToast('Select a file to upload', 'error'); return; }
|
||||
formData.append('file', fileInput.files[0]);
|
||||
} else if (tabName === 'webdav') {
|
||||
if (!_aiWebdavSelectedPath) { showToast('Select a file from Nextcloud', 'error'); return; }
|
||||
formData.append('webdavPath', _aiWebdavSelectedPath);
|
||||
}
|
||||
|
||||
var btn = document.getElementById('btn-lh-ai-generate');
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
|
||||
showLoading('AI is generating content…');
|
||||
|
||||
// For FormData, do NOT set Content-Type (browser sets it with boundary)
|
||||
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
||||
fetch('/api/admin/learning/ai-generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
body: formData
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
|
||||
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
||||
applyAiContent(data.content, contentType);
|
||||
closeAiPanel();
|
||||
showToast('Content generated! Review and save.', 'success');
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideLoading();
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
|
||||
showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function applyAiContent(content, contentType) {
|
||||
// Fill title
|
||||
var titleEl = document.getElementById('lh-cms-edit-title');
|
||||
if (titleEl && content.title) titleEl.value = content.title;
|
||||
|
||||
// Fill subject
|
||||
var subjectEl = document.getElementById('lh-cms-edit-subject');
|
||||
if (subjectEl && content.subject) subjectEl.value = content.subject;
|
||||
|
||||
// Set content type
|
||||
var typeEl = document.getElementById('lh-cms-edit-type');
|
||||
if (typeEl && contentType) typeEl.value = contentType;
|
||||
|
||||
// Fill body via Tiptap
|
||||
if (_bodyEditor && content.body) _bodyEditor.commands.setContent(content.body);
|
||||
|
||||
// Replace questions
|
||||
var qContainer = document.getElementById('lh-cms-questions');
|
||||
if (qContainer) {
|
||||
qContainer.innerHTML = '';
|
||||
if (content.questions && Array.isArray(content.questions)) {
|
||||
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runAiRefineBody() {
|
||||
var instructions = prompt('How should the body be refined?\n(e.g., "Make it more concise", "Add a clinical case", "Focus on management")');
|
||||
if (!instructions || !instructions.trim()) return;
|
||||
|
||||
var currentBody = _bodyEditor ? getTpHTML(_bodyEditor) : '';
|
||||
if (!currentBody) { showToast('No body content to refine', 'error'); return; }
|
||||
|
||||
var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
|
||||
|
||||
showLoading('Refining content…');
|
||||
|
||||
fetch('/api/admin/learning/ai-refine', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ content: currentBody, instructions: instructions.trim(), model: model })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
|
||||
if (_bodyEditor) _bodyEditor.commands.setContent(data.refined);
|
||||
showToast('Body refined!', 'success');
|
||||
})
|
||||
.catch(function(err) { hideLoading(); showToast(err.message, 'error'); });
|
||||
}
|
||||
|
||||
function loadCmsStats() {
|
||||
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@
|
|||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var pathSection = document.getElementById('nc-webdav-path-section');
|
||||
if (data.user && data.user.nextcloud_url) {
|
||||
document.getElementById('nc-status').innerHTML = '✅ Connected to <strong>' + data.user.nextcloud_url + '</strong> as ' + data.user.nextcloud_user;
|
||||
document.getElementById('nc-url').value = data.user.nextcloud_url;
|
||||
document.getElementById('nc-user').value = data.user.nextcloud_user;
|
||||
document.getElementById('btn-nc-disconnect').classList.remove('hidden');
|
||||
if (pathSection) {
|
||||
pathSection.classList.remove('hidden');
|
||||
var pathEl = document.getElementById('nc-webdav-path');
|
||||
if (pathEl) pathEl.value = data.user.webdav_learning_path || '';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('nc-status').textContent = 'Not connected';
|
||||
document.getElementById('btn-nc-disconnect').classList.add('hidden');
|
||||
if (pathSection) pathSection.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -54,6 +61,15 @@
|
|||
.then(function() { showToast('Disconnected', 'info'); loadNextcloudStatus(); });
|
||||
});
|
||||
|
||||
document.getElementById('btn-nc-save-path').addEventListener('click', function() {
|
||||
var p = (document.getElementById('nc-webdav-path').value || '').trim();
|
||||
fetch('/api/admin/learning/webdav-path', {
|
||||
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ path: p })
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(d) { d.success ? showToast('Path saved', 'success') : showToast(d.error || 'Failed', 'error'); })
|
||||
.catch(function() { showToast('Failed to save', 'error'); });
|
||||
});
|
||||
|
||||
console.log('✅ Nextcloud module loaded');
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ app.use('/api', require('./src/routes/encounters'));
|
|||
app.use('/api', require('./src/routes/memories'));
|
||||
app.use('/api', require('./src/routes/wellVisit'));
|
||||
app.use('/api', require('./src/routes/sickVisit'));
|
||||
app.use('/api/admin/learning', require('./src/routes/learningAI'));
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@ async function initDatabase() {
|
|||
|
||||
// Add updated_by column to app_settings if upgrading
|
||||
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
|
||||
// Add webdav_learning_path to users for Learning Hub file browser
|
||||
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
|
||||
|
||||
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
|
||||
var defaults = [
|
||||
|
|
|
|||
304
src/routes/learningAI.js
Normal file
304
src/routes/learningAI.js
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
// ============================================================
|
||||
// LEARNING AI ROUTES — AI-assisted content generation for Learning Hub
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var multer = require('multer');
|
||||
var axios = require('axios');
|
||||
var path = require('path');
|
||||
var { callAI } = require('../utils/ai');
|
||||
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
|
||||
var db = require('../db/database');
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(moderatorMiddleware);
|
||||
|
||||
var upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 20 * 1024 * 1024 } // 20 MB
|
||||
});
|
||||
|
||||
// ── Text extraction helpers ──────────────────────────────────
|
||||
|
||||
async function extractText(buffer, mimetype, filename) {
|
||||
var ext = (filename || '').split('.').pop().toLowerCase();
|
||||
|
||||
// PDF
|
||||
if (mimetype === 'application/pdf' || ext === 'pdf') {
|
||||
try {
|
||||
var pdfParse = require('pdf-parse');
|
||||
var data = await pdfParse(buffer);
|
||||
return data.text || '';
|
||||
} catch (e) {
|
||||
throw new Error('Could not parse PDF: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Plain text / markdown / HTML
|
||||
if (mimetype.startsWith('text/') || ['txt', 'md', 'html', 'htm', 'csv'].includes(ext)) {
|
||||
return buffer.toString('utf8');
|
||||
}
|
||||
|
||||
// JSON
|
||||
if (mimetype === 'application/json' || ext === 'json') {
|
||||
return buffer.toString('utf8');
|
||||
}
|
||||
|
||||
// Fallback — try utf8
|
||||
return buffer.toString('utf8');
|
||||
}
|
||||
|
||||
// ── Build AI prompt ──────────────────────────────────────────
|
||||
|
||||
function buildGeneratePrompt(opts) {
|
||||
var { topic, docText, contentType, questionCount, refinement } = opts;
|
||||
|
||||
var source = docText
|
||||
? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 12000) + '\n"""\n'
|
||||
: 'Generate educational content on the following topic for a medical professional audience (pediatrics / primary care).\n\nTOPIC: ' + topic + '\n';
|
||||
|
||||
var typeInstr = '';
|
||||
if (contentType === 'quiz') {
|
||||
typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs), then generate ' + questionCount + ' quiz questions.';
|
||||
} else if (contentType === 'pearl') {
|
||||
typeInstr = 'This is a clinical pearl. Write a concise, high-impact body (2-4 paragraphs focusing on key takeaways), then generate ' + questionCount + ' quiz question(s).';
|
||||
} else {
|
||||
typeInstr = 'This is an article. Write a comprehensive body (4-8 paragraphs), then generate ' + questionCount + ' quiz questions.';
|
||||
}
|
||||
|
||||
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
|
||||
|
||||
return source + '\n' + typeInstr + refineInstr + `
|
||||
|
||||
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
||||
{
|
||||
"title": "string",
|
||||
"subject": "string (1-3 word sub-topic label)",
|
||||
"body": "string (valid HTML using only: p, h2, h3, ul, ol, li, strong, em, blockquote, code — no inline styles)",
|
||||
"questions": [
|
||||
{
|
||||
"question_text": "string",
|
||||
"question_type": "mcq",
|
||||
"explanation": "string (general explanation shown after answering)",
|
||||
"options": [
|
||||
{ "option_text": "string", "is_correct": false, "explanation": "string (shown if this wrong option chosen)" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true
|
||||
- question_type must be "mcq" or "true_false" (true_false has exactly 2 options: "True" and "False")
|
||||
- body must be clean HTML, no raw markdown
|
||||
- Do not include any text outside the JSON object`;
|
||||
}
|
||||
|
||||
// ── POST /api/admin/learning/ai-generate ────────────────────
|
||||
// Accepts: multipart/form-data OR application/json
|
||||
|
||||
router.post('/ai-generate', upload.single('file'), async function(req, res) {
|
||||
try {
|
||||
var topic = req.body.topic || '';
|
||||
var contentType = req.body.contentType || 'article';
|
||||
var questionCount = Math.min(parseInt(req.body.questionCount) || 5, 20);
|
||||
var model = req.body.model || null;
|
||||
var refinement = req.body.refinement || '';
|
||||
var webdavPath = req.body.webdavPath || '';
|
||||
|
||||
var docText = '';
|
||||
|
||||
// 1 — Uploaded file
|
||||
if (req.file) {
|
||||
docText = await extractText(req.file.buffer, req.file.mimetype, req.file.originalname);
|
||||
}
|
||||
|
||||
// 2 — Nextcloud WebDAV path
|
||||
else if (webdavPath) {
|
||||
var user = await db.get(
|
||||
'SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?',
|
||||
[req.user.id]
|
||||
);
|
||||
if (!user || !user.nextcloud_url) {
|
||||
return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings first.' });
|
||||
}
|
||||
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 },
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000
|
||||
});
|
||||
var mimeType = response.headers['content-type'] || 'text/plain';
|
||||
var fname = path.basename(webdavPath);
|
||||
docText = await extractText(Buffer.from(response.data), mimeType, fname);
|
||||
}
|
||||
|
||||
// 3 — Topic only (no file)
|
||||
else if (!topic.trim()) {
|
||||
return res.status(400).json({ error: 'Provide a topic or upload a file.' });
|
||||
}
|
||||
|
||||
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement });
|
||||
|
||||
var result = await callAI(
|
||||
[{ role: 'user', content: prompt }],
|
||||
{ model: model, temperature: 0.4, maxTokens: 6000 }
|
||||
);
|
||||
|
||||
// Parse JSON from AI response
|
||||
var raw = result.content.trim();
|
||||
// Strip code fences if model added them anyway
|
||||
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
|
||||
|
||||
var parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
// Try to extract JSON object from surrounding text
|
||||
var match = raw.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try { parsed = JSON.parse(match[0]); }
|
||||
catch (e2) { return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' }); }
|
||||
} else {
|
||||
return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
content: parsed,
|
||||
model: result.model,
|
||||
docLength: docText.length
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('[LearningAI]', err.message);
|
||||
res.status(500).json({ error: err.message || 'Generation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/admin/learning/ai-refine ───────────────────────
|
||||
// Refine an existing body or specific question
|
||||
|
||||
router.post('/ai-refine', async function(req, res) {
|
||||
try {
|
||||
var { content, instructions, model } = req.body;
|
||||
if (!content || !instructions) return res.status(400).json({ error: 'content and instructions required' });
|
||||
|
||||
var prompt = `You are editing educational medical content. Refine the following HTML body according to the instructions below.
|
||||
|
||||
INSTRUCTIONS: ${instructions}
|
||||
|
||||
CURRENT CONTENT:
|
||||
${content.substring(0, 8000)}
|
||||
|
||||
Return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown fences). Keep all HTML tags intact.`;
|
||||
|
||||
var result = await callAI(
|
||||
[{ role: 'user', content: prompt }],
|
||||
{ model: model, temperature: 0.3, maxTokens: 4000 }
|
||||
);
|
||||
|
||||
var refined = result.content.trim().replace(/^```(?:html)?\s*/i, '').replace(/\s*```\s*$/, '');
|
||||
res.json({ success: true, refined, model: result.model });
|
||||
} catch (err) {
|
||||
console.error('[LearningAI]', err.message);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/admin/learning/webdav-browse ────────────────────
|
||||
// Browse user's Nextcloud folder
|
||||
|
||||
router.get('/webdav-browse', async function(req, res) {
|
||||
try {
|
||||
var user = await db.get(
|
||||
'SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder, webdav_learning_path FROM users WHERE id = ?',
|
||||
[req.user.id]
|
||||
);
|
||||
if (!user || !user.nextcloud_url) {
|
||||
return res.status(400).json({ error: 'Nextcloud not connected' });
|
||||
}
|
||||
|
||||
var browsePath = req.query.path || user.webdav_learning_path || user.nextcloud_folder || '/';
|
||||
// Ensure it starts with /
|
||||
if (!browsePath.startsWith('/')) browsePath = '/' + browsePath;
|
||||
|
||||
var davUrl = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + browsePath;
|
||||
|
||||
var davResponse = await axios({
|
||||
method: 'PROPFIND',
|
||||
url: davUrl,
|
||||
auth: { username: user.nextcloud_user, password: user.nextcloud_token },
|
||||
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||
data: `<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop></d:propfind>`,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Parse WebDAV XML response
|
||||
var xml = davResponse.data;
|
||||
var items = [];
|
||||
var responseRegex = /<d:response>([\s\S]*?)<\/d:response>/g;
|
||||
var match;
|
||||
while ((match = responseRegex.exec(xml)) !== null) {
|
||||
var block = match[1];
|
||||
var hrefMatch = block.match(/<d:href>([^<]+)<\/d:href>/);
|
||||
var nameMatch = block.match(/<d:displayname>([^<]*)<\/d:displayname>/);
|
||||
var typeMatch = block.match(/<d:getcontenttype>([^<]*)<\/d:getcontenttype>/);
|
||||
var sizeMatch = block.match(/<d:getcontentlength>([^<]*)<\/d:getcontentlength>/);
|
||||
var isCollMatch = block.includes('<d:collection');
|
||||
|
||||
if (!hrefMatch) continue;
|
||||
|
||||
var href = decodeURIComponent(hrefMatch[1]);
|
||||
// Strip the /remote.php/dav/files/username prefix to get the relative path
|
||||
var relPath = href.replace(/^.*\/remote\.php\/dav\/files\/[^/]+/, '') || '/';
|
||||
|
||||
items.push({
|
||||
path: relPath,
|
||||
name: nameMatch ? nameMatch[1] : path.basename(relPath) || relPath,
|
||||
isDir: !!isCollMatch,
|
||||
contentType: typeMatch ? typeMatch[1] : '',
|
||||
size: sizeMatch ? parseInt(sizeMatch[1]) : 0
|
||||
});
|
||||
}
|
||||
|
||||
// Filter: skip the parent directory entry (same path as requested)
|
||||
var normBrowse = browsePath.replace(/\/$/, '');
|
||||
items = items.filter(function(item) {
|
||||
var normPath = item.path.replace(/\/$/, '');
|
||||
return normPath !== normBrowse;
|
||||
});
|
||||
|
||||
// Sort: directories first, then files
|
||||
items.sort(function(a, b) {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Compute parent path
|
||||
var parent = browsePath.replace(/\/$/, '');
|
||||
var parentPath = parent.includes('/') ? parent.substring(0, parent.lastIndexOf('/')) || '/' : '/';
|
||||
|
||||
res.json({ success: true, path: browsePath, parentPath, items });
|
||||
} catch (err) {
|
||||
console.error('[WebDAV]', err.message);
|
||||
res.status(500).json({ error: 'WebDAV browse failed: ' + err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/admin/learning/webdav-path ─────────────────────
|
||||
// Save user's preferred WebDAV learning path
|
||||
|
||||
router.post('/webdav-path', async function(req, res) {
|
||||
try {
|
||||
var { path: wPath } = req.body;
|
||||
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [wPath || null, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Reference in a new issue