diff --git a/public/css/extensions-panel.css b/public/css/extensions-panel.css index 40185b490..18783a614 100644 --- a/public/css/extensions-panel.css +++ b/public/css/extensions-panel.css @@ -90,7 +90,7 @@ label[for="extensions_autoconnect"] { border-radius: 10px; align-items: center; justify-content: space-between; - gap: 10px; + gap: 5px; } .extensions_info .extension_name { diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index c8b27333d..776b7e775 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -21,7 +21,11 @@ export { /** @type {string[]} */ export let extensionNames = []; -/** @type {Record} */ +/** + * Holds the type of each extension. + * Don't use this directly, use getExtensionType instead! + * @type {Record} + */ export let extensionTypes = {}; let manifests = {}; @@ -198,6 +202,16 @@ function showHideExtensionsMenu() { // Periodically check for new extensions const menuInterval = setInterval(showHideExtensionsMenu, 1000); +/** + * Gets the type of an extension based on its external ID. + * @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/') + * @returns {string} Type of the extension (global, local, system, or empty string if not found) + */ +function getExtensionType(externalId) { + const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId))); + return id ? extensionTypes[id] : ''; +} + async function doExtrasFetch(endpoint, args) { if (!args) { args = {}; @@ -457,63 +471,72 @@ function updateStatus(success) { $('#extensions_status').attr('class', _class); } +/** + * Adds a CSS file for an extension. + * @param {string} name Extension name + * @param {object} manifest Extension manifest + * @returns {Promise} When the CSS is loaded + */ function addExtensionStyle(name, manifest) { - if (manifest.css) { - return new Promise((resolve, reject) => { - const url = `/scripts/extensions/${name}/${manifest.css}`; - - if ($(`link[id="${name}"]`).length === 0) { - const link = document.createElement('link'); - link.id = name; - link.rel = 'stylesheet'; - link.type = 'text/css'; - link.href = url; - link.onload = function () { - resolve(); - }; - link.onerror = function (e) { - reject(e); - }; - document.head.appendChild(link); - } - }); + if (!manifest.css) { + return Promise.resolve(); } - return Promise.resolve(); + return new Promise((resolve, reject) => { + const url = `/scripts/extensions/${name}/${manifest.css}`; + + if ($(`link[id="${name}"]`).length === 0) { + const link = document.createElement('link'); + link.id = name; + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = url; + link.onload = function () { + resolve(); + }; + link.onerror = function (e) { + reject(e); + }; + document.head.appendChild(link); + } + }); } +/** + * Loads a JS file for an extension. + * @param {string} name Extension name + * @param {object} manifest Extension manifest + * @returns {Promise} When the script is loaded + */ function addExtensionScript(name, manifest) { - if (manifest.js) { - return new Promise((resolve, reject) => { - const url = `/scripts/extensions/${name}/${manifest.js}`; - let ready = false; - - if ($(`script[id="${name}"]`).length === 0) { - const script = document.createElement('script'); - script.id = name; - script.type = 'module'; - script.src = url; - script.async = true; - script.onerror = function (err) { - reject(err, script); - }; - script.onload = script.onreadystatechange = function () { - // console.log(this.readyState); // uncomment this line to see which ready states are called. - if (!ready && (!this.readyState || this.readyState == 'complete')) { - ready = true; - resolve(); - } - }; - document.body.appendChild(script); - } - }); + if (!manifest.js) { + return Promise.resolve(); } - return Promise.resolve(); + return new Promise((resolve, reject) => { + const url = `/scripts/extensions/${name}/${manifest.js}`; + let ready = false; + + if ($(`script[id="${name}"]`).length === 0) { + const script = document.createElement('script'); + script.id = name; + script.type = 'module'; + script.src = url; + script.async = true; + script.onerror = function (err) { + reject(err); + }; + script.onload = function () { + if (!ready) { + ready = true; + resolve(); + } + }; + document.body.appendChild(script); + } + }); } - - /** * Generates HTML string for displaying an extension in the UI. * @@ -526,6 +549,22 @@ function addExtensionScript(name, manifest) { * @return {string} - The HTML string that represents the extension. */ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { + function getExtensionIcon() { + const type = getExtensionType(name); + switch (type) { + case 'global': + return ''; + case 'local': + return ''; + case 'system': + return ''; + default: + return ''; + } + } + + const isUserAdmin = isAdmin(); + const extensionIcon = getExtensionIcon(); const displayName = manifest.display_name; let displayVersion = manifest.version ? ` v${manifest.version}` : ''; const externalId = name.replace('third-party', ''); @@ -540,6 +579,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, let deleteButton = isExternal ? `` : ''; let updateButton = isExternal ? `` : ''; + let moveButton = isExternal && isUserAdmin ? `` : ''; let modulesInfo = ''; if (isActive && Array.isArray(manifest.optional)) { @@ -565,6 +605,9 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
${toggleElement}
+
+ ${extensionIcon} +
${originHtml} @@ -577,6 +620,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
${updateButton} + ${moveButton} ${deleteButton}
`; @@ -622,6 +666,7 @@ function getModuleInformation() { * Generates the HTML strings for all extensions and displays them in a popup. */ async function showExtensionsDetails() { + const abortController = new AbortController(); let popupPromise; try { const htmlDefault = $('

Built-in Extensions:

'); @@ -688,13 +733,14 @@ async function showExtensionsDetails() { }, }); popupPromise = popup.show(); - checkForUpdatesManual().finally(() => htmlLoading.remove()); + checkForUpdatesManual(abortController.signal).finally(() => htmlLoading.remove()); } catch (error) { toastr.error('Error loading extensions. See browser console for details.'); console.error(error); } if (popupPromise) { await popupPromise; + abortController.abort(); } if (requiresReload) { showLoader(); @@ -702,7 +748,6 @@ async function showExtensionsDetails() { } } - /** * Handles the click event for the update button of an extension. * This function makes a POST request to '/update_extension' with the extension's name. @@ -712,7 +757,7 @@ async function showExtensionsDetails() { async function onUpdateClick() { const isCurrentUserAdmin = isAdmin(); const extensionName = $(this).data('name'); - const isGlobal = extensionTypes[extensionName] === 'global'; + const isGlobal = getExtensionType(extensionName) === 'global'; if (isGlobal && !isCurrentUserAdmin) { toastr.error(t`You don't have permission to update global extensions.`); return; @@ -734,7 +779,7 @@ async function updateExtension(extensionName, quiet) { headers: getRequestHeaders(), body: JSON.stringify({ extensionName, - global: extensionTypes[extensionName] === 'global', + global: getExtensionType(extensionName) === 'global', }), }); @@ -765,7 +810,7 @@ async function updateExtension(extensionName, quiet) { async function onDeleteClick() { const extensionName = $(this).data('name'); const isCurrentUserAdmin = isAdmin(); - const isGlobal = extensionTypes[extensionName] === 'global'; + const isGlobal = getExtensionType(extensionName) === 'global'; if (isGlobal && !isCurrentUserAdmin) { toastr.error(t`You don't have permission to delete global extensions.`); return; @@ -778,6 +823,18 @@ async function onDeleteClick() { } } +async function onMoveClick() { + const extensionName = $(this).data('name'); + const isCurrentUserAdmin = isAdmin(); + const isGlobal = getExtensionType(extensionName) === 'global'; + if (isGlobal && !isCurrentUserAdmin) { + toastr.error(t`You don't have permission to move extensions.`); + return; + } + + toastr.info('Not implemented yet'); +} + /** * Deletes an extension via the API. * @param {string} extensionName Extension name to delete @@ -789,7 +846,7 @@ export async function deleteExtension(extensionName) { headers: getRequestHeaders(), body: JSON.stringify({ extensionName, - global: extensionTypes[extensionName] === 'global', + global: getExtensionType(extensionName) === 'global', }), }); } catch (error) { @@ -806,16 +863,21 @@ export async function deleteExtension(extensionName) { * Fetches the version details of a specific extension. * * @param {string} extensionName - The name of the extension. + * @param {AbortSignal} [abortSignal] - The signal to abort the operation. * @return {Promise} - An object containing the extension's version details. * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl. * @throws {error} - If there is an error during the fetch operation, it logs the error to the console. */ -async function getExtensionVersion(extensionName) { +async function getExtensionVersion(extensionName, abortSignal) { try { const response = await fetch('/api/extensions/version', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ extensionName }), + body: JSON.stringify({ + extensionName, + global: getExtensionType(extensionName) === 'global', + }), + signal: abortSignal, }); const data = await response.json(); @@ -900,13 +962,18 @@ export function doDailyExtensionUpdatesCheck() { }, 1); } -async function checkForUpdatesManual() { +/** + * Performs a manual check for updates on all 3rd-party extensions. + * @param {AbortSignal} abortSignal Signal to abort the operation + * @returns {Promise} + */ +async function checkForUpdatesManual(abortSignal) { const promises = []; for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party'))) { const externalId = id.replace('third-party', ''); const promise = new Promise(async (resolve, reject) => { try { - const data = await getExtensionVersion(externalId); + const data = await getExtensionVersion(externalId, abortSignal); const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); if (extensionBlock) { if (data.isUpToDate === false) { @@ -969,7 +1036,7 @@ async function checkForExtensionUpdates(force) { const promises = []; for (const [id, manifest] of Object.entries(manifests)) { - const isGlobal = extensionTypes[id] === 'global'; + const isGlobal = getExtensionType(id) === 'global'; if (isGlobal && !isCurrentUserAdmin) { console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); continue; @@ -1012,7 +1079,7 @@ async function autoUpdateExtensions(forceAll) { const isCurrentUserAdmin = isAdmin(); const promises = []; for (const [id, manifest] of Object.entries(manifests)) { - const isGlobal = extensionTypes[id] === 'global'; + const isGlobal = getExtensionType(id) === 'global'; if (isGlobal && !isCurrentUserAdmin) { console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); continue; @@ -1043,9 +1110,9 @@ async function runGenerationInterceptors(chat, contextSize) { for (const manifest of Object.values(manifests).sort((a, b) => a.loading_order - b.loading_order)) { const interceptorKey = manifest.generate_interceptor; - if (typeof window[interceptorKey] === 'function') { + if (typeof globalThis[interceptorKey] === 'function') { try { - await window[interceptorKey](chat, contextSize, abort); + await globalThis[interceptorKey](chat, contextSize, abort); } catch (e) { console.error(`Failed running interceptor for ${manifest.display_name}`, e); } @@ -1124,7 +1191,7 @@ export async function openThirdPartyExtensionMenu(suggestUrl = '') { let global = false; const installForAllButton = { - text: t`Install for all`, + text: t`Install for all users`, appendAtEnd: false, action: async () => { global = true; @@ -1153,10 +1220,11 @@ export async function initExtensions() { $('#extensions_autoconnect').on('input', autoConnectInputHandler); $('#extensions_details').on('click', showExtensionsDetails); $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler); - $(document).on('click', '.toggle_disable', onDisableExtensionClick); - $(document).on('click', '.toggle_enable', onEnableExtensionClick); - $(document).on('click', '.btn_update', onUpdateClick); - $(document).on('click', '.btn_delete', onDeleteClick); + $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick); + $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); + $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); + $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); + $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); /** * Handles the click event for the third-party extension import button. diff --git a/src/endpoints/extensions.js b/src/endpoints/extensions.js index ebad7da06..de829291f 100644 --- a/src/endpoints/extensions.js +++ b/src/endpoints/extensions.js @@ -246,26 +246,28 @@ router.get('/discover', jsonParser, function (request, response) { } // Get all folders in system extensions folder, excluding third-party - const buildInExtensions = fs + const builtInExtensions = fs .readdirSync(PUBLIC_DIRECTORIES.extensions) .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.extensions, f)).isDirectory()) .filter(f => f !== 'third-party') .map(f => ({ type: 'system', name: f })); - // Get all folders in global extensions folder - const globalExtensions = fs - .readdirSync(PUBLIC_DIRECTORIES.globalExtensions) - .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory()) - .map(f => ({ type: 'global', name: `third-party/${f}` })); - // Get all folders in local extensions folder const userExtensions = fs .readdirSync(path.join(request.user.directories.extensions)) .filter(f => fs.statSync(path.join(request.user.directories.extensions, f)).isDirectory()) .map(f => ({ type: 'local', name: `third-party/${f}` })); + // Get all folders in global extensions folder + // In case of a conflict, the extension will be loaded from the user folder + const globalExtensions = fs + .readdirSync(PUBLIC_DIRECTORIES.globalExtensions) + .filter(f => fs.statSync(path.join(PUBLIC_DIRECTORIES.globalExtensions, f)).isDirectory()) + .map(f => ({ type: 'global', name: `third-party/${f}` })) + .filter(f => !userExtensions.some(e => e.name === f.name)); + // Combine all extensions - const allExtensions = Array.from(new Set([...buildInExtensions, ...globalExtensions, ...userExtensions])); + const allExtensions = [...builtInExtensions, ...userExtensions, ...globalExtensions]; console.log(allExtensions); return response.send(allExtensions);