@intranefr/superbackend 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.env.example +6 -1
  2. package/README.md +5 -5
  3. package/index.js +23 -5
  4. package/package.json +5 -2
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/error-tracking/browser/package.json +4 -3
  7. package/sdk/error-tracking/browser/src/embed.js +29 -0
  8. package/sdk/ui-components/browser/src/index.js +228 -0
  9. package/src/controllers/admin.controller.js +139 -1
  10. package/src/controllers/adminHeadless.controller.js +82 -0
  11. package/src/controllers/adminMigration.controller.js +5 -1
  12. package/src/controllers/adminScripts.controller.js +229 -0
  13. package/src/controllers/adminTerminals.controller.js +39 -0
  14. package/src/controllers/adminUiComponents.controller.js +315 -0
  15. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  16. package/src/controllers/orgAdmin.controller.js +286 -0
  17. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  18. package/src/middleware/auth.js +7 -0
  19. package/src/middleware.js +119 -0
  20. package/src/models/HeadlessModelDefinition.js +10 -0
  21. package/src/models/ScriptDefinition.js +42 -0
  22. package/src/models/ScriptRun.js +22 -0
  23. package/src/models/UiComponent.js +29 -0
  24. package/src/models/UiComponentProject.js +26 -0
  25. package/src/models/UiComponentProjectComponent.js +18 -0
  26. package/src/routes/admin.routes.js +2 -0
  27. package/src/routes/adminHeadless.routes.js +6 -0
  28. package/src/routes/adminScripts.routes.js +21 -0
  29. package/src/routes/adminTerminals.routes.js +13 -0
  30. package/src/routes/adminUiComponents.routes.js +29 -0
  31. package/src/routes/llmUi.routes.js +26 -0
  32. package/src/routes/orgAdmin.routes.js +5 -0
  33. package/src/routes/uiComponentsPublic.routes.js +9 -0
  34. package/src/services/consoleOverride.service.js +291 -0
  35. package/src/services/email.service.js +17 -1
  36. package/src/services/headlessExternalModels.service.js +292 -0
  37. package/src/services/headlessModels.service.js +26 -6
  38. package/src/services/scriptsRunner.service.js +259 -0
  39. package/src/services/terminals.service.js +152 -0
  40. package/src/services/terminalsWs.service.js +100 -0
  41. package/src/services/uiComponentsAi.service.js +312 -0
  42. package/src/services/uiComponentsCrypto.service.js +39 -0
  43. package/src/services/webhook.service.js +2 -2
  44. package/src/services/workflow.service.js +1 -1
  45. package/src/utils/encryption.js +5 -3
  46. package/views/admin-coolify-deploy.ejs +1 -1
  47. package/views/admin-dashboard-home.ejs +1 -1
  48. package/views/admin-dashboard.ejs +1 -1
  49. package/views/admin-errors.ejs +2 -2
  50. package/views/admin-global-settings.ejs +3 -3
  51. package/views/admin-headless.ejs +294 -24
  52. package/views/admin-json-configs.ejs +8 -1
  53. package/views/admin-llm.ejs +2 -2
  54. package/views/admin-organizations.ejs +365 -9
  55. package/views/admin-scripts.ejs +497 -0
  56. package/views/admin-seo-config.ejs +1 -1
  57. package/views/admin-terminals.ejs +328 -0
  58. package/views/admin-test.ejs +3 -3
  59. package/views/admin-ui-components.ejs +709 -0
  60. package/views/admin-users.ejs +440 -4
  61. package/views/admin-webhooks.ejs +1 -1
  62. package/views/admin-workflows.ejs +1 -1
  63. package/views/partials/admin-assets-script.ejs +3 -3
  64. package/views/partials/dashboard/nav-items.ejs +3 -0
  65. package/views/partials/dashboard/palette.ejs +1 -1
@@ -44,7 +44,10 @@
44
44
  <div class="lg:col-span-1">
45
45
  <div class="flex items-center justify-between mb-3">
46
46
  <h2 class="text-lg font-semibold">Models</h2>
47
- <button id="btn-new-model" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 text-sm">New</button>
47
+ <div class="flex gap-2">
48
+ <button id="btn-import-external" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm">Import from Mongo</button>
49
+ <button id="btn-new-model" class="bg-green-500 text-white px-3 py-2 rounded hover:bg-green-600 text-sm">New</button>
50
+ </div>
48
51
  </div>
49
52
  <div id="models-list" class="space-y-2">
50
53
  <div class="text-gray-500 text-sm">Loading…</div>
@@ -58,6 +61,7 @@
58
61
  <div id="selected-model-title" class="text-xl font-semibold">None</div>
59
62
  </div>
60
63
  <div class="flex gap-2">
64
+ <button id="btn-sync-external" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" disabled>Sync/Infer</button>
61
65
  <button id="btn-save-model" class="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700 text-sm" disabled>Save schema</button>
62
66
  <button id="btn-delete-model" class="bg-red-500 text-white px-3 py-2 rounded hover:bg-red-600 text-sm" disabled>Disable</button>
63
67
  </div>
@@ -89,7 +93,7 @@
89
93
  <div>
90
94
  <label class="block text-sm font-medium mb-1">Code identifier</label>
91
95
  <input id="model-code" class="border rounded px-3 py-2 w-full mono" placeholder="e.g. products" disabled />
92
- <div class="text-xs text-gray-500 mt-1">Stored as Mongo collection <span class="mono">headless_&lt;code&gt;</span></div>
96
+ <div class="text-xs text-gray-500 mt-1" id="model-collection-hint">Stored as Mongo collection <span class="mono">headless_&lt;code&gt;</span></div>
93
97
  </div>
94
98
  </div>
95
99
 
@@ -435,6 +439,57 @@
435
439
  <div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50"></div>
436
440
  </div>
437
441
 
442
+ <div id="modal-import-external" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
443
+ <div class="bg-white rounded-lg shadow-lg w-full max-w-2xl mx-4">
444
+ <div class="px-6 py-4 border-b flex items-center justify-between">
445
+ <div>
446
+ <div class="text-lg font-semibold">Import from Mongo</div>
447
+ <div class="text-xs text-gray-500">Creates an external model with code identifier prefix <span class="mono">ext_</span>. Schema is inferred and read-only.</div>
448
+ </div>
449
+ <button id="btn-import-close" class="text-gray-500 hover:text-gray-800" type="button">Close</button>
450
+ </div>
451
+
452
+ <div class="px-6 py-4 space-y-4">
453
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
454
+ <div>
455
+ <label class="block text-sm font-medium mb-1">Mongo collection</label>
456
+ <select id="import-collection" class="border rounded px-3 py-2 w-full"></select>
457
+ <div class="text-xs text-gray-500 mt-1">Lists collections from the connected MongoDB.</div>
458
+ </div>
459
+ <div>
460
+ <label class="block text-sm font-medium mb-1">Sample size</label>
461
+ <input id="import-sampleSize" type="number" class="border rounded px-3 py-2 w-full" value="200" />
462
+ <div class="text-xs text-gray-500 mt-1">Max 1000.</div>
463
+ </div>
464
+ </div>
465
+
466
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
467
+ <div>
468
+ <label class="block text-sm font-medium mb-1">Model code identifier</label>
469
+ <input id="import-code" class="border rounded px-3 py-2 w-full mono" placeholder="ext_users" />
470
+ <div class="text-xs text-gray-500 mt-1">Must start with <span class="mono">ext_</span>.</div>
471
+ </div>
472
+ <div>
473
+ <label class="block text-sm font-medium mb-1">Display name</label>
474
+ <input id="import-displayName" class="border rounded px-3 py-2 w-full" placeholder="Users" />
475
+ </div>
476
+ </div>
477
+
478
+ <div class="flex items-center justify-between">
479
+ <div class="text-sm text-gray-600">Inference preview</div>
480
+ <div class="flex gap-2">
481
+ <button id="btn-import-infer" class="bg-white border px-3 py-2 rounded hover:bg-gray-100 text-sm" type="button">Infer preview</button>
482
+ <button id="btn-import-run" class="bg-blue-600 text-white px-3 py-2 rounded hover:bg-blue-700 text-sm" type="button">Import</button>
483
+ </div>
484
+ </div>
485
+
486
+ <div id="import-preview" class="hidden">
487
+ <pre id="import-preview-pre" class="mono text-xs bg-gray-50 border rounded p-3 overflow-x-auto overflow-y-auto max-h-64"></pre>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ </div>
492
+
438
493
  <script>
439
494
  const baseUrl = '<%= baseUrl %>';
440
495
  const adminPath = '<%= adminPath %>';
@@ -520,6 +575,115 @@
520
575
  }
521
576
  }
522
577
 
578
+ function setImportModalOpen(open) {
579
+ const modal = document.getElementById('modal-import-external');
580
+ if (!modal) return;
581
+ modal.classList.toggle('hidden', !open);
582
+ modal.classList.toggle('flex', open);
583
+ }
584
+
585
+ function setImportPreview(value) {
586
+ const container = document.getElementById('import-preview');
587
+ const pre = document.getElementById('import-preview-pre');
588
+ if (!container || !pre) return;
589
+ if (!value) {
590
+ container.classList.add('hidden');
591
+ pre.textContent = '';
592
+ return;
593
+ }
594
+ container.classList.remove('hidden');
595
+ pre.textContent = JSON.stringify(value, null, 2);
596
+ }
597
+
598
+ function normalizeExtCodeFromCollectionName(collectionName) {
599
+ const base = String(collectionName || '')
600
+ .trim()
601
+ .toLowerCase()
602
+ .replace(/[^a-z0-9_]+/g, '_')
603
+ .replace(/^_+|_+$/g, '');
604
+ const safe = base && /^[a-z][a-z0-9_]*$/.test(base) ? base : 'model';
605
+ return `ext_${safe}`;
606
+ }
607
+
608
+ async function loadExternalCollectionsIntoModal() {
609
+ const select = document.getElementById('import-collection');
610
+ if (!select) return;
611
+ select.innerHTML = '';
612
+ const { items } = await api('/api/admin/headless/external/collections');
613
+ const cols = Array.isArray(items) ? items : [];
614
+ cols.forEach((c) => {
615
+ const name = String(c && c.name ? c.name : '').trim();
616
+ if (!name) return;
617
+ const opt = document.createElement('option');
618
+ opt.value = name;
619
+ opt.textContent = name;
620
+ select.appendChild(opt);
621
+ });
622
+
623
+ const selected = select.value;
624
+ const codeInput = document.getElementById('import-code');
625
+ const dnInput = document.getElementById('import-displayName');
626
+ if (codeInput && selected) codeInput.value = normalizeExtCodeFromCollectionName(selected);
627
+ if (dnInput && selected) dnInput.value = String(selected).replace(/_/g, ' ');
628
+ }
629
+
630
+ async function importInferPreview() {
631
+ const collectionName = document.getElementById('import-collection')?.value;
632
+ const sampleSize = Number(document.getElementById('import-sampleSize')?.value || 200) || 200;
633
+ if (!collectionName) {
634
+ toast('Select a collection', 'error');
635
+ return;
636
+ }
637
+ const res = await api('/api/admin/headless/external/infer', {
638
+ method: 'POST',
639
+ body: JSON.stringify({ collectionName, sampleSize }),
640
+ });
641
+ setImportPreview(res);
642
+ }
643
+
644
+ async function importRun() {
645
+ const collectionName = document.getElementById('import-collection')?.value;
646
+ const sampleSize = Number(document.getElementById('import-sampleSize')?.value || 200) || 200;
647
+ const codeIdentifier = String(document.getElementById('import-code')?.value || '').trim();
648
+ const displayName = String(document.getElementById('import-displayName')?.value || '').trim();
649
+ if (!collectionName) {
650
+ toast('Select a collection', 'error');
651
+ return;
652
+ }
653
+ if (!codeIdentifier || !codeIdentifier.startsWith('ext_')) {
654
+ toast('codeIdentifier must start with ext_', 'error');
655
+ return;
656
+ }
657
+ const res = await api('/api/admin/headless/external/import', {
658
+ method: 'POST',
659
+ body: JSON.stringify({ collectionName, codeIdentifier, displayName, sampleSize }),
660
+ });
661
+ toast('Imported', 'success');
662
+ setImportModalOpen(false);
663
+ setImportPreview(null);
664
+ await loadModels();
665
+ if (res && res.item && res.item.codeIdentifier) {
666
+ await selectModel(res.item.codeIdentifier);
667
+ }
668
+ }
669
+
670
+ async function syncExternalSelectedModel() {
671
+ const m = state.selectedModel;
672
+ if (!m) return;
673
+ const isExternal = m && (m.sourceType === 'external' || m.isExternal === true);
674
+ if (!isExternal) return;
675
+ const sampleSize = Number(prompt('Sample size for inference (max 1000):', '200') || '200');
676
+ const res = await api(`/api/admin/headless/models/${encodeURIComponent(m.codeIdentifier)}/sync`, {
677
+ method: 'POST',
678
+ body: JSON.stringify({ sampleSize }),
679
+ });
680
+ toast('Synced', 'success');
681
+ await loadModels();
682
+ if (res && res.item && res.item.codeIdentifier) {
683
+ await selectModel(res.item.codeIdentifier);
684
+ }
685
+ }
686
+
523
687
  async function advancedSave() {
524
688
  try {
525
689
  const v = await advancedValidate();
@@ -758,8 +922,15 @@
758
922
  state.models.forEach((m) => {
759
923
  const btn = document.createElement('button');
760
924
  const active = state.selectedModel && state.selectedModel.codeIdentifier === m.codeIdentifier;
925
+ const isExternal = m && (m.sourceType === 'external' || m.isExternal === true);
761
926
  btn.className = `w-full text-left px-3 py-2 rounded border ${active ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200 hover:bg-gray-50'}`;
762
- btn.innerHTML = `<div class="font-medium">${m.displayName}</div><div class="text-xs text-gray-500 mono">${m.codeIdentifier}</div>`;
927
+ const label = isExternal
928
+ ? `<div class="flex items-center justify-between gap-2"><div class="font-medium">${m.displayName}</div><span class="text-[10px] uppercase bg-gray-200 text-gray-800 px-2 py-0.5 rounded">External</span></div>`
929
+ : `<div class="font-medium">${m.displayName}</div>`;
930
+ const sub = isExternal && m.sourceCollectionName
931
+ ? `<div class="text-xs text-gray-500 mono">${m.codeIdentifier} &middot; ${String(m.sourceCollectionName).replace(/</g,'&lt;')}</div>`
932
+ : `<div class="text-xs text-gray-500 mono">${m.codeIdentifier}</div>`;
933
+ btn.innerHTML = `${label}${sub}`;
763
934
  btn.addEventListener('click', () => selectModel(m.codeIdentifier));
764
935
  list.appendChild(btn);
765
936
  });
@@ -771,6 +942,7 @@
771
942
  document.getElementById('btn-add-field').disabled = !enabled;
772
943
  document.getElementById('btn-save-model').disabled = !enabled;
773
944
  document.getElementById('btn-delete-model').disabled = !enabled || isNew;
945
+ document.getElementById('btn-sync-external').disabled = true;
774
946
  document.getElementById('btn-mode-simple').disabled = !enabled;
775
947
  document.getElementById('btn-mode-advanced').disabled = !enabled;
776
948
  document.getElementById('btn-mode-ai').disabled = !enabled;
@@ -786,6 +958,34 @@
786
958
  // Data tab controls are managed separately
787
959
  }
788
960
 
961
+ function setModelFormEnabledForModel(model) {
962
+ if (!model) {
963
+ setModelFormEnabled(false);
964
+ return;
965
+ }
966
+
967
+ const isNew = Boolean(model.__isNew);
968
+ const isExternal = model && (model.sourceType === 'external' || model.isExternal === true);
969
+
970
+ setModelFormEnabled(true, isNew);
971
+
972
+ if (isExternal) {
973
+ document.getElementById('btn-save-model').disabled = true;
974
+ document.getElementById('btn-add-field').disabled = true;
975
+ document.getElementById('btn-mode-advanced').disabled = true;
976
+ document.getElementById('btn-mode-ai').disabled = true;
977
+ document.getElementById('btn-advanced-load').disabled = true;
978
+ document.getElementById('btn-advanced-validate').disabled = true;
979
+ document.getElementById('btn-advanced-save').disabled = true;
980
+ document.getElementById('ai-input').disabled = true;
981
+ document.getElementById('btn-ai-send').disabled = true;
982
+ document.getElementById('btn-ai-clear').disabled = true;
983
+ document.getElementById('btn-ai-apply').disabled = true;
984
+ document.getElementById('btn-sync-external').disabled = false;
985
+ if (state.modelUi.mode !== 'simple') setModelMode('simple');
986
+ }
987
+ }
988
+
789
989
  function setModelMode(mode) {
790
990
  state.modelUi.mode = mode;
791
991
  document.getElementById('panel-simple').classList.toggle('hidden', mode !== 'simple');
@@ -856,21 +1056,32 @@
856
1056
  const title = document.getElementById('selected-model-title');
857
1057
  const codeInput = document.getElementById('model-code');
858
1058
  const nameInput = document.getElementById('model-displayName');
1059
+ const hint = document.getElementById('model-collection-hint');
859
1060
 
860
1061
  const m = state.selectedModel;
861
1062
  if (!m) {
862
1063
  title.textContent = 'None';
863
1064
  codeInput.value = '';
864
1065
  nameInput.value = '';
865
- setModelFormEnabled(false);
1066
+ if (hint) hint.innerHTML = 'Stored as Mongo collection <span class="mono">headless_&lt;code&gt;</span>';
1067
+ setModelFormEnabledForModel(null);
866
1068
  renderFieldsTable();
867
1069
  return;
868
1070
  }
869
1071
 
1072
+ const isExternal = m && (m.sourceType === 'external' || m.isExternal === true);
870
1073
  title.textContent = m.displayName;
871
1074
  codeInput.value = m.codeIdentifier;
872
1075
  nameInput.value = m.displayName;
873
- setModelFormEnabled(true, !!m.__isNew);
1076
+ if (hint) {
1077
+ if (isExternal && m.sourceCollectionName) {
1078
+ hint.innerHTML = `External Mongo collection <span class="mono">${String(m.sourceCollectionName).replace(/</g,'&lt;')}</span>`;
1079
+ } else {
1080
+ hint.innerHTML = 'Stored as Mongo collection <span class="mono">headless_&lt;code&gt;</span>';
1081
+ }
1082
+ }
1083
+
1084
+ setModelFormEnabledForModel(m);
874
1085
  renderFieldsTable();
875
1086
  }
876
1087
 
@@ -891,6 +1102,8 @@
891
1102
 
892
1103
  tbody.innerHTML = '';
893
1104
 
1105
+ const isExternal = m && (m.sourceType === 'external' || m.isExternal === true);
1106
+
894
1107
  fields.forEach((f, idx) => {
895
1108
  const tr = document.createElement('tr');
896
1109
  tr.className = 'border-t';
@@ -899,32 +1112,36 @@
899
1112
  const opts = typeOptions.map((t) => `<option value="${t}" ${String(f.type).toLowerCase()===t?'selected':''}>${t}</option>`).join('');
900
1113
 
901
1114
  tr.innerHTML = `
902
- <td class="py-2 pr-3"><input class="field-name border rounded px-2 py-1 w-40 mono" value="${f.name || ''}" data-idx="${idx}" /></td>
903
- <td class="py-2 pr-3"><select class="field-type border rounded px-2 py-1" data-idx="${idx}">${opts}</select></td>
904
- <td class="py-2 pr-3"><input type="checkbox" class="field-required" data-idx="${idx}" ${f.required?'checked':''} /></td>
905
- <td class="py-2 pr-3"><input type="checkbox" class="field-unique" data-idx="${idx}" ${f.unique?'checked':''} /></td>
906
- <td class="py-2 pr-3"><input class="field-default border rounded px-2 py-1 w-40" value="${f.default===undefined?'':String(f.default)}" data-idx="${idx}" placeholder="(optional)" /></td>
907
- <td class="py-2 pr-3"><input class="field-ref border rounded px-2 py-1 w-40 mono" value="${f.refModelCode||''}" data-idx="${idx}" placeholder="ref model code" /></td>
908
- <td class="py-2 pr-3 text-right"><button class="btn-remove-field text-red-600 hover:underline" data-idx="${idx}">Remove</button></td>
1115
+ <td class="py-2 pr-3"><input class="field-name border rounded px-2 py-1 w-40 mono" value="${f.name || ''}" data-idx="${idx}" ${isExternal ? 'disabled' : ''} /></td>
1116
+ <td class="py-2 pr-3"><select class="field-type border rounded px-2 py-1" data-idx="${idx}" ${isExternal ? 'disabled' : ''}>${opts}</select></td>
1117
+ <td class="py-2 pr-3"><input type="checkbox" class="field-required" data-idx="${idx}" ${f.required?'checked':''} ${isExternal ? 'disabled' : ''} /></td>
1118
+ <td class="py-2 pr-3"><input type="checkbox" class="field-unique" data-idx="${idx}" ${f.unique?'checked':''} ${isExternal ? 'disabled' : ''} /></td>
1119
+ <td class="py-2 pr-3"><input class="field-default border rounded px-2 py-1 w-40" value="${f.default===undefined?'':String(f.default)}" data-idx="${idx}" placeholder="(optional)" ${isExternal ? 'disabled' : ''} /></td>
1120
+ <td class="py-2 pr-3"><input class="field-ref border rounded px-2 py-1 w-40 mono" value="${f.refModelCode||''}" data-idx="${idx}" placeholder="ref model code" ${isExternal ? 'disabled' : ''} /></td>
1121
+ <td class="py-2 pr-3 text-right">${isExternal ? '' : `<button class="btn-remove-field text-red-600 hover:underline" data-idx="${idx}">Remove</button>`}</td>
909
1122
  `;
910
1123
 
911
1124
  tbody.appendChild(tr);
912
1125
  });
913
1126
 
914
- tbody.querySelectorAll('input,select').forEach((el) => {
915
- el.addEventListener('change', () => syncFieldsFromUI());
916
- el.addEventListener('input', () => syncFieldsFromUI());
917
- });
1127
+ if (!isExternal) {
1128
+ tbody.querySelectorAll('input,select').forEach((el) => {
1129
+ el.addEventListener('change', () => syncFieldsFromUI());
1130
+ el.addEventListener('input', () => syncFieldsFromUI());
1131
+ });
1132
+ }
918
1133
 
919
- tbody.querySelectorAll('.btn-remove-field').forEach((btn) => {
920
- btn.addEventListener('click', () => {
921
- const idx = Number(btn.dataset.idx);
922
- const fields = Array.isArray(state.selectedModel.fields) ? state.selectedModel.fields : [];
923
- fields.splice(idx, 1);
924
- state.selectedModel.fields = fields;
925
- renderFieldsTable();
1134
+ if (!isExternal) {
1135
+ tbody.querySelectorAll('.btn-remove-field').forEach((btn) => {
1136
+ btn.addEventListener('click', () => {
1137
+ const idx = Number(btn.dataset.idx);
1138
+ const fields = Array.isArray(state.selectedModel.fields) ? state.selectedModel.fields : [];
1139
+ fields.splice(idx, 1);
1140
+ state.selectedModel.fields = fields;
1141
+ renderFieldsTable();
1142
+ });
926
1143
  });
927
- });
1144
+ }
928
1145
 
929
1146
  updateRefVisibility();
930
1147
  }
@@ -1045,6 +1262,12 @@
1045
1262
  try {
1046
1263
  const m = state.selectedModel;
1047
1264
  if (!m) return;
1265
+
1266
+ if (m && (m.sourceType === 'external' || m.isExternal === true)) {
1267
+ toast('External models are read-only. Use Sync/Infer to refresh.', 'error');
1268
+ return;
1269
+ }
1270
+
1048
1271
  syncFieldsFromUI();
1049
1272
 
1050
1273
  const payload = {
@@ -1580,6 +1803,53 @@
1580
1803
  await loadTokens();
1581
1804
  });
1582
1805
 
1806
+ document.getElementById('btn-import-external').addEventListener('click', async () => {
1807
+ try {
1808
+ setImportPreview(null);
1809
+ setImportModalOpen(true);
1810
+ await loadExternalCollectionsIntoModal();
1811
+ } catch (e) {
1812
+ toast(e.message, 'error');
1813
+ }
1814
+ });
1815
+
1816
+ document.getElementById('btn-import-close').addEventListener('click', () => {
1817
+ setImportModalOpen(false);
1818
+ });
1819
+
1820
+ document.getElementById('import-collection').addEventListener('change', (e) => {
1821
+ const name = String(e.target.value || '').trim();
1822
+ const codeInput = document.getElementById('import-code');
1823
+ const dnInput = document.getElementById('import-displayName');
1824
+ if (codeInput && name) codeInput.value = normalizeExtCodeFromCollectionName(name);
1825
+ if (dnInput && name) dnInput.value = String(name).replace(/_/g, ' ');
1826
+ });
1827
+
1828
+ document.getElementById('btn-import-infer').addEventListener('click', async () => {
1829
+ try {
1830
+ await importInferPreview();
1831
+ toast('Inferred', 'success');
1832
+ } catch (e) {
1833
+ toast(e.message, 'error');
1834
+ }
1835
+ });
1836
+
1837
+ document.getElementById('btn-import-run').addEventListener('click', async () => {
1838
+ try {
1839
+ await importRun();
1840
+ } catch (e) {
1841
+ toast(e.message, 'error');
1842
+ }
1843
+ });
1844
+
1845
+ document.getElementById('btn-sync-external').addEventListener('click', async () => {
1846
+ try {
1847
+ await syncExternalSelectedModel();
1848
+ } catch (e) {
1849
+ toast(e.message, 'error');
1850
+ }
1851
+ });
1852
+
1583
1853
  // Copy cURL buttons
1584
1854
  function setupCurlCopyButtons() {
1585
1855
  document.querySelectorAll('.btn-copy-curl').forEach(btn => {
@@ -229,7 +229,14 @@
229
229
  function updateSnippet() {
230
230
  const slug = $('slug').value || '<slug>';
231
231
  const snippet =
232
- `const saasbackend = require('saasbackend');\n\n// Works when your host app mounts saasbackend.middleware(...) and shares the same process/DB connection\nconst { getJsonConfig } = saasbackend.services.jsonConfigs;\n\nconst cfg = await getJsonConfig('${slug}', {\n bypassCache: false,\n});\n`;
232
+ `const superbackend = require('@intranefr/superbackend');
233
+
234
+ // Works when your host app mounts superbackend.middleware(...) and shares the same process/DB connection
235
+ const { getJsonConfig } = superbackend.services.jsonConfigs;
236
+
237
+ const cfg = await getJsonConfig('${slug}', {
238
+ bypassCache: false,
239
+ });\n`;
233
240
  $('snippet').textContent = snippet;
234
241
  }
235
242
 
@@ -369,9 +369,9 @@
369
369
  <div class="mt-4 bg-blue-50 border-l-4 border-blue-500 p-4 text-sm text-blue-900">
370
370
  <p class="font-semibold mb-1">Storage model</p>
371
371
  <p>Providers and prompts are stored in <code>GlobalSetting</code> rows as JSON under keys <code>llm.providers</code> and <code>llm.prompts</code>. Use the internal service:</p>
372
- <pre class="mt-2 bg-blue-100 rounded p-2 text-xs overflow-auto">const saasbackend = require('saasbackend');
372
+ <pre class="mt-2 bg-blue-100 rounded p-2 text-xs overflow-auto">const superbackend = require('@intranefr/superbackend');
373
373
 
374
- const result = await saasbackend.services.llm.call('tell_joke', {
374
+ const result = await superbackend.services.llm.call('tell_joke', {
375
375
  theme: 'universe',
376
376
  }, {
377
377
  temperature: 0.8,