feat: ai generated image metadata (#24422)

* feat: chat

feat: apply changes
feat: integrate with ctb

feat: marker

chore: remove comment

feat: new chat

feat: copy message

feat: upload modal

feat: upload file

feat: errors and stop chat

chore: refactor transforms
chore: format relations

chore: chat title

chore: remove architect dependency

feat: empt state

chore: improve text area focus

* feat: chat imports

feat: resizable text area
fix: re add chat
chore: translations
feat: env vars

fix: minor chat issues

feat: feedback

fix: rebase

feat: import folder

feat: limits

feat: attachments dropzone

chore: file attachments cleanup
chore: track chat id

feat: figma import

fix :token

feat: figma token
fix: attribute status when chat makes updates

feat: image upload

* feat: staging integration

* chore: remove logs

* feat: use tool call result instead of annotation

* fix: invalid component uid

* chore: chat input focus ring

* fix: ui issues

* fix: default draft and publish and do not modify singular name

* fix: minor transforms

* fix: linting

* test(front): update snapshots

* chore: fix misplaced getstarted project schema

* chore: remove unused import

* security: validate exact path of host

* fix: define process better for playwright to work

* fix: process env in vite config for playwright

* chore: use production url

* feat(ctb): Tracking events for AI Chat interaction (#23731)

* feat(content-type-builder): WIP tracking events for chat interactions

* fix: typescript build errors

* fix: event name and build errors

* chore: send ai key with analytics

* chore: actually send licenseKey

* chore: send ailicensekey with groupproperties

* fix: didStartNewChat

* chore: track new chats

* feat: enhance attachment type management in AI chat components

* fix: update chat status handling in ChatProvider component

* feat: add optional aiLicenseKey to Strapi interface

---------

Co-authored-by: Jamie Howard <jhoward1994@gmail.com>

* refactor: remove didusersendmessage tracking event (#23777)

* fix: merge conflict

* fix: send projectId to AI server

* feat: add a enabled config for AI features (#24060)

* feat: add getAiToken endpoint (#24172)

* feat: add getAiToken route

* fix: change route name, remove project id

* fix: type issue and fix schema

* feat: retrieve ai token from frontend (#24226)

---------

Co-authored-by: Jamie Howard <jhoward1994@gmail.com>

* fix: use primary500 for links in ai chat

* chore: migrate to AI SDK v5 (#24252)

* fix: migrate code for v5

* t:wq

* feat: push schemas to ctb

* chore: remove old code

* chore: remove ts-no-check

* chore: fix comment

* fix: ai server logs (#24318)

* test(back): fix error log tests

---------

Co-authored-by: Marc Roig <marc12info@gmail.com>

* fix: configure ai ctb csp middleware without overriding user or default config

* future(upload): generate image metadata on file upload (#24365)

* chore: create aiMetadata service with isEnabled

* chore: extract getAiToken to service

* fix: unit test

* future(upload): generate metadata with ai

* fix: ts build

* fix: only send images to ai server

* test: add unit tests

* fix: unit test

---------

Co-authored-by: markkaylor <mark.kaylor@strapi.io>

* AI media lib bulk update (#24414)

* feat(packages): adding endpoint for bulk update

* feat(packages): linting

* feat(packages): adding tests

* feat(packages): cleanup

* feat: guided tour for ai ctb (#24411)

* feat(upload): adding aiMetadata into settings (#24468)

feat(upload): adding aiMetadata into settings

* feat: add AI upload modal (#24407)

* chore: create aiMetadata service with isEnabled

* chore: extract getAiToken to service

* fix: unit test

* future(upload): generate metadata with ai

* feat: add AI upload modal

* feat: add edit and delete to upload modal

* fix: remove sparkle icon on edit

* fix: add error handling

* chore: refactor ai upload modal reducer

* fix: catch ai token generation error

* chore: add useBulkEdit hook

* feat: connect to bulk edit endpoint

* fix: e2e test

* fix: ci in both ce and ee

---------

Co-authored-by: Rémi de Juvigny <remi.dejuvigny@strapi.io>
Co-authored-by: Rémi de Juvigny <8087692+remidej@users.noreply.github.com>

* feat(upload): applying ai enabled logic for media library (#24486)

feat(upload): applying ai enabled logic for media library

* fix: sparkle icon

* fix: set ai server prod url

* fix: default config to enabled

* fix: cursor moving to the end when editing text

* fix: upload in the right folder

* fix: close modal when deleting last item

* chore: use STRAPI_AI_URL everywhere

* fix: bulk upload from frontend

* fix: restore sparkle icon on inputs

* fix: unit test ci

* feat(upload): generating metadata from thumbnail (#24515)

* feat(upload): generating metadata from thumbnail

* feat(upload): fixing linting issue

* feat(upload): fixing tests and addressiing feedback

* feat(upload): fixing lint

* fix: check for cms-ai entitlement

* fix: tests

* fix: race condition failing front unit tests

---------

Co-authored-by: Marc-Roig <marc12info@gmail.com>
Co-authored-by: Ben Irvin <ben@innerdvations.com>
Co-authored-by: Bassel Kanso <bassel.kanso@strapi.io>
Co-authored-by: Ben Irvin <ben.irvin@strapi.io>
Co-authored-by: Jamie Howard <jhoward1994@gmail.com>
Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com>
Co-authored-by: Bassel Kanso <basselkanso82@gmail.com>
Co-authored-by: Ziyi <daydreamnation@live.com>
Co-authored-by: markkaylor <mark.kaylor@strapi.io>
Co-authored-by: Araksya Gevorgyan <31159659+araksyagevorgyan@users.noreply.github.com>
Co-authored-by: Adrien L <thewebsdoor@gmail.com>
This commit is contained in:
Rémi de Juvigny 2025-10-07 12:14:34 +02:00 committed by GitHub
parent 3ae249212a
commit 5e751dbf11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2365 additions and 213 deletions

View File

@ -60,6 +60,7 @@ export { useFocusInputField } from './hooks/useFocusInputField';
export { useRBAC, type AllowedActions } from './hooks/useRBAC';
export { useClipboard } from './hooks/useClipboard';
export { useElementOnScreen } from './hooks/useElementOnScreen';
export { useDebounce } from './hooks/useDebounce';
export { useMediaQuery, useIsDesktop, useIsTablet, useIsMobile } from './hooks/useMediaQuery';
export { useDeviceType } from './hooks/useDeviceType';
export { useAdminUsers } from './services/users';

View File

@ -14,7 +14,7 @@ import { fetchBaseQuery } from '../utils/baseQuery';
const adminApi = createApi({
reducerPath: 'adminApi',
baseQuery: fetchBaseQuery(),
tagTypes: ['GuidedTourMeta', 'HomepageKeyStatistics'],
tagTypes: ['GuidedTourMeta', 'HomepageKeyStatistics', 'AIUsage'],
endpoints: () => ({}),
});

View File

@ -10,7 +10,12 @@ import {
} from '../../../shared/contracts/authentication';
import { Check } from '../../../shared/contracts/permissions';
import { GetProviders, IsSSOLocked } from '../../../shared/contracts/providers';
import { type GetOwnPermissions, type GetMe, type UpdateMe } from '../../../shared/contracts/users';
import {
type GetOwnPermissions,
type GetMe,
type UpdateMe,
type GetAiToken,
} from '../../../shared/contracts/users';
import { adminApi } from './api';
@ -53,6 +58,15 @@ const authService = adminApi
},
invalidatesTags: ['Me'],
}),
getAiToken: builder.query<GetAiToken.Response['data'], void>({
query: () => ({
method: 'GET',
url: '/admin/users/me/ai-token',
}),
transformResponse(res: GetAiToken.Response) {
return res.data;
},
}),
/**
* Permissions
*/
@ -197,7 +211,7 @@ const authService = adminApi
invalidatesTags: ['ProvidersOptions'],
}),
}),
overrideExisting: false,
overrideExisting: true,
});
const {
@ -214,6 +228,8 @@ const {
useGetRegistrationInfoQuery,
useForgotPasswordMutation,
useGetMyPermissionsQuery,
useGetAiTokenQuery,
useLazyGetAiTokenQuery,
useIsSSOLockedQuery,
useGetProvidersQuery,
useGetProviderOptionsQuery,
@ -234,6 +250,8 @@ export {
useGetRegistrationInfoQuery,
useForgotPasswordMutation,
useGetMyPermissionsQuery,
useGetAiTokenQuery,
useLazyGetAiTokenQuery,
useIsSSOLockedQuery,
useGetProvidersQuery,
useGetProviderOptionsQuery,

View File

@ -8,6 +8,7 @@ const aiService = adminApi.injectEndpoints({
method: 'GET',
url: `/admin/ai-usage`,
}),
providesTags: ['AIUsage'],
}),
getAiToken: builder.query<GetAiToken.Response['data'], void>({
query: () => ({
@ -19,7 +20,7 @@ const aiService = adminApi.injectEndpoints({
},
}),
}),
overrideExisting: false,
overrideExisting: true,
});
const { useGetAIUsageQuery, useGetAiTokenQuery, useLazyGetAiTokenQuery } = aiService;

View File

@ -0,0 +1,101 @@
// @ts-expect-error - types are not generated for this file
// eslint-disable-next-line import/no-relative-packages
import createContext from '../../../../../../../tests/helpers/create-context';
import authenticatedUserController from '../authenticated-user';
// Mock the getService function
const mockUserService = {
getAiToken: jest.fn(),
};
jest.mock('../../utils', () => ({
getService: jest.fn((serviceName: string) => {
if (serviceName === 'user') {
return mockUserService;
}
return {};
}),
}));
describe('Authenticated User Controller', () => {
const ORIGINAL_ENV = process.env;
beforeEach(() => {
jest.clearAllMocks();
mockUserService.getAiToken.mockReset();
process.env = { ...ORIGINAL_ENV }; // fresh copy for each test
// Reset global fetch
delete (global as any).fetch;
});
afterAll(() => {
process.env = ORIGINAL_ENV; // fully restore after suite
});
describe('getAiToken', () => {
const mockUser = {
id: 1,
email: 'test@example.com',
firstname: 'Test',
lastname: 'User',
};
const createMockContext = (user = mockUser as any, overrides = {}) => {
return createContext(
{},
{
state: { user },
unauthorized: jest.fn(),
badRequest: jest.fn(),
forbidden: jest.fn(),
notFound: jest.fn(),
internalServerError: jest.fn(),
requestTimeout: jest.fn(),
...overrides,
}
);
};
test('Should return unauthorized when user is not authenticated', async () => {
const ctx = createMockContext(null);
await authenticatedUserController.getAiToken(ctx as any);
expect(ctx.unauthorized).toHaveBeenCalledWith('Authentication required');
});
test('Should return internal server error when service throws error', async () => {
const ctx = createMockContext();
// Mock service to throw an error
mockUserService.getAiToken.mockRejectedValueOnce(new Error('Service error'));
await authenticatedUserController.getAiToken(ctx as any);
expect(ctx.internalServerError).toHaveBeenCalledWith(
'AI token request failed. Check server logs for details.'
);
expect(mockUserService.getAiToken).toHaveBeenCalledWith();
});
test('Should successfully return AI token when service succeeds', async () => {
const ctx = createMockContext();
const tokenData = {
token: 'test-jwt-token',
expiresAt: '2025-01-01T12:00:00Z',
};
// Mock service to return successful result
mockUserService.getAiToken.mockResolvedValueOnce(tokenData);
await authenticatedUserController.getAiToken(ctx as any);
expect(mockUserService.getAiToken).toHaveBeenCalledWith();
expect(ctx.body).toEqual({
data: tokenData,
});
});
});
});

View File

@ -3,7 +3,7 @@ import type { AdminUser } from '../../../shared/contracts/shared';
import { getService } from '../utils';
import { validateProfileUpdateInput } from '../validation/user';
import { GetMe, GetOwnPermissions, UpdateMe } from '../../../shared/contracts/users';
import { GetMe, GetOwnPermissions, UpdateMe, GetAiToken } from '../../../shared/contracts/users';
export default {
async getMe(ctx: Context) {
@ -52,4 +52,22 @@ export default {
data: userPermissions.map(sanitizePermission),
} satisfies GetOwnPermissions.Response;
},
async getAiToken(ctx: Context) {
try {
// Security check: Ensure user is authenticated and has proper permissions
if (!ctx.state.user) {
return ctx.unauthorized('Authentication required');
}
const tokenData = await getService('user').getAiToken();
ctx.body = {
data: tokenData,
} satisfies GetAiToken.Response;
} catch (error) {
const errorMessage = 'AI token request failed. Check server logs for details.';
return ctx.internalServerError(errorMessage);
}
},
};

View File

@ -23,6 +23,14 @@ export default [
policies: ['admin::isAuthenticatedAdmin'],
},
},
{
method: 'GET',
path: '/users/me/ai-token',
handler: 'authenticated-user.getAiToken',
config: {
policies: ['admin::isAuthenticatedAdmin'],
},
},
{
method: 'POST',
path: '/users',

View File

@ -3,6 +3,9 @@ import _ from 'lodash';
import { defaults } from 'lodash/fp';
import { arrays, errors } from '@strapi/utils';
import type { Data } from '@strapi/types';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { createUser, hasSuperAdminRole } from '../domain/user';
import type {
AdminUser,
@ -414,6 +417,140 @@ const getLanguagesInUse = async (): Promise<string[]> => {
return users.map((user) => user.preferedLanguage || 'en');
};
/**
* Generate an AI token for the user performing the request
*/
const getAiToken = async (): Promise<{ token: string; expiresAt?: string }> => {
const ERROR_PREFIX = 'AI token request failed:';
// Check if EE features are enabled first
if (!strapi.ee?.isEE) {
strapi.log.error(`${ERROR_PREFIX} Enterprise Edition features are not enabled`);
throw new Error('AI token request failed. Check server logs for details.');
}
// Get the EE license
// First try environment variable, then try reading from file
let eeLicense = process.env.STRAPI_LICENSE;
if (!eeLicense) {
try {
const licensePath = path.join(strapi.dirs.app.root, 'license.txt');
eeLicense = fs.readFileSync(licensePath).toString();
} catch (error) {
// License file doesn't exist or can't be read
}
}
if (!eeLicense) {
strapi.log.error(
`${ERROR_PREFIX} No EE license found. Please ensure STRAPI_LICENSE environment variable is set or license.txt file exists.`
);
throw new Error('AI token request failed. Check server logs for details.');
}
const aiServerUrl = process.env.STRAPI_AI_URL || 'https://strapi-ai.apps.strapi.io';
if (!aiServerUrl) {
strapi.log.error(
`${ERROR_PREFIX} AI server URL not configured. Please set STRAPI_AI_URL environment variable.`
);
throw new Error('AI token request failed. Check server logs for details.');
}
// Create a secure user identifier using only user ID
const user = strapi.requestContext.get()?.state?.user as AdminUser | undefined;
if (!user) {
strapi.log.error(`${ERROR_PREFIX} No authenticated user in request context`);
throw new Error('AI token request failed. Check server logs for details.');
}
const userIdentifier = user.id.toString();
// Get project ID
const projectId = strapi.config.get('uuid');
if (!projectId) {
strapi.log.error(`${ERROR_PREFIX} Project ID not configured`);
throw new Error('AI token request failed. Check server logs for details.');
}
strapi.log.http('Contacting AI Server for token generation');
try {
// Call the AI server's getAiJWT endpoint
const response = await fetch(`${aiServerUrl}/auth/getAiJWT`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// No authorization header needed for public endpoint
// Add request ID for tracing
'X-Request-Id': crypto.randomUUID(),
},
body: JSON.stringify({
eeLicense,
userIdentifier,
projectId,
}),
});
if (!response.ok) {
let errorData;
let errorText;
try {
errorText = await response.text();
errorData = JSON.parse(errorText);
} catch {
errorData = { error: errorText || 'Failed to parse error response' };
}
strapi.log.error(`${ERROR_PREFIX} ${errorData?.error || 'Unknown error'}`, {
status: response.status,
statusText: response.statusText,
error: errorData,
errorText,
projectId,
});
throw new Error('AI token request failed. Check server logs for details.');
}
let data;
try {
data = (await response.json()) as {
jwt: string;
expiresAt?: string;
};
} catch (parseError) {
strapi.log.error(`${ERROR_PREFIX} Failed to parse AI server response`, parseError);
throw new Error('AI token request failed. Check server logs for details.');
}
if (!data.jwt) {
strapi.log.error(`${ERROR_PREFIX} Invalid response: missing JWT token`);
throw new Error('AI token request failed. Check server logs for details.');
}
strapi.log.info('AI token generated successfully', {
userId: user.id,
expiresAt: data.expiresAt,
});
// Return the AI JWT with metadata
// Note: Token expires in 1 hour, client should handle refresh
return {
token: data.jwt,
expiresAt: data.expiresAt, // 1 hour from generation
};
} catch (fetchError) {
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
strapi.log.error(`${ERROR_PREFIX} Request to AI server timed out`);
throw new Error('AI token request failed. Check server logs for details.');
}
throw fetchError;
}
};
export default {
create,
updateById,
@ -433,4 +570,5 @@ export default {
resetPasswordByEmail,
getLanguagesInUse,
isFirstSuperAdminUser,
getAiToken,
};

View File

@ -76,3 +76,21 @@ export declare namespace GetOwnPermissions {
error?: errors.ApplicationError;
}
}
/**
* GET /users/me/ai-token - Get AI token for the current admin user
*/
export declare namespace GetAiToken {
export interface Request {
query: {};
body: {};
}
export interface Response {
data: {
token: string;
expiresAt?: string;
};
error?: errors.ApplicationError;
}
}

View File

@ -0,0 +1,491 @@
import * as React from 'react';
import { ConfirmDialog } from '@strapi/admin/strapi-admin';
import {
Box,
Card,
CardAction,
CardAsset,
CardBadge,
CardBody,
CardContent,
CardHeader,
CardSubtitle,
CardTitle,
CardTimer,
Field,
Flex,
Grid,
TextInput,
Typography,
IconButton,
Dialog,
Modal,
} from '@strapi/design-system';
import { Pencil, Sparkle, Trash } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { styled } from 'styled-components';
import { AudioPreview } from '../../components/AssetCard/AudioPreview';
import { VideoPreview } from '../../components/AssetCard/VideoPreview';
import { type Asset, EditAssetContent } from '../../components/EditAssetDialog/EditAssetContent';
import { AssetType } from '../../constants';
import { useMediaLibraryPermissions } from '../../hooks/useMediaLibraryPermissions';
import { useRemoveAsset } from '../../hooks/useRemoveAsset';
import {
formatBytes,
formatDuration,
getFileExtension,
getTrad,
prefixFileUrlWithBackendUrl,
} from '../../utils';
import { typeFromMime } from '../../utils/typeFromMime';
import { useAIUploadModalContext } from './AIUploadModal';
import type { File } from '../../../../shared/contracts/files';
const CardActionsContainer = styled(CardAction)`
opacity: 0;
z-index: 1;
&:focus-within {
opacity: 1;
}
`;
const CardContainer = styled(Box)`
background: ${({ theme }) => theme.colors.neutral0};
border: 1px solid ${({ theme }) => theme.colors.neutral150};
border-radius: ${({ theme }) => theme.borderRadius};
&:hover {
${CardActionsContainer} {
opacity: 1;
}
}
`;
/* -------------------------------------------------------------------------------------------------
* AssetCardActions
* -----------------------------------------------------------------------------------------------*/
const AssetCardActions = ({ asset }: { asset: File }) => {
const { formatMessage } = useIntl();
const dispatch = useAIUploadModalContext('AssetCardActions', (s) => s.dispatch);
const state = useAIUploadModalContext('AssetCardActions', (s) => s.state);
const onClose = useAIUploadModalContext('AssetCardActions', (s) => s.onClose);
const { canUpdate, canCopyLink, canDownload } = useMediaLibraryPermissions();
const [isEditModalOpen, setIsEditModalOpen] = React.useState(false);
const { removeAsset } = useRemoveAsset(() => {});
const handleConfirmRemove = async (event?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event?.preventDefault();
await removeAsset(asset.id);
dispatch({
type: 'remove_uploaded_asset',
payload: { id: asset.id },
});
// Close modal if this was the last asset
if (state.uploadedAssets.length === 1) {
onClose();
}
};
const handlePropagationClick = (event: React.MouseEvent) => {
event.stopPropagation();
};
const handleEditAsset = (editedAsset?: File | null) => {
if (editedAsset) {
dispatch({
type: 'edit_uploaded_asset',
payload: { editedAsset },
});
setIsEditModalOpen(false);
}
};
return (
<CardActionsContainer onClick={handlePropagationClick} position="end">
<Dialog.Root>
<Dialog.Trigger>
<IconButton
label={formatMessage({
id: getTrad('control-card.remove-selection'),
defaultMessage: 'Remove from selection',
})}
>
<Trash />
</IconButton>
</Dialog.Trigger>
<ConfirmDialog onConfirm={handleConfirmRemove} />
</Dialog.Root>
<Modal.Root open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<Modal.Trigger>
<IconButton
label={formatMessage({ id: getTrad('control-card.edit'), defaultMessage: 'Edit' })}
>
<Pencil />
</IconButton>
</Modal.Trigger>
<Modal.Content>
<EditAssetContent
// Is Local must be set to false to trigger the correct branch of logic in the EditAssetContent on submit
asset={
{
...asset,
isLocal: false,
folder: typeof asset.folder === 'number' ? { id: asset.folder } : asset.folder,
} as Asset
}
onClose={(arg) => handleEditAsset(arg as File)}
canUpdate={canUpdate}
canCopyLink={canCopyLink}
canDownload={canDownload}
omitFields={['caption', 'alternativeText']}
omitActions={['replace']}
/>
</Modal.Content>
</Modal.Root>
</CardActionsContainer>
);
};
/* -------------------------------------------------------------------------------------------------
* Asset
* -----------------------------------------------------------------------------------------------*/
interface AssetProps {
assetType: AssetType;
thumbnailUrl: string;
assetUrl: string;
asset: File;
}
interface AssetCardProps {
asset: File;
onCaptionChange: (caption: string) => void;
onAltTextChange: (altText: string) => void;
wasCaptionChanged: boolean;
wasAltTextChanged: boolean;
}
const Extension = styled.span`
text-transform: uppercase;
`;
const VideoPreviewWrapper = styled(Box)`
position: relative;
height: 100%;
overflow: hidden;
canvas,
video {
display: block;
pointer-events: none;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: ${({ theme }) => theme.borderRadius};
}
`;
const VideoTimerOverlay = styled(CardTimer)`
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
`;
const AudioPreviewWrapper = styled(Box)`
height: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
canvas,
audio {
display: block;
max-width: 100%;
max-height: 100%;
}
`;
const Asset = ({ assetType, thumbnailUrl, assetUrl, asset }: AssetProps) => {
const [duration, setDuration] = React.useState<number>();
const formattedDuration = duration ? formatDuration(duration) : undefined;
switch (assetType) {
case AssetType.Image:
return <CardAsset src={thumbnailUrl} size="S" alt={asset.alternativeText || asset.name} />;
case AssetType.Video:
return (
<CardAsset size="S">
<VideoPreviewWrapper>
<VideoPreview
url={assetUrl}
mime={asset.mime || 'video/mp4'}
onLoadDuration={setDuration}
alt={asset.alternativeText || asset.name}
/>
{formattedDuration && <VideoTimerOverlay>{formattedDuration}</VideoTimerOverlay>}
</VideoPreviewWrapper>
</CardAsset>
);
case AssetType.Audio:
return (
<CardAsset size="S">
<AudioPreviewWrapper>
<AudioPreview url={assetUrl} alt={asset.alternativeText || asset.name} />
</AudioPreviewWrapper>
</CardAsset>
);
default:
return <CardAsset src={thumbnailUrl} size="S" alt={asset.alternativeText || asset.name} />;
}
};
/* -------------------------------------------------------------------------------------------------
* AssetCard
* -----------------------------------------------------------------------------------------------*/
const StyledCardBody = styled(CardBody)`
display: flex;
padding: ${({ theme }) => theme.spaces[2]} ${({ theme }) => theme.spaces[1]};
`;
const StyledCard = styled(Card)`
width: 100%;
height: 100%;
border: none;
box-shadow: none;
border-radius: 0;
padding: 0;
`;
const getAssetBadgeLabel = (assetType: AssetType) => {
switch (assetType) {
case AssetType.Image:
return { id: getTrad('settings.section.image.label'), defaultMessage: 'IMAGE' };
case AssetType.Video:
return { id: getTrad('settings.section.video.label'), defaultMessage: 'VIDEO' };
case AssetType.Audio:
return { id: getTrad('settings.section.audio.label'), defaultMessage: 'AUDIO' };
default:
return { id: getTrad('settings.section.doc.label'), defaultMessage: 'DOC' };
}
};
export const AIAssetCard = ({
asset,
onCaptionChange,
onAltTextChange,
wasAltTextChanged,
wasCaptionChanged,
}: AssetCardProps) => {
const { formatMessage } = useIntl();
const assetType = typeFromMime(asset.mime || '');
const thumbnailUrl =
prefixFileUrlWithBackendUrl(asset?.formats?.thumbnail?.url || asset.url) || '';
const assetUrl = prefixFileUrlWithBackendUrl(asset.url) || '';
const subtitle = asset.height && asset.width ? ` - ${asset.width}x${asset.height}` : '';
const formattedSize = asset.size ? formatBytes(asset.size) : '';
const fullSubtitle = `${subtitle}${subtitle && formattedSize ? ' - ' : ''}${formattedSize}`;
const [caption, setCaption] = React.useState(asset.caption || '');
React.useEffect(() => {
onCaptionChange(caption);
}, [caption, onCaptionChange]);
const [altText, setAltText] = React.useState(asset.alternativeText || '');
React.useEffect(() => {
onAltTextChange(altText);
}, [altText, onAltTextChange]);
return (
<CardContainer>
<Grid.Root>
<Grid.Item col={5} alignItems="stretch">
<StyledCard width="100%" height="100%" shadow="none" borderRadius={0} padding={0}>
<CardHeader style={{ borderStyle: 'none' }}>
<AssetCardActions asset={asset} />
<Asset
assetType={assetType}
thumbnailUrl={thumbnailUrl}
assetUrl={assetUrl}
asset={asset}
/>
</CardHeader>
<StyledCardBody>
<CardContent width="100%">
<Flex justifyContent="space-between" alignItems="start">
<Typography tag="h2">
<CardTitle tag="span">{asset.name}</CardTitle>
</Typography>
<CardBadge>{formatMessage(getAssetBadgeLabel(assetType))}</CardBadge>
</Flex>
<Flex>
<CardSubtitle>
<Extension>{getFileExtension(asset.ext)}</Extension>
{fullSubtitle}
</CardSubtitle>
</Flex>
</CardContent>
</StyledCardBody>
</StyledCard>
</Grid.Item>
<Grid.Item col={7} flex={1}>
<Flex direction="column" height="100%" alignItems="stretch" flex={1} padding={4} gap={2}>
<Field.Root name="caption">
<Flex alignItems="center" gap={2}>
<Field.Label>
{formatMessage({
id: getTrad('form.input.label.file-caption'),
defaultMessage: 'Caption',
})}
</Field.Label>
</Flex>
<TextInput
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder={formatMessage({
id: getTrad('form.input.placeholder.file-caption'),
defaultMessage: 'Enter caption',
})}
endAction={
!wasCaptionChanged &&
asset.caption && <Sparkle width="16px" height="16px" fill="#AC73E6" />
}
/>
</Field.Root>
<Field.Root
name="alternativeText"
hint={formatMessage({
id: getTrad('form.input.description.file-alt'),
defaultMessage: "This text will be displayed if the asset can't be shown.",
})}
>
<Flex alignItems="center" gap={2}>
<Field.Label>
{formatMessage({
id: getTrad('form.input.label.file-alt'),
defaultMessage: 'Alternative text',
})}
</Field.Label>
</Flex>
<TextInput
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder={formatMessage({
id: getTrad('form.input.placeholder.file-alt'),
defaultMessage: 'Enter alternative text',
})}
endAction={
!wasAltTextChanged &&
asset.alternativeText && <Sparkle width="16px" height="16px" fill="#AC73E6" />
}
/>
<Field.Hint />
</Field.Root>
</Flex>
</Grid.Item>
</Grid.Root>
</CardContainer>
);
};
/* -------------------------------------------------------------------------------------------------
* AssetCardSkeletons
* -----------------------------------------------------------------------------------------------*/
const SkeletonBox = styled(Box)<{ width?: string; height?: string }>`
background: linear-gradient(
90deg,
${({ theme }) => theme.colors.neutral100} 25%,
${({ theme }) => theme.colors.neutral150} 50%,
${({ theme }) => theme.colors.neutral100} 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: ${({ theme }) => theme.borderRadius};
width: ${({ width }) => width || '100%'};
height: ${({ height }) => height || '1rem'};
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
`;
export const AIAssetCardSkeletons = ({ count = 1 }: { count?: number }) => {
const skeletons = Array.from({ length: count }, (_, i) => i);
return skeletons.map((index) => (
<Box
key={index}
background="neutral0"
borderColor="neutral150"
borderStyle="solid"
borderWidth="1px"
borderRadius="4px"
marginBottom={4}
>
<Grid.Root>
<Grid.Item col={5} alignItems="stretch">
<Card
height="100%"
width="100%"
borderStyle="none"
shadow="none"
borderRadius={0}
padding={2}
>
<Box height="150px" padding={2}>
<SkeletonBox height="100%" />
</Box>
<CardBody style={{ display: 'flex', padding: '8px 4px' }}>
<CardContent width="100%">
<Flex justifyContent="space-between" alignItems="start" marginBottom={1}>
<SkeletonBox width="60%" height="18px" />
<SkeletonBox width="40px" height="16px" />
</Flex>
<SkeletonBox width="80%" height="14px" />
</CardContent>
</CardBody>
</Card>
</Grid.Item>
<Grid.Item col={7} flex={1}>
<Flex direction="column" height="100%" alignItems="stretch" flex={1} padding={4} gap={2}>
<Box>
<SkeletonBox width="60px" height="16px" marginBottom={1} />
<SkeletonBox height="32px" />
</Box>
<Box>
<SkeletonBox width="100px" height="16px" marginBottom={1} />
<SkeletonBox height="32px" />
<Box marginTop={1}>
<SkeletonBox width="70%" height="12px" />
</Box>
</Box>
</Flex>
</Grid.Item>
</Grid.Root>
</Box>
));
};

View File

@ -0,0 +1,378 @@
import * as React from 'react';
import { createContext } from '@strapi/admin/strapi-admin';
import { Alert, Button, Flex, Modal } from '@strapi/design-system';
import { produce } from 'immer';
import { useIntl } from 'react-intl';
import { styled } from 'styled-components';
import {
AddAssetStep,
FileWithRawFile,
} from '../../components/UploadAssetDialog/AddAssetStep/AddAssetStep';
import { useBulkEdit } from '../../hooks/useBulkEdit';
import { useUpload } from '../../hooks/useUpload';
import { getTrad } from '../../utils';
import { AIAssetCard, AIAssetCardSkeletons } from './AIAssetCard';
import type { File } from '../../../../shared/contracts/files';
/* -------------------------------------------------------------------------------------------------
* ModalBody
* -----------------------------------------------------------------------------------------------*/
const StyledModalBody = styled(Modal.Body)`
padding: 0;
display: flex;
justify-content: center;
[data-radix-scroll-area-viewport] {
padding-top: ${({ theme }) => theme.spaces[6]};
padding-bottom: ${({ theme }) => theme.spaces[6]};
padding-left: ${({ theme }) => theme.spaces[7]};
padding-right: ${({ theme }) => theme.spaces[7]};
}
`;
const StyledAlert = styled(Alert)`
& > button {
display: none;
}
`;
const ModalContent = ({ onClose }: Pick<AIUploadModalProps, 'onClose'>) => {
const { formatMessage } = useIntl();
const state = useAIUploadModalContext('ModalContent', (s) => s.state);
const dispatch = useAIUploadModalContext('ModalContent', (s) => s.dispatch);
const folderId = useAIUploadModalContext('ModalContent', (s) => s.folderId);
const { upload } = useUpload();
const { edit, isLoading: isSaving } = useBulkEdit();
const [isUploading, setIsUploading] = React.useState(false);
const [uploadError, setUploadError] = React.useState<Error | null>(null);
const handleCaptionChange = (assetId: number, caption: string) => {
dispatch({
type: 'set_uploaded_asset_caption',
payload: { id: assetId, caption },
});
};
const handleAltTextChange = (assetId: number, altText: string) => {
dispatch({
type: 'set_uploaded_asset_alt_text',
payload: { id: assetId, altText },
});
};
const resetState = () => {
dispatch({ type: 'set_uploaded_assets', payload: [] });
};
const handleFinish = async () => {
if (state.hasUnsavedChanges) {
const assetsToUpdate = state.uploadedAssets.filter(
(asset) => (asset.wasCaptionChanged || asset.wasAltTextChanged) && asset.file.id
);
if (assetsToUpdate.length > 0) {
const updates = assetsToUpdate.map((asset) => ({
id: asset.file.id!,
fileInfo: {
name: asset.file.name,
alternativeText: asset.file.alternativeText ?? null,
caption: asset.file.caption ?? null,
folder:
typeof asset.file.folder === 'object' && asset.file.folder !== null
? // @ts-expect-error types are wrong
asset.file.folder.id
: asset.file.folder,
},
}));
try {
await edit(updates);
dispatch({ type: 'clear_unsaved_changes' });
} catch (err) {
console.error('Failed to save asset changes:', err);
return; // Don't close modal on error
}
}
}
resetState();
onClose();
};
const handleCancel = () => {
resetState();
onClose();
};
const handleUpload = async (assets: FileWithRawFile[]) => {
dispatch({ type: 'set_assets_to_upload_length', payload: assets.length });
setUploadError(null);
setIsUploading(true);
try {
const assetsForUpload = assets.map((asset) => ({
...asset,
id: asset.id ? Number(asset.id) : undefined,
}));
const uploadedFiles = await upload(assetsForUpload, folderId);
const filesWithFolder = uploadedFiles.map((file: File) => ({
...file,
// The upload API doesn't populate the folder relation, so we add it manually
folder: folderId || file.folder,
}));
dispatch({ type: 'set_uploaded_assets', payload: filesWithFolder });
} catch (error) {
console.error('Upload failed:', error);
setUploadError(error instanceof Error ? error : new Error('Upload failed'));
} finally {
setIsUploading(false);
}
};
if (state.assetsToUploadLength === 0) {
return (
<Modal.Content>
<AddAssetStep onClose={onClose} onAddAsset={handleUpload} />
</Modal.Content>
);
}
if (
isUploading ||
(state.assetsToUploadLength > 0 && state.uploadedAssets.length === 0 && !uploadError)
) {
return (
<Modal.Content>
<Modal.Header>
<Modal.Title>
{formatMessage({
id: getTrad('ai.modal.uploading.title'),
defaultMessage: 'Uploading and processing with AI...',
})}
</Modal.Title>
</Modal.Header>
<StyledModalBody>
<AIAssetCardSkeletons count={state.assetsToUploadLength} />
</StyledModalBody>
</Modal.Content>
);
}
const title = formatMessage(
{
id: getTrad('ai.modal.title'),
defaultMessage:
'{count, plural, one {# Asset uploaded} other {# Assets uploaded}} time to review AI generated content',
},
{ count: state.uploadedAssets.length }
);
if (uploadError) {
return (
<Modal.Content>
<Modal.Header>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<StyledAlert closeLabel="" variant="danger">
{formatMessage({
id: getTrad('ai.modal.error'),
defaultMessage: 'Could not generate AI metadata for the uploaded files.',
})}
</StyledAlert>
</Modal.Body>
<Modal.Footer>
<Button onClick={handleCancel} variant="tertiary">
{formatMessage({ id: 'cancel', defaultMessage: 'Cancel' })}
</Button>
<Button onClick={handleFinish} loading={isSaving}>
{formatMessage({ id: 'global.finish', defaultMessage: 'Finish' })}
</Button>
</Modal.Footer>
</Modal.Content>
);
}
return (
<Modal.Content>
<Modal.Header>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<StyledModalBody>
<Flex gap={6} direction="column" alignItems="stretch">
{state.uploadedAssets.map(({ file: asset, wasCaptionChanged, wasAltTextChanged }) => (
<AIAssetCard
key={asset.id}
asset={asset}
onCaptionChange={(caption: string) =>
asset.id && handleCaptionChange(asset.id, caption)
}
onAltTextChange={(altText: string) =>
asset.id && handleAltTextChange(asset.id, altText)
}
wasCaptionChanged={wasCaptionChanged}
wasAltTextChanged={wasAltTextChanged}
/>
))}
</Flex>
</StyledModalBody>
<Modal.Footer>
<Button onClick={handleCancel} variant="tertiary">
{formatMessage({ id: 'cancel', defaultMessage: 'Cancel' })}
</Button>
<Button onClick={handleFinish} loading={isSaving}>
{formatMessage({ id: 'global.finish', defaultMessage: 'Finish' })}
</Button>
</Modal.Footer>
</Modal.Content>
);
};
/* -------------------------------------------------------------------------------------------------
* UploadModal
* -----------------------------------------------------------------------------------------------*/
interface AIUploadModalProps {
open: boolean;
onClose: () => void;
folderId?: number | null;
}
type State = {
uploadedAssets: Array<{ file: File; wasCaptionChanged: boolean; wasAltTextChanged: boolean }>;
assetsToUploadLength: number;
hasUnsavedChanges: boolean;
};
type Action =
| {
type: 'set_uploaded_assets';
payload: File[];
}
| {
type: 'set_assets_to_upload_length';
payload: number;
}
| {
type: 'set_uploaded_asset_caption';
payload: { id: number; caption: string };
}
| {
type: 'set_uploaded_asset_alt_text';
payload: { id: number; altText: string };
}
| {
type: 'remove_uploaded_asset';
payload: { id: number };
}
| {
type: 'edit_uploaded_asset';
payload: { editedAsset: File };
}
| {
type: 'clear_unsaved_changes';
};
const [AIUploadModalContext, useAIUploadModalContext] = createContext<{
state: State;
dispatch: React.Dispatch<Action>;
folderId: number | null;
onClose: () => void;
}>('AIUploadModalContext');
const reducer = (state: State, action: Action): State => {
return produce(state, (draft: State) => {
if (action.type === 'set_uploaded_assets') {
draft.uploadedAssets = action.payload.map((file) => ({
file,
wasCaptionChanged: false,
wasAltTextChanged: false,
}));
draft.hasUnsavedChanges = false;
}
if (action.type === 'set_assets_to_upload_length') {
draft.assetsToUploadLength = action.payload;
}
if (action.type === 'set_uploaded_asset_caption') {
const asset = draft.uploadedAssets.find((a) => a.file.id === action.payload.id);
if (asset && asset.file.caption !== action.payload.caption) {
asset.file.caption = action.payload.caption;
asset.wasCaptionChanged = true;
draft.hasUnsavedChanges = true;
}
}
if (action.type === 'set_uploaded_asset_alt_text') {
const asset = draft.uploadedAssets.find((a) => a.file.id === action.payload.id);
if (asset && asset.file.alternativeText !== action.payload.altText) {
asset.file.alternativeText = action.payload.altText;
asset.wasAltTextChanged = true;
draft.hasUnsavedChanges = true;
}
}
if (action.type === 'remove_uploaded_asset') {
draft.uploadedAssets = draft.uploadedAssets.filter((a) => a.file.id !== action.payload.id);
}
if (action.type === 'edit_uploaded_asset') {
const assetIndex = draft.uploadedAssets.findIndex(
(a) => a.file.id === action.payload.editedAsset.id
);
if (assetIndex !== -1) {
draft.uploadedAssets[assetIndex] = {
file: action.payload.editedAsset,
wasCaptionChanged: draft.uploadedAssets[assetIndex].wasCaptionChanged,
wasAltTextChanged: draft.uploadedAssets[assetIndex].wasAltTextChanged,
};
}
}
if (action.type === 'clear_unsaved_changes') {
draft.hasUnsavedChanges = false;
draft.uploadedAssets.forEach((asset) => {
asset.wasCaptionChanged = false;
asset.wasAltTextChanged = false;
});
}
});
};
export const AIUploadModal = ({ open, onClose, folderId = null }: AIUploadModalProps) => {
const [state, dispatch] = React.useReducer(reducer, {
uploadedAssets: [],
assetsToUploadLength: 0,
hasUnsavedChanges: false,
});
const handleClose = React.useCallback(() => {
// Reset state when modal closes
dispatch({ type: 'set_uploaded_assets', payload: [] });
onClose();
}, [onClose]);
return (
<AIUploadModalContext
state={state}
dispatch={dispatch}
folderId={folderId}
onClose={handleClose}
>
<Modal.Root open={open} onOpenChange={handleClose}>
<ModalContent onClose={handleClose} />
</Modal.Root>
</AIUploadModalContext>
);
};
export { useAIUploadModalContext };

View File

@ -59,6 +59,8 @@ interface EditAssetContentProps {
canDownload?: boolean;
trackedLocation?: string;
onClose: (arg?: Asset | null | boolean) => void;
omitFields?: ('caption' | 'alternativeText')[];
omitActions?: 'replace'[];
}
interface FormInitialData {
@ -78,6 +80,8 @@ export const EditAssetContent = ({
canCopyLink = false,
canDownload = false,
trackedLocation,
omitFields = [],
omitActions = [],
}: EditAssetContentProps) => {
const { formatMessage, formatDate } = useIntl();
const { trackUsage } = useTracking();
@ -274,42 +278,47 @@ export const EditAssetContent = ({
<Field.Error />
</Field.Root>
<Field.Root
name="alternativeText"
hint={formatMessage({
id: getTrad('form.input.description.file-alt'),
defaultMessage: 'This text will be displayed if the asset cant be shown.',
})}
error={errors.alternativeText}
>
<Field.Label>
{formatMessage({
id: getTrad('form.input.label.file-alt'),
defaultMessage: 'Alternative text',
{!omitFields?.includes('alternativeText') && (
<Field.Root
name="alternativeText"
hint={formatMessage({
id: getTrad('form.input.description.file-alt'),
defaultMessage:
'This text will be displayed if the asset cant be shown.',
})}
</Field.Label>
<TextInput
value={values.alternativeText}
onChange={handleChange}
disabled={formDisabled}
/>
<Field.Hint />
<Field.Error />
</Field.Root>
error={errors.alternativeText}
>
<Field.Label>
{formatMessage({
id: getTrad('form.input.label.file-alt'),
defaultMessage: 'Alternative text',
})}
</Field.Label>
<TextInput
value={values.alternativeText}
onChange={handleChange}
disabled={formDisabled}
/>
<Field.Hint />
<Field.Error />
</Field.Root>
)}
<Field.Root name="caption" error={errors.caption}>
<Field.Label>
{formatMessage({
id: getTrad('form.input.label.file-caption'),
defaultMessage: 'Caption',
})}
</Field.Label>
<TextInput
value={values.caption}
onChange={handleChange}
disabled={formDisabled}
/>
</Field.Root>
{!omitFields?.includes('caption') && (
<Field.Root name="caption" error={errors.caption}>
<Field.Label>
{formatMessage({
id: getTrad('form.input.label.file-caption'),
defaultMessage: 'Caption',
})}
</Field.Label>
<TextInput
value={values.caption}
onChange={handleChange}
disabled={formDisabled}
/>
</Field.Root>
)}
<Flex direction="column" alignItems="stretch" gap={1}>
<Field.Root name="parent" id="asset-folder">
@ -356,12 +365,14 @@ export const EditAssetContent = ({
{formatMessage({ id: 'global.cancel', defaultMessage: 'Cancel' })}
</Button>
<Flex gap={2}>
<ReplaceMediaButton
onSelectMedia={setReplacementFile}
acceptedMime={asset?.mime ?? ''}
disabled={formDisabled}
trackedLocation={trackedLocation}
/>
{!omitActions?.includes('replace') && (
<ReplaceMediaButton
onSelectMedia={setReplacementFile}
acceptedMime={asset?.mime ?? ''}
disabled={formDisabled}
trackedLocation={trackedLocation}
/>
)}
<Button
onClick={() => submitButtonRef.current?.click()}

View File

@ -0,0 +1,14 @@
import { useAIAvailability as useGlobalAIAvailability } from '@strapi/admin/strapi-admin/ee';
import { useSettings } from './useSettings';
export const useAIAvailability = () => {
const isAiAvailable = useGlobalAIAvailability();
const { status, data } = useSettings(isAiAvailable);
if (!isAiAvailable) {
return { status: 'success' as const, isEnabled: false };
}
return { status, isEnabled: data?.aiMetadata };
};

View File

@ -0,0 +1,65 @@
import { useNotification, useFetchClient } from '@strapi/admin/strapi-admin';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from 'react-query';
import { BulkUpdateFiles } from '../../../shared/contracts/files';
import { pluginId } from '../pluginId';
import { getTrad } from '../utils';
interface FileInfoUpdate {
name: string;
alternativeText: string | null;
caption: string | null;
folder: number | null;
}
interface BulkEditParams {
updates: Array<{
id: number;
fileInfo: FileInfoUpdate;
}>;
}
export const useBulkEdit = () => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const queryClient = useQueryClient();
const { post } = useFetchClient();
const bulkEditQuery = ({ updates }: BulkEditParams) => {
return post('/upload/actions/bulk-update', { updates });
};
const mutation = useMutation<
BulkUpdateFiles.Response,
BulkUpdateFiles.Response['error'],
BulkEditParams
>(bulkEditQuery, {
onSuccess(res) {
const { data } = res;
if (data && data.length > 0) {
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
queryClient.refetchQueries([pluginId, 'folders'], { active: true });
}
toggleNotification({
type: 'success',
message: formatMessage({
id: getTrad('modal.edit.success-label'),
defaultMessage: 'Files have been successfully updated.',
}),
});
},
});
const edit = (
updates: Array<{
id: number;
fileInfo: FileInfoUpdate;
}>
) => mutation.mutateAsync({ updates });
return { ...mutation, edit };
};

View File

@ -0,0 +1,20 @@
import { useFetchClient } from '@strapi/admin/strapi-admin';
import { useQuery } from 'react-query';
import type { GetSettings } from '../../../shared/contracts/settings';
export function useSettings(isEnabled: boolean = true) {
const { get } = useFetchClient();
return useQuery({
queryKey: ['upload', 'settings'],
enabled: isEnabled,
async queryFn() {
const {
data: { data },
} = await get<GetSettings.Response['data']>('/upload/settings');
return data;
},
});
}

View File

@ -15,27 +15,35 @@ interface Asset extends Omit<File, 'id' | 'hash'> {
hash?: File['hash'];
}
const uploadAsset = (
asset: Asset,
const uploadAssets = (
assets: Asset | Asset[],
folderId: number | null,
signal: AbortSignal,
onProgress: (progress: number) => void,
post: FetchClient['post']
) => {
const { rawFile, caption, name, alternativeText } = asset;
const assetsArray = Array.isArray(assets) ? assets : [assets];
const formData = new FormData();
formData.append('files', rawFile!);
// Add all files to the form data
assetsArray.forEach((asset) => {
if (asset.rawFile) {
formData.append('files', asset.rawFile);
}
});
formData.append(
'fileInfo',
JSON.stringify({
name,
caption,
alternativeText,
folder: folderId,
})
);
// Add each fileInfo as a separate stringified field
assetsArray.forEach((asset) => {
formData.append(
'fileInfo',
JSON.stringify({
name: asset.name,
caption: asset.caption,
alternativeText: asset.alternativeText,
folder: folderId,
})
);
});
/**
* onProgress is not possible using native fetch
@ -58,22 +66,22 @@ export const useUpload = () => {
const mutation = useMutation<
CreateFile.Response['data'],
CreateFile.Response['error'],
{ asset: Asset; folderId: number | null }
{ assets: Asset | Asset[]; folderId: number | null }
>(
({ asset, folderId }) => {
return uploadAsset(asset, folderId, signal, setProgress, post);
({ assets, folderId }) => {
return uploadAssets(assets, folderId, signal, setProgress, post);
},
{
onSuccess() {
queryClient.refetchQueries([pluginId, 'assets'], { active: true });
queryClient.refetchQueries([pluginId, 'asset-count'], { active: true });
dispatch(adminApi.util.invalidateTags(['HomepageKeyStatistics']));
dispatch(adminApi.util.invalidateTags(['HomepageKeyStatistics', 'AIUsage']));
},
}
);
const upload = (asset: Asset, folderId: number | null) =>
mutation.mutateAsync({ asset, folderId });
const upload = (assets: Asset | Asset[], folderId: number | null) =>
mutation.mutateAsync({ assets, folderId });
const cancel = () => abortController.abort();

View File

@ -25,6 +25,7 @@ import { useIntl } from 'react-intl';
import { Link as ReactRouterLink, useNavigate, useLocation } from 'react-router-dom';
import { styled } from 'styled-components';
import { AIUploadModal } from '../../../ai/components/AIUploadModal';
import { AssetGridList } from '../../../components/AssetGridList/AssetGridList';
import { EditAssetDialog } from '../../../components/EditAssetDialog/EditAssetContent';
import { EditFolderDialog } from '../../../components/EditFolderDialog/EditFolderDialog';
@ -37,6 +38,7 @@ import { SortPicker } from '../../../components/SortPicker/SortPicker';
import { TableList } from '../../../components/TableList/TableList';
import { UploadAssetDialog } from '../../../components/UploadAssetDialog/UploadAssetDialog';
import { localStorageKeys, viewOptions } from '../../../constants';
import { useAIAvailability } from '../../../hooks/useAiAvailability';
import { useAssets } from '../../../hooks/useAssets';
import { useFolder } from '../../../hooks/useFolder';
import { useFolders } from '../../../hooks/useFolders';
@ -87,6 +89,7 @@ export const MediaLibrary = () => {
canConfigureView,
isLoading: permissionsLoading,
} = useMediaLibraryPermissions();
const { isEnabled: isAiEnabled, status: aiAvailabilityStatus } = useAIAvailability();
const currentFolderToEditRef = React.useRef<HTMLDivElement>();
const { formatMessage } = useIntl();
const { pathname } = useLocation();
@ -144,7 +147,12 @@ export const MediaLibrary = () => {
const assetCount = assets?.length ?? 0;
const totalAssetCount = assetsData?.pagination?.total;
const isLoading = isCurrentFolderLoading || foldersLoading || permissionsLoading || assetsLoading;
const isLoading =
isCurrentFolderLoading ||
foldersLoading ||
permissionsLoading ||
assetsLoading ||
aiAvailabilityStatus === 'loading';
const [showUploadAssetDialog, setShowUploadAssetDialog] = React.useState(false);
const [showEditFolderDialog, setShowEditFolderDialog] = React.useState(false);
const [assetToEdit, setAssetToEdit] = React.useState<Asset | undefined>(undefined);
@ -227,7 +235,7 @@ export const MediaLibrary = () => {
return <Page.Loading />;
}
if (assetsError || foldersError) {
if (assetsError || foldersError || aiAvailabilityStatus === 'error') {
return <Page.Error />;
}
@ -519,14 +527,21 @@ export const MediaLibrary = () => {
</Pagination.Root>
</Layouts.Content>
</Page.Main>
{showUploadAssetDialog && (
<UploadAssetDialog
open={showUploadAssetDialog}
onClose={toggleUploadAssetDialog}
trackedLocation="upload"
folderId={query?.folder as string | number | null | undefined}
/>
)}
{showUploadAssetDialog &&
(isAiEnabled ? (
<AIUploadModal
open={showUploadAssetDialog}
onClose={toggleUploadAssetDialog}
folderId={query?.folder ? Number(query.folder) : null}
/>
) : (
<UploadAssetDialog
open={showUploadAssetDialog}
onClose={toggleUploadAssetDialog}
trackedLocation="upload"
folderId={query?.folder as string | number | null | undefined}
/>
))}
{showEditFolderDialog && (
<EditFolderDialog
open={showEditFolderDialog}

View File

@ -3,13 +3,14 @@ import * as React from 'react';
import { Page, useNotification, useFetchClient, Layouts } from '@strapi/admin/strapi-admin';
import { Box, Button, Flex, Grid, Toggle, Typography, Field } from '@strapi/design-system';
import { Check } from '@strapi/icons';
import { Check, Sparkle } from '@strapi/icons';
import isEqual from 'lodash/isEqual';
import { useIntl } from 'react-intl';
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import { UpdateSettings } from '../../../../shared/contracts/settings';
import { PERMISSIONS } from '../../constants';
import { useSettings } from '../../hooks/useSettings';
import { getTrad } from '../../utils';
import { init } from './init';
@ -20,20 +21,11 @@ import type { InitialState } from './reducer';
export const SettingsPage = () => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const { get, put } = useFetchClient();
const { put } = useFetchClient();
const [{ initialData, modifiedData }, dispatch] = React.useReducer(reducer, initialState, init);
const { data, isLoading, refetch } = useQuery({
queryKey: ['upload', 'settings'],
async queryFn() {
const {
data: { data },
} = await get('/upload/settings');
return data;
},
});
const { data, isLoading, refetch } = useSettings();
React.useEffect(() => {
if (data) {
@ -102,7 +94,7 @@ export const SettingsPage = () => {
<Page.Title>
{formatMessage({
id: getTrad('page.title'),
defaultMessage: 'Settings - Media Libray',
defaultMessage: 'Settings - Media Library',
})}
</Page.Title>
<form onSubmit={handleSubmit}>
@ -132,7 +124,62 @@ export const SettingsPage = () => {
/>
<Layouts.Content>
<Layouts.Root>
<Flex direction="column" alignItems="stretch" gap={12}>
<Flex direction="column" alignItems="stretch" gap={4}>
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
<Flex direction="column" alignItems="stretch" gap={1}>
<Grid.Root gap={6}>
<Grid.Item col={8} s={12} direction="column" alignItems="stretch">
<Flex gap={2}>
<Box color="alternative700">
<Sparkle />
</Box>
<Typography variant="delta" tag="h2">
{formatMessage({
id: getTrad('settings.form.aiMetadata.label'),
defaultMessage:
'Generate AI captions and alt texts automatically on upload!',
})}
</Typography>
</Flex>
<Flex paddingTop={1}>
<Typography variant="pi" textColor="neutral600">
{formatMessage({
id: getTrad('settings.form.aiMetadata.description'),
defaultMessage:
'Enable this feature to save time, optimize your SEO and increase accessibility by letting our AI generate captions and alternative texts for you.',
})}
</Typography>
</Flex>
</Grid.Item>
<Grid.Item
col={4}
s={12}
direction="column"
alignItems="end"
justifyContent={'center'}
>
<Field.Root name="aiMetadata" width={'158px'}>
<Toggle
checked={modifiedData?.aiMetadata}
offLabel={formatMessage({
id: 'app.components.ToggleCheckbox.off-label',
defaultMessage: 'Disabled',
})}
onLabel={formatMessage({
id: 'app.components.ToggleCheckbox.on-label',
defaultMessage: 'Enabled',
})}
onChange={(e) => {
handleChange({
target: { name: 'aiMetadata', value: e.target.checked },
});
}}
/>
</Field.Root>
</Grid.Item>
</Grid.Root>
</Flex>
</Box>
<Box background="neutral0" padding={6} shadow="filterShadow" hasRadius>
<Flex direction="column" alignItems="stretch" gap={4}>
<Flex>

View File

@ -1,19 +1,19 @@
import { produce } from 'immer';
import set from 'lodash/set';
import { SettingsData } from '../../../../shared/contracts/settings';
const initialData: SettingsData = {
responsiveDimensions: true,
sizeOptimization: true,
autoOrientation: false,
videoPreview: false,
aiMetadata: true,
};
export type InitialState = {
initialData: {
responsiveDimensions?: boolean;
sizeOptimization?: boolean;
autoOrientation?: boolean;
videoPreview?: boolean;
} | null;
modifiedData: {
responsiveDimensions?: boolean;
sizeOptimization?: boolean;
autoOrientation?: boolean;
videoPreview?: boolean;
} | null;
initialData: SettingsData | null;
modifiedData: SettingsData | null;
};
interface ActionGetDataSucceeded {
@ -30,18 +30,8 @@ interface ActionOnChange {
export type Action = ActionGetDataSucceeded | ActionOnChange;
const initialState: InitialState = {
initialData: {
responsiveDimensions: true,
sizeOptimization: true,
autoOrientation: false,
videoPreview: false,
},
modifiedData: {
responsiveDimensions: true,
sizeOptimization: true,
autoOrientation: false,
videoPreview: false,
},
initialData,
modifiedData: { ...initialData },
};
const reducer = (state: InitialState, action: Action) =>

View File

@ -12,6 +12,7 @@
"control-card.crop": "Crop",
"control-card.download": "Download",
"control-card.edit": "Edit",
"control-card.remove-selection": "Remove from selection",
"control-card.replace-media": "Replace Media",
"control-card.save": "Save",
"control-card.stop-crop": "Stop cropping",
@ -20,6 +21,7 @@
"form.input.description.file-alt": "This text will be displayed if the asset cant be shown.",
"form.input.label.file-alt": "Alternative text",
"form.input.label.file-caption": "Caption",
"form.input.placeholder.file-caption": "Enter caption",
"form.input.label.file-name": "File name",
"form.upload-url.error.url.invalid": "One URL is invalid",
"form.upload-url.error.url.invalids": "{number} URLs are invalids",
@ -105,6 +107,7 @@
"settings.form.videoPreview.description": "It will generate a six-second preview of the video (GIF)",
"settings.form.videoPreview.label": "Preview",
"settings.header.label": "Media Library",
"settings.section.audio.label": "Audio",
"settings.section.doc.label": "Doc",
"settings.section.image.label": "Image",
"settings.section.video.label": "Video",
@ -136,5 +139,8 @@
"config.note": "Note: You can override this value in the media library.",
"config.popUpWarning.warning.updateAllSettings": "This will modify all your settings",
"view-switch.list": "List View",
"view-switch.grid": "Grid View"
"view-switch.grid": "Grid View",
"ai.modal.uploading.title": "Uploading and processing with AI...",
"ai.modal.title": "{count, plural, one {# Asset uploaded} other {# Assets uploaded}} time to review AI generated content",
"ai.modal.error": "Could not generate AI metadata for the uploaded files."
}

View File

@ -159,6 +159,7 @@ const handlers = [
sizeOptimization: true,
responsiveDimensions: true,
autoOrientation: true,
aiMetadata: true,
},
})
);

View File

@ -81,6 +81,7 @@ describe('Upload plugin bootstrap function', () => {
expect(setStore).toHaveBeenCalledWith({
value: {
aiMetadata: true,
autoOrientation: false,
sizeOptimization: true,
responsiveDimensions: true,

View File

@ -9,6 +9,7 @@ export async function bootstrap({ strapi }: { strapi: Core.Strapi }) {
sizeOptimization: true,
responsiveDimensions: true,
autoOrientation: false,
aiMetadata: true,
},
view_configuration: {
pageSize: 10,

View File

@ -0,0 +1,281 @@
import type { Context } from 'koa';
import adminUploadController from '../admin-upload';
import { getService } from '../../utils';
import { validateBulkUpdateBody, validateUploadBody } from '../validation/admin/upload';
import * as findEntityAndCheckPermissionsModule from '../utils/find-entity-and-check-permissions';
import { ACTIONS } from '../../constants';
jest.mock('../../utils');
jest.mock('../validation/admin/upload');
jest.mock('../utils/find-entity-and-check-permissions');
const mockGetService = getService as jest.MockedFunction<typeof getService>;
const mockValidateUploadBody = validateUploadBody as jest.MockedFunction<typeof validateUploadBody>;
const mockValidateBulkUpdateBody = validateBulkUpdateBody as jest.MockedFunction<
typeof validateBulkUpdateBody
>;
const mockFindEntityAndCheckPermissions =
findEntityAndCheckPermissionsModule.findEntityAndCheckPermissions as jest.MockedFunction<
typeof findEntityAndCheckPermissionsModule.findEntityAndCheckPermissions
>;
describe('Admin Upload Controller - AI Service Connection', () => {
let mockContext: Partial<Context>;
let ctxBulk: Partial<Context>;
let mockAiMetadataService: any;
let uploadService: {
upload: jest.Mock;
updateFileInfo: jest.Mock;
replace: jest.Mock;
};
beforeEach(() => {
jest.clearAllMocks();
mockAiMetadataService = {
isEnabled: jest.fn(),
processFiles: jest.fn(),
};
uploadService = {
upload: jest.fn().mockResolvedValue([{}]),
updateFileInfo: jest.fn(),
replace: jest.fn(),
};
mockGetService.mockImplementation((serviceName: string) => {
if (serviceName === 'aiMetadata') return mockAiMetadataService;
if (serviceName === 'upload') return uploadService;
if (serviceName === 'file') {
return {
upload: jest.fn().mockResolvedValue([{}]),
signFileUrls: jest.fn((file) => Promise.resolve(file)),
};
}
return {};
});
const pm = {
isAllowed: true,
sanitizeOutput: jest.fn((data) => Promise.resolve(data)),
};
global.strapi = {
service: jest.fn().mockReturnValue({
createPermissionsManager: jest.fn().mockReturnValue(pm),
}),
admin: {
services: {
permission: {
createPermissionsManager: jest.fn().mockReturnValue(pm),
},
},
},
log: { warn: jest.fn() },
} as any;
mockValidateUploadBody.mockResolvedValue({
fileInfo: { name: 'test.jpg', alternativeText: '', caption: '', folder: null },
});
mockValidateBulkUpdateBody.mockResolvedValue({
updates: [],
});
mockFindEntityAndCheckPermissions.mockResolvedValue({
pm: {
sanitizeOutput: jest.fn((data) => Promise.resolve({ ...data, cleaned: true })),
},
} as any);
mockContext = {
state: { userAbility: {}, user: { id: 1 } },
request: {
body: {},
files: { files: { filepath: '/tmp/test.jpg', mimetype: 'image/jpeg' } },
} as any,
forbidden: jest.fn(),
} as any;
ctxBulk = {
state: { userAbility: {}, user: { id: 42 } },
request: { body: {} },
} as any;
});
describe('uploadFiles - AI Service Connection', () => {
it('should call AI processFiles when service is enabled', async () => {
mockAiMetadataService.isEnabled.mockReturnValue(true);
mockAiMetadataService.processFiles.mockResolvedValue([{}]);
// Mock upload service to return files with proper structure
uploadService.upload.mockResolvedValue([
{
id: 1,
name: 'test.jpg',
mime: 'image/jpeg',
url: '/uploads/test.jpg',
provider: 'local',
},
]);
await adminUploadController.uploadFiles(mockContext as Context);
expect(mockAiMetadataService.processFiles).toHaveBeenCalledWith([
expect.objectContaining({
filepath: '/uploads/test.jpg',
mimetype: 'image/jpeg',
}),
]);
});
it('should not call AI processFiles when service is disabled', async () => {
mockAiMetadataService.isEnabled.mockReturnValue(false);
await adminUploadController.uploadFiles(mockContext as Context);
expect(mockAiMetadataService.processFiles).not.toHaveBeenCalled();
});
it('should handle AI service errors gracefully', async () => {
mockAiMetadataService.isEnabled.mockReturnValue(true);
mockAiMetadataService.processFiles.mockRejectedValue(new Error('AI service unavailable'));
await adminUploadController.uploadFiles(mockContext as Context);
expect(strapi.log.warn).toHaveBeenCalledWith(
'AI metadata generation failed, proceeding without AI enhancements',
{ error: 'AI service unavailable' }
);
});
});
describe('bulkUpdateFileInfo', () => {
it('updates multiple files, sanitizes outputs, and returns an array', async () => {
mockValidateBulkUpdateBody.mockResolvedValue({
updates: [
{
id: 1,
fileInfo: {
name: 'fileA.jpg',
caption: 'A',
alternativeText: 'alternativeA',
folder: null,
},
},
{
id: 2,
fileInfo: {
name: 'fileB.jpg',
alternativeText: 'alternativeB',
caption: 'B',
folder: null,
},
},
],
});
uploadService.updateFileInfo
.mockResolvedValueOnce({ id: 1, caption: 'A' })
.mockResolvedValueOnce({ id: 2, alternativeText: 'B' });
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
expect(mockValidateBulkUpdateBody).toHaveBeenCalledWith({});
expect(uploadService.updateFileInfo).toHaveBeenNthCalledWith(
1,
1,
{ name: 'fileA.jpg', alternativeText: 'alternativeA', caption: 'A', folder: null },
{ user: { id: 42 } }
);
expect(uploadService.updateFileInfo).toHaveBeenNthCalledWith(
2,
2,
{ name: 'fileB.jpg', alternativeText: 'alternativeB', caption: 'B', folder: null },
{ user: { id: 42 } }
);
expect(mockFindEntityAndCheckPermissions).toHaveBeenCalledTimes(2);
expect(ctxBulk.body).toEqual([
{ id: 1, caption: 'A', cleaned: true },
{ id: 2, alternativeText: 'B', cleaned: true },
]);
});
it('returns an empty array when no updates provided', async () => {
mockValidateBulkUpdateBody.mockResolvedValue({ updates: [] });
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
expect(ctxBulk.body).toEqual([]);
expect(uploadService.updateFileInfo).not.toHaveBeenCalled();
expect(mockFindEntityAndCheckPermissions).not.toHaveBeenCalled();
});
it('propagates validation errors from validateBulkUpdateBody', async () => {
mockValidateBulkUpdateBody.mockRejectedValue(new Error('Invalid payload'));
await expect(adminUploadController.bulkUpdateFileInfo(ctxBulk as Context)).rejects.toThrow(
'Invalid payload'
);
});
it('sanitizes each updated entity with ACTIONS.read', async () => {
const sanitizeOutput = jest.fn((data) => Promise.resolve({ ok: true, ...data }));
mockFindEntityAndCheckPermissions.mockResolvedValue({ pm: { sanitizeOutput } } as any);
mockValidateBulkUpdateBody.mockResolvedValue({
updates: [
{
id: 10,
fileInfo: {
name: 'fileA.jpg',
caption: 'X',
alternativeText: 'alternativeA',
folder: null,
},
},
],
});
uploadService.updateFileInfo.mockResolvedValue({ id: 10, caption: 'X' });
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
expect(sanitizeOutput).toHaveBeenCalledWith(
{ id: 10, caption: 'X' },
{ action: ACTIONS.read }
);
expect(ctxBulk.body).toEqual([{ ok: true, id: 10, caption: 'X' }]);
});
it('passes the authenticated user to updateFileInfo', async () => {
mockValidateBulkUpdateBody.mockResolvedValue({
updates: [
{
id: 7,
fileInfo: {
name: 'fileA.jpg',
alternativeText: 'hello',
caption: 'A',
},
},
],
} as any);
uploadService.updateFileInfo.mockResolvedValue({ id: 7 });
await adminUploadController.bulkUpdateFileInfo(ctxBulk as Context);
expect(uploadService.updateFileInfo).toHaveBeenCalledWith(
7,
{ name: 'fileA.jpg', alternativeText: 'hello', caption: 'A' },
{ user: { id: 42 } }
);
});
});
});

View File

@ -5,11 +5,38 @@ import type { Context } from 'koa';
import { getService } from '../utils';
import { ACTIONS, FILE_MODEL_UID } from '../constants';
import { validateUploadBody } from './validation/admin/upload';
import { validateBulkUpdateBody, validateUploadBody } from './validation/admin/upload';
import { findEntityAndCheckPermissions } from './utils/find-entity-and-check-permissions';
import { FileInfo } from '../types';
export default {
async bulkUpdateFileInfo(ctx: Context) {
const {
state: { userAbility, user },
request: { body },
} = ctx;
const { updates } = await validateBulkUpdateBody(body);
const uploadService = getService('upload');
const results = await async.map(
updates,
async ({ id, fileInfo }: { id: number; fileInfo: FileInfo }) => {
const { pm } = await findEntityAndCheckPermissions(
userAbility,
ACTIONS.update,
FILE_MODEL_UID,
id
);
const updated = await uploadService.updateFileInfo(id, fileInfo as any, { user });
return pm.sanitizeOutput(updated, { action: ACTIONS.read });
}
);
ctx.body = results;
},
async updateFileInfo(ctx: Context) {
const {
state: { userAbility, user },
@ -85,8 +112,55 @@ export default {
return ctx.forbidden();
}
const data = await validateUploadBody(body);
const uploadedFiles = await uploadService.upload({ data, files }, { user });
const data = await validateUploadBody(body, Array.isArray(files));
const filesArray = Array.isArray(files) ? files : [files];
// Upload files first to get thumbnails
const uploadedFiles = await uploadService.upload({ data, files: filesArray }, { user });
const aiMetadataService = getService('aiMetadata');
// AFTER upload - use thumbnail versions for AI processing
if (await aiMetadataService.isEnabled()) {
try {
// Use thumbnail URLs instead of original files
const thumbnailFiles = uploadedFiles.map(
(file) =>
({
filepath: file.formats?.thumbnail?.url || file.url, // Use thumbnail if available
mimetype: file.mime,
originalFilename: file.name,
size: file.formats?.thumbnail?.size || file.size,
provider: file.provider,
}) as unknown as any
);
const metadataResults = await aiMetadataService.processFiles(thumbnailFiles);
// Update the uploaded files with AI metadata
await Promise.all(
uploadedFiles.map(async (uploadedFile, index) => {
const aiMetadata = metadataResults[index];
if (aiMetadata) {
await uploadService.updateFileInfo(
uploadedFile.id,
{
alternativeText: aiMetadata.altText,
caption: aiMetadata.caption,
},
{ user }
);
uploadedFiles[index].alternativeText = aiMetadata.altText;
uploadedFiles[index].caption = aiMetadata.caption;
}
})
);
} catch (error) {
strapi.log.warn('AI metadata generation failed, proceeding without AI enhancements', {
error: error instanceof Error ? error.message : String(error),
});
}
}
// Sign file urls for private providers
const signedFiles = await async.map(uploadedFiles, getService('file').signFileUrls);

View File

@ -134,7 +134,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => {
const uploadedFiles = await getService('upload').upload({
data,
files,
files: Array.isArray(files) ? files : [files],
});
ctx.body = await sanitizeOutput(uploadedFiles as any, ctx);

View File

@ -4,6 +4,7 @@ const settingsSchema = yup.object({
sizeOptimization: yup.boolean().required(),
responsiveDimensions: yup.boolean().required(),
autoOrientation: yup.boolean(),
aiMetadata: yup.boolean().default(true),
});
export default validateYupSchema(settingsSchema);

View File

@ -39,3 +39,18 @@ export { validateUploadBody };
export type UploadBody =
| yup.InferType<typeof uploadSchema>
| yup.InferType<typeof multiUploadSchema>;
const bulkUpdatesSchema = yup.object({
updates: yup
.array()
.of(
yup.object({
id: yup.number().required(),
fileInfo: fileInfoSchema.required(),
})
)
.min(1)
.required(),
});
export const validateBulkUpdateBody = validateYupSchema(bulkUpdatesSchema);

View File

@ -201,5 +201,21 @@ export const routes = {
],
},
},
{
method: 'POST',
path: '/actions/bulk-update',
handler: 'admin-upload.bulkUpdateFileInfo',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::upload.assets.update'],
},
},
],
},
},
],
};

View File

@ -0,0 +1,308 @@
import { readFile } from 'node:fs/promises';
import { createAIMetadataService } from '../ai-metadata';
import type { InputFile } from '../../types';
// Mock dependencies
jest.mock('node:fs/promises');
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
const mockGetSettings = jest.fn();
// Mock fetch globally
const mockArrayBuffer = jest.fn().mockResolvedValue(new ArrayBuffer(8));
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: mockArrayBuffer, // Add this
json: jest.fn().mockResolvedValue({
results: [{ altText: 'image alt', caption: 'image caption' }],
}),
text: jest.fn().mockResolvedValue(''),
headers: {
get: jest.fn().mockReturnValue('image/jpeg'),
},
} as any);
global.fetch = mockFetch;
// Mock FormData
global.FormData = jest.fn().mockImplementation(() => ({
append: jest.fn(),
}));
// Mock Blob
global.Blob = jest.fn().mockImplementation((parts, options) => ({
parts,
type: options?.type,
}));
describe('AI Metadata Service', () => {
let mockStrapi: any;
let aiMetadataService: ReturnType<typeof createAIMetadataService>;
beforeEach(() => {
jest.clearAllMocks();
mockStrapi = {
config: {
get: jest.fn(),
},
ee: {
isEE: true,
features: {
isEnabled: jest.fn().mockReturnValue(true),
},
},
service: jest.fn().mockReturnValue({
getAiToken: jest.fn().mockResolvedValue({ token: 'mock-token' }),
}),
log: {
http: jest.fn(),
warn: jest.fn(),
},
plugin: jest.fn().mockImplementation((pluginName) => {
if (pluginName === 'upload') {
return {
service: jest.fn().mockImplementation((serviceName) => {
if (serviceName === 'upload') {
return { getSettings: mockGetSettings };
}
// ...other services if needed
return {};
}),
};
}
return {};
}),
};
process.env.STRAPI_AI_URL = 'https://ai.strapi.com';
aiMetadataService = createAIMetadataService({ strapi: mockStrapi });
});
afterEach(() => {
delete process.env.STRAPI_AI_URL;
});
describe('isEnabled', () => {
it('should return true when AI is enabled, EE is available and aiMetadata is set to true', async () => {
mockStrapi.config.get.mockReturnValue(true);
mockGetSettings.mockResolvedValue({ aiMetadata: true });
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(true);
expect(await aiMetadataService.isEnabled()).toBe(true);
expect(mockGetSettings).toHaveBeenCalled();
});
it('should return false when AI is disabled but EE is available', async () => {
mockStrapi.config.get.mockReturnValue(false);
mockGetSettings.mockResolvedValue({ aiMetadata: false });
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(true);
expect(await aiMetadataService.isEnabled()).toBe(false);
});
it('should return false when AI is enabled but EE is not available', async () => {
mockStrapi.config.get.mockReturnValue(true);
mockGetSettings.mockResolvedValue({ aiMetadata: true });
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(false);
expect(await aiMetadataService.isEnabled()).toBe(false);
});
it('should return false when both AI and EE are disabled', async () => {
mockStrapi.config.get.mockReturnValue(false);
mockGetSettings.mockResolvedValue({ aiMetadata: false });
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(false);
expect(await aiMetadataService.isEnabled()).toBe(false);
});
it('should return false when both AI and EE are enabled but aiMetadata is disabled', async () => {
mockStrapi.config.get.mockReturnValue(true);
mockGetSettings.mockResolvedValue({ aiMetadata: false });
mockStrapi.ee.features.isEnabled = jest.fn().mockReturnValue(true);
expect(await aiMetadataService.isEnabled()).toBe(false);
});
});
describe('processFiles', () => {
const mockImageFile: InputFile = {
filepath: '/tmp/image.jpg',
mimetype: 'image/jpeg',
originalFilename: 'image.jpg',
size: 1024,
provider: 'local',
} as InputFile;
const mockImageFile2: InputFile = {
filepath: 'image2.png',
mimetype: 'image/png',
originalFilename: 'image2.png',
size: 2048,
} as InputFile;
const mockPdfFile: InputFile = {
filepath: '/tmp/document.pdf',
mimetype: 'application/pdf',
originalFilename: 'document.pdf',
size: 2048,
} as InputFile;
beforeEach(() => {
// Mock strapi config
mockStrapi.config.get.mockImplementation((key: string) => {
if (key === 'server.url') return 'test-url';
if (key === 'admin.ai.enabled') return true;
if (key === 'server.port') return 1337;
return undefined;
});
// Mock service as enabled by default
mockGetSettings.mockResolvedValue({ aiMetadata: true });
mockStrapi.ee.isEE = true;
// Mock readFile to return proper Buffer with .buffer property
const mockBuffer = Buffer.from('image-data');
mockReadFile.mockResolvedValue(mockBuffer);
});
describe('error cases', () => {
it('should throw error when service is disabled', async () => {
mockStrapi.config.get.mockReturnValue(false);
mockGetSettings.mockResolvedValue({ aiMetadata: false });
await expect(aiMetadataService.processFiles([mockImageFile])).rejects.toThrow(
'AI Metadata service is not enabled'
);
});
it('should throw if getSettings throws an error', async () => {
mockStrapi.config.get.mockReturnValue(true);
mockGetSettings.mockRejectedValue(new Error('Settings error'));
mockStrapi.ee.isEE = true;
const files = [mockImageFile, mockPdfFile, mockImageFile2, mockPdfFile];
await expect(aiMetadataService.processFiles(files)).rejects.toThrow('Settings error');
});
});
describe('non-image file handling', () => {
it('should return array of nulls for non-image files', async () => {
const result = await aiMetadataService.processFiles([mockPdfFile]);
expect(result).toEqual([null]);
expect(mockFetch).not.toHaveBeenCalled();
});
it('should return proper sparse array for mixed file types', async () => {
mockFetch.mockResolvedValue({
ok: true,
arrayBuffer: mockArrayBuffer,
json: jest.fn().mockResolvedValue({
results: [{ altText: 'image alt', caption: 'image caption' }],
}),
} as any);
const files = [mockPdfFile, mockImageFile, mockPdfFile];
const result = await aiMetadataService.processFiles(files);
expect(result).toEqual([null, { altText: 'image alt', caption: 'image caption' }, null]);
});
});
describe('image file processing', () => {
it('should process single image file correctly', async () => {
const expectedMetadata = { altText: 'A beautiful image', caption: 'Image caption' };
mockFetch.mockResolvedValue({
ok: true,
arrayBuffer: mockArrayBuffer,
json: jest.fn().mockResolvedValue({
results: [expectedMetadata],
}),
} as any);
const result = await aiMetadataService.processFiles([mockImageFile]);
expect(result).toEqual([expectedMetadata]);
expect(mockFetch).toHaveBeenCalledWith('test-url/tmp/image.jpg');
expect(mockFetch).toHaveBeenCalledWith(
'https://ai.strapi.com/media-library/generate-metadata',
{
method: 'POST',
body: expect.any(Object),
headers: {
Authorization: 'Bearer mock-token',
},
}
);
});
it('should process multiple image files correctly', async () => {
const expectedMetadata = [
{ altText: 'First image', caption: 'First caption' },
{ altText: 'Second image', caption: 'Second caption' },
];
mockFetch.mockResolvedValue({
ok: true,
arrayBuffer: mockArrayBuffer,
json: jest.fn().mockResolvedValue({
results: expectedMetadata,
}),
} as any);
const result = await aiMetadataService.processFiles([mockImageFile, mockImageFile2]);
expect(result).toEqual(expectedMetadata);
expect(mockFetch).toHaveBeenCalledTimes(3); // 2 files + 1 AI service call
expect(mockFetch).toHaveBeenCalledWith('test-url/tmp/image.jpg');
expect(mockFetch).toHaveBeenCalledWith('image2.png');
});
it('should handle mixed file types with correct sparse array mapping', async () => {
const expectedMetadata = [
{ altText: 'First image', caption: 'First caption' },
{ altText: 'Second image', caption: 'Second caption' },
];
mockFetch.mockResolvedValue({
ok: true,
arrayBuffer: mockArrayBuffer,
json: jest.fn().mockResolvedValue({
results: expectedMetadata,
}),
} as any);
// Order: image, pdf, image, pdf
const files = [mockImageFile, mockPdfFile, mockImageFile2, mockPdfFile];
const result = await aiMetadataService.processFiles(files);
expect(result).toEqual([
expectedMetadata[0], // first image
null, // pdf
expectedMetadata[1], // second image
null, // pdf
]);
});
it('should not call fetch and throw if aiMetadata is false, even if AI and EE are enabled', async () => {
mockStrapi.config.get.mockReturnValue(true);
mockGetSettings.mockResolvedValue({ aiMetadata: false });
mockStrapi.ee.isEE = true;
const files = [mockImageFile, mockPdfFile, mockImageFile2, mockPdfFile];
await expect(aiMetadataService.processFiles(files)).rejects.toThrow(
'AI Metadata service is not enabled'
);
expect(mockFetch).not.toHaveBeenCalled();
});
});
});
});

View File

@ -0,0 +1,113 @@
import type { Core } from '@strapi/types';
import { z } from 'zod';
import { InputFile } from '../types';
import { Settings } from '../controllers/validation/admin/settings';
const createAIMetadataService = ({ strapi }: { strapi: Core.Strapi }) => {
const aiServerUrl = process.env.STRAPI_AI_URL || 'https://strapi-ai.apps.strapi.io';
return {
async isEnabled() {
// Check if user disabled AI features globally
const isAIEnabled = strapi.config.get('admin.ai.enabled', true);
if (!isAIEnabled) {
return false;
}
// Check if the user's license grants access to AI features
const hasAccess = strapi.ee.features.isEnabled('cms-ai');
if (!hasAccess) {
return false;
}
// Check if feature is specifically enabled, defaulting to true
const settings: Settings = await strapi.plugin('upload').service('upload').getSettings();
const aiMetadata: boolean = settings.aiMetadata ?? true;
return aiMetadata;
},
async processFiles(
files: InputFile[]
): Promise<Array<{ altText: string; caption: string } | null>> {
if (!(await this.isEnabled()) || !aiServerUrl) {
throw new Error('AI Metadata service is not enabled');
}
// Filter for image files only and track their original positions
// We need to maintain the original indices so we can map AI results back correctly
const imageFiles = files
.map((file, index) => ({ file, originalIndex: index }))
.filter(({ file }) => file.mimetype?.startsWith('image/'));
// If no image files, return sparse array with all nulls to avoid calling the AI server
// This maintains the same array length as input files for proper index alignment
if (imageFiles.length === 0) {
return new Array(files.length).fill(null);
}
const formData = new FormData();
for (const { file } of imageFiles) {
const fullUrl =
file.provider === 'local'
? strapi.config.get('server.url') + file.filepath
: file.filepath;
const resp = await fetch(fullUrl);
if (!resp.ok) {
throw new Error(`Failed to fetch image from URL: ${fullUrl} (${resp.status})`);
}
const ab = await resp.arrayBuffer();
const blob: Blob = new Blob([ab], { type: file.mimetype || undefined });
formData.append('files', blob);
}
let token: string;
try {
const tokenData = await strapi.service('admin::user').getAiToken();
token = tokenData.token;
} catch (error) {
throw new Error('Failed to retrieve AI token', {
cause: error instanceof Error ? error : undefined,
});
}
strapi.log.http('Contacting AI Server for media metadata generation');
const res = await fetch(`${aiServerUrl}/media-library/generate-metadata`, {
method: 'POST',
body: formData,
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
throw Error(`AI metadata generation failed`, { cause: await res.text() });
}
const responseSchema = z.object({
results: z.array(
z.object({
altText: z.string(),
caption: z.string(),
})
),
});
const { results } = responseSchema.parse(await res.json());
strapi.log.http(`Media metadata generated successfully for ${results.length} files`);
// Create sparse array with results at original indices
// Example: files=[img1, pdf, img2] -> imageFiles=[{img1, index:0}, {img2, index:2}]
// AI results=[meta1, meta2] -> sparse=[meta1, null, meta2]
// This ensures metadata[i] corresponds to files[i], with null for non-images
return imageFiles.reduce((sparseResults, { originalIndex }, resultIndex) => {
sparseResults[originalIndex] = results[resultIndex];
return sparseResults;
}, new Array(files.length).fill(null));
},
};
};
export { createAIMetadataService };

View File

@ -7,6 +7,7 @@ import weeklyMetrics from './weekly-metrics';
import metrics from './metrics';
import apiUploadFolder from './api-upload-folder';
import extensions from './extensions';
import { createAIMetadataService } from './ai-metadata';
export const services = {
provider,
@ -18,4 +19,5 @@ export const services = {
'image-manipulation': imageManipulation,
'api-upload-folder': apiUploadFolder,
extensions,
aiMetadata: createAIMetadataService,
};

View File

@ -223,7 +223,7 @@ export default ({ strapi }: { strapi: Core.Strapi }) => {
files,
}: {
data: Record<string, unknown>;
files: InputFile | InputFile[];
files: InputFile[];
},
opts?: CommonOptions
) {

View File

@ -3,6 +3,7 @@ import type { File as FormidableFile } from 'formidable';
export type InputFile = FormidableFile & {
path?: string;
tmpWorkingDirectory?: string;
provider?: string;
};
export interface File {

View File

@ -7,6 +7,7 @@ import type file from '../services/file';
import type weeklyMetrics from '../services/weekly-metrics';
import type metrics from '../services/metrics';
import type extensions from '../services/extensions';
import type { createAIMetadataService } from '../services/ai-metadata';
type Services = {
upload: ReturnType<typeof upload>;
@ -18,6 +19,7 @@ type Services = {
metrics: ReturnType<typeof metrics>;
'api-upload-folder': typeof apiUploadFolder;
extensions: typeof extensions;
aiMetadata: ReturnType<typeof createAIMetadataService>;
};
export const getService = <TName extends keyof Services>(name: TName): Services[TName] => {

View File

@ -234,3 +234,27 @@ export declare namespace UpdateFile {
error?: errors.ApplicationError | errors.ValidationError;
}
}
/**
* POST /upload/actions/bulk-update - Bulk update files
*/
export declare namespace BulkUpdateFiles {
export interface Request {
body: {
updates: Array<{
id: number;
fileInfo: {
name?: string;
alternativeText?: string | null;
caption?: string | null;
folder?: number | null;
};
}>;
};
}
export interface Response {
data: File[];
error?: errors.ApplicationError | errors.ValidationError;
}
}

View File

@ -12,9 +12,12 @@ export interface Settings {
responsiveDimensions?: boolean;
autoOrientation?: boolean;
videoPreview?: boolean;
aiMetadata?: boolean;
};
}
export type SettingsData = Settings['data'];
/**
* GET /upload/settings
*

View File

@ -43,6 +43,7 @@ describe('Settings', () => {
autoOrientation: false,
sizeOptimization: true,
responsiveDimensions: true,
aiMetadata: true,
},
});
});

View File

@ -5,6 +5,8 @@ import { clickAndWait, navToHeader } from '../../utils/shared';
import { waitForRestart } from '../../utils/restart';
import { EDITOR_EMAIL_ADDRESS, EDITOR_PASSWORD } from '../../constants';
const edition = process.env.STRAPI_DISABLE_EE === 'true' ? 'CE' : 'EE';
test.describe('Home as super admin', () => {
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('with-admin.tar');
@ -174,10 +176,13 @@ test.describe('Home as super admin', () => {
await page
.getByLabel('Drag & Drop here or')
.setInputFiles('public/assets/administration_panel.png');
await page
.getByRole('button', { name: 'Upload 1 asset to the library' })
.waitFor({ state: 'visible', timeout: 5000 });
await page.getByRole('button', { name: 'Upload 1 asset to the library' }).click();
const uploadButton = page.getByRole('button', { name: 'Upload 1 asset to the library' });
try {
await uploadButton.waitFor({ state: 'visible', timeout: 5000 });
await uploadButton.click();
} catch {
await page.getByRole('button', { name: /^finish$/i }).click();
}
await clickAndWait(page, page.getByRole('link', { name: 'Home' }));
// Create a content type and a component

135
yarn.lock
View File

@ -13430,7 +13430,7 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:1.12.2, axios@npm:^1.6.0, axios@npm:^1.6.8, axios@npm:^1.7.4":
"axios@npm:1.12.2":
version: 1.12.2
resolution: "axios@npm:1.12.2"
dependencies:
@ -13441,6 +13441,28 @@ __metadata:
languageName: node
linkType: hard
"axios@npm:^1.6.0, axios@npm:^1.7.4":
version: 1.8.2
resolution: "axios@npm:1.8.2"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.0"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/d8c2969e4642dc6d39555ac58effe06c051ba7aac2bd40cad7a9011c019fb2f16ee011c5a6906cb25b8a4f87258c359314eb981f852e60ad445ecaeb793c7aa2
languageName: node
linkType: hard
"axios@npm:^1.6.8":
version: 1.7.7
resolution: "axios@npm:1.7.7"
dependencies:
follow-redirects: "npm:^1.15.6"
form-data: "npm:^4.0.0"
proxy-from-env: "npm:^1.1.0"
checksum: 10c0/4499efc89e86b0b49ffddc018798de05fab26e3bf57913818266be73279a6418c3ce8f9e934c7d2d707ab8c095e837fc6c90608fb7715b94d357720b5f568af7
languageName: node
linkType: hard
"axobject-query@npm:^3.1.1":
version: 3.1.1
resolution: "axobject-query@npm:3.1.1"
@ -14120,7 +14142,7 @@ __metadata:
languageName: node
linkType: hard
"call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
@ -14143,28 +14165,6 @@ __metadata:
languageName: node
linkType: hard
"call-bind@npm:^1.0.8":
version: 1.0.8
resolution: "call-bind@npm:1.0.8"
dependencies:
call-bind-apply-helpers: "npm:^1.0.0"
es-define-property: "npm:^1.0.0"
get-intrinsic: "npm:^1.2.4"
set-function-length: "npm:^1.2.2"
checksum: 10c0/a13819be0681d915144467741b69875ae5f4eba8961eb0bf322aab63ec87f8250eb6d6b0dcbb2e1349876412a56129ca338592b3829ef4343527f5f18a0752d4
languageName: node
linkType: hard
"call-bound@npm:^1.0.3, call-bound@npm:^1.0.4":
version: 1.0.4
resolution: "call-bound@npm:1.0.4"
dependencies:
call-bind-apply-helpers: "npm:^1.0.2"
get-intrinsic: "npm:^1.3.0"
checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644
languageName: node
linkType: hard
"call-me-maybe@npm:^1.0.1":
version: 1.0.1
resolution: "call-me-maybe@npm:1.0.1"
@ -18667,15 +18667,6 @@ __metadata:
languageName: node
linkType: hard
"for-each@npm:^0.3.5":
version: 0.3.5
resolution: "for-each@npm:0.3.5"
dependencies:
is-callable: "npm:^1.2.7"
checksum: 10c0/0e0b50f6a843a282637d43674d1fb278dda1dd85f4f99b640024cfb10b85058aac0cc781bf689d5fe50b4b7f638e91e548560723a4e76e04fe96ae35ef039cee
languageName: node
linkType: hard
"for-in@npm:^1.0.1":
version: 1.0.2
resolution: "for-in@npm:1.0.2"
@ -18732,7 +18723,7 @@ __metadata:
languageName: node
linkType: hard
"form-data@npm:4.0.4, form-data@npm:^4.0.0, form-data@npm:^4.0.4":
"form-data@npm:4.0.4, form-data@npm:^4.0.4":
version: 4.0.4
resolution: "form-data@npm:4.0.4"
dependencies:
@ -18745,6 +18736,17 @@ __metadata:
languageName: node
linkType: hard
"form-data@npm:^4.0.0":
version: 4.0.0
resolution: "form-data@npm:4.0.0"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
mime-types: "npm:^2.1.12"
checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e
languageName: node
linkType: hard
"form-data@npm:~2.3.2":
version: 2.3.3
resolution: "form-data@npm:2.3.3"
@ -19054,7 +19056,7 @@ __metadata:
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0":
"get-intrinsic@npm:^1.2.6":
version: 1.3.0
resolution: "get-intrinsic@npm:1.3.0"
dependencies:
@ -21300,15 +21302,6 @@ __metadata:
languageName: node
linkType: hard
"is-typed-array@npm:^1.1.14":
version: 1.1.15
resolution: "is-typed-array@npm:1.1.15"
dependencies:
which-typed-array: "npm:^1.1.16"
checksum: 10c0/415511da3669e36e002820584e264997ffe277ff136643a3126cc949197e6ca3334d0f12d084e83b1994af2e9c8141275c741cf2b7da5a2ff62dd0cac26f76c4
languageName: node
linkType: hard
"is-typedarray@npm:^1.0.0, is-typedarray@npm:~1.0.0":
version: 1.0.0
resolution: "is-typedarray@npm:1.0.0"
@ -28860,7 +28853,7 @@ __metadata:
languageName: node
linkType: hard
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.2, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3
@ -29157,7 +29150,7 @@ __metadata:
languageName: node
linkType: hard
"set-function-length@npm:^1.2.1, set-function-length@npm:^1.2.2":
"set-function-length@npm:^1.2.1":
version: 1.2.2
resolution: "set-function-length@npm:1.2.2"
dependencies:
@ -29216,15 +29209,14 @@ __metadata:
linkType: hard
"sha.js@npm:^2.4.11":
version: 2.4.12
resolution: "sha.js@npm:2.4.12"
version: 2.4.11
resolution: "sha.js@npm:2.4.11"
dependencies:
inherits: "npm:^2.0.4"
safe-buffer: "npm:^5.2.1"
to-buffer: "npm:^1.2.0"
inherits: "npm:^2.0.1"
safe-buffer: "npm:^5.0.1"
bin:
sha.js: bin.js
checksum: 10c0/9d36bdd76202c8116abbe152a00055ccd8a0099cb28fc17c01fa7bb2c8cffb9ca60e2ab0fe5f274ed6c45dc2633d8c39cf7ab050306c231904512ba9da4d8ab1
sha.js: ./bin.js
checksum: 10c0/b7a371bca8821c9cc98a0aeff67444a03d48d745cb103f17228b96793f455f0eb0a691941b89ea1e60f6359207e36081d9be193252b0f128e0daf9cfea2815a5
languageName: node
linkType: hard
@ -30929,17 +30921,6 @@ __metadata:
languageName: node
linkType: hard
"to-buffer@npm:^1.2.0":
version: 1.2.1
resolution: "to-buffer@npm:1.2.1"
dependencies:
isarray: "npm:^2.0.5"
safe-buffer: "npm:^5.2.1"
typed-array-buffer: "npm:^1.0.3"
checksum: 10c0/bbf07a2a7d6ff9e3ffe503c689176c7149cf3ec25887ce7c4aa5c4841a8845cc71121cd7b4a4769957f823b3f31dbf6b1be6e0a5955798ad864bf2245ee8b5e4
languageName: node
linkType: hard
"to-fast-properties@npm:^2.0.0":
version: 2.0.0
resolution: "to-fast-properties@npm:2.0.0"
@ -31420,17 +31401,6 @@ __metadata:
languageName: node
linkType: hard
"typed-array-buffer@npm:^1.0.3":
version: 1.0.3
resolution: "typed-array-buffer@npm:1.0.3"
dependencies:
call-bound: "npm:^1.0.3"
es-errors: "npm:^1.3.0"
is-typed-array: "npm:^1.1.14"
checksum: 10c0/1105071756eb248774bc71646bfe45b682efcad93b55532c6ffa4518969fb6241354e4aa62af679ae83899ec296d69ef88f1f3763657cdb3a4d29321f7b83079
languageName: node
linkType: hard
"typed-array-byte-length@npm:^1.0.0":
version: 1.0.0
resolution: "typed-array-byte-length@npm:1.0.0"
@ -32699,21 +32669,6 @@ __metadata:
languageName: node
linkType: hard
"which-typed-array@npm:^1.1.16":
version: 1.1.19
resolution: "which-typed-array@npm:1.1.19"
dependencies:
available-typed-arrays: "npm:^1.0.7"
call-bind: "npm:^1.0.8"
call-bound: "npm:^1.0.4"
for-each: "npm:^0.3.5"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-tostringtag: "npm:^1.0.2"
checksum: 10c0/702b5dc878addafe6c6300c3d0af5983b175c75fcb4f2a72dfc3dd38d93cf9e89581e4b29c854b16ea37e50a7d7fca5ae42ece5c273d8060dcd603b2404bbb3f
languageName: node
linkType: hard
"which@npm:^1.2.14, which@npm:^1.2.9":
version: 1.3.1
resolution: "which@npm:1.3.1"