mirror of
https://github.com/4ian/GDevelop.git
synced 2026-10-12 02:01:17 +00:00
Ask to save the project in the cloud before adding attached files as resources on the mobile app
On the mobile app, files added by the AI to a project not saved yet were kept as blob URLs: previews can't read them (the image shows as a placeholder) and they are lost when the app is closed. The user is now asked to save the project in the cloud (which stores the files), and the changes are undone if it is not saved, with the AI told to stop and ask the user to save it. Desktop and web keep storing the files when the project is saved. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017eKFmVQyhUBf7SwuEjSy5v
This commit is contained in:
@@ -4,10 +4,21 @@ import axios from 'axios';
|
||||
import AuthenticatedUserContext from '../../Profile/AuthenticatedUserContext';
|
||||
import { createAiAttachmentDownloads } from '../../Utils/GDevelopServices/Generation';
|
||||
import { MAX_ATTACHMENTS_PER_MESSAGE } from './UseAiAttachmentDrafts';
|
||||
import { t } from '@lingui/macro';
|
||||
import { type ResourceManagementProps } from '../../ResourcesList/ResourceSource';
|
||||
import { type FileMetadata } from '../../ProjectsStorage';
|
||||
import { type AttachmentsForResources } from '../../EditorFunctions/AttachmentResources';
|
||||
import {
|
||||
type FileMetadata,
|
||||
type StorageProvider,
|
||||
type SaveAsLocation,
|
||||
} from '../../ProjectsStorage';
|
||||
import CloudStorageProvider from '../../ProjectsStorage/CloudStorageProvider';
|
||||
import {
|
||||
type AttachmentsForResources,
|
||||
type ResourceFilesStorage,
|
||||
} from '../../EditorFunctions/AttachmentResources';
|
||||
import { getFileOfUploadedAttachment } from './AiAttachmentsUpload';
|
||||
import useAlertDialog from '../../UI/Alert/useAlertDialog';
|
||||
import { isNativeMobileApp } from '../../Utils/Platform';
|
||||
|
||||
const downloadFile = async ({
|
||||
url,
|
||||
@@ -22,17 +33,34 @@ const downloadFile = async ({
|
||||
return new File([response.data], name, { type: mimeType });
|
||||
};
|
||||
|
||||
const canStorageProviderStoreResourceFiles = (
|
||||
storageProvider: StorageProvider
|
||||
): boolean =>
|
||||
storageProvider.internalName === 'Cloud' ||
|
||||
storageProvider.internalName === 'LocalFile';
|
||||
|
||||
/** The files attached to the AI, for the functions making resources of them. */
|
||||
export const useAttachmentsForResources = ({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
fileMetadata,
|
||||
onSaveProjectAsWithStorageProvider,
|
||||
}: {|
|
||||
project: ?gdProject,
|
||||
resourceManagementProps: ResourceManagementProps,
|
||||
fileMetadata: ?FileMetadata,
|
||||
onSaveProjectAsWithStorageProvider?: (
|
||||
options: ?{|
|
||||
requestedStorageProvider?: StorageProvider,
|
||||
forcedSavedAsLocation?: SaveAsLocation,
|
||||
createdProject?: gdProject,
|
||||
|}
|
||||
) => Promise<?FileMetadata>,
|
||||
|}): AttachmentsForResources => {
|
||||
const { profile, getAuthorizationHeader } = React.useContext(
|
||||
AuthenticatedUserContext
|
||||
);
|
||||
const { showConfirmation } = useAlertDialog();
|
||||
|
||||
const getFiles = React.useCallback(
|
||||
async (attachmentIds: Array<string>) => {
|
||||
@@ -81,22 +109,60 @@ export const useAttachmentsForResources = ({
|
||||
[profile, getAuthorizationHeader]
|
||||
);
|
||||
|
||||
const storeResourceFiles = React.useCallback(
|
||||
async () => {
|
||||
resourceManagementProps.onNewResourcesAdded();
|
||||
// A project not saved yet (or opened from a URL) has no storage for
|
||||
// its files: they are stored when it is saved.
|
||||
const storageProviderName = resourceManagementProps.getStorageProvider()
|
||||
.internalName;
|
||||
if (
|
||||
!fileMetadata ||
|
||||
(storageProviderName !== 'Cloud' && storageProviderName !== 'LocalFile')
|
||||
)
|
||||
return false;
|
||||
await resourceManagementProps.onFetchNewlyAddedResources();
|
||||
return true;
|
||||
const saveProjectInCloudIfUserAgrees = React.useCallback(
|
||||
async (): Promise<boolean> => {
|
||||
if (!project || !onSaveProjectAsWithStorageProvider) return false;
|
||||
const shouldSave = await showConfirmation({
|
||||
title: t`Save your project`,
|
||||
message: t`Your project must be saved in the cloud to add the files you attached. Save it now?`,
|
||||
confirmButtonLabel: t`Save`,
|
||||
});
|
||||
if (!shouldSave) return false;
|
||||
|
||||
// Saving it also stores the files of its resources.
|
||||
const newFileMetadata = await onSaveProjectAsWithStorageProvider({
|
||||
requestedStorageProvider: CloudStorageProvider,
|
||||
forcedSavedAsLocation: { name: project.getName() },
|
||||
});
|
||||
return (
|
||||
!!newFileMetadata &&
|
||||
canStorageProviderStoreResourceFiles(
|
||||
resourceManagementProps.getStorageProvider()
|
||||
)
|
||||
);
|
||||
},
|
||||
[resourceManagementProps, fileMetadata]
|
||||
[
|
||||
project,
|
||||
onSaveProjectAsWithStorageProvider,
|
||||
showConfirmation,
|
||||
resourceManagementProps,
|
||||
]
|
||||
);
|
||||
|
||||
const storeResourceFiles = React.useCallback(
|
||||
async (): Promise<ResourceFilesStorage> => {
|
||||
resourceManagementProps.onNewResourcesAdded();
|
||||
if (
|
||||
fileMetadata &&
|
||||
canStorageProviderStoreResourceFiles(
|
||||
resourceManagementProps.getStorageProvider()
|
||||
)
|
||||
) {
|
||||
await resourceManagementProps.onFetchNewlyAddedResources();
|
||||
return 'stored';
|
||||
}
|
||||
|
||||
// A project not saved yet (or opened from a URL) has no storage for its
|
||||
// files. On desktop and on the web, they are kept in memory, where the
|
||||
// previews can read them, until the project is saved. On the mobile
|
||||
// app, the previews can't read them and they are lost if the app is
|
||||
// closed: the project must be saved first.
|
||||
if (!isNativeMobileApp()) return 'stored-when-project-is-saved';
|
||||
if (!(await saveProjectInCloudIfUserAgrees())) return 'project-not-saved';
|
||||
await resourceManagementProps.onFetchNewlyAddedResources();
|
||||
return 'stored';
|
||||
},
|
||||
[resourceManagementProps, fileMetadata, saveProjectInCloudIfUserAgrees]
|
||||
);
|
||||
|
||||
return React.useMemo(() => ({ getFiles, storeResourceFiles }), [
|
||||
|
||||
@@ -1119,6 +1119,7 @@ export const AskAiEditor: React.ComponentType<Props> = React.memo<Props>(
|
||||
getIsAutoEditEnabled,
|
||||
suspendAiRequest,
|
||||
requestEditApproval,
|
||||
onSaveProjectAsWithStorageProvider,
|
||||
});
|
||||
|
||||
// Wrap onProcessFunctionCalls to bind the selected AI request for the chat UI.
|
||||
|
||||
@@ -264,6 +264,7 @@ export const useProcessFunctionCalls = ({
|
||||
getIsAutoEditEnabled,
|
||||
suspendAiRequest,
|
||||
requestEditApproval,
|
||||
onSaveProjectAsWithStorageProvider,
|
||||
}: {|
|
||||
i18n: I18nType,
|
||||
project: ?gdProject,
|
||||
@@ -321,6 +322,15 @@ export const useProcessFunctionCalls = ({
|
||||
getIsAutoEditEnabled: () => boolean,
|
||||
suspendAiRequest: (aiRequestId: string) => Promise<void>,
|
||||
requestEditApproval: (request: EditApprovalRequest) => Promise<boolean>,
|
||||
// Absent where the project can't be saved: the files attached by the user
|
||||
// can then only be kept in memory.
|
||||
onSaveProjectAsWithStorageProvider?: (
|
||||
options: ?{|
|
||||
requestedStorageProvider?: StorageProvider,
|
||||
forcedSavedAsLocation?: SaveAsLocation,
|
||||
createdProject?: gdProject,
|
||||
|}
|
||||
) => Promise<?FileMetadata>,
|
||||
|}): {
|
||||
onProcessFunctionCalls: (
|
||||
aiRequest: AiRequest,
|
||||
@@ -346,8 +356,10 @@ export const useProcessFunctionCalls = ({
|
||||
resourceManagementProps,
|
||||
});
|
||||
const attachmentsForResources = useAttachmentsForResources({
|
||||
project,
|
||||
resourceManagementProps,
|
||||
fileMetadata,
|
||||
onSaveProjectAsWithStorageProvider,
|
||||
});
|
||||
const { generateEvents } = useGenerateEvents({ project });
|
||||
const { triggerUnsavedChanges } = React.useContext(UnsavedChangesContext);
|
||||
|
||||
@@ -10,15 +10,22 @@ import {
|
||||
import { triggerOnResourceExternallyChanged } from '../MainFrame/ResourcesWatcher';
|
||||
import newNameGenerator from '../Utils/NewNameGenerator';
|
||||
|
||||
export type ResourceFilesStorage =
|
||||
| 'stored'
|
||||
// Kept in memory, and stored when the project is saved.
|
||||
| 'stored-when-project-is-saved'
|
||||
// The project was not saved, and the files can't be kept in memory.
|
||||
| 'project-not-saved';
|
||||
|
||||
export type AttachmentsForResources = {|
|
||||
// The files attached by the user to the AI (null for an unknown or expired one).
|
||||
getFiles: (
|
||||
attachmentIds: Array<string>
|
||||
) => Promise<{ [attachmentId: string]: ?File }>,
|
||||
// Stores the files of the resources just added or changed (uploaded to the
|
||||
// cloud, or written in the project folder). Resolves to false when the
|
||||
// project is not saved yet: they are then stored when it is saved.
|
||||
storeResourceFiles: () => Promise<boolean>,
|
||||
// cloud, or written in the project folder), asking the user to save the
|
||||
// project first where files can't be kept in memory until it is saved.
|
||||
storeResourceFiles: () => Promise<ResourceFilesStorage>,
|
||||
|};
|
||||
|
||||
export type AttachmentResourceAddition = {|
|
||||
@@ -187,30 +194,47 @@ export const addOrReplaceResourcesFromAttachments = async ({
|
||||
|
||||
if (!appliedChanges.length) return { changes, warnings };
|
||||
|
||||
const areFilesStored = await attachmentsForResources.storeResourceFiles();
|
||||
appliedChanges.forEach(
|
||||
({ resourceName, blobUrl, previousFile, description }) => {
|
||||
const undoChange = ({
|
||||
resourceName,
|
||||
blobUrl,
|
||||
previousFile,
|
||||
}: AppliedChange) => {
|
||||
if (previousFile) {
|
||||
const resource = resourcesManager.getResource(resourceName);
|
||||
if (areFilesStored && resource.getFile() === blobUrl) {
|
||||
if (previousFile) {
|
||||
resource.setFile(previousFile.file);
|
||||
resource.setMetadata(previousFile.metadata);
|
||||
} else {
|
||||
resourcesManager.removeResource(resourceName);
|
||||
}
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
warnings.push(
|
||||
`The file for "${resourceName}" could not be stored in the project: nothing was changed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (areFilesStored) URL.revokeObjectURL(blobUrl);
|
||||
// Reload what displays the previous file of the resource.
|
||||
if (previousFile)
|
||||
triggerOnResourceExternallyChanged({ identifier: resource.getFile() });
|
||||
changes.push(description);
|
||||
resource.setFile(previousFile.file);
|
||||
resource.setMetadata(previousFile.metadata);
|
||||
} else {
|
||||
resourcesManager.removeResource(resourceName);
|
||||
}
|
||||
);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
|
||||
const resourceFilesStorage = await attachmentsForResources.storeResourceFiles();
|
||||
if (resourceFilesStorage === 'project-not-saved') {
|
||||
appliedChanges.forEach(undoChange);
|
||||
warnings.push(
|
||||
'The attached files were not added: the project must be saved (in the cloud) to store them, and the user did not save it, or saving it failed. Nothing was changed. Stop and ask the user to save their project, then try again.'
|
||||
);
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
const areFilesStored = resourceFilesStorage === 'stored';
|
||||
appliedChanges.forEach(appliedChange => {
|
||||
const { resourceName, blobUrl, previousFile, description } = appliedChange;
|
||||
const resource = resourcesManager.getResource(resourceName);
|
||||
if (areFilesStored && resource.getFile() === blobUrl) {
|
||||
undoChange(appliedChange);
|
||||
warnings.push(
|
||||
`The file for "${resourceName}" could not be stored in the project: nothing was changed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (areFilesStored) URL.revokeObjectURL(blobUrl);
|
||||
// Reload what displays the previous file of the resource.
|
||||
if (previousFile)
|
||||
triggerOnResourceExternallyChanged({ identifier: resource.getFile() });
|
||||
changes.push(description);
|
||||
});
|
||||
if (!areFilesStored) {
|
||||
changes.push(
|
||||
'The project is not saved yet: the files will be stored in it when it is saved.'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { editorFunctions, type EditorFunctionGenericOutput } from './index';
|
||||
import { makeFakeLaunchFunctionOptionsWithProject } from './TestHelpers';
|
||||
import { type ResourceFilesStorage } from './AttachmentResources';
|
||||
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
@@ -394,7 +395,7 @@ describe('change_project_properties_resources', () => {
|
||||
const fontFile = makeFile('fake ttf', 'title.ttf', 'font/ttf');
|
||||
|
||||
/** Stores the in-memory files like the cloud or local storage would. */
|
||||
const storeResourceFilesInProject = async () => {
|
||||
const storeResourceFilesInProject = async (): Promise<ResourceFilesStorage> => {
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
resourcesManager
|
||||
.getAllResourceNames()
|
||||
@@ -404,12 +405,12 @@ describe('change_project_properties_resources', () => {
|
||||
if (resource.getFile().startsWith('blob:'))
|
||||
resource.setFile(`https://project-resources/${name}`);
|
||||
});
|
||||
return true;
|
||||
return 'stored';
|
||||
};
|
||||
|
||||
const launchWithAttachments = (
|
||||
args: Object,
|
||||
storeResourceFiles: () => Promise<boolean> = storeResourceFilesInProject
|
||||
storeResourceFiles: () => Promise<ResourceFilesStorage> = storeResourceFilesInProject
|
||||
) =>
|
||||
editorFunctions.change_project_properties_resources.launchFunction({
|
||||
...makeFakeLaunchFunctionOptionsWithProject(project),
|
||||
@@ -502,7 +503,7 @@ describe('change_project_properties_resources', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
async () => true
|
||||
async () => 'stored'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
@@ -519,7 +520,7 @@ describe('change_project_properties_resources', () => {
|
||||
it('keeps the files in memory until a project not saved yet is saved', async () => {
|
||||
const result: EditorFunctionGenericOutput = await launchWithAttachments(
|
||||
{ added_resources: [{ attachment_id: 'logo-id' }] },
|
||||
async () => false
|
||||
async () => 'stored-when-project-is-saved'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
@@ -534,6 +535,36 @@ describe('change_project_properties_resources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('undoes all the changes when the project was not saved to store the files', async () => {
|
||||
addProjectResources();
|
||||
|
||||
const result: EditorFunctionGenericOutput = await launchWithAttachments(
|
||||
{
|
||||
added_resources: [{ attachment_id: 'font-id' }],
|
||||
changed_resources: [
|
||||
{
|
||||
resource_name: 'hero.png',
|
||||
replace_file_with_attachment_id: 'logo-id',
|
||||
},
|
||||
],
|
||||
},
|
||||
async () => 'project-not-saved'
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
expect(resourcesManager.hasResource('title.ttf')).toBe(false);
|
||||
expect(resourcesManager.getResource('hero.png').getFile()).toBe(
|
||||
'assets/hero.png'
|
||||
);
|
||||
expect(result.message).toContain(
|
||||
'The attached files were not added: the project must be saved (in the cloud) to store them'
|
||||
);
|
||||
expect(result.message).toContain(
|
||||
'Stop and ask the user to save their project, then try again.'
|
||||
);
|
||||
});
|
||||
|
||||
it('asks to attach again a file that is not available anymore', async () => {
|
||||
const result: EditorFunctionGenericOutput = await launchWithAttachments({
|
||||
added_resources: [{ attachment_id: 'expired-id' }],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { makeTestExtensions } from '../fixtures/TestExtensions';
|
||||
import { editorFunctions, type EditorFunctionGenericOutput } from './index';
|
||||
import { makeFakeLaunchFunctionOptionsWithProject } from './TestHelpers';
|
||||
import { type ResourceFilesStorage } from './AttachmentResources';
|
||||
import {
|
||||
serializeToJSObject,
|
||||
unserializeFromJSObject,
|
||||
@@ -412,7 +413,7 @@ describe('object raw JSON and renames', () => {
|
||||
return files;
|
||||
},
|
||||
// Stores the files like the cloud or local storage would.
|
||||
storeResourceFiles: async () => {
|
||||
storeResourceFiles: async (): Promise<ResourceFilesStorage> => {
|
||||
const resourcesManager = project.getResourcesManager();
|
||||
resourcesManager
|
||||
.getAllResourceNames()
|
||||
@@ -422,7 +423,7 @@ describe('object raw JSON and renames', () => {
|
||||
if (resource.getFile().startsWith('blob:'))
|
||||
resource.setFile(`https://project-resources/${name}`);
|
||||
});
|
||||
return true;
|
||||
return 'stored';
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ export const makeFakeLaunchFunctionOptionsWithoutProject = (): LaunchFunctionOpt
|
||||
},
|
||||
attachmentsForResources: {
|
||||
getFiles: async () => ({}),
|
||||
storeResourceFiles: async () => true,
|
||||
storeResourceFiles: async () => 'stored',
|
||||
},
|
||||
onObjectsModifiedOutsideEditor: jest.fn(),
|
||||
onEffectsModifiedOutsideEditor: jest.fn(),
|
||||
|
||||
Reference in New Issue
Block a user