Improve extension type indication
| @@ -90,7 +90,7 @@ label[for="extensions_autoconnect"] { | ||
| 90 | 90 | border-radius: 10px; |
| 91 | 91 | align-items: center; |
| 92 | 92 | justify-content: space-between; |
| 93 | 93 | gap: 10px5px; |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | 96 | .extensions_info .extension_name { |
| @@ -21,7 +21,11 @@ export { | ||
| 21 | 21 | |
| 22 | 22 | /** @type {string[]} */ |
| 23 | 23 | export 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 | + */ | |
| 25 | 29 | export let extensionTypes = {}; |
| 26 | 30 | |
| 27 | 31 | let manifests = {}; |
| @@ -198,6 +202,16 @@ function showHideExtensionsMenu() { | ||
| 198 | 202 | // Periodically check for new extensions |
| 199 | 203 | const menuInterval = setInterval(showHideExtensionsMenu, 1000); |
| 200 | 204 | |
| 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 | + | |
| 201 | 215 | async function doExtrasFetch(endpoint, args) { |
| 202 | 216 | if (!args) { |
| 203 | 217 | args = {}; |
| @@ -457,63 +471,72 @@ function updateStatus(success) { | ||
| 457 | 471 | $('#extensions_status').attr('class', _class); |
| 458 | 472 | } |
| 459 | 473 | |
| 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 | + */ | |
| 460 | 480 | function addExtensionStyle(name, manifest) { |
| 461 | 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 | } |
| 481 | 484 | |
| 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 | } |
| 484 | 504 | |
| 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 | + */ | |
| 485 | 511 | function addExtensionScript(name, manifest) { |
| 486 | 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 | } |
| 511 | 515 | |
| 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 | } |
| 514 | 539 | |
| 515 | - | |
| 516 | - | |
| 517 | 540 | /** |
| 518 | 541 | * Generates HTML string for displaying an extension in the UI. |
| 519 | 542 | * |
| @@ -526,6 +549,22 @@ function addExtensionScript(name, manifest) { | ||
| 526 | 549 | * @return {string} - The HTML string that represents the extension. |
| 527 | 550 | */ |
| 528 | 551 | function 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 | 568 | const displayName = manifest.display_name; |
| 530 | 569 | let displayVersion = manifest.version ? ` v${manifest.version}` : ''; |
| 531 | 570 | const externalId = name.replace('third-party', ''); |
| @@ -540,6 +579,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 540 | 579 | |
| 541 | 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 | 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 | 583 | let modulesInfo = ''; |
| 544 | 584 | |
| 545 | 585 | if (isActive && Array.isArray(manifest.optional)) { |
| @@ -565,6 +605,9 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 565 | 605 | <div class="extension_toggle"> |
| 566 | 606 | ${toggleElement} |
| 567 | 607 | </div> |
| 608 | + <div class="extension_icon"> | |
| 609 | + ${extensionIcon} | |
| 610 | + </div> | |
| 568 | 611 | <div class="flexGrow"> |
| 569 | 612 | ${originHtml} |
| 570 | 613 | <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}"> |
| @@ -577,6 +620,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 577 | 620 | |
| 578 | 621 | <div class="extension_actions flex-container alignItemsCenter"> |
| 579 | 622 | ${updateButton} |
| 623 | + ${moveButton} | |
| 580 | 624 | ${deleteButton} |
| 581 | 625 | </div> |
| 582 | 626 | </div>`; |
| @@ -622,6 +666,7 @@ function getModuleInformation() { | ||
| 622 | 666 | * Generates the HTML strings for all extensions and displays them in a popup. |
| 623 | 667 | */ |
| 624 | 668 | async function showExtensionsDetails() { |
| 669 | + const abortController = new AbortController(); | |
| 625 | 670 | let popupPromise; |
| 626 | 671 | try { |
| 627 | 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 | 735 | popupPromise = popup.show(); |
| 691 | 736 | checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove()); |
| 692 | 737 | } catch (error) { |
| 693 | 738 | toastr.error('Error loading extensions. See browser console for details.'); |
| 694 | 739 | console.error(error); |
| 695 | 740 | } |
| 696 | 741 | if (popupPromise) { |
| 697 | 742 | await popupPromise; |
| 743 | + abortController.abort(); | |
| 698 | 744 | } |
| 699 | 745 | if (requiresReload) { |
| 700 | 746 | showLoader(); |
| @@ -702,7 +748,6 @@ async function showExtensionsDetails() { | ||
| 702 | 748 | } |
| 703 | 749 | } |
| 704 | 750 | |
| 705 | - | |
| 706 | 751 | /** |
| 707 | 752 | * Handles the click event for the update button of an extension. |
| 708 | 753 | * This function makes a POST request to '/update_extension' with the extension's name. |
| @@ -712,7 +757,7 @@ async function showExtensionsDetails() { | ||
| 712 | 757 | async function onUpdateClick() { |
| 713 | 758 | const isCurrentUserAdmin = isAdmin(); |
| 714 | 759 | const extensionName = $(this).data('name'); |
| 715 | 760 | const isGlobal = extensionTypes[getExtensionType(extensionName]) === 'global'; |
| 716 | 761 | if (isGlobal && !isCurrentUserAdmin) { |
| 717 | 762 | toastr.error(t`You don't have permission to update global extensions.`); |
| 718 | 763 | return; |
| @@ -734,7 +779,7 @@ async function updateExtension(extensionName, quiet) { | ||
| 734 | 779 | headers: getRequestHeaders(), |
| 735 | 780 | body: JSON.stringify({ |
| 736 | 781 | extensionName, |
| 737 | 782 | global: extensionTypes[getExtensionType(extensionName]) === 'global', |
| 738 | 783 | }), |
| 739 | 784 | }); |
| 740 | 785 | |
| @@ -765,7 +810,7 @@ async function updateExtension(extensionName, quiet) { | ||
| 765 | 810 | async function onDeleteClick() { |
| 766 | 811 | const extensionName = $(this).data('name'); |
| 767 | 812 | const isCurrentUserAdmin = isAdmin(); |
| 768 | 813 | const isGlobal = extensionTypes[getExtensionType(extensionName]) === 'global'; |
| 769 | 814 | if (isGlobal && !isCurrentUserAdmin) { |
| 770 | 815 | toastr.error(t`You don't have permission to delete global extensions.`); |
| 771 | 816 | return; |
| @@ -778,6 +823,18 @@ async function onDeleteClick() { | ||
| 778 | 823 | } |
| 779 | 824 | } |
| 780 | 825 | |
| 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 | + | |
| 781 | 838 | /** |
| 782 | 839 | * Deletes an extension via the API. |
| 783 | 840 | * @param {string} extensionName Extension name to delete |
| @@ -789,7 +846,7 @@ export async function deleteExtension(extensionName) { | ||
| 789 | 846 | headers: getRequestHeaders(), |
| 790 | 847 | body: JSON.stringify({ |
| 791 | 848 | extensionName, |
| 792 | 849 | global: extensionTypes[getExtensionType(extensionName]) === 'global', |
| 793 | 850 | }), |
| 794 | 851 | }); |
| 795 | 852 | } catch (error) { |
| @@ -806,16 +863,21 @@ export async function deleteExtension(extensionName) { | ||
| 806 | 863 | * Fetches the version details of a specific extension. |
| 807 | 864 | * |
| 808 | 865 | * @param {string} extensionName - The name of the extension. |
| 866 | + * @param {AbortSignal} [abortSignal] - The signal to abort the operation. | |
| 809 | 867 | * @return {Promise<object>} - An object containing the extension's version details. |
| 810 | 868 | * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl. |
| 811 | 869 | * @throws {error} - If there is an error during the fetch operation, it logs the error to the console. |
| 812 | 870 | */ |
| 813 | 871 | async function getExtensionVersion(extensionName, abortSignal) { |
| 814 | 872 | try { |
| 815 | 873 | const response = await fetch('/api/extensions/version', { |
| 816 | 874 | method: 'POST', |
| 817 | 875 | headers: getRequestHeaders(), |
| 818 | 876 | body: JSON.stringify({ extensionName }), |
| 877 | + extensionName, | |
| 878 | + global: getExtensionType(extensionName) === 'global', | |
| 879 | + }), | |
| 880 | + signal: abortSignal, | |
| 819 | 881 | }); |
| 820 | 882 | |
| 821 | 883 | const data = await response.json(); |
| @@ -900,13 +962,18 @@ export function doDailyExtensionUpdatesCheck() { | ||
| 900 | 962 | }, 1); |
| 901 | 963 | } |
| 902 | 964 | |
| 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) { | |
| 904 | 971 | const promises = []; |
| 905 | 972 | for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party'))) { |
| 906 | 973 | const externalId = id.replace('third-party', ''); |
| 907 | 974 | const promise = new Promise(async (resolve, reject) => { |
| 908 | 975 | try { |
| 909 | 976 | const data = await getExtensionVersion(externalId, abortSignal); |
| 910 | 977 | const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); |
| 911 | 978 | if (extensionBlock) { |
| 912 | 979 | if (data.isUpToDate === false) { |
| @@ -969,7 +1036,7 @@ async function checkForExtensionUpdates(force) { | ||
| 969 | 1036 | const promises = []; |
| 970 | 1037 | |
| 971 | 1038 | for (const [id, manifest] of Object.entries(manifests)) { |
| 972 | 1039 | const isGlobal = extensionTypes[getExtensionType(id]) === 'global'; |
| 973 | 1040 | if (isGlobal && !isCurrentUserAdmin) { |
| 974 | 1041 | console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); |
| 975 | 1042 | continue; |
| @@ -1012,7 +1079,7 @@ async function autoUpdateExtensions(forceAll) { | ||
| 1012 | 1079 | const isCurrentUserAdmin = isAdmin(); |
| 1013 | 1080 | const promises = []; |
| 1014 | 1081 | for (const [id, manifest] of Object.entries(manifests)) { |
| 1015 | 1082 | const isGlobal = extensionTypes[getExtensionType(id]) === 'global'; |
| 1016 | 1083 | if (isGlobal && !isCurrentUserAdmin) { |
| 1017 | 1084 | console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); |
| 1018 | 1085 | continue; |
| @@ -1043,9 +1110,9 @@ async function runGenerationInterceptors(chat, contextSize) { | ||
| 1043 | 1110 | |
| 1044 | 1111 | for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) { |
| 1045 | 1112 | const interceptorKey = manifest.generate_interceptor; |
| 1046 | 1113 | if (typeof windowglobalThis[interceptorKey] === 'function') { |
| 1047 | 1114 | try { |
| 1048 | 1115 | await windowglobalThis[interceptorKey](chat, contextSize, abort); |
| 1049 | 1116 | } catch (e) { |
| 1050 | 1117 | console.error(`Failed running interceptor for ${manifest.display_name}`, e); |
| 1051 | 1118 | } |
| @@ -1124,7 +1191,7 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { | ||
| 1124 | 1191 | |
| 1125 | 1192 | let global = false; |
| 1126 | 1193 | const installForAllButton = { |
| 1127 | 1194 | text: t`Install for all users`, |
| 1128 | 1195 | appendAtEnd: false, |
| 1129 | 1196 | action: async () => { |
| 1130 | 1197 | global = true; |
| @@ -1153,10 +1220,11 @@ export async function initExtensions() { | ||
| 1153 | 1220 | $('#extensions_autoconnect').on('input', autoConnectInputHandler); |
| 1154 | 1221 | $('#extensions_details').on('click', showExtensionsDetails); |
| 1155 | 1222 | $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler); |
| 1156 | 1223 | $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick); |
| 1157 | 1224 | $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); |
| 1158 | 1225 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); |
| 1159 | 1226 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); |
| 1227 | + $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); | |
| 1160 | 1228 | |
| 1161 | 1229 | /** |
| 1162 | 1230 | * Handles the click event for the third-party extension import button. |
| @@ -246,26 +246,28 @@ router.get('/discover', jsonParser, function (request, response) { | ||
| 246 | 246 | } |
| 247 | 247 | |
| 248 | 248 | // Get all folders in system extensions folder, excluding third-party |
| 249 | 249 | const buildInExtensionsbuiltInExtensions = fs |
| 250 | 250 | .readdirSync(PUBLIC_DIRECTORIES.extensions) |
| 251 | 251 | .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory()) |
| 252 | 252 | .filter(f => f !== 'third-party') |
| 253 | 253 | .map(f => ({ type: 'system', name: f })); |
| 254 | 254 | |
| 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 | 255 | // Get all folders in local extensions folder |
| 262 | 256 | const userExtensions = fs |
| 263 | 257 | .readdirSync(path.join(request.user.directories.extensions)) |
| 264 | 258 | .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory()) |
| 265 | 259 | .map(f => ({ type: 'local', name: `third-party/${f}` })); |
| 266 | 260 | |
| 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 | 269 | // Combine all extensions |
| 268 | 270 | const allExtensions = Array.from(new Set([...buildInExtensionsbuiltInExtensions, ...globalExtensionsuserExtensions, ...userExtensionsglobalExtensions])); |
| 269 | 271 | console.log(allExtensions); |
| 270 | 272 | |
| 271 | 273 | return response.send(allExtensions); |