Improve extension type indication

c33649753b7ab730712fac522b432c0749fbb728

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

3 files changed, +111 -41Showing whitespace changes
public/css/extensions-panel.css+1 -1
@@ -90,7 +90,7 @@ label[for="extensions_autoconnect"] {
9090 border-radius: 10px;
9191 align-items: center;
9292 justify-content: space-between;
9393 gap: 10px5px;
9494}
9595
9696.extensions_info .extension_name {
public/scripts/extensions.js+100 -32
@@ -21,7 +21,11 @@ export {
2121
2222/** @type {string[]} */
2323export 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+ */
2529export let extensionTypes = {};
2630
2731let manifests = {};
@@ -198,6 +202,16 @@ function showHideExtensionsMenu() {
198202// Periodically check for new extensions
199203const 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+ */
210+function 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+
201215async function doExtrasFetch(endpoint, args) {
202216 if (!args) {
203217 args = {};
@@ -457,8 +471,17 @@ function updateStatus(success) {
457471 $('#extensions_status').attr('class', _class);
458472}
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+ */
460480function addExtensionStyle(name, manifest) {
461481 if (!manifest.css) {
482+ return Promise.resolve();
483+ }
484+
462485 return new Promise((resolve, reject) => {
463486 const url = `/scripts/extensions/${name}/${manifest.css}`;
464487
@@ -479,11 +502,17 @@ function addExtensionStyle(name, manifest) {
479502 });
480503}
481504
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+ */
511+function addExtensionScript(name, manifest) {
512+ if (!manifest.js) {
482513 return Promise.resolve();
483514 }
484515
485-function addExtensionScript(name, manifest) {
486- if (manifest.js) {
487516 return new Promise((resolve, reject) => {
488517 const url = `/scripts/extensions/${name}/${manifest.js}`;
489518 let ready = false;
@@ -495,11 +524,10 @@ function addExtensionScript(name, manifest) {
495524 script.src = url;
496525 script.async = true;
497526 script.onerror = function (err) {
498527 reject(err, script);
499528 };
500529 script.onload = script.onreadystatechange = function () {
501- // console.log(this.readyState); // uncomment this line to see which ready states are called.
530+ if (!ready) {
502- if (!ready && (!this.readyState || this.readyState == 'complete')) {
503531 ready = true;
504532 resolve();
505533 }
@@ -509,11 +537,6 @@ function addExtensionScript(name, manifest) {
509537 });
510538}
511539
512- return Promise.resolve();
513-}
514-
515-
516-
517540/**
518541 * Generates HTML string for displaying an extension in the UI.
519542 *
@@ -526,6 +549,22 @@ function addExtensionScript(name, manifest) {
526549 * @return {string} - The HTML string that represents the extension.
527550 */
528551function 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();
529568 const displayName = manifest.display_name;
530569 let displayVersion = manifest.version ? ` v${manifest.version}` : '';
531570 const externalId = name.replace('third-party', '');
@@ -540,6 +579,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
540579
541580 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>` : '';
542581 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>` : '';
543583 let modulesInfo = '';
544584
545585 if (isActive && Array.isArray(manifest.optional)) {
@@ -565,6 +605,9 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
565605 <div class="extension_toggle">
566606 ${toggleElement}
567607 </div>
608+ <div class="extension_icon">
609+ ${extensionIcon}
610+ </div>
568611 <div class="flexGrow">
569612 ${originHtml}
570613 <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
@@ -577,6 +620,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
577620
578621 <div class="extension_actions flex-container alignItemsCenter">
579622 ${updateButton}
623+ ${moveButton}
580624 ${deleteButton}
581625 </div>
582626 </div>`;
@@ -622,6 +666,7 @@ function getModuleInformation() {
622666 * Generates the HTML strings for all extensions and displays them in a popup.
623667 */
624668async function showExtensionsDetails() {
669+ const abortController = new AbortController();
625670 let popupPromise;
626671 try {
627672 const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">Built-in Extensions:</h3></div>');
@@ -688,13 +733,14 @@ async function showExtensionsDetails() {
688733 },
689734 });
690735 popupPromise = popup.show();
691736 checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove());
692737 } catch (error) {
693738 toastr.error('Error loading extensions. See browser console for details.');
694739 console.error(error);
695740 }
696741 if (popupPromise) {
697742 await popupPromise;
743+ abortController.abort();
698744 }
699745 if (requiresReload) {
700746 showLoader();
@@ -702,7 +748,6 @@ async function showExtensionsDetails() {
702748 }
703749}
704750
705-
706751/**
707752 * Handles the click event for the update button of an extension.
708753 * This function makes a POST request to '/update_extension' with the extension's name.
@@ -712,7 +757,7 @@ async function showExtensionsDetails() {
712757async function onUpdateClick() {
713758 const isCurrentUserAdmin = isAdmin();
714759 const extensionName = $(this).data('name');
715760 const isGlobal = extensionTypes[getExtensionType(extensionName]) === 'global';
716761 if (isGlobal && !isCurrentUserAdmin) {
717762 toastr.error(t`You don't have permission to update global extensions.`);
718763 return;
@@ -734,7 +779,7 @@ async function updateExtension(extensionName, quiet) {
734779 headers: getRequestHeaders(),
735780 body: JSON.stringify({
736781 extensionName,
737782 global: extensionTypes[getExtensionType(extensionName]) === 'global',
738783 }),
739784 });
740785
@@ -765,7 +810,7 @@ async function updateExtension(extensionName, quiet) {
765810async function onDeleteClick() {
766811 const extensionName = $(this).data('name');
767812 const isCurrentUserAdmin = isAdmin();
768813 const isGlobal = extensionTypes[getExtensionType(extensionName]) === 'global';
769814 if (isGlobal && !isCurrentUserAdmin) {
770815 toastr.error(t`You don't have permission to delete global extensions.`);
771816 return;
@@ -778,6 +823,18 @@ async function onDeleteClick() {
778823 }
779824}
780825
826+async 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+
781838/**
782839 * Deletes an extension via the API.
783840 * @param {string} extensionName Extension name to delete
@@ -789,7 +846,7 @@ export async function deleteExtension(extensionName) {
789846 headers: getRequestHeaders(),
790847 body: JSON.stringify({
791848 extensionName,
792849 global: extensionTypes[getExtensionType(extensionName]) === 'global',
793850 }),
794851 });
795852 } catch (error) {
@@ -806,16 +863,21 @@ export async function deleteExtension(extensionName) {
806863 * Fetches the version details of a specific extension.
807864 *
808865 * @param {string} extensionName - The name of the extension.
866+ * @param {AbortSignal} [abortSignal] - The signal to abort the operation.
809867 * @return {Promise<object>} - An object containing the extension's version details.
810868 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.
811869 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.
812870 */
813871async function getExtensionVersion(extensionName, abortSignal) {
814872 try {
815873 const response = await fetch('/api/extensions/version', {
816874 method: 'POST',
817875 headers: getRequestHeaders(),
818876 body: JSON.stringify({ extensionName }),
877+ extensionName,
878+ global: getExtensionType(extensionName) === 'global',
879+ }),
880+ signal: abortSignal,
819881 });
820882
821883 const data = await response.json();
@@ -900,13 +962,18 @@ export function doDailyExtensionUpdatesCheck() {
900962 }, 1);
901963}
902964
903-async 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+ */
970+async function checkForUpdatesManual(abortSignal) {
904971 const promises = [];
905972 for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party'))) {
906973 const externalId = id.replace('third-party', '');
907974 const promise = new Promise(async (resolve, reject) => {
908975 try {
909976 const data = await getExtensionVersion(externalId, abortSignal);
910977 const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);
911978 if (extensionBlock) {
912979 if (data.isUpToDate === false) {
@@ -969,7 +1036,7 @@ async function checkForExtensionUpdates(force) {
9691036 const promises = [];
9701037
9711038 for (const [id, manifest] of Object.entries(manifests)) {
9721039 const isGlobal = extensionTypes[getExtensionType(id]) === 'global';
9731040 if (isGlobal && !isCurrentUserAdmin) {
9741041 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
9751042 continue;
@@ -1012,7 +1079,7 @@ async function autoUpdateExtensions(forceAll) {
10121079 const isCurrentUserAdmin = isAdmin();
10131080 const promises = [];
10141081 for (const [id, manifest] of Object.entries(manifests)) {
10151082 const isGlobal = extensionTypes[getExtensionType(id]) === 'global';
10161083 if (isGlobal && !isCurrentUserAdmin) {
10171084 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
10181085 continue;
@@ -1043,9 +1110,9 @@ async function runGenerationInterceptors(chat, contextSize) {
10431110
10441111 for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) {
10451112 const interceptorKey = manifest.generate_interceptor;
10461113 if (typeof windowglobalThis[interceptorKey] === 'function') {
10471114 try {
10481115 await windowglobalThis[interceptorKey](chat, contextSize, abort);
10491116 } catch (e) {
10501117 console.error(`Failed running interceptor for ${manifest.display_name}`, e);
10511118 }
@@ -1124,7 +1191,7 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') {
11241191
11251192 let global = false;
11261193 const installForAllButton = {
11271194 text: t`Install for all users`,
11281195 appendAtEnd: false,
11291196 action: async () => {
11301197 global = true;
@@ -1153,10 +1220,11 @@ export async function initExtensions() {
11531220 $('#extensions_autoconnect').on('input', autoConnectInputHandler);
11541221 $('#extensions_details').on('click', showExtensionsDetails);
11551222 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);
11561223 $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick);
11571224 $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick);
11581225 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
11591226 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
1227+ $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
11601228
11611229 /**
11621230 * 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) {
246246 }
247247
248248 // Get all folders in system extensions folder, excluding third-party
249249 const buildInExtensionsbuiltInExtensions = fs
250250 .readdirSync(PUBLIC_DIRECTORIES.extensions)
251251 .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory())
252252 .filter(f => f !== 'third-party')
253253 .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-
261255 // Get all folders in local extensions folder
262256 const userExtensions = fs
263257 .readdirSync(path.join(request.user.directories.extensions))
264258 .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory())
265259 .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+
267269 // Combine all extensions
268270 const allExtensions = Array.from(new Set([...buildInExtensionsbuiltInExtensions, ...globalExtensionsuserExtensions, ...userExtensionsglobalExtensions]));
269271 console.log(allExtensions);
270272
271273 return response.send(allExtensions);