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.
This commit is contained in:
Nariman Jelveh authored and GitHub committed 2026-10-20 15:33:12 -07:00
1 parent ed531e3992
commit 66093e78f3
8 files changed
+143 -107

No files matched your search

+5 -93
View File
@@ -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);
},
});
}
+18 -7
View File
@@ -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('-');
}
+44
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<unknown>} 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;
+73
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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',
}));
});
+1 -2
View File
@@ -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ář.",
+1 -2
View File
@@ -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',
+1 -2
View File
@@ -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',
-1
View File
@@ -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: '資料夾',