Improve extension type indication

c33649753b7ab730712fac522b432c0749fbb728

Cohee <18619528+Cohee1207@users.noreply.github.com>

3 files changed, +146 -76Ignore whitespace
public/css/extensions-panel.css+1 -1
@@ -90,7 +90,7 @@ label[for="extensions_autoconnect"] {
90 border-radius: 10px;90 border-radius: 10px;
91 align-items: center;91 align-items: center;
92 justify-content: space-between;92 justify-content: space-between;
93 gap: 10px;93 gap: 5px;
94}94}
9595
96.extensions_info .extension_name {96.extensions_info .extension_name {
public/scripts/extensions.js+135 -67
@@ -21,7 +21,11 @@ export {
2121
22/** @type {string[]} */22/** @type {string[]} */
23export let extensionNames = [];23export let extensionNames = [];
24/** @type {Record<string, string>} */24/**
25 * Holds the type of each extension.
26 * Don't use this directly, use getExtensionType instead!
27 * @type {Record<string, string>}
28 */
25export let extensionTypes = {};29export let extensionTypes = {};
2630
27let manifests = {};31let manifests = {};
@@ -198,6 +202,16 @@ function showHideExtensionsMenu() {
198// Periodically check for new extensions202// Periodically check for new extensions
199const menuInterval = setInterval(showHideExtensionsMenu, 1000);203const menuInterval = setInterval(showHideExtensionsMenu, 1000);
200204
205/**
206 * Gets the type of an extension based on its external ID.
207 * @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/')
208 * @returns {string} Type of the extension (global, local, system, or empty string if not found)
209 */
210function getExtensionType(externalId) {
211 const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId)));
212 return id ? extensionTypes[id] : '';
213}
214
201async function doExtrasFetch(endpoint, args) {215async function doExtrasFetch(endpoint, args) {
202 if (!args) {216 if (!args) {
203 args = {};217 args = {};
@@ -457,63 +471,72 @@ function updateStatus(success) {
457 $('#extensions_status').attr('class', _class);471 $('#extensions_status').attr('class', _class);
458}472}
459473
474/**
475 * Adds a CSS file for an extension.
476 * @param {string} name Extension name
477 * @param {object} manifest Extension manifest
478 * @returns {Promise<void>} When the CSS is loaded
479 */
460function addExtensionStyle(name, manifest) {480function addExtensionStyle(name, manifest) {
461 if (manifest.css) {481 if (!manifest.css) {
462 return new Promise((resolve, reject) => {482 return Promise.resolve();
463 const url = `/scripts/extensions/${name}/${manifest.css}`;
464
465 if ($(`link[id="${name}"]`).length === 0) {
466 const link = document.createElement('link');
467 link.id = name;
468 link.rel = 'stylesheet';
469 link.type = 'text/css';
470 link.href = url;
471 link.onload = function () {
472 resolve();
473 };
474 link.onerror = function (e) {
475 reject(e);
476 };
477 document.head.appendChild(link);
478 }
479 });
480 }483 }
481484
482 return Promise.resolve();485 return new Promise((resolve, reject) => {
486 const url = `/scripts/extensions/${name}/${manifest.css}`;
487
488 if ($(`link[id="${name}"]`).length === 0) {
489 const link = document.createElement('link');
490 link.id = name;
491 link.rel = 'stylesheet';
492 link.type = 'text/css';
493 link.href = url;
494 link.onload = function () {
495 resolve();
496 };
497 link.onerror = function (e) {
498 reject(e);
499 };
500 document.head.appendChild(link);
501 }
502 });
483}503}
484504
505/**
506 * Loads a JS file for an extension.
507 * @param {string} name Extension name
508 * @param {object} manifest Extension manifest
509 * @returns {Promise<void>} When the script is loaded
510 */
485function addExtensionScript(name, manifest) {511function addExtensionScript(name, manifest) {
486 if (manifest.js) {512 if (!manifest.js) {
487 return new Promise((resolve, reject) => {513 return Promise.resolve();
488 const url = `/scripts/extensions/${name}/${manifest.js}`;
489 let ready = false;
490
491 if ($(`script[id="${name}"]`).length === 0) {
492 const script = document.createElement('script');
493 script.id = name;
494 script.type = 'module';
495 script.src = url;
496 script.async = true;
497 script.onerror = function (err) {
498 reject(err, script);
499 };
500 script.onload = script.onreadystatechange = function () {
501 // console.log(this.readyState); // uncomment this line to see which ready states are called.
502 if (!ready && (!this.readyState || this.readyState == 'complete')) {
503 ready = true;
504 resolve();
505 }
506 };
507 document.body.appendChild(script);
508 }
509 });
510 }514 }
511515
512 return Promise.resolve();516 return new Promise((resolve, reject) => {
517 const url = `/scripts/extensions/${name}/${manifest.js}`;
518 let ready = false;
519
520 if ($(`script[id="${name}"]`).length === 0) {
521 const script = document.createElement('script');
522 script.id = name;
523 script.type = 'module';
524 script.src = url;
525 script.async = true;
526 script.onerror = function (err) {
527 reject(err);
528 };
529 script.onload = function () {
530 if (!ready) {
531 ready = true;
532 resolve();
533 }
534 };
535 document.body.appendChild(script);
536 }
537 });
513}538}
514539
515
516
517/**540/**
518 * Generates HTML string for displaying an extension in the UI.541 * Generates HTML string for displaying an extension in the UI.
519 *542 *
@@ -526,6 +549,22 @@ function addExtensionScript(name, manifest) {
526 * @return {string} - The HTML string that represents the extension.549 * @return {string} - The HTML string that represents the extension.
527 */550 */
528function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {551function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
552 function getExtensionIcon() {
553 const type = getExtensionType(name);
554 switch (type) {
555 case 'global':
556 return '<i class="fa-fw fa-solid fa-server" data-i18n="[title]ext_type_global" title="This is a global extension, available for all users."></i>';
557 case 'local':
558 return '<i class="fa-fw fa-solid fa-user" data-i18n="[title]ext_type_local" title="This is a local extension, available only for you."></i>';
559 case 'system':
560 return '<i class="fa-fw fa-solid fa-cog" data-i18n="[title]ext_type_system" title="This is a built-in extension. It cannot be deleted and updates with the app."></i>';
561 default:
562 return '<i class="fa-fw fa-solid fa-question" title="Unknown extension type."></i>';
563 }
564 }
565
566 const isUserAdmin = isAdmin();
567 const extensionIcon = getExtensionIcon();
529 const displayName = manifest.display_name;568 const displayName = manifest.display_name;
530 let displayVersion = manifest.version ? ` v${manifest.version}` : '';569 let displayVersion = manifest.version ? ` v${manifest.version}` : '';
531 const externalId = name.replace('third-party', '');570 const externalId = name.replace('third-party', '');
@@ -540,6 +579,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
540579
541 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';580 let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';
542 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';581 let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
582 let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
543 let modulesInfo = '';583 let modulesInfo = '';
544584
545 if (isActive && Array.isArray(manifest.optional)) {585 if (isActive && Array.isArray(manifest.optional)) {
@@ -565,6 +605,9 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
565 <div class="extension_toggle">605 <div class="extension_toggle">
566 ${toggleElement}606 ${toggleElement}
567 </div>607 </div>
608 <div class="extension_icon">
609 ${extensionIcon}
610 </div>
568 <div class="flexGrow">611 <div class="flexGrow">
569 ${originHtml}612 ${originHtml}
570 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">613 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
@@ -577,6 +620,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
577620
578 <div class="extension_actions flex-container alignItemsCenter">621 <div class="extension_actions flex-container alignItemsCenter">
579 ${updateButton}622 ${updateButton}
623 ${moveButton}
580 ${deleteButton}624 ${deleteButton}
581 </div>625 </div>
582 </div>`;626 </div>`;
@@ -622,6 +666,7 @@ function getModuleInformation() {
622 * Generates the HTML strings for all extensions and displays them in a popup.666 * Generates the HTML strings for all extensions and displays them in a popup.
623 */667 */
624async function showExtensionsDetails() {668async function showExtensionsDetails() {
669 const abortController = new AbortController();
625 let popupPromise;670 let popupPromise;
626 try {671 try {
627 const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">Built-in Extensions:</h3></div>');672 const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">Built-in Extensions:</h3></div>');
@@ -688,13 +733,14 @@ async function showExtensionsDetails() {
688 },733 },
689 });734 });
690 popupPromise = popup.show();735 popupPromise = popup.show();
691 checkForUpdatesManual().finally(() => htmlLoading.remove());736 checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove());
692 } catch (error) {737 } catch (error) {
693 toastr.error('Error loading extensions. See browser console for details.');738 toastr.error('Error loading extensions. See browser console for details.');
694 console.error(error);739 console.error(error);
695 }740 }
696 if (popupPromise) {741 if (popupPromise) {
697 await popupPromise;742 await popupPromise;
743 abortController.abort();
698 }744 }
699 if (requiresReload) {745 if (requiresReload) {
700 showLoader();746 showLoader();
@@ -702,7 +748,6 @@ async function showExtensionsDetails() {
702 }748 }
703}749}
704750
705
706/**751/**
707 * Handles the click event for the update button of an extension.752 * Handles the click event for the update button of an extension.
708 * This function makes a POST request to '/update_extension' with the extension's name.753 * This function makes a POST request to '/update_extension' with the extension's name.
@@ -712,7 +757,7 @@ async function showExtensionsDetails() {
712async function onUpdateClick() {757async function onUpdateClick() {
713 const isCurrentUserAdmin = isAdmin();758 const isCurrentUserAdmin = isAdmin();
714 const extensionName = $(this).data('name');759 const extensionName = $(this).data('name');
715 const isGlobal = extensionTypes[extensionName] === 'global';760 const isGlobal = getExtensionType(extensionName) === 'global';
716 if (isGlobal && !isCurrentUserAdmin) {761 if (isGlobal && !isCurrentUserAdmin) {
717 toastr.error(t`You don't have permission to update global extensions.`);762 toastr.error(t`You don't have permission to update global extensions.`);
718 return;763 return;
@@ -734,7 +779,7 @@ async function updateExtension(extensionName, quiet) {
734 headers: getRequestHeaders(),779 headers: getRequestHeaders(),
735 body: JSON.stringify({780 body: JSON.stringify({
736 extensionName,781 extensionName,
737 global: extensionTypes[extensionName] === 'global',782 global: getExtensionType(extensionName) === 'global',
738 }),783 }),
739 });784 });
740785
@@ -765,7 +810,7 @@ async function updateExtension(extensionName, quiet) {
765async function onDeleteClick() {810async function onDeleteClick() {
766 const extensionName = $(this).data('name');811 const extensionName = $(this).data('name');
767 const isCurrentUserAdmin = isAdmin();812 const isCurrentUserAdmin = isAdmin();
768 const isGlobal = extensionTypes[extensionName] === 'global';813 const isGlobal = getExtensionType(extensionName) === 'global';
769 if (isGlobal && !isCurrentUserAdmin) {814 if (isGlobal && !isCurrentUserAdmin) {
770 toastr.error(t`You don't have permission to delete global extensions.`);815 toastr.error(t`You don't have permission to delete global extensions.`);
771 return;816 return;
@@ -778,6 +823,18 @@ async function onDeleteClick() {
778 }823 }
779}824}
780825
826async function onMoveClick() {
827 const extensionName = $(this).data('name');
828 const isCurrentUserAdmin = isAdmin();
829 const isGlobal = getExtensionType(extensionName) === 'global';
830 if (isGlobal && !isCurrentUserAdmin) {
831 toastr.error(t`You don't have permission to move extensions.`);
832 return;
833 }
834
835 toastr.info('Not implemented yet');
836}
837
781/**838/**
782 * Deletes an extension via the API.839 * Deletes an extension via the API.
783 * @param {string} extensionName Extension name to delete840 * @param {string} extensionName Extension name to delete
@@ -789,7 +846,7 @@ export async function deleteExtension(extensionName) {
789 headers: getRequestHeaders(),846 headers: getRequestHeaders(),
790 body: JSON.stringify({847 body: JSON.stringify({
791 extensionName,848 extensionName,
792 global: extensionTypes[extensionName] === 'global',849 global: getExtensionType(extensionName) === 'global',
793 }),850 }),
794 });851 });
795 } catch (error) {852 } catch (error) {
@@ -806,16 +863,21 @@ export async function deleteExtension(extensionName) {
806 * Fetches the version details of a specific extension.863 * Fetches the version details of a specific extension.
807 *864 *
808 * @param {string} extensionName - The name of the extension.865 * @param {string} extensionName - The name of the extension.
866 * @param {AbortSignal} [abortSignal] - The signal to abort the operation.
809 * @return {Promise<object>} - An object containing the extension's version details.867 * @return {Promise<object>} - An object containing the extension's version details.
810 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.868 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.
811 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.869 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.
812 */870 */
813async function getExtensionVersion(extensionName) {871async function getExtensionVersion(extensionName, abortSignal) {
814 try {872 try {
815 const response = await fetch('/api/extensions/version', {873 const response = await fetch('/api/extensions/version', {
816 method: 'POST',874 method: 'POST',
817 headers: getRequestHeaders(),875 headers: getRequestHeaders(),
818 body: JSON.stringify({ extensionName }),876 body: JSON.stringify({
877 extensionName,
878 global: getExtensionType(extensionName) === 'global',
879 }),
880 signal: abortSignal,
819 });881 });
820882
821 const data = await response.json();883 const data = await response.json();
@@ -900,13 +962,18 @@ export function doDailyExtensionUpdatesCheck() {
900 }, 1);962 }, 1);
901}963}
902964
903async function checkForUpdatesManual() {965/**
966 * Performs a manual check for updates on all 3rd-party extensions.
967 * @param {AbortSignal} abortSignal Signal to abort the operation
968 * @returns {Promise<any[]>}
969 */
970async function checkForUpdatesManual(abortSignal) {
904 const promises = [];971 const promises = [];
905 for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party'))) {972 for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party'))) {
906 const externalId = id.replace('third-party', '');973 const externalId = id.replace('third-party', '');
907 const promise = new Promise(async (resolve, reject) => {974 const promise = new Promise(async (resolve, reject) => {
908 try {975 try {
909 const data = await getExtensionVersion(externalId);976 const data = await getExtensionVersion(externalId, abortSignal);
910 const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);977 const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);
911 if (extensionBlock) {978 if (extensionBlock) {
912 if (data.isUpToDate === false) {979 if (data.isUpToDate === false) {
@@ -969,7 +1036,7 @@ async function checkForExtensionUpdates(force) {
969 const promises = [];1036 const promises = [];
9701037
971 for (const [id, manifest] of Object.entries(manifests)) {1038 for (const [id, manifest] of Object.entries(manifests)) {
972 const isGlobal = extensionTypes[id] === 'global';1039 const isGlobal = getExtensionType(id) === 'global';
973 if (isGlobal && !isCurrentUserAdmin) {1040 if (isGlobal && !isCurrentUserAdmin) {
974 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);1041 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
975 continue;1042 continue;
@@ -1012,7 +1079,7 @@ async function autoUpdateExtensions(forceAll) {
1012 const isCurrentUserAdmin = isAdmin();1079 const isCurrentUserAdmin = isAdmin();
1013 const promises = [];1080 const promises = [];
1014 for (const [id, manifest] of Object.entries(manifests)) {1081 for (const [id, manifest] of Object.entries(manifests)) {
1015 const isGlobal = extensionTypes[id] === 'global';1082 const isGlobal = getExtensionType(id) === 'global';
1016 if (isGlobal && !isCurrentUserAdmin) {1083 if (isGlobal && !isCurrentUserAdmin) {
1017 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);1084 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
1018 continue;1085 continue;
@@ -1043,9 +1110,9 @@ async function runGenerationInterceptors(chat, contextSize) {
10431110
1044 for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) {1111 for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) {
1045 const interceptorKey = manifest.generate_interceptor;1112 const interceptorKey = manifest.generate_interceptor;
1046 if (typeof window[interceptorKey] === 'function') {1113 if (typeof globalThis[interceptorKey] === 'function') {
1047 try {1114 try {
1048 await window[interceptorKey](chat, contextSize, abort);1115 await globalThis[interceptorKey](chat, contextSize, abort);
1049 } catch (e) {1116 } catch (e) {
1050 console.error(`Failed running interceptor for ${manifest.display_name}`, e);1117 console.error(`Failed running interceptor for ${manifest.display_name}`, e);
1051 }1118 }
@@ -1124,7 +1191,7 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
11241191
1125 let global = false;1192 let global = false;
1126 const installForAllButton = {1193 const installForAllButton = {
1127 text: t`Install for all`,1194 text: t`Install for all users`,
1128 appendAtEnd: false,1195 appendAtEnd: false,
1129 action: async () => {1196 action: async () => {
1130 global = true;1197 global = true;
@@ -1153,10 +1220,11 @@ export async function initExtensions() {
1153 $('#extensions_autoconnect').on('input', autoConnectInputHandler);1220 $('#extensions_autoconnect').on('input', autoConnectInputHandler);
1154 $('#extensions_details').on('click', showExtensionsDetails);1221 $('#extensions_details').on('click', showExtensionsDetails);
1155 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);1222 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);
1156 $(document).on('click', '.toggle_disable', onDisableExtensionClick);1223 $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick);
1157 $(document).on('click', '.toggle_enable', onEnableExtensionClick);1224 $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick);
1158 $(document).on('click', '.btn_update', onUpdateClick);1225 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
1159 $(document).on('click', '.btn_delete', onDeleteClick);1226 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
1227 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
11601228
1161 /**1229 /**
1162 * Handles the click event for the third-party extension import button.1230 * Handles the click event for the third-party extension import button.
src/endpoints/extensions.js+10 -8
@@ -246,26 +246,28 @@ router.get('/discover', jsonParser, function (request, response) {
246 }246 }
247247
248 // Get all folders in system extensions folder, excluding third-party248 // Get all folders in system extensions folder, excluding third-party
249 const buildInExtensions = fs249 const builtInExtensions = fs
250 .readdirSync(PUBLIC_DIRECTORIES.extensions)250 .readdirSync(PUBLIC_DIRECTORIES.extensions)
251 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())251 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
252 .filter(f => f !== 'third-party')252 .filter(f => f !== 'third-party')
253 .map(f => ({ type: 'system', name: f }));253 .map(f => ({ type: 'system', name: f }));
254254
255 // Get all folders in global extensions folder
256 const globalExtensions = fs
257 .readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
258 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
259 .map(f => ({ type: 'global', name: `third-party/${f}` }));
260
261 // Get all folders in local extensions folder255 // Get all folders in local extensions folder
262 const userExtensions = fs256 const userExtensions = fs
263 .readdirSync(path.join(request.user.directories.extensions))257 .readdirSync(path.join(request.user.directories.extensions))
264 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())258 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
265 .map(f => ({ type: 'local', name: `third-party/${f}` }));259 .map(f => ({ type: 'local', name: `third-party/${f}` }));
266260
261 // Get all folders in global extensions folder
262 // In case of a conflict, the extension will be loaded from the user folder
263 const globalExtensions = fs
264 .readdirSync(PUBLIC_DIRECTORIES.globalExtensions)
265 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory())
266 .map(f => ({ type: 'global', name: `third-party/${f}` }))
267 .filter(f => !userExtensions.some(e => e.name === f.name));
268
267 // Combine all extensions269 // Combine all extensions
268 const allExtensions = Array.from(new Set([...buildInExtensions, ...globalExtensions, ...userExtensions]));270 const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions];
269 console.log(allExtensions);271 console.log(allExtensions);
270272
271 return response.send(allExtensions);273 return response.send(allExtensions);