From b1a5c410280facf6df80d560ea29523a8ae6a7c4 Mon Sep 17 00:00:00 2001 From: jelveh Date: Fri, 17 Apr 2026 11:58:04 -0700 Subject: [PATCH 01/18] Remove custom domain UI and Entri integration --- src/gui/src/UI/UIWindowPublishWebsite.js | 215 +++-------------------- 1 file changed, 27 insertions(+), 188 deletions(-) diff --git a/src/gui/src/UI/UIWindowPublishWebsite.js b/src/gui/src/UI/UIWindowPublishWebsite.js index 28a7ac0bee..774b30e150 100644 --- a/src/gui/src/UI/UIWindowPublishWebsite.js +++ b/src/gui/src/UI/UIWindowPublishWebsite.js @@ -35,46 +35,7 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d // error msg h += '
'; - // Publishing options - h += '
'; - h += ``; - - // Check if user has active subscription for custom domains - const hasActiveSubscription = window.user && window.user.subscription && window.user.subscription.active; - - // Puter subdomain option - h += '
'; - h += ''; - h += '
'; - - // Custom domain option - h += '
'; - const customDomainDisabled = !hasActiveSubscription; - const customDomainStyle = customDomainDisabled ? - 'display: flex; align-items: center; cursor: not-allowed; padding: 10px; border: 2px solid #e1e8ed; border-radius: 8px; opacity: 0.5; background-color: #f8f9fa;' : - 'display: flex; align-items: center; cursor: pointer; padding: 10px; border: 2px solid #e1e8ed; border-radius: 8px;'; - - h += `'; - h += '
'; - h += '
'; - - // Puter subdomain input (shown by default) + // Subdomain input h += '
'; h += ``; h += '
'; @@ -84,12 +45,6 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d h += '
'; h += '
'; - // Custom domain input (hidden by default) - h += ''; - // uid h += ``; // Publish @@ -117,54 +72,6 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d onAppend: function (this_window) { $(this_window).find('.publish-website-subdomain').val(window.generate_identifier()); $(this_window).find('.publish-website-subdomain').get(0).focus({ preventScroll: true }); - - // Handle radio button changes - $(this_window).find('input[name="publishing-type"]:not(:disabled)').on('change', function () { - const selectedValue = $(this).val(); - const puterSection = $(this_window).find('.puter-subdomain-section'); - const customSection = $(this_window).find('.custom-domain-section'); - const puterLabel = $(this_window).find('input[value="puter"]').closest('.option-label'); - const customLabel = $(this_window).find('input[value="custom"]').closest('.option-label'); - - // Update visual selection (only if not disabled) - puterLabel.css('border-color', selectedValue === 'puter' ? '#007bff' : '#e1e8ed'); - if ( ! $(this_window).find('input[value="custom"]').is(':disabled') ) { - customLabel.css('border-color', selectedValue === 'custom' ? '#007bff' : '#e1e8ed'); - } - - if ( selectedValue === 'puter' ) { - puterSection.show(); - customSection.hide(); - $(this_window).find('.publish-website-subdomain').focus(); - } else if ( selectedValue === 'custom' ) { - puterSection.hide(); - customSection.show(); - $(this_window).find('.publish-website-custom-domain').focus(); - } - }); - - // Add click handler for disabled custom domain option to show upgrade message - $(this_window).find('.custom-domain-label').on('click', function (e) { - const radioButton = $(this).find('input[type="radio"]'); - if ( radioButton.is(':disabled') ) { - e.preventDefault(); - // Could show upgrade modal here in the future - if ( puter.defaultGUIOrigin === 'https://puter.com' ) { - $(this_window).find('.publish-website-error-msg').html( - 'Custom domains require a Premium subscription. Upgrade now to use your own domain name.'); - } else { - $(this_window).find('.publish-website-error-msg').html( - 'Custom domains are not available on this instance of Puter. Yet!'); - } - $(this_window).find('.publish-website-error-msg').fadeIn(); - setTimeout(() => { - $(this_window).find('.publish-website-error-msg').fadeOut(); - }, 5000); - } - }); - - // Style the selected option initially - $(this_window).find('input[value="puter"]').closest('.option-label').css('border-color', '#007bff'); }, window_class: 'window-publishWebsite', window_css: { @@ -178,116 +85,48 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d }, }); - // Function to load Entri SDK - async function loadEntriSDK () { - if ( ! window.entri ) { - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.type = 'text/javascript'; - script.src = 'https://cdn.goentri.com/entri.js'; - script.addEventListener('load', () => { - resolve(window.entri); - }); - script.addEventListener('error', () => { - reject(new Error('Failed to load the Entri SDK.')); - }); - document.body.appendChild(script); - }); - } - } - $(el_window).find('.publish-btn').on('click', async function (e) { e.preventDefault(); - // Get the selected publishing type - const publishingType = $(el_window).find('input[name="publishing-type"]:checked').val(); - // disable 'Publish' button $(el_window).find('.publish-btn').prop('disabled', true); try { - if ( publishingType === 'puter' ) { - // Handle Puter subdomain publishing - let subdomain = $(el_window).find('.publish-website-subdomain').val(); - - if ( ! subdomain.trim() ) { - throw new Error('Please enter a subdomain name'); - } - - const res = await puter.hosting.create(subdomain, target_dir_path); - let url = `https://${ subdomain }.${ window.hosting_domain }/`; - - // Show success - $(el_window).find('.window-publishWebsite-form').hide(100, function () { - $(el_window).find('.publishWebsite-published-link').attr('href', url); - $(el_window).find('.publishWebsite-published-link').text(url); - $(el_window).find('.window-publishWebsite-success').show(100); - $(`.item[data-uid="${target_dir_uid}"] .item-has-website-badge`).show(); - }); - - // find all items whose path starts with target_dir_path - $(`.item[data-path^="${target_dir_path}/"]`).each(function () { - // show the link badge - $(this).find('.item-has-website-url-badge').show(); - // update item's website_url attribute - $(this).attr('data-website_url', url + $(this).attr('data-path').substring(target_dir_path.length)); - }); + let subdomain = $(el_window).find('.publish-website-subdomain').val(); - window.update_sites_cache(); - } else if ( publishingType === 'custom' ) { - // Handle custom domain publishing with Entri - let customDomain = $(el_window).find('.publish-website-custom-domain').val(); - - if ( ! customDomain.trim() ) { - throw new Error('Please enter your custom domain'); - } - - // Step 1: First create a Puter subdomain to host the content - let subdomain = $(el_window).find('.publish-website-subdomain').val(); - if ( ! subdomain.trim() ) { - // Generate a subdomain if not provided - subdomain = window.generate_identifier(); - } - - const hostingRes = await puter.hosting.create(subdomain, target_dir_path); - const puterSiteUrl = `https://${ subdomain }.${ window.hosting_domain}`; - - // Step 2: Load Entri SDK - await loadEntriSDK(); - - // Step 3: Get Entri config from the backend using the Puter subdomain as userHostedSite - const entriConfig = await puter.drivers.call('entri', 'entri-service', 'getConfig', { - domain: customDomain, - userHostedSite: `${subdomain }.${ window.hosting_domain}`, - }); + if ( ! subdomain.trim() ) { + throw new Error('Please enter a subdomain name'); + } - // Step 4: Show Entri interface for custom domain setup - await entri.showEntri(entriConfig.result); + const res = await puter.hosting.create(subdomain, target_dir_path); + let url = `https://${ subdomain }.${ window.hosting_domain }/`; - // Step 5: Show success message with custom domain - let customUrl = `https://${ customDomain }/`; + // Show success + $(el_window).find('.window-publishWebsite-form').hide(100, function () { + $(el_window).find('.publishWebsite-published-link').attr('href', url); + $(el_window).find('.publishWebsite-published-link').text(url); + $(el_window).find('.window-publishWebsite-success').show(100); + $(`.item[data-uid="${target_dir_uid}"] .item-has-website-badge`).show(); + }); - // Update items to show both the Puter subdomain and custom domain - $(`.item[data-path^="${target_dir_path}/"]`).each(function () { - // show the link badge - $(this).find('.item-has-website-url-badge').show(); - // update item's website_url attribute to use custom domain - $(this).attr('data-website_url', customUrl + $(this).attr('data-path').substring(target_dir_path.length)); - // Also store the puter subdomain URL as backup - $(this).attr('data-puter_website_url', puterSiteUrl + $(this).attr('data-path').substring(target_dir_path.length)); - }); + // find all items whose path starts with target_dir_path + $(`.item[data-path^="${target_dir_path}/"]`).each(function () { + // show the link badge + $(this).find('.item-has-website-url-badge').show(); + // update item's website_url attribute + $(this).attr('data-website_url', url + $(this).attr('data-path').substring(target_dir_path.length)); + }); - window.update_sites_cache(); - $(el_window).close(); - } + window.update_sites_cache(); } catch ( err ) { const errorMessage = err.message || (err.error && err.error.message) || 'An error occurred while publishing'; $(el_window).find('.publish-website-error-msg').html( - errorMessage + ( - err.error && err.error.code === 'subdomain_limit_reached' ? - ` ${ i18n('manage_your_subdomains') }` : '' - )); + errorMessage + ( + err.error && err.error.code === 'subdomain_limit_reached' ? + ` ${ i18n('manage_your_subdomains') }` : '' + ), + ); $(el_window).find('.publish-website-error-msg').fadeIn(); // re-enable 'Publish' button $(el_window).find('.publish-btn').prop('disabled', false); From 4adf7b41b9fdd6ba83bc09b21f8f55b1e7be0142 Mon Sep 17 00:00:00 2001 From: jelveh Date: Fri, 17 Apr 2026 12:05:10 -0700 Subject: [PATCH 02/18] Remove Drivers docs and sidebar entry --- src/docs/src/Drivers.md | 10 ---------- src/docs/src/Drivers/call.md | 33 --------------------------------- src/docs/src/sidebar.js | 16 ---------------- 3 files changed, 59 deletions(-) delete mode 100644 src/docs/src/Drivers.md delete mode 100755 src/docs/src/Drivers/call.md diff --git a/src/docs/src/Drivers.md b/src/docs/src/Drivers.md deleted file mode 100644 index c23b63e8d0..0000000000 --- a/src/docs/src/Drivers.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Drivers -description: Interact and access various system resources with Puter drivers. ---- - -The Drivers API allows you to interact with puter drivers. It provides a way to access and control various system resources and peripherals. - -## Available Functions - -- **[`puter.drivers.call()`](/Drivers/call/)** - Call driver functions \ No newline at end of file diff --git a/src/docs/src/Drivers/call.md b/src/docs/src/Drivers/call.md deleted file mode 100755 index e95b204ff9..0000000000 --- a/src/docs/src/Drivers/call.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: puter.drivers.call() -description: Call drivers that are not directly exposed by Puter.js high level API. -platforms: [websites, apps, nodejs, workers] ---- - - -A low-level function that allows you to call any driver on any interface. This function is useful when you want to call a driver that is not directly exposed by Puter.js's high-level API or for when you need more control over the driver call. - -## Syntax -```js -puter.drivers.call(interface, driver, method) -puter.drivers.call(interface, driver, method, args = {}) -``` - -## Parameters -#### `interface` (String) (Required) -The name of the interface you want to call. - -#### `driver` (String) (Required) -The name of the driver you want to call. - -#### `method` (String) (Required) -The name of the method you want to call on the driver. - -#### `args` (Array) (Optional) -An object containing the arguments you want to pass to the driver. - -## Return value - -A `Promise` that will resolve to the result of the driver call. The result can be of any type, depending on the driver you are calling. - -In case of an error, the `Promise` will reject with an error message. diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 6f98d79b14..0d63798076 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -1074,22 +1074,6 @@ let sidebar = [ }, ], }, - { - title: 'Drivers', - title_tag: 'Drivers', - source: '/Drivers.md', - path: '/Drivers', - children: [ - { - title: 'call', - page_title: 'puter.drivers.call()', - title_tag: 'puter.drivers.call()', - icon: '/assets/img/function.svg', - source: '/Drivers/call.md', - path: '/Drivers/call', - }, - ], - }, { title: 'Utilities', title_tag: 'Utilities', From f6c19910684fa139c490065a8ded14981fbbe3be Mon Sep 17 00:00:00 2001 From: jelveh Date: Fri, 17 Apr 2026 12:25:29 -0700 Subject: [PATCH 03/18] Remove referral UI and related logic --- src/gui/src/UI/UIDesktop.js | 28 +----- src/gui/src/UI/UIWindowClaimReferral.js | 71 -------------- src/gui/src/UI/UIWindowRefer.js | 118 ------------------------ src/gui/src/UI/UIWindowSignup.js | 1 - src/gui/src/css/style.css | 17 +--- src/gui/src/initgui.js | 15 --- 6 files changed, 3 insertions(+), 247 deletions(-) delete mode 100644 src/gui/src/UI/UIWindowClaimReferral.js delete mode 100644 src/gui/src/UI/UIWindowRefer.js diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index 13c17dba0c..1bdfad4968 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -18,7 +18,7 @@ */ import path from '../lib/path.js'; -import UIWindowClaimReferral from './UIWindowClaimReferral.js'; + import UIContextMenu from './UIContextMenu.js'; import UIItem from './UIItem.js'; import UIAlert from './UIAlert.js'; @@ -29,7 +29,7 @@ import UIWindowMyWebsites from './UIWindowMyWebsites.js'; import UIWindowFeedback from './UIWindowFeedback.js'; import UIWindowLogin from './UIWindowLogin.js'; import UIWindowQR from './UIWindowQR.js'; -import UIWindowRefer from './UIWindowRefer.js'; + import UIWindowProgress from './UIWindowProgress.js'; import UITaskbar from './UITaskbar.js'; import new_context_menu_item from '../helpers/new_context_menu_item.js'; @@ -1244,11 +1244,6 @@ async function UIDesktop (options) { // 'Show Desktop' ht += ``; - // refer - if ( window.user.referral_code ) { - ht += `
`; - } - // github ht += ``; @@ -1458,21 +1453,6 @@ async function UIDesktop (options) { display_ct(); setInterval(display_ct, 1000); - // show referral notice window - if ( window.show_referral_notice && !window.user.email_confirmed ) { - puter.kv.get('shown_referral_notice').then(async (val) => { - if ( !val || val === 'false' || val === false ) { - setTimeout(() => { - UIWindowClaimReferral(); - }, 1000); - puter.kv.set({ - key: 'shown_referral_notice', - value: true, - }); - } - }); - } - window.hide_toolbar = (animate = true) => { // Always show toolbar on mobile and tablet devices if ( isMobile.phone || isMobile.tablet ) { @@ -2388,10 +2368,6 @@ $(document).on('click', '.user-options-create-account-btn', async function (e) { }); }); -$(document).on('click', '.refer-btn', async function (e) { - UIWindowRefer(); -}); - $(document).on('click', '.start-app', async function (e) { launch_app({ name: $(this).attr('data-app-name'), diff --git a/src/gui/src/UI/UIWindowClaimReferral.js b/src/gui/src/UI/UIWindowClaimReferral.js deleted file mode 100644 index 769bde9a15..0000000000 --- a/src/gui/src/UI/UIWindowClaimReferral.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import UIWindow from './UIWindow.js'; -import UIWindowSaveAccount from './UIWindowSaveAccount.js'; - -async function UIWindowClaimReferral (options) { - let h = ''; - - h += '
'; - h += '
×
'; - h += ``; - h += `

${i18n('you_have_been_referred_to_puter_by_a_friend')}

`; - h += `

${i18n('confirm_account_for_free_referral_storage_c2a')}

`; - h += ``; - h += '
'; - - const el_window = await UIWindow({ - title: 'Refer a friend!', - icon: null, - uid: null, - is_dir: false, - body_content: h, - has_head: false, - selectable_body: false, - draggable_body: true, - allow_context_menu: false, - is_draggable: true, - is_resizable: false, - is_droppable: false, - init_center: true, - allow_native_ctxmenu: true, - allow_user_select: true, - width: 400, - dominant: true, - window_css: { - height: 'initial', - }, - body_css: { - width: 'initial', - 'max-height': 'calc(100vh - 200px)', - 'background-color': 'rgb(241 246 251)', - 'backdrop-filter': 'blur(3px)', - 'padding': '10px 20px 20px 20px', - 'height': 'initial', - }, - }); - - $(el_window).find('.create-account-ref-btn').on('click', function (e) { - UIWindowSaveAccount(); - $(el_window).close(); - }); -} - -export default UIWindowClaimReferral; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowRefer.js b/src/gui/src/UI/UIWindowRefer.js deleted file mode 100644 index 8d052642ab..0000000000 --- a/src/gui/src/UI/UIWindowRefer.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import UIWindow from './UIWindow.js'; -import UIPopover from './UIPopover.js'; -import socialLink from '../helpers/socialLink.js'; - -async function UIWindowRefer (options) { - let h = ''; - const url = `${window.gui_origin}/?r=${window.user.referral_code}`; - - h += '
'; - h += '
×
'; - h += ``; - h += `

${i18n('refer_friends_c2a')}

`; - h += ``; - h += ''; - h += ``; - h += ``; - h += '
'; - - const el_window = await UIWindow({ - title: i18n('window_title_refer_friend'), - window_class: 'window-refer-friend', - icon: null, - uid: null, - is_dir: false, - body_content: h, - has_head: false, - selectable_body: false, - draggable_body: true, - allow_context_menu: false, - is_draggable: true, - is_resizable: false, - is_droppable: false, - init_center: true, - allow_native_ctxmenu: true, - allow_user_select: true, - width: 500, - dominant: true, - window_css: { - height: 'initial', - }, - body_css: { - width: 'initial', - 'max-height': 'calc(100vh - 200px)', - 'background-color': 'rgb(241 246 251)', - 'backdrop-filter': 'blur(3px)', - 'padding': '10px 20px 20px 20px', - 'height': 'initial', - }, - }); - - $(el_window).find('.window-body .downloadable-link').val(url); - - $(el_window).find('.window-body .share-copy-link-on-social').on('click', function (e) { - const social_links = socialLink({ url: url, title: i18n('refer_friends_social_media_c2a'), description: i18n('refer_friends_social_media_c2a') }); - - let social_links_html = ''; - social_links_html += '
'; - social_links_html += `

${i18n('share_to')}

`; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += ``; - social_links_html += '
'; - - UIPopover({ - content: social_links_html, - snapToElement: this, - parent_element: this, - // width: 300, - height: 100, - position: 'bottom', - }); - }); - - $(el_window).find('.window-body .copy-downloadable-link').on('click', async function (e) { - var copy_btn = this; - if ( navigator.clipboard ) { - // Get link text - const selected_text = $(el_window).find('.window-body .downloadable-link').val(); - // copy selected text to clipboard - await navigator.clipboard.writeText(selected_text); - } - else { - // Get the text field - $(el_window).find('.window-body .downloadable-link').select(); - // Copy the text inside the text field - document.execCommand('copy'); - } - - $(this).html(i18n('link_copied')); - setTimeout(function () { - $(copy_btn).html(i18n('copy_link')); - }, 1000); - }); -} - -export default UIWindowRefer; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 7ca113424d..1fef2b9b00 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -318,7 +318,6 @@ function UIWindowSignup (options) { // Include captcha in request only if required const requestData = { username: username, - referral_code: window.referral_code, email: email, password: password, referrer: options.referrer ?? window.referrerStr, diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 4c55b55a1e..d30337affd 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -5417,13 +5417,6 @@ html.dark-mode .usage-table-show-less:hover { max-width: calc(100% - 30px); } -.device-phone .window.window-refer-friend { - left: 50% !important; - transform: translate(-50%) !important; - height: initial !important; - max-width: calc(100% - 30px); -} - .device-phone .window.window-task-manager { height: initial !important; } @@ -5682,15 +5675,7 @@ html.dark-mode .usage-table-show-less:hover { visibility: hidden; } -.refer-friend-c2a { - text-align: center; - font-size: 16px; - padding: 20px; - font-weight: 400; - margin: -10px 10px 20px 10px; - -webkit-font-smoothing: antialiased; - color: #5f626d; -} + .progress-report{ font-size:15px; overflow: hidden; diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index dcbb89b535..4ae28f7b3a 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -516,20 +516,6 @@ window.initgui = async function (options) { window.history.replaceState(null, document.title, cleanUrl); } - //-------------------------------------------------------------------------------------- - // Get user referral code from URL query params - // i.e. https://puter.com/?r=123456 - //-------------------------------------------------------------------------------------- - if ( window.url_query_params.has('r') ) { - window.referral_code = window.url_query_params.get('r'); - // remove 'r' from URL - window.history.pushState(null, document.title, '/'); - // show referral notice, this will be used later if Desktop is loaded - if ( window.first_visit_ever ) { - window.show_referral_notice = true; - } - } - //-------------------------------------------------------------------------------------- // Desktop background (early) // Set before action=login/signup so OIDC error redirects show the background behind the form. @@ -1161,7 +1147,6 @@ window.initgui = async function (options) { let spinner_init_ts = Date.now(); const requestData = { referrer: referrer, - referral_code: window.referral_code, is_temp: true, }; From eaad07e002d5cf5f36458ce88a1b44d0dee3a726 Mon Sep 17 00:00:00 2001 From: Shruc <42489293+P3il4@users.noreply.github.com> Date: Sat, 18 Apr 2026 00:28:26 +0300 Subject: [PATCH 04/18] adjust and refactor together models costs (#2811) * adjust, refactor together costs * aliases follow rule --- .../costMaps/togetherCostMap.ts | 19 +- .../TogetherImageGenerationProvider.ts | 46 ++-- .../TogetherImageGenerationProvider/models.ts | 256 ++++++++++++------ .../src/services/ai/image/providers/types.ts | 16 ++ .../TogetherVideoGenerationProvider/models.ts | 20 +- 5 files changed, 239 insertions(+), 118 deletions(-) diff --git a/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts b/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts index e6f8445dc0..c6884c57ba 100644 --- a/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts +++ b/src/backend/src/services/MeteringService/costMaps/togetherCostMap.ts @@ -16,28 +16,28 @@ export const TOGETHER_COST_MAP = { 'together-image:Qwen/Qwen-Image': 0.0058 * 100_000_000, 'together-image:RunDiffusion/Juggernaut-pro-flux': 0.0049 * 100_000_000, 'together-image:Rundiffusion/Juggernaut-Lightning-Flux': 0.0017 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-Canny-pro': 0.05 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-dev': 0.025 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-dev-lora': 0.025 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-kontext-dev': 0.025 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-kontext-max': 0.08 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-kontext-pro': 0.04 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-krea-dev': 0.025 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1-pro': 0.05 * 100_000_000, 'together-image:black-forest-labs/FLUX.1-schnell': 0.0027 * 100_000_000, - 'together-image:black-forest-labs/FLUX.1.1-pro': 0.05 * 100_000_000, + 'together-image:black-forest-labs/FLUX.1.1-pro': 0.04 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-pro': 0.03 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-flex': 0.03 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-dev': 0.0154 * 100_000_000, 'together-image:black-forest-labs/FLUX.2-max': 0.07 * 100_000_000, 'together-image:google/flash-image-2.5': 0.039 * 100_000_000, + 'together-image:google/flash-image-3.1': 0.067 * 100_000_000, 'together-image:google/gemini-3-pro-image': 0.134 * 100_000_000, 'together-image:google/imagen-4.0-fast': 0.02 * 100_000_000, 'together-image:google/imagen-4.0-preview': 0.04 * 100_000_000, 'together-image:google/imagen-4.0-ultra': 0.06 * 100_000_000, 'together-image:ideogram/ideogram-3.0': 0.06 * 100_000_000, + 'together-image:openai/gpt-image-1.5': 0.034 * 100_000_000, + 'together-image:Qwen/Qwen-Image-2.0': 0.04 * 100_000_000, + 'together-image:Qwen/Qwen-Image-2.0-Pro': 0.08 * 100_000_000, + 'together-image:Wan-AI/Wan2.6-image': 0.03 * 100_000_000, 'together-image:stabilityai/stable-diffusion-3-medium': 0.0019 * 100_000_000, - 'together-image:stabilityai/stable-diffusion-xl-base-1.0': 0.0045 * 100_000_000, + 'together-image:stabilityai/stable-diffusion-xl-base-1.0': 0.0019 * 100_000_000, // Video generation placeholder (per-video pricing). Update with real pricing when available. 'together-video:default': 0, @@ -45,6 +45,7 @@ export const TOGETHER_COST_MAP = { 'together-video:ByteDance/Seedance-1.0-pro': 0.57 * 100_000_000, 'together-video:Wan-AI/Wan2.2-I2V-A14B': 0.31 * 100_000_000, 'together-video:Wan-AI/Wan2.2-T2V-A14B': 0.66 * 100_000_000, + 'together-video:Wan-AI/wan2.7-t2v': 0.10 * 100_000_000, 'together-video:google/veo-2.0': 2.50 * 100_000_000, 'together-video:google/veo-3.0': 1.60 * 100_000_000, 'together-video:google/veo-3.0-audio': 3.20 * 100_000_000, @@ -56,10 +57,10 @@ export const TOGETHER_COST_MAP = { 'together-video:kwaivgI/kling-2.1-master': 0.92 * 100_000_000, 'together-video:kwaivgI/kling-2.1-pro': 0.32 * 100_000_000, 'together-video:kwaivgI/kling-2.1-standard': 0.18 * 100_000_000, - 'together-video:minimax/hailuo-02': 0.56 * 100_000_000, + 'together-video:minimax/hailuo-02': 0.49 * 100_000_000, 'together-video:minimax/video-01-director': 0.28 * 100_000_000, 'together-video:openai/sora-2': 0.80 * 100_000_000, - 'together-video:openai/sora-2-pro': 4.00 * 100_000_000, + 'together-video:openai/sora-2-pro': 3.00 * 100_000_000, 'together-video:pixverse/pixverse-v5': 0.30 * 100_000_000, 'together-video:vidu/vidu-2.0': 0.28 * 100_000_000, 'together-video:vidu/vidu-q1': 0.22 * 100_000_000, diff --git a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts index 8cb9387ae1..9c35e9e44d 100644 --- a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts +++ b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/TogetherImageGenerationProvider.ts @@ -24,14 +24,13 @@ import { Context } from '../../../../../util/context.js'; import { EventService } from '../../../../EventService.js'; import { MeteringService } from '../../../../MeteringService/MeteringService.js'; import { IGenerateParams, IImageModel, IImageProvider } from '../types.js'; -import { TOGETHER_IMAGE_GENERATION_MODELS, GEMINI_3_IMAGE_RESOLUTION_MAP } from './models.js'; +import { TOGETHER_IMAGE_GENERATION_MODELS } from './models.js'; const TOGETHER_DEFAULT_RATIO = { w: 1024, h: 1024 }; type TogetherGenerateParams = IGenerateParams & { steps?: number; seed?: number; negative_prompt?: string; - n?: number; image_url?: string; image_base64?: string; mask_image_url?: string; @@ -101,28 +100,43 @@ export class TogetherImageGenerationProvider implements IImageProvider { throw new Error('actor not found in context'); } - const isGemini3 = selectedModel.id === 'togetherai:google/gemini-3-pro-image'; + const pricingUnit = selectedModel.pricing_unit ?? 'per-MP'; let costInMicroCents: number; let usageAmount: number; - const qualityCostKey = isGemini3 && quality && selectedModel.costs[quality] !== undefined ? quality : undefined; + let usageKey: string; - if ( qualityCostKey ) { - const centsPerImage = selectedModel.costs[qualityCostKey]; + if ( pricingUnit === 'per-image' ) { + const centsPerImage = selectedModel.costs['per-image']; + if ( centsPerImage === undefined ) { + throw new Error(`Model ${selectedModel.id} missing 'per-image' cost`); + } costInMicroCents = centsPerImage * 1_000_000; usageAmount = 1; + usageKey = 'per-image'; + } else if ( pricingUnit === 'per-tier' ) { + const tierKey = quality && selectedModel.costs[quality] !== undefined + ? quality + : Object.keys(selectedModel.costs)[0]; + const centsPerImage = selectedModel.costs[tierKey]; + if ( centsPerImage === undefined ) { + throw new Error(`Model ${selectedModel.id} missing tier cost`); + } + costInMicroCents = centsPerImage * 1_000_000; + usageAmount = 1; + usageKey = tierKey; } else { - const priceKey = '1MP'; - const centsPerMP = selectedModel.costs[priceKey]; + const centsPerMP = selectedModel.costs['1MP']; if ( centsPerMP === undefined ) { - throw new Error(`No pricing configured for model ${selectedModel.id}`); + throw new Error(`Model ${selectedModel.id} missing '1MP' cost`); } const MP = (ratio.h * ratio.w) / 1_000_000; costInMicroCents = centsPerMP * MP * 1_000_000; usageAmount = MP; + usageKey = '1MP'; } - const usageType = `${selectedModel.id}:${quality || '1MP'}`; + const usageType = `${selectedModel.id}:${usageKey}`; const usageAllowed = await this.#meteringService.hasEnoughCredits(actor, costInMicroCents); @@ -130,11 +144,12 @@ export class TogetherImageGenerationProvider implements IImageProvider { throw APIError.create('insufficient_funds'); } - // Resolve abstract aspect ratios to actual pixel dimensions for Gemini 3 Pro + // Resolve abstract aspect ratios (e.g. 1:1, 16:9) to concrete pixel + // dimensions via the model's own resolution_map. let resolvedRatio = ratio; - if ( isGemini3 && quality ) { + if ( pricingUnit === 'per-tier' && quality && selectedModel.resolution_map ) { const ratioKey = `${ratio.w}:${ratio.h}`; - const resolutionEntry = GEMINI_3_IMAGE_RESOLUTION_MAP[ratioKey]?.[quality]; + const resolutionEntry = selectedModel.resolution_map[ratioKey]?.[quality]; if ( resolutionEntry ) { resolvedRatio = resolutionEntry; } @@ -174,7 +189,6 @@ export class TogetherImageGenerationProvider implements IImageProvider { steps, seed, negative_prompt, - n, image_url, image_base64, mask_image_url, @@ -188,6 +202,7 @@ export class TogetherImageGenerationProvider implements IImageProvider { const request: Record = { prompt, model: model ?? DEFAULT_MODEL, + n: 1, }; const requiresConditionImage = this.#modelRequiresConditionImage(request.model as string); @@ -206,9 +221,6 @@ export class TogetherImageGenerationProvider implements IImageProvider { } if ( typeof seed === 'number' && Number.isFinite(seed) ) request.seed = Math.round(seed); if ( typeof negative_prompt === 'string' ) request.negative_prompt = negative_prompt; - if ( typeof n === 'number' && Number.isFinite(n) ) { - request.n = Math.max(1, Math.min(4, Math.round(n))); - } if ( disable_safety_checker ) { request.disable_safety_checker = true; } diff --git a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts index 5eb43ace39..1b3e2abd18 100644 --- a/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts +++ b/src/backend/src/services/ai/image/providers/TogetherImageGenerationProvider/models.ts @@ -19,201 +19,254 @@ import { IImageModel } from '../types'; +type ResolutionMap = Record>; + +export const GEMINI_3_IMAGE_RESOLUTION_MAP: ResolutionMap = { + '1:1': { '1K': { w: 1024, h: 1024 }, '2K': { w: 2048, h: 2048 }, '4K': { w: 4096, h: 4096 } }, + '2:3': { '1K': { w: 848, h: 1264 }, '2K': { w: 1696, h: 2528 }, '4K': { w: 3392, h: 5096 } }, + '3:2': { '1K': { w: 1264, h: 848 }, '2K': { w: 2528, h: 1696 }, '4K': { w: 5096, h: 3392 } }, + '3:4': { '1K': { w: 896, h: 1200 }, '2K': { w: 1792, h: 2400 }, '4K': { w: 3584, h: 4800 } }, + '4:3': { '1K': { w: 1200, h: 896 }, '2K': { w: 2400, h: 1792 }, '4K': { w: 4800, h: 3584 } }, + '4:5': { '1K': { w: 928, h: 1152 }, '2K': { w: 1856, h: 2304 }, '4K': { w: 3712, h: 4608 } }, + '5:4': { '1K': { w: 1152, h: 928 }, '2K': { w: 2304, h: 1856 }, '4K': { w: 4608, h: 3712 } }, + '9:16': { '1K': { w: 768, h: 1376 }, '2K': { w: 1536, h: 2752 }, '4K': { w: 3072, h: 5504 } }, + '16:9': { '1K': { w: 1376, h: 768 }, '2K': { w: 2752, h: 1536 }, '4K': { w: 5504, h: 3072 } }, + '21:9': { '1K': { w: 1584, h: 672 }, '2K': { w: 3168, h: 1344 }, '4K': { w: 6336, h: 2688 } }, +}; + +export const FLASH_IMAGE_3_1_RESOLUTION_MAP: ResolutionMap = { + '1:1': { '0.5K': { w: 512, h: 512 }, '1K': { w: 1024, h: 1024 }, '2K': { w: 2048, h: 2048 }, '4K': { w: 4096, h: 4096 } }, + '1:4': { '0.5K': { w: 256, h: 1024 }, '1K': { w: 512, h: 2048 }, '2K': { w: 1024, h: 4096 }, '4K': { w: 2048, h: 8192 } }, + '1:8': { '0.5K': { w: 192, h: 1536 }, '1K': { w: 384, h: 3072 }, '2K': { w: 768, h: 6144 }, '4K': { w: 1536, h: 12288 } }, + '2:3': { '0.5K': { w: 424, h: 632 }, '1K': { w: 848, h: 1264 }, '2K': { w: 1696, h: 2528 }, '4K': { w: 3392, h: 5056 } }, + '3:2': { '0.5K': { w: 632, h: 424 }, '1K': { w: 1264, h: 848 }, '2K': { w: 2528, h: 1696 }, '4K': { w: 5056, h: 3392 } }, + '3:4': { '0.5K': { w: 448, h: 600 }, '1K': { w: 896, h: 1200 }, '2K': { w: 1792, h: 2400 }, '4K': { w: 3584, h: 4800 } }, + '4:1': { '0.5K': { w: 1024, h: 256 }, '1K': { w: 2048, h: 512 }, '2K': { w: 4096, h: 1024 }, '4K': { w: 8192, h: 2048 } }, + '4:3': { '0.5K': { w: 600, h: 448 }, '1K': { w: 1200, h: 896 }, '2K': { w: 2400, h: 1792 }, '4K': { w: 4800, h: 3584 } }, + '4:5': { '0.5K': { w: 464, h: 576 }, '1K': { w: 928, h: 1152 }, '2K': { w: 1856, h: 2304 }, '4K': { w: 3712, h: 4608 } }, + '5:4': { '0.5K': { w: 576, h: 464 }, '1K': { w: 1152, h: 928 }, '2K': { w: 2304, h: 1856 }, '4K': { w: 4608, h: 3712 } }, + '8:1': { '0.5K': { w: 1536, h: 192 }, '1K': { w: 3072, h: 384 }, '2K': { w: 6144, h: 768 }, '4K': { w: 12288, h: 1536 } }, + '9:16': { '0.5K': { w: 384, h: 688 }, '1K': { w: 768, h: 1376 }, '2K': { w: 1536, h: 2752 }, '4K': { w: 3072, h: 5504 } }, + '16:9': { '0.5K': { w: 688, h: 384 }, '1K': { w: 1376, h: 768 }, '2K': { w: 2752, h: 1536 }, '4K': { w: 5504, h: 3072 } }, + '21:9': { '0.5K': { w: 792, h: 168 }, '1K': { w: 1584, h: 672 }, '2K': { w: 3168, h: 1344 }, '4K': { w: 6336, h: 2688 } }, +}; + export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [ { id: 'togetherai:ByteDance-Seed/Seedream-3.0', - aliases: ['ByteDance-Seed/Seedream-3.0'], + aliases: ['ByteDance-Seed/Seedream-3.0', 'Seedream-3.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'ByteDance-Seed/Seedream-3.0', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 1.8 }, }, { id: 'togetherai:ByteDance-Seed/Seedream-4.0', - aliases: ['ByteDance-Seed/Seedream-4.0'], + aliases: ['ByteDance-Seed/Seedream-4.0', 'Seedream-4.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'ByteDance-Seed/Seedream-4.0', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 3 }, }, { id: 'togetherai:HiDream-ai/HiDream-I1-Dev', - aliases: ['HiDream-ai/HiDream-I1-Dev'], + aliases: ['HiDream-ai/HiDream-I1-Dev', 'HiDream-I1-Dev'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'HiDream-ai/HiDream-I1-Dev', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.45 }, }, { id: 'togetherai:HiDream-ai/HiDream-I1-Fast', - aliases: ['HiDream-ai/HiDream-I1-Fast'], + aliases: ['HiDream-ai/HiDream-I1-Fast', 'HiDream-I1-Fast'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'HiDream-ai/HiDream-I1-Fast', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.32 }, }, { id: 'togetherai:HiDream-ai/HiDream-I1-Full', - aliases: ['HiDream-ai/HiDream-I1-Full'], + aliases: ['HiDream-ai/HiDream-I1-Full', 'HiDream-I1-Full'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'HiDream-ai/HiDream-I1-Full', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.9 }, }, { id: 'togetherai:Lykon/DreamShaper', - aliases: ['Lykon/DreamShaper'], + aliases: ['Lykon/DreamShaper', 'DreamShaper'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'Lykon/DreamShaper', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.06 }, }, { id: 'togetherai:Qwen/Qwen-Image', - aliases: ['Qwen/Qwen-Image'], + aliases: ['Qwen/Qwen-Image', 'Qwen-Image'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'Qwen/Qwen-Image', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.58 }, }, { - id: 'togetherai:RunDiffusion/Juggernaut-pro-flux', - aliases: ['RunDiffusion/Juggernaut-pro-flux'], + id: 'togetherai:Qwen/Qwen-Image-2.0', + aliases: ['Qwen/Qwen-Image-2.0', 'Qwen-Image-2.0'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'RunDiffusion/Juggernaut-pro-flux', + index_cost_key: 'per-image', + name: 'Qwen/Qwen-Image-2.0', allowedQualityLevels: [''], - costs: { '1MP': 0.49 }, + pricing_unit: 'per-image', + costs: { 'per-image': 4 }, }, { - id: 'togetherai:Rundiffusion/Juggernaut-Lightning-Flux', - aliases: ['Rundiffusion/Juggernaut-Lightning-Flux'], + id: 'togetherai:Qwen/Qwen-Image-2.0-Pro', + aliases: ['Qwen/Qwen-Image-2.0-Pro', 'Qwen-Image-2.0-Pro'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'Rundiffusion/Juggernaut-Lightning-Flux', + index_cost_key: 'per-image', + name: 'Qwen/Qwen-Image-2.0-Pro', allowedQualityLevels: [''], - costs: { '1MP': 0.17 }, + pricing_unit: 'per-image', + costs: { 'per-image': 8 }, }, { - id: 'togetherai:black-forest-labs/FLUX.1-dev', - aliases: ['black-forest-labs/FLUX.1-dev'], + id: 'togetherai:RunDiffusion/Juggernaut-pro-flux', + aliases: ['RunDiffusion/Juggernaut-pro-flux', 'Juggernaut-pro-flux'], costs_currency: 'usd-cents', index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-dev', + name: 'RunDiffusion/Juggernaut-pro-flux', allowedQualityLevels: [''], - costs: { '1MP': 2.5 }, + pricing_unit: 'per-MP', + costs: { '1MP': 0.49 }, }, { - id: 'togetherai:black-forest-labs/FLUX.1-dev-lora', - aliases: ['black-forest-labs/FLUX.1-dev-lora'], + id: 'togetherai:Rundiffusion/Juggernaut-Lightning-Flux', + aliases: ['Rundiffusion/Juggernaut-Lightning-Flux', 'Juggernaut-Lightning-Flux'], costs_currency: 'usd-cents', index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-dev-lora', + name: 'Rundiffusion/Juggernaut-Lightning-Flux', allowedQualityLevels: [''], - costs: { '1MP': 2.5 }, + pricing_unit: 'per-MP', + costs: { '1MP': 0.17 }, }, { - id: 'togetherai:black-forest-labs/FLUX.1-kontext-dev', - aliases: ['black-forest-labs/FLUX.1-kontext-dev'], + id: 'togetherai:Wan-AI/Wan2.6-image', + aliases: ['Wan-AI/Wan2.6-image', 'Wan2.6-image'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-kontext-dev', + index_cost_key: 'per-image', + name: 'Wan-AI/Wan2.6-image', allowedQualityLevels: [''], - costs: { '1MP': 2.5 }, + pricing_unit: 'per-image', + costs: { 'per-image': 3 }, }, { id: 'togetherai:black-forest-labs/FLUX.1-kontext-max', - aliases: ['black-forest-labs/FLUX.1-kontext-max'], + aliases: ['black-forest-labs/FLUX.1-kontext-max', 'FLUX.1-kontext-max'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-kontext-max', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 8 }, }, { id: 'togetherai:black-forest-labs/FLUX.1-kontext-pro', - aliases: ['black-forest-labs/FLUX.1-kontext-pro'], + aliases: ['black-forest-labs/FLUX.1-kontext-pro', 'FLUX.1-kontext-pro'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-kontext-pro', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 4 }, }, { id: 'togetherai:black-forest-labs/FLUX.1-krea-dev', - aliases: ['black-forest-labs/FLUX.1-krea-dev'], + aliases: ['black-forest-labs/FLUX.1-krea-dev', 'FLUX.1-krea-dev'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-krea-dev', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 2.5 }, }, - { - id: 'togetherai:black-forest-labs/FLUX.1-pro', - aliases: ['black-forest-labs/FLUX.1-pro'], - costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.1-pro', - allowedQualityLevels: [''], - costs: { '1MP': 5 }, - }, { id: 'togetherai:black-forest-labs/FLUX.1-schnell', - aliases: ['black-forest-labs/FLUX.1-schnell'], + aliases: ['black-forest-labs/FLUX.1-schnell', 'FLUX.1-schnell'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1-schnell', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.27 }, }, { id: 'togetherai:black-forest-labs/FLUX.1.1-pro', - aliases: ['black-forest-labs/FLUX.1.1-pro'], + aliases: ['black-forest-labs/FLUX.1.1-pro', 'FLUX.1.1-pro'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'black-forest-labs/FLUX.1.1-pro', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 4 }, }, { - id: 'togetherai:black-forest-labs/FLUX.2-pro', - aliases: ['black-forest-labs/FLUX.2-pro'], + id: 'togetherai:black-forest-labs/FLUX.2-dev', + aliases: ['black-forest-labs/FLUX.2-dev', 'FLUX.2-dev'], costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.2-pro', + index_cost_key: 'per-image', + name: 'black-forest-labs/FLUX.2-dev', allowedQualityLevels: [''], - costs: { '1MP': 3 }, + pricing_unit: 'per-image', + costs: { 'per-image': 1.54 }, }, { id: 'togetherai:black-forest-labs/FLUX.2-flex', - aliases: ['black-forest-labs/FLUX.2-flex'], + aliases: ['black-forest-labs/FLUX.2-flex', 'FLUX.2-flex'], costs_currency: 'usd-cents', - index_cost_key: '1MP', + index_cost_key: 'per-image', name: 'black-forest-labs/FLUX.2-flex', allowedQualityLevels: [''], - costs: { '1MP': 3 }, + pricing_unit: 'per-image', + costs: { 'per-image': 3 }, }, { - id: 'togetherai:black-forest-labs/FLUX.2-dev', - aliases: ['black-forest-labs/FLUX.2-dev'], + id: 'togetherai:black-forest-labs/FLUX.2-max', + aliases: ['black-forest-labs/FLUX.2-max', 'FLUX.2-max'], costs_currency: 'usd-cents', index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.2-dev', + name: 'black-forest-labs/FLUX.2-max', allowedQualityLevels: [''], - costs: { '1MP': 3 }, + pricing_unit: 'per-MP', + costs: { '1MP': 7 }, + }, + { + id: 'togetherai:black-forest-labs/FLUX.2-pro', + aliases: ['black-forest-labs/FLUX.2-pro', 'FLUX.2-pro'], + costs_currency: 'usd-cents', + index_cost_key: 'per-image', + name: 'black-forest-labs/FLUX.2-pro', + allowedQualityLevels: [''], + pricing_unit: 'per-image', + costs: { 'per-image': 3 }, }, { id: 'togetherai:google/flash-image-2.5', - aliases: ['google/flash-image-2.5'], + aliases: ['google/flash-image-2.5', 'flash-image-2.5'], costs_currency: 'usd-cents', - index_cost_key: '1MP', + index_cost_key: 'per-image', name: 'google/flash-image-2.5', allowedQualityLevels: ['1K'], allowedRatios: [ @@ -228,9 +281,36 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [ { w: 1344, h: 768 }, { w: 1536, h: 672 }, { w: 672, h: 1536 }, - ], - costs: { '1MP': 3.91 }, + pricing_unit: 'per-image', + costs: { 'per-image': 3.9 }, + }, + { + id: 'togetherai:google/flash-image-3.1', + aliases: ['google/flash-image-3.1', 'flash-image-3.1', 'nano-banana-2'], + name: 'google/flash-image-3.1', + costs_currency: 'usd-cents', + index_cost_key: '1K', + allowedQualityLevels: ['0.5K', '1K', '2K', '4K'], + allowedRatios: [ + { w: 1, h: 1 }, + { w: 2, h: 3 }, + { w: 3, h: 2 }, + { w: 3, h: 4 }, + { w: 4, h: 3 }, + { w: 4, h: 5 }, + { w: 5, h: 4 }, + { w: 9, h: 16 }, + { w: 16, h: 9 }, + { w: 21, h: 9 }, + { w: 1, h: 4 }, + { w: 4, h: 1 }, + { w: 1, h: 8 }, + { w: 8, h: 1 }, + ], + pricing_unit: 'per-tier', + costs: { '0.5K': 4.5, '1K': 6.7, '2K': 10.1, '4K': 15.1 }, + resolution_map: FLASH_IMAGE_3_1_RESOLUTION_MAP, }, { id: 'togetherai:google/gemini-3-pro-image', @@ -251,82 +331,78 @@ export const TOGETHER_IMAGE_GENERATION_MODELS: IImageModel[] = [ { w: 16, h: 9 }, { w: 21, h: 9 }, ], + pricing_unit: 'per-tier', costs: { '1K': 13.4, '2K': 13.4, '4K': 24 }, + resolution_map: GEMINI_3_IMAGE_RESOLUTION_MAP, }, { id: 'togetherai:google/imagen-4.0-fast', - aliases: ['google/imagen-4.0-fast'], + aliases: ['google/imagen-4.0-fast', 'imagen-4.0-fast'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'google/imagen-4.0-fast', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 2 }, }, { id: 'togetherai:google/imagen-4.0-preview', - aliases: ['google/imagen-4.0-preview'], + aliases: ['google/imagen-4.0-preview', 'imagen-4.0-preview'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'google/imagen-4.0-preview', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 4 }, }, { id: 'togetherai:google/imagen-4.0-ultra', - aliases: ['google/imagen-4.0-ultra'], + aliases: ['google/imagen-4.0-ultra', 'imagen-4.0-ultra'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'google/imagen-4.0-ultra', allowedQualityLevels: [''], - costs: { '1MP': 6.02 }, + pricing_unit: 'per-MP', + costs: { '1MP': 6 }, }, { id: 'togetherai:ideogram/ideogram-3.0', - aliases: ['ideogram/ideogram-3.0'], + aliases: ['ideogram/ideogram-3.0', 'ideogram-3.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'ideogram/ideogram-3.0', allowedQualityLevels: [''], - costs: { '1MP': 6.02 }, + pricing_unit: 'per-MP', + costs: { '1MP': 6 }, + }, + { + id: 'togetherai:openai/gpt-image-1.5', + aliases: ['openai/gpt-image-1.5', 'gpt-image-1.5'], + costs_currency: 'usd-cents', + index_cost_key: 'per-image', + name: 'openai/gpt-image-1.5', + allowedQualityLevels: [''], + pricing_unit: 'per-image', + costs: { 'per-image': 3.4 }, }, { id: 'togetherai:stabilityai/stable-diffusion-3-medium', - aliases: ['stabilityai/stable-diffusion-3-medium'], + aliases: ['stabilityai/stable-diffusion-3-medium', 'stable-diffusion-3-medium'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'stabilityai/stable-diffusion-3-medium', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.19 }, }, { id: 'togetherai:stabilityai/stable-diffusion-xl-base-1.0', - aliases: ['stabilityai/stable-diffusion-xl-base-1.0'], + aliases: ['stabilityai/stable-diffusion-xl-base-1.0', 'stable-diffusion-xl-base-1.0'], costs_currency: 'usd-cents', index_cost_key: '1MP', name: 'stabilityai/stable-diffusion-xl-base-1.0', allowedQualityLevels: [''], + pricing_unit: 'per-MP', costs: { '1MP': 0.19 }, }, - { - id: 'togetherai:black-forest-labs/FLUX.2-max', - aliases: ['black-forest-labs/FLUX.2-max', 'FLUX.2-max'], - costs_currency: 'usd-cents', - index_cost_key: '1MP', - name: 'black-forest-labs/FLUX.2-max', - allowedQualityLevels: [''], - costs: { '1MP': 7 }, - }, ]; - -export const GEMINI_3_IMAGE_RESOLUTION_MAP: Record> = { - '1:1': { '1K': { w: 1024, h: 1024 }, '2K': { w: 2048, h: 2048 }, '4K': { w: 4096, h: 4096 } }, - '2:3': { '1K': { w: 848, h: 1264 }, '2K': { w: 1696, h: 2528 }, '4K': { w: 3392, h: 5096 } }, - '3:2': { '1K': { w: 1264, h: 848 }, '2K': { w: 2528, h: 1696 }, '4K': { w: 5096, h: 3392 } }, - '3:4': { '1K': { w: 896, h: 1200 }, '2K': { w: 1792, h: 2400 }, '4K': { w: 3584, h: 4800 } }, - '4:3': { '1K': { w: 1200, h: 896 }, '2K': { w: 2400, h: 1792 }, '4K': { w: 4800, h: 3584 } }, - '4:5': { '1K': { w: 928, h: 1152 }, '2K': { w: 1856, h: 2304 }, '4K': { w: 3712, h: 4608 } }, - '5:4': { '1K': { w: 1152, h: 928 }, '2K': { w: 2304, h: 1856 }, '4K': { w: 4608, h: 3712 } }, - '9:16': { '1K': { w: 768, h: 1376 }, '2K': { w: 1536, h: 2752 }, '4K': { w: 3072, h: 5504 } }, - '16:9': { '1K': { w: 1376, h: 768 }, '2K': { w: 2752, h: 1536 }, '4K': { w: 5504, h: 3072 } }, - '21:9': { '1K': { w: 1584, h: 672 }, '2K': { w: 3168, h: 1344 }, '4K': { w: 6336, h: 2688 } }, -}; diff --git a/src/backend/src/services/ai/image/providers/types.ts b/src/backend/src/services/ai/image/providers/types.ts index eac489101c..ffe3ca67d9 100644 --- a/src/backend/src/services/ai/image/providers/types.ts +++ b/src/backend/src/services/ai/image/providers/types.ts @@ -1,3 +1,5 @@ +export type ImagePricingUnit = 'per-image' | 'per-MP' | 'per-tier'; + export interface IImageModel { id: string; name: string; @@ -10,6 +12,20 @@ export interface IImageModel { index_cost_key?: string; index_input_cost_key?: string; costs: Record; + /** + * How `costs` should be interpreted: + * - 'per-image': flat cost per generated image (key: 'per-image') + * - 'per-MP': cost scales with width*height/1e6 (key: '1MP') + * - 'per-tier': cost is picked by `quality` (keys: e.g. '1K','2K','4K') + * Defaults to 'per-MP' when unset (legacy behavior). + */ + pricing_unit?: ImagePricingUnit; + /** + * For per-tier models: resolves an abstract aspect ratio (keyed `{w}:{h}`) + * + quality tier (e.g. '1K'/'2K'/'4K') to concrete pixel dimensions sent + * to the provider. Only consulted when `pricing_unit === 'per-tier'`. + */ + resolution_map?: Record>; allowedQualityLevels?: string[]; allowedRatios?: { w: number, h: number }[]; } diff --git a/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts b/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts index 3563621c70..913bd1791d 100644 --- a/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts +++ b/src/backend/src/services/ai/video/providers/TogetherVideoGenerationProvider/models.ts @@ -54,7 +54,7 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [ name: 'MiniMax Hailuo 02', model: 'minimax/hailuo-02', costs_currency: 'usd-cents', - costs: { 'per-video': 56 }, + costs: { 'per-video': 49 }, output_cost_key: 'per-video', durationSeconds: [10], dimensions: ['1366x768', '1920x1080'], @@ -362,6 +362,22 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [ promptLength: null, promptSupported: null, }, + { + id: 'togetherai:Wan-AI/wan2.7-t2v', + puterId: 'togetherai:wan-ai/wan2.7-t2v', + organization: 'Wan-AI', + name: 'Wan 2.7 T2V', + model: 'Wan-AI/wan2.7-t2v', + costs_currency: 'usd-cents', + costs: { 'per-video': 10 }, + output_cost_key: 'per-video', + durationSeconds: null, + dimensions: null, + fps: null, + keyframes: null, + promptLength: null, + promptSupported: null, + }, { id: 'togetherai:vidu/vidu-2.0', puterId: 'togetherai:vidu/vidu-2.0', @@ -427,7 +443,7 @@ export const TOGETHER_VIDEO_GENERATION_MODELS: ITogetherVideoModel[] = [ name: 'Sora 2 Pro', model: 'openai/sora-2-pro', costs_currency: 'usd-cents', - costs: { 'per-video': 400 }, + costs: { 'per-video': 300 }, output_cost_key: 'per-video', durationSeconds: [8], dimensions: ['1280x720', '720x1280'], From d66b70e05e1fe49a403b576e2a73d73363cfa9f5 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 19:34:00 -0700 Subject: [PATCH 05/18] Request 128px icons and prefer `iconUrl` --- src/gui/src/UI/Dashboard/TabApps.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index ec4c68fc02..738a0a1b23 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -270,7 +270,7 @@ const TabApps = { }, ), fetch( - `${window.api_origin}/get-launch-apps?icon_size=64`, + `${window.api_origin}/get-launch-apps?icon_size=128`, { headers: { 'Authorization': `Bearer ${window.auth_token}` }, method: 'GET', @@ -289,7 +289,7 @@ const TabApps = { name: app.name, title: app.title, uid: app.uuid || app.uid || null, - iconUrl: app.icon || null, + iconUrl: app.iconUrl || app.icon || null, })); // Build seen set from launch apps From 1260bd506b242c96fcdcb1490f99baae56dc25ba Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 19:44:41 -0700 Subject: [PATCH 06/18] Pass parent to context menu; keep icon scaled --- src/gui/src/UI/Dashboard/TabApps.js | 1 + src/gui/src/css/dashboard.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 738a0a1b23..8570a4390b 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -250,6 +250,7 @@ const TabApps = { e.stopPropagation(); UIContextMenu({ + parent_element: $(this), position: { top: e.clientY, left: e.clientX }, items, }); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index b8fd477f5e..d7de5f1fc4 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -733,7 +733,7 @@ body { } @media (hover: hover) { - .myapps-tile:hover .myapps-tile-icon { + .myapps-tile:hover .myapps-tile-icon, .myapps-tile.has-open-contextmenu .myapps-tile-icon{ transform: scale(1.08); } } From 2a1f0da04b511c2376babac5b261c54b75623080 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 19:50:43 -0700 Subject: [PATCH 07/18] Add bottom padding to .myapps-container --- src/gui/src/css/dashboard.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index d7de5f1fc4..3e51fb3891 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -669,6 +669,7 @@ body { .myapps-container { min-height: 200px; + padding-bottom: 50px; } .myapps-empty { From a3fd2757834c427a4bc496ecf7bd7bdc1cb52eb4 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 19:56:29 -0700 Subject: [PATCH 08/18] Remove Beta badge from dashboard sidebar --- src/gui/src/UI/Dashboard/UIDashboard.js | 3 +-- src/gui/src/css/dashboard.css | 16 +--------------- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 7b912e9b7d..2f36ea9634 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -99,8 +99,7 @@ async function UIDashboard (options) { continue; } const isActive = i === 0 ? ' active' : ''; - const isBeta = tab.label === 'Apps'; - h += `
`; + h += `
`; h += tab.icon; h += tab.label; h += '
'; diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 3e51fb3891..e9b295c816 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -284,10 +284,6 @@ body { margin: 8px 4px; } -.dashboard-sidebar.collapsed .dashboard-sidebar-item.beta::after { - display: none; -} - .dashboard-sidebar.collapsed .dashboard-user-options { padding-top: 8px; } @@ -377,17 +373,6 @@ body { font-weight: 500; } -.dashboard-sidebar-item.beta:after { - content: "Beta"; - font-size: 12px; - color: var(--dashboard-background); - background: var(--dashboard-text-muted); - padding: 1px 4px; - border-radius: 4px; - position: absolute; - right: 4px; -} - /* User options button at bottom of sidebar */ .dashboard-user-options { border-top: 1px solid var(--dashboard-border); @@ -731,6 +716,7 @@ body { width: 100%; height: 100%; object-fit: cover; + filter: drop-shadow(0px 0px .3px rgb(173, 173, 173)); } @media (hover: hover) { From 4c578acce23b912233cb82766d4662dc85ddb3d9 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 20:14:22 -0700 Subject: [PATCH 09/18] Update dashboard.css --- src/gui/src/css/dashboard.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index e9b295c816..506f6ed98a 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -719,6 +719,10 @@ body { filter: drop-shadow(0px 0px .3px rgb(173, 173, 173)); } +.myapps-tile.has-open-contextmenu{ + overflow: visible !important; +} + @media (hover: hover) { .myapps-tile:hover .myapps-tile-icon, .myapps-tile.has-open-contextmenu .myapps-tile-icon{ transform: scale(1.08); From 7bb27ffbb410c0006dcee4505d059ea5e8fa8bd7 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 21:40:14 -0700 Subject: [PATCH 10/18] fix #2803 --- src/gui/src/UI/UIItem.js | 60 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index c13797eb02..a460131a85 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -601,12 +601,14 @@ async function UIItem (options) { // If alt key is down, create shortcut items else if ( event.altKey && window.feature_flags.create_shortcut ) { items_to_move.forEach((item_to_move) => { - window.create_shortcut(path.basename($(item_to_move).attr('data-path')), - $(item_to_move).attr('data-is_dir') === '1', - options.is_dir ? $(el_item).attr('data-path') : path.dirname($(el_item).attr('data-path')), - null, - $(item_to_move).attr('data-shortcut_to') === '' ? $(item_to_move).attr('data-uid') : $(item_to_move).attr('data-shortcut_to'), - $(item_to_move).attr('data-shortcut_to_path') === '' ? $(item_to_move).attr('data-path') : $(item_to_move).attr('data-shortcut_to_path')); + window.create_shortcut( + path.basename($(item_to_move).attr('data-path')), + $(item_to_move).attr('data-is_dir') === '1', + options.is_dir ? $(el_item).attr('data-path') : path.dirname($(el_item).attr('data-path')), + null, + $(item_to_move).attr('data-shortcut_to') === '' ? $(item_to_move).attr('data-uid') : $(item_to_move).attr('data-shortcut_to'), + $(item_to_move).attr('data-shortcut_to_path') === '' ? $(item_to_move).attr('data-path') : $(item_to_move).attr('data-shortcut_to_path'), + ); }); } // Otherwise, move items @@ -1156,12 +1158,14 @@ async function UIItem (options) { } if ( is_shared_with_me ) base_dir = window.desktop_path; // create shortcut - window.create_shortcut(path.basename($(this).attr('data-path')), - $(this).attr('data-is_dir') === '1', - base_dir, - $(this).closest('.item-container'), - $(this).attr('data-shortcut_to') === '' ? $(this).attr('data-uid') : $(this).attr('data-shortcut_to'), - $(this).attr('data-shortcut_to_path') === '' ? $(this).attr('data-path') : $(this).attr('data-shortcut_to_path')); + window.create_shortcut( + path.basename($(this).attr('data-path')), + $(this).attr('data-is_dir') === '1', + base_dir, + $(this).closest('.item-container'), + $(this).attr('data-shortcut_to') === '' ? $(this).attr('data-uid') : $(this).attr('data-shortcut_to'), + $(this).attr('data-shortcut_to_path') === '' ? $(this).attr('data-path') : $(this).attr('data-shortcut_to_path'), + ); }); }, }); @@ -1628,12 +1632,14 @@ async function UIItem (options) { if ( is_shared_with_me ) base_dir = window.desktop_path; - window.create_shortcut(path.basename($(el_item).attr('data-path')), - options.is_dir, - base_dir, - options.appendTo, - options.shortcut_to === '' ? options.uid : options.shortcut_to, - options.shortcut_to_path === '' ? options.path : options.shortcut_to_path); + window.create_shortcut( + path.basename($(el_item).attr('data-path')), + options.is_dir, + base_dir, + options.appendTo, + options.shortcut_to === '' ? options.uid : options.shortcut_to, + options.shortcut_to_path === '' ? options.path : options.shortcut_to_path, + ); }, }); } @@ -1660,10 +1666,12 @@ async function UIItem (options) { buttons: [ { label: i18n('delete'), + value: 'Delete', type: 'primary', }, { label: i18n('cancel'), + value: 'Cancel', }, ], }); @@ -1715,13 +1723,15 @@ async function UIItem (options) { let top = $(el_item).position().top + $(el_item).height(); top = top > (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) ? (window.innerHeight - (window_height + window.taskbar_height + window.toolbar_height)) : top; - UIWindowItemProperties($(el_item).attr('data-name'), - $(el_item).attr('data-path'), - $(el_item).attr('data-uid'), - left, - top, - window_width, - window_height); + UIWindowItemProperties( + $(el_item).attr('data-name'), + $(el_item).attr('data-path'), + $(el_item).attr('data-uid'), + left, + top, + window_width, + window_height, + ); }, }); } From 4de724c996774ba64c89399f1a62e08b192565f4 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 19 Apr 2026 21:41:24 -0700 Subject: [PATCH 11/18] Update UIItem.js --- src/gui/src/UI/UIItem.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index a460131a85..87af3161e6 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -1115,10 +1115,12 @@ async function UIItem (options) { buttons: [ { label: i18n('delete'), + value: 'Delete', type: 'primary', }, { label: i18n('cancel'), + value: 'Cancel', }, ], }); From 7f9873edd234c9b144092b0e6fb5459809704be7 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 20 Apr 2026 11:59:35 -0700 Subject: [PATCH 12/18] fix: cdn cache invalidation (#2826) --- .../fsv2/src/controllers/FSController.ts | 754 ++++++++++++------ 1 file changed, 507 insertions(+), 247 deletions(-) diff --git a/extensions/fsv2/src/controllers/FSController.ts b/extensions/fsv2/src/controllers/FSController.ts index 77b265e179..39ca51ca07 100644 --- a/extensions/fsv2/src/controllers/FSController.ts +++ b/extensions/fsv2/src/controllers/FSController.ts @@ -35,11 +35,13 @@ import type { ThumbnailUploadPreparePayload, } from './types.js'; -const { Controller, ExtensionController, HttpError, Post } = extension.import('extensionController'); +const { Controller, ExtensionController, HttpError, Post } = extension.import( + 'extensionController', +); const { Context } = extension.import('core'); -const getApp = extension.import('core').util.helpers.get_app as ( - query: { uid: string }, -) => Promise<{ id?: unknown } | null>; +const getApp = extension.import('core').util.helpers.get_app as (query: { + uid: string; +}) => Promise<{ id?: unknown } | null>; class UploadProgressTracker implements UploadProgressTrackerLike { total = 0; progress = 0; @@ -74,7 +76,6 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8; @Controller('/fs') export class FSController extends ExtensionController { - constructor ( private fsEntryService: FSEntryService, private eventService: EventService, @@ -82,20 +83,32 @@ export class FSController extends ExtensionController { super(); } @Post('/startWrite', { subdomain: 'api' }) - async startWrite (req: Request, res: Response) { + async startWrite ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); - requestBody.fileMetadata = this.#normalizeFileMetadataPath(req, requestBody.fileMetadata, requestBody); - requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(requestBody.fileMetadata, requestBody); + requestBody.fileMetadata = this.#normalizeFileMetadataPath( + req, + requestBody.fileMetadata, + requestBody, + ); + requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( + requestBody.fileMetadata, + requestBody, + ); await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); - const { - response, - createdDirectoryEntries, - } = await this.fsEntryService.startUrlWriteWithCreatedDirectories(userId, requestBody, storageAllowanceMax); + const { response, createdDirectoryEntries } = + await this.fsEntryService.startUrlWriteWithCreatedDirectories( + userId, + requestBody, + storageAllowanceMax, + ); await this.#attachSignedThumbnailUploadTargets([requestBody], [response]); if ( ! requestBody.directory ) { await this.#runNonCritical(async () => { @@ -117,25 +130,35 @@ export class FSController extends ExtensionController { } @Post('/startBatchWrite', { subdomain: 'api' }) - async startBatchWrites (req: Request, res: Response) { + async startBatchWrites ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const appUidLookupCache = new Map>(); const requests = Array.isArray(req.body) - ? await Promise.all(req.body.map(async (requestBody) => { - const normalizedRequestBody = this.#withGuiMetadata(requestBody, req.body); - normalizedRequestBody.fileMetadata = this.#normalizeFileMetadataPath( - req, - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - ); - normalizedRequestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - appUidLookupCache, - ); - return normalizedRequestBody; - })) + ? await Promise.all( + req.body.map(async (requestBody) => { + const normalizedRequestBody = this.#withGuiMetadata( + requestBody, + req.body, + ); + normalizedRequestBody.fileMetadata = + this.#normalizeFileMetadataPath( + req, + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + ); + normalizedRequestBody.fileMetadata = + await this.#resolveAssociatedAppMetadata( + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + appUidLookupCache, + ); + return normalizedRequestBody; + }), + ) : []; await this.#assertBatchWriteAccess( req, @@ -143,11 +166,16 @@ export class FSController extends ExtensionController { { pathAlreadyNormalized: true }, ); - const { - responses, - createdDirectoryEntries, - } = await this.fsEntryService.batchStartUrlWritesWithCreatedDirectories(userId, requests, storageAllowanceMax); - const directoryGuiMetadataByPath = new Map( + const { responses, createdDirectoryEntries } = + await this.fsEntryService.batchStartUrlWritesWithCreatedDirectories( + userId, + requests, + storageAllowanceMax, + ); + const directoryGuiMetadataByPath = new Map< + string, + WriteGuiMetadata | undefined + >( requests .filter((request) => request.directory) .map((request) => [request.fileMetadata.path, request.guiMetadata]), @@ -163,7 +191,11 @@ export class FSController extends ExtensionController { const requestBody = requests[index]; if ( requestBody && writeResponse ) { if ( ! requestBody.directory ) { - await this.#emitGuiPendingWriteEvent(userId, requestBody, writeResponse); + await this.#emitGuiPendingWriteEvent( + userId, + requestBody, + writeResponse, + ); } } }, @@ -188,12 +220,18 @@ export class FSController extends ExtensionController { } @Post('/completeWrite', { subdomain: 'api' }) - async completeWrite (req: Request, res: Response) { + async completeWrite ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const requestBody = this.#withGuiMetadata(req.body, req.body); this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData); - const response = await this.fsEntryService.completeUrlWrite(userId, requestBody); + const response = await this.fsEntryService.completeUrlWrite( + userId, + requestBody, + ); const writeResponse = await this.#applyWriteResponseSideEffects( userId, { @@ -221,7 +259,10 @@ export class FSController extends ExtensionController { for ( const requestBody of requests ) { this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData); } - const response = await this.fsEntryService.batchCompleteUrlWrite(userId, requests); + const response = await this.fsEntryService.batchCompleteUrlWrite( + userId, + requests, + ); const updatedResponse = await runWithConcurrencyLimit( response, DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY, @@ -244,7 +285,10 @@ export class FSController extends ExtensionController { } @Post('/abortWrite', { subdomain: 'api' }) - async abortWrite (req: Request, res: Response<{ ok: true }>) { + async abortWrite ( + req: Request, + res: Response<{ ok: true }>, + ) { const userId = this.#getActorUserId(req); if ( ! req.body?.uploadId ) { throw new HttpError(400, 'Missing uploadId'); @@ -260,17 +304,30 @@ export class FSController extends ExtensionController { res: Response, ) { const userId = this.#getActorUserId(req); - const response = await this.fsEntryService.signMultipartParts(userId, req.body); + const response = await this.fsEntryService.signMultipartParts( + userId, + req.body, + ); res.json(response); } @Post('/write', { subdomain: 'api' }) - async write (req: Request, res: Response) { + async write ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); - requestBody.fileMetadata = this.#normalizeFileMetadataPath(req, requestBody.fileMetadata, requestBody); - requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(requestBody.fileMetadata, requestBody); + requestBody.fileMetadata = this.#normalizeFileMetadataPath( + req, + requestBody.fileMetadata, + requestBody, + ); + requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( + requestBody.fileMetadata, + requestBody, + ); await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); @@ -282,7 +339,12 @@ export class FSController extends ExtensionController { Number(requestBody.fileMetadata.size ?? 0), requestBody.guiMetadata, ); - const response = await this.fsEntryService.write(userId, requestBody, uploadTracker, storageAllowanceMax); + const response = await this.fsEntryService.write( + userId, + requestBody, + uploadTracker, + storageAllowanceMax, + ); const updatedResponse = await this.#applyWriteResponseSideEffects( userId, response, @@ -292,7 +354,10 @@ export class FSController extends ExtensionController { } @Post('/batchWrite', { subdomain: 'api' }) - async batchWrites (req: Request, res: Response) { + async batchWrites ( + req: Request, + res: Response, + ) { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestMode = this.#resolveBatchWriteRequestMode(req); @@ -321,14 +386,21 @@ export class FSController extends ExtensionController { busboy.on('field', (fieldName, value, info) => { if ( info.fieldnameTruncated || info.valueTruncated ) { - failParse(new HttpError(400, 'Batch write manifest field is truncated')); + failParse( + new HttpError(400, 'Batch write manifest field is truncated'), + ); return; } if ( fieldName !== 'manifest' ) { return; } if ( manifestPreparationPromise ) { - failParse(new HttpError(409, 'Batch write manifest was provided more than once')); + failParse( + new HttpError( + 409, + 'Batch write manifest was provided more than once', + ), + ); return; } @@ -339,7 +411,11 @@ export class FSController extends ExtensionController { ...parsedManifest, items: parsedManifest.items.map((item) => ({ ...item, - fileMetadata: this.#normalizeFileMetadataPath(req, item.fileMetadata, item), + fileMetadata: this.#normalizeFileMetadataPath( + req, + item.fileMetadata, + item, + ), })), ignoredItemIndexes, }; @@ -355,17 +431,20 @@ export class FSController extends ExtensionController { } parsedManifest = { ...parsedManifest, - items: await Promise.all(parsedManifest.items.map(async (item) => ({ - ...item, - fileMetadata: await this.#resolveAssociatedAppMetadata( - item.fileMetadata, - item, - appUidLookupCache, - ), - }))), + items: await Promise.all( + parsedManifest.items.map(async (item) => ({ + ...item, + fileMetadata: await this.#resolveAssociatedAppMetadata( + item.fileMetadata, + item, + appUidLookupCache, + ), + })), + ), }; - const activeManifestItems = parsedManifest.items - .filter((item) => !parsedManifest?.ignoredItemIndexes?.has(item.index)); + const activeManifestItems = parsedManifest.items.filter( + (item) => !parsedManifest?.ignoredItemIndexes?.has(item.index), + ); await this.#assertBatchWriteAccess( req, @@ -405,7 +484,10 @@ export class FSController extends ExtensionController { throw parseFailure; } if ( ! manifestPreparationPromise ) { - throw new HttpError(400, 'Batch write manifest must come before file content'); + throw new HttpError( + 400, + 'Batch write manifest must come before file content', + ); } await manifestPreparationPromise; @@ -428,13 +510,19 @@ export class FSController extends ExtensionController { return null; } if ( uploadedIndexes.has(itemIndex) ) { - throw new HttpError(409, `Duplicate file content for batch index ${itemIndex}`); + throw new HttpError( + 409, + `Duplicate file content for batch index ${itemIndex}`, + ); } uploadedIndexes.add(itemIndex); const preparedItem = preparedBatch.itemsByIndex.get(itemIndex); if ( ! preparedItem ) { - throw new HttpError(400, `Batch write metadata was not found for index ${itemIndex}`); + throw new HttpError( + 400, + `Batch write metadata was not found for index ${itemIndex}`, + ); } const uploadTracker = await this.#createUploadTracker( @@ -475,27 +563,47 @@ export class FSController extends ExtensionController { await manifestPreparationPromise; const uploadResults = await Promise.allSettled(uploadPromises); const uploadedItems = uploadResults - .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .filter( + ( + result, + ): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) .map((result) => result.value) - .filter((uploadedItem): uploadedItem is UploadedBatchWriteItem => uploadedItem !== null); + .filter( + (uploadedItem): uploadedItem is UploadedBatchWriteItem => + uploadedItem !== null, + ); if ( parseFailure ) { if ( preparedBatch ) { - await this.fsEntryService.cleanupPreparedBatchUploads(preparedBatch, uploadedItems); + await this.fsEntryService.cleanupPreparedBatchUploads( + preparedBatch, + uploadedItems, + ); } throw parseFailure; } if ( ! preparedBatch ) { throw new HttpError(500, 'Failed to prepare batch write operation'); } - const failedUpload = uploadResults.find((result) => result.status === 'rejected'); + const failedUpload = uploadResults.find( + (result) => result.status === 'rejected', + ); if ( failedUpload?.status === 'rejected' ) { - await this.fsEntryService.cleanupPreparedBatchUploads(preparedBatch, uploadedItems); - throw (failedUpload.reason instanceof Error + await this.fsEntryService.cleanupPreparedBatchUploads( + preparedBatch, + uploadedItems, + ); + throw failedUpload.reason instanceof Error ? failedUpload.reason - : new Error('Failed to upload multipart batch item')); + : new Error('Failed to upload multipart batch item'); } - const writeResponses = await this.fsEntryService.finalizePreparedBatchWrites(preparedBatch, uploadedItems); + const writeResponses = + await this.fsEntryService.finalizePreparedBatchWrites( + preparedBatch, + uploadedItems, + ); const updatedResponses = await runWithConcurrencyLimit( writeResponses, 32, @@ -513,20 +621,27 @@ export class FSController extends ExtensionController { } const requests = Array.isArray(req.body) - ? await Promise.all(req.body.map(async (requestBody) => { - const normalizedRequestBody = this.#withGuiMetadata(requestBody, req.body); - normalizedRequestBody.fileMetadata = this.#normalizeFileMetadataPath( - req, - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - ); - normalizedRequestBody.fileMetadata = await this.#resolveAssociatedAppMetadata( - normalizedRequestBody.fileMetadata, - normalizedRequestBody, - appUidLookupCache, - ); - return normalizedRequestBody; - })) + ? await Promise.all( + req.body.map(async (requestBody) => { + const normalizedRequestBody = this.#withGuiMetadata( + requestBody, + req.body, + ); + normalizedRequestBody.fileMetadata = + this.#normalizeFileMetadataPath( + req, + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + ); + normalizedRequestBody.fileMetadata = + await this.#resolveAssociatedAppMetadata( + normalizedRequestBody.fileMetadata, + normalizedRequestBody, + appUidLookupCache, + ); + return normalizedRequestBody; + }), + ) : []; const filteredRequests = requests.filter((requestBody) => { return !this.#shouldIgnoreUploadPath(requestBody.fileMetadata.path); @@ -562,7 +677,9 @@ export class FSController extends ExtensionController { async (requestBody, index) => { const preparedItem = preparedBatch.items[index]; if ( ! preparedItem ) { - throw new Error(`Failed to resolve prepared batch item for index ${index}`); + throw new Error( + `Failed to resolve prepared batch item for index ${index}`, + ); } const uploadTracker = await this.#createUploadTracker( userId, @@ -581,17 +698,29 @@ export class FSController extends ExtensionController { }, ); const uploadedItems = uploadResults - .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) .map((result) => result.value); - const failedUpload = uploadResults.find((result) => result.status === 'rejected'); + const failedUpload = uploadResults.find( + (result) => result.status === 'rejected', + ); if ( failedUpload?.status === 'rejected' ) { - await this.fsEntryService.cleanupPreparedBatchUploads(preparedBatch, uploadedItems); - throw (failedUpload.reason instanceof Error + await this.fsEntryService.cleanupPreparedBatchUploads( + preparedBatch, + uploadedItems, + ); + throw failedUpload.reason instanceof Error ? failedUpload.reason - : new Error('Failed to upload batch write item')); + : new Error('Failed to upload batch write item'); } - const writeResponses = await this.fsEntryService.finalizePreparedBatchWrites(preparedBatch, uploadedItems); + const writeResponses = + await this.fsEntryService.finalizePreparedBatchWrites( + preparedBatch, + uploadedItems, + ); const updatedResponses = await runWithConcurrencyLimit( writeResponses, 32, @@ -607,14 +736,14 @@ export class FSController extends ExtensionController { res.json(updatedResponses); } - #getActorUserId ( - req: Request, - ): number { - const requestUser = (req as Request & { - user?: { - id?: unknown; - }; - }).user; + #getActorUserId (req: Request): number { + const requestUser = ( + req as Request & { + user?: { + id?: unknown; + }; + } + ).user; const actorUser = req.actor?.type?.user; const candidateUserId = requestUser?.id ?? actorUser?.id; if ( candidateUserId === undefined || candidateUserId === null ) { @@ -629,17 +758,20 @@ export class FSController extends ExtensionController { return userId; } - #getActorUsername ( - req: Request, - ): string { - const requestUser = (req as Request & { - user?: { - username?: unknown; - }; - }).user; + #getActorUsername (req: Request): string { + const requestUser = ( + req as Request & { + user?: { + username?: unknown; + }; + } + ).user; const actorUser = req.actor?.type?.user; const actorUsername = requestUser?.username ?? actorUser?.username; - if ( typeof actorUsername !== 'string' || actorUsername.trim().length === 0 ) { + if ( + typeof actorUsername !== 'string' || + actorUsername.trim().length === 0 + ) { throw new HttpError(401, 'Unauthorized'); } return actorUsername.trim(); @@ -721,7 +853,9 @@ export class FSController extends ExtensionController { normalizedFileMetadata.path = path; } - const size = this.#toNumber(this.#firstDefined(metadataRecord.size, fallbackRecord.size)); + const size = this.#toNumber( + this.#firstDefined(metadataRecord.size, fallbackRecord.size), + ); if ( size !== undefined ) { normalizedFileMetadata.size = size; } @@ -746,70 +880,79 @@ export class FSController extends ExtensionController { normalizedFileMetadata.checksumSha256 = checksumSha256; } - const overwrite = this.#toBoolean(this.#firstDefined( - metadataRecord.overwrite, - fallbackRecord.overwrite, - )); + const overwrite = this.#toBoolean( + this.#firstDefined(metadataRecord.overwrite, fallbackRecord.overwrite), + ); if ( overwrite !== undefined ) { normalizedFileMetadata.overwrite = overwrite; } - const dedupeName = this.#toBoolean(this.#firstDefined( - metadataRecord.dedupeName, - metadataRecord.dedupe_name, - fallbackRecord.dedupeName, - fallbackRecord.dedupe_name, - fallbackRecord.rename, - fallbackRecord.change_name, - )); + const dedupeName = this.#toBoolean( + this.#firstDefined( + metadataRecord.dedupeName, + metadataRecord.dedupe_name, + fallbackRecord.dedupeName, + fallbackRecord.dedupe_name, + fallbackRecord.rename, + fallbackRecord.change_name, + ), + ); if ( dedupeName !== undefined ) { normalizedFileMetadata.dedupeName = dedupeName; } - const createMissingParents = this.#toBoolean(this.#firstDefined( - metadataRecord.createMissingParents, - metadataRecord.create_missing_parents, - metadataRecord.create_missing_ancestors, - fallbackRecord.createMissingParents, - fallbackRecord.create_missing_parents, - fallbackRecord.createMissingAncestors, - fallbackRecord.create_missing_ancestors, - fallbackRecord.createFileParent, - fallbackRecord.create_file_parent, - )); + const createMissingParents = this.#toBoolean( + this.#firstDefined( + metadataRecord.createMissingParents, + metadataRecord.create_missing_parents, + metadataRecord.create_missing_ancestors, + fallbackRecord.createMissingParents, + fallbackRecord.create_missing_parents, + fallbackRecord.createMissingAncestors, + fallbackRecord.create_missing_ancestors, + fallbackRecord.createFileParent, + fallbackRecord.create_file_parent, + ), + ); if ( createMissingParents !== undefined ) { normalizedFileMetadata.createMissingParents = createMissingParents; } - const immutable = this.#toBoolean(this.#firstDefined( - metadataRecord.immutable, - fallbackRecord.immutable, - )); + const immutable = this.#toBoolean( + this.#firstDefined(metadataRecord.immutable, fallbackRecord.immutable), + ); if ( immutable !== undefined ) { normalizedFileMetadata.immutable = immutable; } - const isPublic = this.#toBoolean(this.#firstDefined( - metadataRecord.isPublic, - metadataRecord.is_public, - fallbackRecord.isPublic, - fallbackRecord.is_public, - )); + const isPublic = this.#toBoolean( + this.#firstDefined( + metadataRecord.isPublic, + metadataRecord.is_public, + fallbackRecord.isPublic, + fallbackRecord.is_public, + ), + ); if ( isPublic !== undefined ) { normalizedFileMetadata.isPublic = isPublic; } - const multipartPartSize = this.#toNumber(this.#firstDefined( - metadataRecord.multipartPartSize, - metadataRecord.multipart_part_size, - fallbackRecord.multipartPartSize, - fallbackRecord.multipart_part_size, - )); + const multipartPartSize = this.#toNumber( + this.#firstDefined( + metadataRecord.multipartPartSize, + metadataRecord.multipart_part_size, + fallbackRecord.multipartPartSize, + fallbackRecord.multipart_part_size, + ), + ); if ( multipartPartSize !== undefined && multipartPartSize > 0 ) { normalizedFileMetadata.multipartPartSize = multipartPartSize; } - const bucket = this.#firstDefined(metadataRecord.bucket, fallbackRecord.bucket); + const bucket = this.#firstDefined( + metadataRecord.bucket, + fallbackRecord.bucket, + ); if ( typeof bucket === 'string' && bucket.length > 0 ) { normalizedFileMetadata.bucket = bucket; } @@ -824,12 +967,14 @@ export class FSController extends ExtensionController { normalizedFileMetadata.bucketRegion = bucketRegion; } - const associatedAppId = this.#toNumber(this.#firstDefined( - metadataRecord.associatedAppId, - metadataRecord.associated_app_id, - fallbackRecord.associatedAppId, - fallbackRecord.associated_app_id, - )); + const associatedAppId = this.#toNumber( + this.#firstDefined( + metadataRecord.associatedAppId, + metadataRecord.associated_app_id, + fallbackRecord.associatedAppId, + fallbackRecord.associated_app_id, + ), + ); if ( associatedAppId !== undefined ) { normalizedFileMetadata.associatedAppId = associatedAppId; } @@ -845,12 +990,14 @@ export class FSController extends ExtensionController { const metadataRecord = this.#toObjectRecord(fileMetadata); const fallbackRecord = this.#toObjectRecord(fallbackSource); - const associatedAppId = this.#toNumber(this.#firstDefined( - metadataRecord.associatedAppId, - metadataRecord.associated_app_id, - fallbackRecord.associatedAppId, - fallbackRecord.associated_app_id, - )); + const associatedAppId = this.#toNumber( + this.#firstDefined( + metadataRecord.associatedAppId, + metadataRecord.associated_app_id, + fallbackRecord.associatedAppId, + fallbackRecord.associated_app_id, + ), + ); if ( associatedAppId !== undefined ) { return { ...fileMetadata, @@ -947,7 +1094,10 @@ export class FSController extends ExtensionController { fileMetadata: FSEntryWriteInput | undefined, fallbackSource?: unknown, ): FSEntryWriteInput { - const resolvedFileMetadata = this.#resolveWriteFileMetadata(fileMetadata, fallbackSource); + const resolvedFileMetadata = this.#resolveWriteFileMetadata( + fileMetadata, + fallbackSource, + ); if ( typeof resolvedFileMetadata.path !== 'string' ) { throw new HttpError(400, 'Missing path'); } @@ -959,46 +1109,60 @@ export class FSController extends ExtensionController { }; } - #extractGuiMetadata (input: unknown, fallback: WriteGuiMetadata | undefined): WriteGuiMetadata | undefined { - const source = input && typeof input === 'object' - ? input as Record - : {}; + #extractGuiMetadata ( + input: unknown, + fallback: WriteGuiMetadata | undefined, + ): WriteGuiMetadata | undefined { + const source = + input && typeof input === 'object' + ? (input as Record) + : {}; const guiMetadata: WriteGuiMetadata = { - originalClientSocketId: typeof source.originalClientSocketId === 'string' - ? source.originalClientSocketId - : typeof source.original_client_socket_id === 'string' - ? source.original_client_socket_id - : fallback?.originalClientSocketId, - socketId: typeof source.socketId === 'string' - ? source.socketId - : typeof source.socket_id === 'string' - ? source.socket_id - : fallback?.socketId, - operationId: typeof source.operationId === 'string' - ? source.operationId - : typeof source.operation_id === 'string' - ? source.operation_id - : fallback?.operationId, - itemUploadId: typeof source.itemUploadId === 'string' - ? source.itemUploadId - : typeof source.item_upload_id === 'string' - ? source.item_upload_id - : fallback?.itemUploadId, + originalClientSocketId: + typeof source.originalClientSocketId === 'string' + ? source.originalClientSocketId + : typeof source.original_client_socket_id === 'string' + ? source.original_client_socket_id + : fallback?.originalClientSocketId, + socketId: + typeof source.socketId === 'string' + ? source.socketId + : typeof source.socket_id === 'string' + ? source.socket_id + : fallback?.socketId, + operationId: + typeof source.operationId === 'string' + ? source.operationId + : typeof source.operation_id === 'string' + ? source.operation_id + : fallback?.operationId, + itemUploadId: + typeof source.itemUploadId === 'string' + ? source.itemUploadId + : typeof source.item_upload_id === 'string' + ? source.item_upload_id + : fallback?.itemUploadId, }; if ( - !guiMetadata.originalClientSocketId - && !guiMetadata.socketId - && !guiMetadata.operationId - && !guiMetadata.itemUploadId + !guiMetadata.originalClientSocketId && + !guiMetadata.socketId && + !guiMetadata.operationId && + !guiMetadata.itemUploadId ) { return undefined; } return guiMetadata; } - #withGuiMetadata (value: T, fallbackSource: unknown): T { - const guiMetadata = this.#extractGuiMetadata(value, this.#extractGuiMetadata(fallbackSource, undefined)); + #withGuiMetadata( + value: T, + fallbackSource: unknown, + ): T { + const guiMetadata = this.#extractGuiMetadata( + value, + this.#extractGuiMetadata(fallbackSource, undefined), + ); if ( ! guiMetadata ) { return value; } @@ -1038,14 +1202,16 @@ export class FSController extends ExtensionController { const dedupeEnabled = this.#isDedupeEnabled(normalizedFileMetadata); let pathToCheck = parentPath; if ( Boolean(normalizedFileMetadata.overwrite) && !dedupeEnabled ) { - const destinationExists = await this.fsEntryService.entryExistsByPath(targetPath); + const destinationExists = + await this.fsEntryService.entryExistsByPath(targetPath); if ( destinationExists ) { pathToCheck = targetPath; } } const fsEntryService = this.fsEntryService; - let ancestorsCache: Promise> | null = null; + let ancestorsCache: Promise> | null = + null; const resourceDescriptor = { path: pathToCheck, resolveAncestors () { @@ -1061,7 +1227,11 @@ export class FSController extends ExtensionController { return; } - const safeAclError = await aclService.get_safe_acl_error(actor, resourceDescriptor, 'write') as { + const safeAclError = (await aclService.get_safe_acl_error( + actor, + resourceDescriptor, + 'write', + )) as { status?: unknown; message?: unknown; fields?: { @@ -1069,15 +1239,17 @@ export class FSController extends ExtensionController { }; }; const safeAclStatus = Number(safeAclError?.status); - const safeAclMessage = typeof safeAclError?.message === 'string' && safeAclError.message.length > 0 - ? safeAclError.message - : 'Write access denied for destination'; - const safeAclCode = typeof safeAclError?.fields?.code === 'string' - ? safeAclError.fields.code - : undefined; - const legacyCode = safeAclCode === 'forbidden' - ? 'access_denied' - : safeAclCode; + const safeAclMessage = + typeof safeAclError?.message === 'string' && + safeAclError.message.length > 0 + ? safeAclError.message + : 'Write access denied for destination'; + const safeAclCode = + typeof safeAclError?.fields?.code === 'string' + ? safeAclError.fields.code + : undefined; + const legacyCode = + safeAclCode === 'forbidden' ? 'access_denied' : safeAclCode; if ( safeAclStatus === 404 ) { throw new HttpError(404, safeAclMessage, { @@ -1122,8 +1294,12 @@ export class FSController extends ExtensionController { ? { original_client_socket_id: guiMetadata.originalClientSocketId } : {}), ...(guiMetadata.socketId ? { socket_id: guiMetadata.socketId } : {}), - ...(guiMetadata.operationId ? { operation_id: guiMetadata.operationId } : {}), - ...(guiMetadata.itemUploadId ? { item_upload_id: guiMetadata.itemUploadId } : {}), + ...(guiMetadata.operationId + ? { operation_id: guiMetadata.operationId } + : {}), + ...(guiMetadata.itemUploadId + ? { item_upload_id: guiMetadata.itemUploadId } + : {}), }; } @@ -1157,15 +1333,20 @@ export class FSController extends ExtensionController { associated_app_id: entry.associatedAppId, }; - if ( typeof response.thumbnail === 'string' && response.thumbnail.length > 0 ) { + if ( + typeof response.thumbnail === 'string' && + response.thumbnail.length > 0 + ) { const thumbnailEntry = { uuid: entry.uuid, thumbnail: response.thumbnail, }; await this.eventService.emit('thumbnail.read', thumbnailEntry); - response.thumbnail = typeof thumbnailEntry.thumbnail === 'string' && thumbnailEntry.thumbnail.length > 0 - ? thumbnailEntry.thumbnail - : null; + response.thumbnail = + typeof thumbnailEntry.thumbnail === 'string' && + thumbnailEntry.thumbnail.length > 0 + ? thumbnailEntry.thumbnail + : null; } return response; @@ -1177,7 +1358,7 @@ export class FSController extends ExtensionController { guiMetadata: WriteGuiMetadata | undefined, ): Promise { const response = { - ...await this.#toGuiFsEntry(fsEntry), + ...(await this.#toGuiFsEntry(fsEntry)), ...this.#toEventGuiMetadata(guiMetadata, false), from_new_service: true, }; @@ -1187,6 +1368,16 @@ export class FSController extends ExtensionController { }); } + async #emitFsLifecycleEvent ( + eventName: 'fs.write.file' | 'fs.create.file', + fsEntry: FSEntry, + ): Promise { + await this.eventService.emit(eventName, { + node: fsEntry, + context: Context.get(), + }); + } + async #emitGuiPendingWriteEvent ( userId: number, requestBody: SignedWriteRequest, @@ -1223,7 +1414,7 @@ export class FSController extends ExtensionController { #estimateDataUrlSize (dataUrl: string): number { const commaIndex = dataUrl.indexOf(','); const base64 = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1); - return Math.ceil(base64.length * 3 / 4); + return Math.ceil((base64.length * 3) / 4); } #isOversizedThumbnailDataUrl (thumbnail: string): boolean { @@ -1247,15 +1438,21 @@ export class FSController extends ExtensionController { const thumbnailPayload = { url: requestedThumbnail }; await this.eventService.emit('thumbnail.created', thumbnailPayload); - const finalThumbnail = typeof thumbnailPayload.url === 'string' && thumbnailPayload.url.length > 0 - ? thumbnailPayload.url - : null; + const finalThumbnail = + typeof thumbnailPayload.url === 'string' && + thumbnailPayload.url.length > 0 + ? thumbnailPayload.url + : null; if ( finalThumbnail === fsEntry.thumbnail || finalThumbnail === null ) { return fsEntry; } - return this.fsEntryService.updateEntryThumbnail(userId, fsEntry.uuid, finalThumbnail); + return this.fsEntryService.updateEntryThumbnail( + userId, + fsEntry.uuid, + finalThumbnail, + ); } #toThumbnailPrepareItem ( @@ -1271,11 +1468,15 @@ export class FSController extends ExtensionController { return null; } - const contentType = typeof thumbnailMetadata.contentType === 'string' - ? thumbnailMetadata.contentType.trim() - : ''; + const contentType = + typeof thumbnailMetadata.contentType === 'string' + ? thumbnailMetadata.contentType.trim() + : ''; if ( ! contentType ) { - throw new HttpError(400, 'thumbnailMetadata.contentType is required for signed thumbnail upload'); + throw new HttpError( + 400, + 'thumbnailMetadata.contentType is required for signed thumbnail upload', + ); } if ( thumbnailMetadata.size === undefined ) { @@ -1284,7 +1485,10 @@ export class FSController extends ExtensionController { const size = Number(thumbnailMetadata.size); if ( !Number.isFinite(size) || size < 0 ) { - throw new HttpError(400, 'thumbnailMetadata.size must be a non-negative number'); + throw new HttpError( + 400, + 'thumbnailMetadata.size must be a non-negative number', + ); } if ( size > MAX_THUMBNAIL_BYTES ) { return null; @@ -1298,30 +1502,39 @@ export class FSController extends ExtensionController { responses: SignedWriteResponse[], ): Promise { const prepareItems = requests - .map((requestBody, index) => this.#toThumbnailPrepareItem(requestBody, index)) + .map((requestBody, index) => + this.#toThumbnailPrepareItem(requestBody, index)) .filter((item): item is ThumbnailUploadPrepareItem => Boolean(item)); if ( prepareItems.length === 0 ) { return; } const payload: ThumbnailUploadPreparePayload = { - items: prepareItems.map((item): ThumbnailUploadPrepareItem => ({ - index: item.index, - contentType: item.contentType, - ...(item.size !== undefined ? { size: item.size } : {}), - })), + items: prepareItems.map( + (item): ThumbnailUploadPrepareItem => ({ + index: item.index, + contentType: item.contentType, + ...(item.size !== undefined ? { size: item.size } : {}), + }), + ), }; await this.eventService.emit('thumbnail.upload.prepare', payload); for ( const item of payload.items ) { const response = responses[item.index]; if ( ! response ) { - throw new HttpError(500, 'Failed to resolve signed thumbnail response target'); + throw new HttpError( + 500, + 'Failed to resolve signed thumbnail response target', + ); } if ( typeof item.uploadUrl !== 'string' || item.uploadUrl.length === 0 ) { continue; } - if ( typeof item.thumbnailUrl !== 'string' || item.thumbnailUrl.length === 0 ) { + if ( + typeof item.thumbnailUrl !== 'string' || + item.thumbnailUrl.length === 0 + ) { continue; } @@ -1356,13 +1569,14 @@ export class FSController extends ExtensionController { } const contentTypeHeader = req.headers['content-type']; - const contentType = typeof contentTypeHeader === 'string' - ? contentTypeHeader.toLowerCase() - : ''; + const contentType = + typeof contentTypeHeader === 'string' + ? contentTypeHeader.toLowerCase() + : ''; if ( - contentType.includes('application/json') - || contentType.startsWith('text/plain;actually=json') + contentType.includes('application/json') || + contentType.startsWith('text/plain;actually=json') ) { return 'json'; } @@ -1373,11 +1587,17 @@ export class FSController extends ExtensionController { ); } - async #runNonCritical (work: () => Promise, operationName: string): Promise { + async #runNonCritical ( + work: () => Promise, + operationName: string, + ): Promise { try { await work(); } catch ( error ) { - console.error(`prodfsv2 non-critical operation failed: ${operationName}`, error); + console.error( + `prodfsv2 non-critical operation failed: ${operationName}`, + error, + ); } } @@ -1410,7 +1630,10 @@ export class FSController extends ExtensionController { return uploadTracker; } - async #emitWriteHashEvent (contentHashSha256: string | null | undefined, entryUuid: string): Promise { + async #emitWriteHashEvent ( + contentHashSha256: string | null | undefined, + entryUuid: string, + ): Promise { if ( ! contentHashSha256 ) { return; } @@ -1441,12 +1664,21 @@ export class FSController extends ExtensionController { await this.#runNonCritical(async () => { await this.#emitGuiWriteEvent( - response.wasOverwrite ? 'outer.gui.item.updated' : 'outer.gui.item.added', + response.wasOverwrite + ? 'outer.gui.item.updated' + : 'outer.gui.item.added', fsEntry, guiMetadata, ); }, 'emitGuiWriteEvent'); + await this.#runNonCritical(async () => { + await this.#emitFsLifecycleEvent( + response.wasOverwrite ? 'fs.write.file' : 'fs.create.file', + fsEntry, + ); + }, 'emitFsLifecycleEvent'); + await hashEventPromise; return { ...response, fsEntry }; @@ -1469,32 +1701,55 @@ export class FSController extends ExtensionController { const manifest: BatchWriteManifest = Array.isArray(parsedManifest) ? { items: parsedManifest as BatchWriteManifestItem[] } - : parsedManifest as BatchWriteManifest; + : (parsedManifest as BatchWriteManifest); - if ( !manifest || !Array.isArray(manifest.items) || manifest.items.length === 0 ) { - throw new HttpError(400, 'Batch write manifest must include a non-empty items array'); + if ( + !manifest || + !Array.isArray(manifest.items) || + manifest.items.length === 0 + ) { + throw new HttpError( + 400, + 'Batch write manifest must include a non-empty items array', + ); } - const manifestGuiMetadata = this.#extractGuiMetadata(manifest, fallbackGuiMetadata); + const manifestGuiMetadata = this.#extractGuiMetadata( + manifest, + fallbackGuiMetadata, + ); const normalizedItems = manifest.items.map((item, orderIndex) => { if ( !item || typeof item !== 'object' ) { - throw new HttpError(400, `Batch write manifest item at position ${orderIndex} is invalid`); + throw new HttpError( + 400, + `Batch write manifest item at position ${orderIndex} is invalid`, + ); } - const candidateIndex = (item as { index?: number | string }).index ?? orderIndex; + const candidateIndex = + (item as { index?: number | string }).index ?? orderIndex; const index = Number(candidateIndex); if ( !Number.isInteger(index) || index < 0 ) { - throw new HttpError(400, `Batch write manifest item index is invalid at position ${orderIndex}`); + throw new HttpError( + 400, + `Batch write manifest item index is invalid at position ${orderIndex}`, + ); } if ( !item.fileMetadata || typeof item.fileMetadata !== 'object' ) { - throw new HttpError(400, `Batch write manifest item ${index} is missing fileMetadata`); + throw new HttpError( + 400, + `Batch write manifest item ${index} is missing fileMetadata`, + ); } return { index, fileMetadata: item.fileMetadata, - thumbnailData: typeof item.thumbnailData === 'string' ? item.thumbnailData : undefined, + thumbnailData: + typeof item.thumbnailData === 'string' + ? item.thumbnailData + : undefined, guiMetadata: this.#extractGuiMetadata(item, manifestGuiMetadata), }; }); @@ -1503,7 +1758,10 @@ export class FSController extends ExtensionController { const fieldIndexMap = new Map(); for ( const item of normalizedItems ) { if ( seenIndexes.has(item.index) ) { - throw new HttpError(409, `Batch write manifest has duplicate index ${item.index}`); + throw new HttpError( + 409, + `Batch write manifest has duplicate index ${item.index}`, + ); } seenIndexes.add(item.index); fieldIndexMap.set(String(item.index), item.index); @@ -1548,7 +1806,9 @@ export class FSController extends ExtensionController { return fallbackItem.index; } - throw new HttpError(400, `Batch write file part "${fieldName}" does not map to manifest metadata`); + throw new HttpError( + 400, + `Batch write file part "${fieldName}" does not map to manifest metadata`, + ); } - } From e9a80d083463046d29060821750af1ee818c804e Mon Sep 17 00:00:00 2001 From: jelveh Date: Mon, 20 Apr 2026 16:17:25 -0700 Subject: [PATCH 13/18] Update dashboard.css --- src/gui/src/css/dashboard.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 506f6ed98a..4667536da1 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -723,6 +723,10 @@ body { overflow: visible !important; } +.myapps-tile:hover{ + overflow: visible !important; +} + @media (hover: hover) { .myapps-tile:hover .myapps-tile-icon, .myapps-tile.has-open-contextmenu .myapps-tile-icon{ transform: scale(1.08); From 6b3196ed0c4f89bf65ddff0274ca2f0c58200e1c Mon Sep 17 00:00:00 2001 From: jelveh Date: Tue, 21 Apr 2026 17:41:35 -0700 Subject: [PATCH 14/18] Increase cached app TTL to 24 hours. Cause, where is your sense of adventure? --- src/backend/src/helpers.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index 2ef22e7280..bc0e212952 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -364,7 +364,7 @@ export async function get_app (options) { const cacheApp = async (app) => { if ( ! app ) return; AppRedisCacheSpace.setCachedApp(app, { - ttlSeconds: 300, + ttlSeconds: 24 * 60 * 60, }); }; const isDecoratedAppCacheEntry = (app) => ( @@ -550,7 +550,7 @@ export const get_apps = spanify('get_apps', async (specifiers, options = {}) => const cacheApp = async (app) => { if ( ! app ) return; AppRedisCacheSpace.setCachedApp(app, { - ttlSeconds: 300, + ttlSeconds: 24 * 60 * 60, }); }; From bdfa12b5660cb72d8e8bdc882df3add527a85998 Mon Sep 17 00:00:00 2001 From: jelveh Date: Tue, 21 Apr 2026 18:52:17 -0700 Subject: [PATCH 15/18] Add app object Redis cache and use in AppES --- .../src/modules/apps/AppRedisCacheSpace.js | 46 ++++++++++-- src/backend/src/om/entitystorage/AppES.js | 75 ++++++++++++++----- 2 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/backend/src/modules/apps/AppRedisCacheSpace.js b/src/backend/src/modules/apps/AppRedisCacheSpace.js index c948d4f9ce..01802304e7 100644 --- a/src/backend/src/modules/apps/AppRedisCacheSpace.js +++ b/src/backend/src/modules/apps/AppRedisCacheSpace.js @@ -21,6 +21,7 @@ import { deleteRedisKeys } from '../../clients/redis/deleteRedisKeys.js'; const appFullNamespace = 'apps'; const appLookupKeys = ['uid', 'name', 'id']; +const appObjectSuffix = 'object'; const safeParseJson = (value, fallback = null) => { if ( value === null || value === undefined ) return fallback; @@ -45,15 +46,29 @@ const appCacheKey = ({ lookup, value }) => ( `${appNamespace()}:${lookup}:${value}` ); +const appObjectNamespace = () => `${appNamespace()}:${appObjectSuffix}`; + +const appObjectCacheKey = ({ lookup, value }) => ( + `${appObjectNamespace()}:${lookup}:${value}` +); + export const AppRedisCacheSpace = { key: appCacheKey, namespace: appNamespace, + objectNamespace: appObjectNamespace, + objectKey: appObjectCacheKey, keysForApp: (app) => { if ( ! app ) return []; return appLookupKeys .filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '') .map(lookup => appCacheKey({ lookup, value: app[lookup] })); }, + objectKeysForApp: (app) => { + if ( ! app ) return []; + return appLookupKeys + .filter(lookup => app[lookup] !== undefined && app[lookup] !== null && app[lookup] !== '') + .map(lookup => appObjectCacheKey({ lookup, value: app[lookup] })); + }, uidScanPattern: () => `${appNamespace()}:uid:*`, pendingNamespace: () => 'pending_app', pendingKey: ({ lookup, value }) => ( @@ -77,6 +92,9 @@ export const AppRedisCacheSpace = { getCachedApp: async ({ lookup, value }) => ( safeParseJson(await redisClient.get(appCacheKey({ lookup, value }))) ), + getCachedAppObject: async ({ lookup, value }) => ( + safeParseJson(await redisClient.get(appObjectCacheKey({ lookup, value }))) + ), setCachedApp: async (app, { ttlSeconds } = {}) => { if ( ! app ) return; const serialized = JSON.stringify(app); @@ -86,9 +104,21 @@ export const AppRedisCacheSpace = { await Promise.all(writes); } }, + setCachedAppObject: async (app, { ttlSeconds } = {}) => { + if ( ! app ) return; + const serialized = JSON.stringify(app); + const writes = AppRedisCacheSpace.objectKeysForApp(app) + .map(key => setKey(key, serialized, { ttlSeconds: ttlSeconds || 60 })); + if ( writes.length ) { + await Promise.all(writes); + } + }, invalidateCachedApp: (app, { includeStats = false } = {}) => { if ( ! app ) return; - const keys = [...AppRedisCacheSpace.keysForApp(app)]; + const keys = [ + ...AppRedisCacheSpace.keysForApp(app), + ...AppRedisCacheSpace.objectKeysForApp(app), + ]; if ( includeStats && app.uid ) { keys.push(...AppRedisCacheSpace.statsKeys(app.uid)); } @@ -98,10 +128,16 @@ export const AppRedisCacheSpace = { }, invalidateCachedAppName: async (name) => { if ( ! name ) return; - const keys = [appCacheKey({ - lookup: 'name', - value: name, - })]; + const keys = [ + appCacheKey({ + lookup: 'name', + value: name, + }), + appObjectCacheKey({ + lookup: 'name', + value: name, + }), + ]; return deleteRedisKeys(keys); }, invalidateAppStats: async (uid) => { diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index 4fa125bb1b..69abe2ef5d 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -33,6 +33,7 @@ const uuidv4 = require('uuid').v4; const APP_UID_ALIAS_KEY_PREFIX = 'app:canonicalUidAlias'; const APP_UID_ALIAS_REVERSE_KEY_PREFIX = 'app:canonicalUidAliasReverse'; const APP_UID_ALIAS_TTL_SECONDS = 60 * 60 * 24 * 90; +const APP_OBJECT_CACHE_TTL_SECONDS = 24 * 60 * 60; const indexUrlUniquenessExemptionCandidates = [ 'https://dev-center.puter.com/coming-soon', ]; @@ -446,6 +447,26 @@ class AppES extends BaseES { }); }, + async get_cached_app_object_ (appUid) { + if ( typeof appUid !== 'string' || !appUid ) return null; + return await AppRedisCacheSpace.getCachedAppObject({ + lookup: 'uid', + value: appUid, + }); + }, + + async set_cached_app_object_ (entity) { + if ( ! entity ) return; + + const cacheable = await entity.get_client_safe(); + delete cacheable.stats; + delete cacheable.privateAccess; + + await AppRedisCacheSpace.setCachedAppObject(cacheable, { + ttlSeconds: APP_OBJECT_CACHE_TTL_SECONDS, + }); + }, + /** * Transforms app data before reading by adding associations and handling permissions * @param {Object} entity - App entity to transform @@ -463,6 +484,7 @@ class AppES extends BaseES { const appIndexUrl = await entity.get('index_url'); const appCreatedAt = await entity.get('created_at'); const appIsPrivate = await entity.get('is_private'); + const cachedAppObject = await this.get_cached_app_object_(appUid); const appInformationService = services.get('app-information'); const authService = services.get('auth'); @@ -473,21 +495,36 @@ class AppES extends BaseES { created_at: appCreatedAt, }) : Promise.resolve(undefined); - const fileAssociationsPromise = this.db.read( - 'SELECT type FROM app_filetype_association WHERE app_id = ?', - [entity.private_meta.mysql_id], + const cachedFiletypeAssociations = Array.isArray(cachedAppObject?.filetype_associations) + ? cachedAppObject.filetype_associations + : null; + const hasCachedCreatedFromOrigin = !!( + cachedAppObject && + Object.prototype.hasOwnProperty.call(cachedAppObject, 'created_from_origin') ); - const createdFromOriginPromise = (async () => { - if ( ! authService ) return null; - try { - const origin = origin_from_url(appIndexUrl); - const expectedUid = await authService.app_uid_from_origin(origin); - return expectedUid === appUid ? origin : null; - } catch { - // This happens when index_url is not a valid URL. - return null; - } - })(); + const shouldRefreshCachedAppObject = + !cachedAppObject || + !cachedFiletypeAssociations || + !hasCachedCreatedFromOrigin; + const fileAssociationsPromise = cachedFiletypeAssociations + ? Promise.resolve(cachedFiletypeAssociations) + : this.db.read( + 'SELECT type FROM app_filetype_association WHERE app_id = ?', + [entity.private_meta.mysql_id], + ).then(rows => rows.map(row => row.type)); + const createdFromOriginPromise = hasCachedCreatedFromOrigin + ? Promise.resolve(cachedAppObject.created_from_origin ?? null) + : (async () => { + if ( ! authService ) return null; + try { + const origin = origin_from_url(appIndexUrl); + const expectedUid = await authService.app_uid_from_origin(origin); + return expectedUid === appUid ? origin : null; + } catch { + // This happens when index_url is not a valid URL. + return null; + } + })(); const privateAccessPromise = resolvePrivateLaunchAccess({ app: { uid: appUid, @@ -501,7 +538,7 @@ class AppES extends BaseES { }); const [ - fileAssociationRows, + filetypeAssociations, stats, createdFromOrigin, privateAccess, @@ -511,13 +548,13 @@ class AppES extends BaseES { createdFromOriginPromise, privateAccessPromise, ]); - await entity.set( - 'filetype_associations', - fileAssociationRows.map(row => row.type), - ); + await entity.set('filetype_associations', filetypeAssociations); await entity.set('stats', stats); await entity.set('created_from_origin', createdFromOrigin); await entity.set('privateAccess', privateAccess); + if ( shouldRefreshCachedAppObject ) { + await this.set_cached_app_object_(entity); + } // Migrate b64 icons to the filesystem-backed icon flow without blocking reads. this.queueIconMigration(entity); From b886dde3d68a9fdde7c1a2a73e05a3219066bb2c Mon Sep 17 00:00:00 2001 From: jelveh Date: Tue, 21 Apr 2026 19:32:19 -0700 Subject: [PATCH 16/18] Await DB write and add UID cache key Make the DB update in AppES awaitable so the write completes before proceeding (avoids race conditions). Also add invalidation of the Redis object key for the app UID in AppInformationService to ensure cached entries keyed by uid are cleared after updates. --- src/backend/src/modules/apps/AppInformationService.js | 4 ++++ src/backend/src/om/entitystorage/AppES.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/src/modules/apps/AppInformationService.js b/src/backend/src/modules/apps/AppInformationService.js index 05a8fc8ccc..7a18dcc77d 100644 --- a/src/backend/src/modules/apps/AppInformationService.js +++ b/src/backend/src/modules/apps/AppInformationService.js @@ -123,6 +123,10 @@ class AppInformationService extends BaseService { value: appUid, rawIcon: false, }), + AppRedisCacheSpace.objectKey({ + lookup: 'uid', + value: appUid, + }), ]), AppRedisCacheSpace.invalidateAppStats(appUid), ]); diff --git a/src/backend/src/om/entitystorage/AppES.js b/src/backend/src/om/entitystorage/AppES.js index 69abe2ef5d..4287d32af9 100644 --- a/src/backend/src/om/entitystorage/AppES.js +++ b/src/backend/src/om/entitystorage/AppES.js @@ -300,7 +300,7 @@ class AppES extends BaseES { }; await svc_event.emit('app.new-icon', event); if ( typeof event.url === 'string' && event.url ) { - this.db.write( + await this.db.write( 'UPDATE apps SET icon = ? WHERE id = ? LIMIT 1', [event.url, insert_id], ); From f14f1bf49e2a32a65a50ef2d199555266cf9bac7 Mon Sep 17 00:00:00 2001 From: Shruc <42489293+P3il4@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:00:18 +0300 Subject: [PATCH 17/18] add gpt image 2 (#2829) * add gpt image 2 * index cost key * docs + default low --- .../OpenAiImageGenerationProvider.ts | 109 +++++++++++++++++- .../OpenAiImageGenerationProvider/models.ts | 21 ++++ src/docs/src/AI/txt2img.md | 8 +- 3 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts index 53fe18418a..3147c076fc 100644 --- a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts +++ b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/OpenAiImageGenerationProvider.ts @@ -85,9 +85,15 @@ export class OpenAiImageGenerationProvider implements IImageProvider { throw new Error('`prompt` must be a string'); } - const validRations = selectedModel?.allowedRatios; - if ( validRations && (!ratio || !validRations.some(r => r.w === ratio.w && r.h === ratio.h)) ) { - ratio = validRations[0]; // Default to the first allowed ratio + const validRatios = selectedModel?.allowedRatios; + if ( validRatios ) { + if ( !ratio || !validRatios.some(r => r.w === ratio.w && r.h === ratio.h) ) { + ratio = validRatios[0]; // Default to the first allowed ratio + } + } else { + // Open-ended size models (gpt-image-2): conform to OpenAI's size + // rules (16px multiples, 3840 cap, 3:1 ratio, pixel budget). + ratio = this.#normalizeGptImage2Ratio(ratio); } if ( ! ratio ) { @@ -101,7 +107,10 @@ export class OpenAiImageGenerationProvider implements IImageProvider { const size = `${ratio.w}x${ratio.h}`; const price_key = this.#buildPriceKey(selectedModel.id, quality!, size); - const outputPriceInCents = selectedModel?.costs[price_key]; + let outputPriceInCents: number | undefined = selectedModel?.costs[price_key]; + if ( outputPriceInCents === undefined ) { + outputPriceInCents = this.#estimateOutputCostFromTokens(selectedModel, ratio, quality); + } if ( outputPriceInCents === undefined ) { const availableSizes = Object.keys(selectedModel?.costs) .filter(key => !OpenAiImageGenerationProvider.#NON_SIZE_COST_KEYS.includes(key)); @@ -412,8 +421,96 @@ export class OpenAiImageGenerationProvider implements IImageProvider { } #isGptImageModel (model: string) { - // Covers gpt-image-1, gpt-image-1-mini, gpt-image-1.5 and future variants. - return model.startsWith('gpt-image-1'); + // Covers gpt-image-1, gpt-image-1-mini, gpt-image-1.5, gpt-image-2 and future variants. + return model.startsWith('gpt-image-'); + } + + // gpt-image-2 size rules: each edge in [16, 3840] and a multiple of 16, + // long:short ratio ≤ 3:1, pixel count in [655360, 8294400]. Silently + // clamps/snaps rather than throwing so arbitrary user input is accepted. + // https://developers.openai.com/api/docs/guides/image-generation + #normalizeGptImage2Ratio (ratio?: { w: number; h: number }) { + const MIN_EDGE = 16; + const MAX_EDGE = 3840; + const STEP = 16; + const MAX_RATIO = 3; + const MIN_PIXELS = 655_360; + const MAX_PIXELS = 8_294_400; + + let w = Number(ratio?.w); + let h = Number(ratio?.h); + if ( !Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0 ) { + return { w: 1024, h: 1024 }; + } + + // 1. Clamp long:short ratio to MAX_RATIO by shrinking the longer edge. + if ( w / h > MAX_RATIO ) w = h * MAX_RATIO; + else if ( h / w > MAX_RATIO ) h = w * MAX_RATIO; + + // 2. Cap each edge at MAX_EDGE, preserving aspect ratio. + if ( w > MAX_EDGE ) { + const s = MAX_EDGE / w; w = MAX_EDGE; h *= s; + } + if ( h > MAX_EDGE ) { + const s = MAX_EDGE / h; h = MAX_EDGE; w *= s; + } + + // 3. Scale uniformly into the pixel budget. + const prescaledPixels = w * h; + if ( prescaledPixels < MIN_PIXELS ) { + const s = Math.sqrt(MIN_PIXELS / prescaledPixels); + w *= s; h *= s; + } else if ( prescaledPixels > MAX_PIXELS ) { + const s = Math.sqrt(MAX_PIXELS / prescaledPixels); + w *= s; h *= s; + } + + // 4. Snap to STEP. Bias rounding direction so snap doesn't push pixels + // back out of the budget. + const dir = prescaledPixels < MIN_PIXELS ? 1 + : prescaledPixels > MAX_PIXELS ? -1 + : 0; + const snap = (v: number) => { + const snapped = dir > 0 ? Math.ceil(v / STEP) * STEP + : dir < 0 ? Math.floor(v / STEP) * STEP + : Math.round(v / STEP) * STEP; + return Math.max(MIN_EDGE, Math.min(MAX_EDGE, snapped)); + }; + w = snap(w); h = snap(h); + + // 5. If snap rounding pushed ratio above MAX_RATIO, trim the longer + // edge by one STEP. Pixel budget had headroom from step 3 so this + // won't drop below MIN_PIXELS. + if ( Math.max(w, h) / Math.min(w, h) > MAX_RATIO ) { + if ( w >= h ) w = Math.max(MIN_EDGE, w - STEP); + else h = Math.max(MIN_EDGE, h - STEP); + } + return { w, h }; + } + + // extracted from calculator at https://developers.openai.com/api/docs/guides/image-generation#cost-and-latency + #estimateGptImage2OutputTokens (width: number, height: number, quality?: string): number { + const FACTORS: Record = { low: 16, medium: 48, high: 96 }; + const factor = FACTORS[quality ?? ''] ?? FACTORS.medium; + const longEdge = Math.max(width, height); + const shortEdge = Math.min(width, height); + const shortLatent = Math.round(factor * shortEdge / longEdge); + const latentW = width >= height ? factor : shortLatent; + const latentH = width >= height ? shortLatent : factor; + const baseArea = latentW * latentH; + return Math.ceil(baseArea * (2_000_000 + width * height) / 4_000_000); + } + + #estimateOutputCostFromTokens ( + selectedModel: IImageModel, + ratio: { w: number; h: number }, + quality?: string, + ): number | undefined { + if ( ! selectedModel.id.startsWith('gpt-image-2') ) return undefined; + const rate = this.#getCostRate(selectedModel, 'image_output'); + if ( rate === undefined ) return undefined; + const tokens = this.#estimateGptImage2OutputTokens(ratio.w, ratio.h, quality); + return this.#costForTokens(tokens, rate); } #buildPriceKey (model: string, quality: string, size: string) { diff --git a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts index 3b86dd05a6..de62f7584f 100644 --- a/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts +++ b/src/backend/src/services/ai/image/providers/OpenAiImageGenerationProvider/models.ts @@ -1,6 +1,27 @@ import { IImageModel } from '../types'; export const OPEN_AI_IMAGE_GENERATION_MODELS: IImageModel[] = [ + { + puterId: 'openai:openai/gpt-image-2', + id: 'gpt-image-2', + aliases: ['openai/gpt-image-2', 'gpt-image-2-2026-04-21'], + name: 'GPT Image 2', + version: '2.0', + costs_currency: 'usd-cents', + index_cost_key: 'low:1024x1024', + costs: { + // Text tokens (per 1M tokens) + text_input: 500, // $5.00 + text_cached_input: 125, // $1.25 + text_output: 1000, // $10.00 + // Image tokens (per 1M tokens) + image_input: 800, // $8.00 + image_cached_input: 200, // $2.00 + image_output: 3000, // $30.00 + 'low:1024x1024': 0.588, + }, + allowedQualityLevels: ['low', 'medium', 'high', 'auto'], + }, { puterId: 'openai:openai/gpt-image-1.5', id: 'gpt-image-1.5', diff --git a/src/docs/src/AI/txt2img.md b/src/docs/src/AI/txt2img.md index 8441d85a9a..adb7b4e107 100755 --- a/src/docs/src/AI/txt2img.md +++ b/src/docs/src/AI/txt2img.md @@ -37,13 +37,13 @@ Additional settings for the generation request. Available options depend on the #### OpenAI Options -Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`): +Available when `provider: 'openai-image-generation'` or inferred from model (`gpt-image-2`, `gpt-image-1.5`, `gpt-image-1`, `gpt-image-1-mini`, `dall-e-3`): | Option | Type | Description | |--------|------|-------------| -| `model` | `String` | Image model to use. Available: `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'`, `'dall-e-3'` | -| `quality` | `String` | Image quality. For GPT models: `'high'`, `'medium'`, `'low'` (default: `'low'`). For DALL-E 3: `'hd'`, `'standard'` (default: `'standard'`) | -| `ratio` | `Object` | Aspect ratio with `w` and `h` properties | +| `model` | `String` | Image model to use. Available: `'gpt-image-2'`, `'gpt-image-1.5'`, `'gpt-image-1'`, `'gpt-image-1-mini'`, `'dall-e-3'` | +| `quality` | `String` | Image quality. For GPT models: `'high'`, `'medium'`, `'low'` (default: `'low'`); `gpt-image-2` also accepts `'auto'`. For DALL-E 3: `'hd'`, `'standard'` (default: `'standard'`) | +| `ratio` | `Object` | Aspect ratio with `w` and `h` properties. `gpt-image-2` accepts arbitrary sizes; other GPT models and DALL-E are restricted to fixed sizes | For more details, see the [OpenAI API reference](https://platform.openai.com/docs/api-reference/images/create). From b6776ab47e731e88c2a86175cab9b1e7824f4af4 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Wed, 22 Apr 2026 13:27:34 -0700 Subject: [PATCH 18/18] shrink redis failure (#2831) --- src/backend/src/clients/redis/redisSingleton.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/clients/redis/redisSingleton.ts b/src/backend/src/clients/redis/redisSingleton.ts index 8691fba6e0..683dd00b6b 100644 --- a/src/backend/src/clients/redis/redisSingleton.ts +++ b/src/backend/src/clients/redis/redisSingleton.ts @@ -4,7 +4,7 @@ import MockRedis from 'ioredis-mock'; const redisStartupRetryMaxDelayMs = 2000; const redisSlotsRefreshTimeoutMs = 5000; const redisConnectTimeoutMs = 10000; -const redisMaxRetriesPerRequest = 2; +const redisMaxRetriesPerRequest = 1; const redisBootRetryRegex = /Cluster(All)?FailedError|None of startup nodes is available/i; const formatRedisError = (error: unknown): string => {