From 66093e78f35d55fcd09da5db3eed12114e959480 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 20 Oct 2026 15:33:12 -0700 Subject: [PATCH] feat(gui): "Open with AI" in the dashboard Files menu, opening like Open With (#4289) "Open with AI" now launches the `ai` app with the item the same way Open With launches any app: launch_app with file_path/file_uid, so the app gets a signed launch item via puter.ui.onLaunchedWithItems. It replaces the custom `ai:openFsEntries` postMessage, which went to an existing AI window. - The helper moved from UIItem into helpers/openWithAI.js, shared by the desktop item menu and the dashboard Files menu (generate_file_context_menu). - A shortcut opens its target. - Dropped from multi-select menus, since a launch opens a single item. - Renamed open_in_ai -> open_with_ai ("Open with AI") and removed the unused ai_app_unavailable string. --- src/gui/src/UI/UIItem.js | 98 +------------------ .../src/helpers/generateFileContextMenu.js | 25 +++-- src/gui/src/helpers/openWithAI.js | 44 +++++++++ src/gui/src/helpers/openWithAI.test.js | 73 ++++++++++++++ src/gui/src/i18n/translations/cs.js | 3 +- src/gui/src/i18n/translations/en.js | 3 +- src/gui/src/i18n/translations/fr.js | 3 +- src/gui/src/i18n/translations/zhtw.js | 1 - 8 files changed, 143 insertions(+), 107 deletions(-) create mode 100644 src/gui/src/helpers/openWithAI.js create mode 100644 src/gui/src/helpers/openWithAI.test.js diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 139788e06..0b504eaf1 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -35,81 +35,7 @@ import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js' import { is_owned_by_me } from '../helpers/pathOwner.js'; import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js'; import { is_default_app } from '../helpers/defaultApp.js'; - -const AI_APP_NAME = 'ai'; - -const parseItemMetadataForAI = (metadata) => { - if ( ! metadata ) { - return undefined; - } - try { - return JSON.parse(metadata); - } catch ( error ) { - console.warn('Failed to parse item metadata for AI payload.', error); - return undefined; - } -}; - -const buildAIPayloadFromItems = ($elements) => { - return $elements.get().map((element) => { - const $element = $(element); - return { - uid: $element.attr('data-uid'), - path: $element.attr('data-path'), - name: $element.attr('data-name'), - is_dir: $element.attr('data-is_dir') === '1', - is_shortcut: $element.attr('data-is_shortcut') === '1', - shortcut_to: $element.attr('data-shortcut_to') || undefined, - shortcut_to_path: $element.attr('data-shortcut_to_path') || undefined, - size: $element.attr('data-size') || undefined, - type: $element.attr('data-type') || undefined, - modified: $element.attr('data-modified') || undefined, - metadata: parseItemMetadataForAI($element.attr('data-metadata')), - }; - }); -}; - -const ensureAIAppIframe = async () => { - let $aiWindow = $(`.window[data-app="${AI_APP_NAME}"]`); - if ( $aiWindow.length === 0 ) { - try { - await launch_app({ name: AI_APP_NAME }); - } catch ( error ) { - console.error('Failed to launch AI app.', error); - return null; - } - $aiWindow = $(`.window[data-app="${AI_APP_NAME}"]`); - } - - if ( $aiWindow.length === 0 ) { - return null; - } - - $aiWindow.makeWindowVisible(); - const iframe = $aiWindow.find('.window-app-iframe').get(0); - return iframe ?? null; -}; - -const sendSelectionToAIApp = async ($elements) => { - const items = buildAIPayloadFromItems($elements); - if ( items.length === 0 ) { - return; - } - - const aiIframe = await ensureAIAppIframe(); - if ( !aiIframe || !aiIframe.contentWindow ) { - await UIAlert({ - message: i18n('ai_app_unavailable'), - }); - return; - } - - aiIframe.contentWindow.postMessage({ - msg: 'ai:openFsEntries', - items, - source: 'desktop-context-menu', - }, '*'); -}; +import openWithAI from '../helpers/openWithAI.js'; async function UIItem (options) { const matching_appendto_count = $(options.appendTo).length; @@ -972,20 +898,6 @@ async function UIItem (options) { menu_items.push('-'); } if ( ! are_trashed ) { - // ------------------------------------------- - // Open in AI - // ------------------------------------------- - menu_items.push({ - html: i18n('open_in_ai'), - onClick: async function () { - await sendSelectionToAIApp($selected_items); - }, - }); - // ------------------------------------------- - // - - // ------------------------------------------- - menu_items.push({ is_divider: true }); - // ------------------------------------------- // Donwload // ------------------------------------------- @@ -1305,13 +1217,13 @@ async function UIItem (options) { } } // ------------------------------------------- - // Open in AI + // Open with AI // ------------------------------------------- if ( !is_trashed && !is_trash ) { menu_items.push({ - html: i18n('open_in_ai'), - onClick: async function () { - await sendSelectionToAIApp($(el_item)); + html: i18n('open_with_ai'), + onClick: function () { + openWithAI(el_item); }, }); } diff --git a/src/gui/src/helpers/generateFileContextMenu.js b/src/gui/src/helpers/generateFileContextMenu.js index da14c6609..c3c2570d4 100644 --- a/src/gui/src/helpers/generateFileContextMenu.js +++ b/src/gui/src/helpers/generateFileContextMenu.js @@ -26,6 +26,7 @@ import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js'; import UIWindowShare from '../UI/UIWindowShare.js'; import publish_as_website from './publishAsWebsite.js'; import open_item from './openItem.js'; +import openWithAI from './openWithAI.js'; import launch_app from './launchApp.js'; import path from '../lib/path.js'; import { isWeblinkName, weblinkChangeIconMenuItem } from './weblink.js'; @@ -96,13 +97,6 @@ const generate_file_context_menu = async function (options) { } }, }); - - // ------------------------------------------- - // Separator - // ------------------------------------------- - if ( options.associated_app_name || is_trash ) { - menu_items.push('-'); - } } // ------------------------------------------- @@ -114,7 +108,24 @@ const generate_file_context_menu = async function (options) { html: i18n('open_with'), items: openWithItems, }); + } + // ------------------------------------------- + // Open with AI + // ------------------------------------------- + if ( !is_trashed && !is_trash ) { + menu_items.push({ + html: i18n('open_with_ai'), + onClick: () => { + openWithAI(el_item); + }, + }); + } + + // ------------------------------------------- + // Separator + // ------------------------------------------- + if ( ! is_trashed ) { menu_items.push('-'); } diff --git a/src/gui/src/helpers/openWithAI.js b/src/gui/src/helpers/openWithAI.js new file mode 100644 index 000000000..90f19cb60 --- /dev/null +++ b/src/gui/src/helpers/openWithAI.js @@ -0,0 +1,44 @@ +/* + * 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 launch_app from './launchApp.js'; +import path from '../lib/path.js'; + +const AI_APP_NAME = 'ai'; + +/** + * Opens a file or folder row (desktop item or dashboard row) in the AI app the + * same way "Open With" opens it in any other app. A shortcut opens its target. + * + * @param {Element} el_item + * @returns {Promise} the launch_app result + */ +const openWithAI = (el_item) => { + const $item = $(el_item); + const file_path = $item.attr('data-shortcut_to_path') || $item.attr('data-path'); + const file_uid = $item.attr('data-shortcut_to') || $item.attr('data-uid'); + return launch_app({ + name: AI_APP_NAME, + file_path, + file_uid, + window_title: path.basename(file_path), + }); +}; + +export default openWithAI; diff --git a/src/gui/src/helpers/openWithAI.test.js b/src/gui/src/helpers/openWithAI.test.js new file mode 100644 index 000000000..170209a4f --- /dev/null +++ b/src/gui/src/helpers/openWithAI.test.js @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +/* + * 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 { afterEach, expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +vi.mock('./launchApp.js', () => ({ default: vi.fn() })); + +globalThis.$ = globalThis.jQuery = jQuery; + +const { default: launch_app } = await import('./launchApp.js'); +const { default: openWithAI } = await import('./openWithAI.js'); + +const fileRow = (attrs = {}) => { + const row = document.createElement('div'); + const all = { + 'data-uid': 'uid-1', + 'data-path': '/user/Documents/notes.txt', + 'data-name': 'notes.txt', + 'data-shortcut_to': '', + 'data-shortcut_to_path': '', + ...attrs, + }; + for ( const [name, value] of Object.entries(all) ) row.setAttribute(name, value); + return row; +}; + +afterEach(() => { + vi.clearAllMocks(); +}); + +it('launches the AI app with the file, as Open With does', () => { + openWithAI(fileRow()); + + expect(launch_app).toHaveBeenCalledWith({ + name: 'ai', + file_path: '/user/Documents/notes.txt', + file_uid: 'uid-1', + window_title: 'notes.txt', + }); +}); + +it('opens a shortcut\'s target rather than the shortcut', () => { + openWithAI(fileRow({ + 'data-uid': 'shortcut-uid', + 'data-path': '/user/Desktop/notes.txt', + 'data-shortcut_to': 'target-uid', + 'data-shortcut_to_path': '/user/Documents/report.txt', + })); + + expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({ + file_path: '/user/Documents/report.txt', + file_uid: 'target-uid', + window_title: 'report.txt', + })); +}); diff --git a/src/gui/src/i18n/translations/cs.js b/src/gui/src/i18n/translations/cs.js index d215aeda0..aaffb7dec 100644 --- a/src/gui/src/i18n/translations/cs.js +++ b/src/gui/src/i18n/translations/cs.js @@ -42,7 +42,6 @@ const cs = { "app_feedback_placeholder": "Co funguje dobře? Co by mohlo být lepší?", "add_another_account": "Přidat další účet", "add_to_desktop": "Přidat na plochu", - "ai_app_unavailable": "Aplikace AI není k dispozici. Zkuste to znovu později.", "app_feedback_c2a": "Vaše zpětná vazba bude odeslána přímo vývojáři této aplikace.", "app_feedback_privacy_note": "Vaše uživatelské jméno a e-mailová adresa budou sdíleny s vývojářem, aby vám mohl odpovědět.", "add_app_request_sent": "Děkujeme – váš požadavek patří našemu týmu. Pokud váš účet obsahuje e-mail, můžeme na něj navázat.", @@ -238,7 +237,7 @@ const cs = { "new_email": "Nový e-mail", "open_desktop": "Otevřít plochu", "new_username": "Nové uživatelské jméno", - "open_in_ai": "Otevřít v AI", + "open_with_ai": "Otevřít pomocí AI", "os": "Operační systém", "name_must_be_string": "Název může být pouze řetězec.", "no_dir_associated_with_site": "K této adrese není přidružen žádný adresář.", diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 3da041f24..1bc50b6da 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -42,7 +42,6 @@ const en = { add_app_request_sent: 'Thanks — your request is with our team. If your account has an email, we may follow up on it.', add_to_desktop: 'Add to Desktop', adjust_profile_picture: 'Adjust your photo', - ai_app_unavailable: 'AI app is not available. Please try again later.', all_fields_required: 'All fields are required.', allow: 'Allow', app_feedback_c2a: 'Your feedback will be sent directly to the developer of this app.', @@ -302,7 +301,7 @@ const en = { open: 'Open', new_window: 'New Window', open_desktop: 'Open Desktop', - open_in_ai: 'Open in AI', + open_with_ai: 'Open with AI', open_in_new_tab: 'Open in New Tab', app_pages: 'App pages', apps_load_failed: 'Failed to load apps', diff --git a/src/gui/src/i18n/translations/fr.js b/src/gui/src/i18n/translations/fr.js index fd9fa3859..07979e138 100644 --- a/src/gui/src/i18n/translations/fr.js +++ b/src/gui/src/i18n/translations/fr.js @@ -27,7 +27,6 @@ const fr = { account_password: 'Vérifier le mot de passe du compte', access_granted_to: 'Accès accordé à', add_another_account: 'Ajouter un autre compte', - ai_app_unavailable: 'L\'application IA n\'est pas disponible. Veuillez réessayer plus tard.', all_fields_required: 'Tous les champs sont requis.', allow: 'Autoriser', apply: 'Appliquer', @@ -198,7 +197,7 @@ const fr = { ok: 'OK', open: 'Ouvrir', new_window: 'Nouvelle fenêtre', - open_in_ai: 'Ouvrir dans l\'IA', + open_with_ai: 'Ouvrir avec l\'IA', open_in_new_tab: 'Ouvrir dans un nouvel onglet', open_in_new_window: 'Ouvrir dans une nouvelle fenêtre', open_trash: 'Ouvrir la corbeille', diff --git a/src/gui/src/i18n/translations/zhtw.js b/src/gui/src/i18n/translations/zhtw.js index c7e2012f8..954f3bbee 100644 --- a/src/gui/src/i18n/translations/zhtw.js +++ b/src/gui/src/i18n/translations/zhtw.js @@ -39,7 +39,6 @@ const zhtw = { add_app_request_sent: '謝謝,您的需求已送達我們的團隊。如果您的帳戶設有電子郵件,我們可能會透過該信箱與您聯絡。', add_another_account: '新增其他帳戶', add_to_desktop: '新增至桌面', - ai_app_unavailable: 'AI 應用程式目前無法使用,請稍後再試。', all_fields_required: '所有欄位都是必填的。', allow: '允許', app_group_default_name: '資料夾',