@jhb.software/payload-alt-text-plugin 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -19
- package/dist/endpoints/bulkGenerateAltTexts.js +13 -4
- package/dist/endpoints/bulkGenerateAltTexts.js.map +1 -1
- package/dist/endpoints/generateAltText.js +13 -4
- package/dist/endpoints/generateAltText.js.map +1 -1
- package/dist/exports/server.d.ts +6 -0
- package/dist/exports/server.js +6 -1
- package/dist/exports/server.js.map +1 -1
- package/dist/hooks/revalidateAltTextHealth.js +31 -17
- package/dist/hooks/revalidateAltTextHealth.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +24 -3
- package/dist/plugin.js.map +1 -1
- package/dist/resolvers/mistral.d.ts +40 -0
- package/dist/resolvers/mistral.js +280 -0
- package/dist/resolvers/mistral.js.map +1 -0
- package/dist/resolvers/openAI.d.ts +10 -0
- package/dist/resolvers/openAI.js +7 -7
- package/dist/resolvers/openAI.js.map +1 -1
- package/dist/resolvers/types.d.ts +24 -1
- package/dist/resolvers/types.js.map +1 -1
- package/dist/translations/index.js.map +1 -1
- package/dist/types/AltTextPluginConfig.d.ts +40 -8
- package/dist/types/AltTextPluginConfig.js.map +1 -1
- package/dist/utilities/altTextHealth.js.map +1 -1
- package/dist/utilities/mimeTypes.d.ts +54 -1
- package/dist/utilities/mimeTypes.js +41 -2
- package/dist/utilities/mimeTypes.js.map +1 -1
- package/package.json +14 -14
package/dist/plugin.js
CHANGED
|
@@ -6,7 +6,7 @@ import { altTextField } from './fields/altTextField.js';
|
|
|
6
6
|
import { keywordsField } from './fields/keywordsField.js';
|
|
7
7
|
import { createRevalidateAltTextHealthAfterChangeHook, createRevalidateAltTextHealthAfterDeleteHook } from './hooks/revalidateAltTextHealth.js';
|
|
8
8
|
import { translations } from './translations/index.js';
|
|
9
|
-
import { normalizeCollectionsConfig } from './utilities/mimeTypes.js';
|
|
9
|
+
import { isValidMimeType, normalizeCollectionsConfig } from './utilities/mimeTypes.js';
|
|
10
10
|
import { deepMergeSimple } from './utils/deepMergeSimple.js';
|
|
11
11
|
const altTextHealthWidgetDefinition = {
|
|
12
12
|
slug: 'alt-text-health',
|
|
@@ -30,7 +30,25 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
|
|
|
30
30
|
}
|
|
31
31
|
const locales = config.localization ? config.localization.locales.map((localeConfig)=>typeof localeConfig === 'string' ? localeConfig : localeConfig.code) : [];
|
|
32
32
|
const enableHealthCheck = incomingPluginConfig.healthCheck !== false;
|
|
33
|
-
const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections
|
|
33
|
+
const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections, {
|
|
34
|
+
imageThumbnailMimeType: incomingPluginConfig.imageThumbnailMimeType
|
|
35
|
+
});
|
|
36
|
+
// A declared thumbnail MIME type replaces the per-document source check, so a
|
|
37
|
+
// wrong one fails at boot rather than as a silently missing guard or a 500 per
|
|
38
|
+
// image.
|
|
39
|
+
const supportedMimeTypes = incomingPluginConfig.resolver.supportedMimeTypes;
|
|
40
|
+
for (const collection of normalizedCollections){
|
|
41
|
+
const declared = collection.imageThumbnailMimeType;
|
|
42
|
+
if (declared === undefined) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!isValidMimeType(declared)) {
|
|
46
|
+
throw new Error(`The alt-text plugin is configured with imageThumbnailMimeType "${declared}" for the "${collection.slug}" collection, ` + 'but that is not a valid MIME type. Expected something like "image/webp".');
|
|
47
|
+
}
|
|
48
|
+
if (supportedMimeTypes && !supportedMimeTypes.includes(declared)) {
|
|
49
|
+
throw new Error(`The alt-text plugin is configured with imageThumbnailMimeType "${declared}" for the "${collection.slug}" collection, ` + `but the "${incomingPluginConfig.resolver.key}" resolver does not support it. ` + `Supported types: ${supportedMimeTypes.join(', ')}. ` + "Either change the transformation in getImageThumbnail, or remove the declaration to fall back to checking each document's own mime type.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
34
52
|
const access = incomingPluginConfig.access ?? (({ req })=>!!req.user);
|
|
35
53
|
// A function form of `healthCheck` doubles as the health report's access
|
|
36
54
|
// gate; otherwise it falls back to the shared `access`.
|
|
@@ -70,7 +88,10 @@ export const payloadAltTextPlugin = (incomingPluginConfig)=>(incomingConfig)=>{
|
|
|
70
88
|
const defaultFields = [
|
|
71
89
|
altTextField({
|
|
72
90
|
localized: Boolean(config.localization),
|
|
73
|
-
|
|
91
|
+
// When the collection declares what getImageThumbnail delivers, the
|
|
92
|
+
// document's own mime type says nothing about whether generation can
|
|
93
|
+
// succeed — so don't let the admin UI disable the button on it.
|
|
94
|
+
supportedMimeTypes: altTextCollectionConfig.imageThumbnailMimeType ? undefined : pluginConfig.resolver.supportedMimeTypes,
|
|
74
95
|
trackedMimeTypes: altTextCollectionConfig.mimeTypes,
|
|
75
96
|
validate: altTextCollectionConfig.validate
|
|
76
97
|
}),
|
package/dist/plugin.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Widget } from 'payload'\n\nimport type {\n AltTextPluginConfig,\n IncomingAltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\n\nimport { PLUGIN_SLUG } from './constants.js'\nimport { altTextHealthEndpoint } from './endpoints/altTextHealth.js'\nimport { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js'\nimport { generateAltTextEndpoint } from './endpoints/generateAltText.js'\nimport { altTextField } from './fields/altTextField.js'\nimport { keywordsField } from './fields/keywordsField.js'\nimport {\n createRevalidateAltTextHealthAfterChangeHook,\n createRevalidateAltTextHealthAfterDeleteHook,\n} from './hooks/revalidateAltTextHealth.js'\nimport { translations } from './translations/index.js'\nimport { normalizeCollectionsConfig } from './utilities/mimeTypes.js'\nimport { deepMergeSimple } from './utils/deepMergeSimple.js'\n\nconst altTextHealthWidgetDefinition = {\n slug: 'alt-text-health',\n // `Component` was renamed from `ComponentPath` in Payload 3.79.0. Set both for backward compatibility.\n Component: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n ComponentPath: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n label: {\n de: 'Alternativtexte Zustand',\n en: 'Alt text health',\n },\n maxWidth: 'full',\n minWidth: 'medium',\n} satisfies { ComponentPath: string } & Widget\n\nexport const payloadAltTextPlugin =\n (incomingPluginConfig: IncomingAltTextPluginConfig) =>\n (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n // If the plugin is disabled, return the config without modifying it\n if (incomingPluginConfig.enabled === false) {\n return config\n }\n\n const locales = config.localization\n ? config.localization.locales.map((localeConfig) =>\n typeof localeConfig === 'string' ? localeConfig : localeConfig.code,\n )\n : []\n\n const enableHealthCheck = incomingPluginConfig.healthCheck !== false\n\n const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections)\n\n const access = incomingPluginConfig.access ?? (({ req }) => !!req.user)\n\n // A function form of `healthCheck` doubles as the health report's access\n // gate; otherwise it falls back to the shared `access`.\n const healthCheckAccess =\n typeof incomingPluginConfig.healthCheck === 'function'\n ? incomingPluginConfig.healthCheck\n : access\n\n const pluginConfig: AltTextPluginConfig = {\n access,\n collections: normalizedCollections,\n enabled: incomingPluginConfig.enabled ?? true,\n fieldsOverride: incomingPluginConfig.fieldsOverride,\n getImageThumbnail: incomingPluginConfig.getImageThumbnail,\n healthCheck: enableHealthCheck,\n healthCheckAccess,\n locale: incomingPluginConfig.locale,\n locales,\n maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,\n maxBulkGenerateIds: incomingPluginConfig.maxBulkGenerateIds ?? 100,\n resolver: incomingPluginConfig.resolver,\n }\n\n // Validate locale requirement for non-localized mode\n if (locales.length === 0 && !incomingPluginConfig.locale) {\n throw new Error(\n 'The alt-text plugin requires a \"locale\" option when Payload localization is disabled. ' +\n 'Please add { locale: \"en\" } (or your preferred locale) to your plugin configuration.',\n )\n }\n\n const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(\n normalizedCollections.map((entry) => [entry.slug, entry]),\n )\n\n // Ensure collections array exists\n config.collections = config.collections || []\n\n // Map over collections and inject AI alt text fields into specified ones\n config.collections = config.collections.map((collectionConfig) => {\n const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)\n\n if (altTextCollectionConfig) {\n if (!collectionConfig.upload) {\n console.warn(\n `AI Alt Text Plugin: Collection \"${collectionConfig.slug}\" is not an upload collection. Skipping field injection.`,\n )\n return collectionConfig\n }\n\n const defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n supportedMimeTypes: pluginConfig.resolver.supportedMimeTypes,\n trackedMimeTypes: altTextCollectionConfig.mimeTypes,\n validate: altTextCollectionConfig.validate,\n }),\n keywordsField({\n localized: Boolean(config.localization),\n }),\n ]\n\n const fields =\n incomingPluginConfig.fieldsOverride &&\n typeof incomingPluginConfig.fieldsOverride === 'function'\n ? incomingPluginConfig.fieldsOverride({ defaultFields })\n : defaultFields\n\n return {\n ...collectionConfig,\n admin: {\n ...collectionConfig.admin,\n components: {\n ...(collectionConfig.admin?.components ?? {}),\n // TODO: use the beforeBulkAction custom component slot once available: https://github.com/payloadcms/payload/pull/11719\n beforeListTable: [\n ...(collectionConfig.admin?.components?.beforeListTable ?? []),\n {\n path: '@jhb.software/payload-alt-text-plugin/client#BulkGenerateAltTextsButton',\n props: {\n collectionSlug: collectionConfig.slug,\n },\n },\n ],\n },\n // enhance the search by adding the filename, keywords and alt fields (if the user has not provided their own listSearchableFields)\n listSearchableFields: collectionConfig.admin?.listSearchableFields ?? [\n 'filename',\n 'keywords',\n 'alt',\n ],\n },\n fields: [...(collectionConfig.fields ?? []), ...fields],\n hooks: {\n ...collectionConfig.hooks,\n ...(enableHealthCheck && {\n afterChange: [\n ...(collectionConfig.hooks?.afterChange ?? []),\n createRevalidateAltTextHealthAfterChangeHook(collectionConfig.slug),\n ],\n afterDelete: [\n ...(collectionConfig.hooks?.afterDelete ?? []),\n createRevalidateAltTextHealthAfterDeleteHook(collectionConfig.slug),\n ],\n }),\n },\n }\n }\n\n return collectionConfig\n })\n\n const existingWidgets = config.admin?.dashboard?.widgets ?? []\n const widgets =\n !enableHealthCheck || existingWidgets.some((widget) => widget.slug === 'alt-text-health')\n ? existingWidgets\n : [...existingWidgets, altTextHealthWidgetDefinition]\n\n return {\n ...config,\n admin: {\n ...config.admin,\n dashboard: {\n ...config.admin?.dashboard,\n widgets,\n },\n },\n custom: {\n ...config.custom,\n // Make plugin config available in hooks/actions\n altTextPluginConfig: pluginConfig,\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n handler: generateAltTextEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate`,\n },\n {\n handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate/bulk`,\n },\n ...(enableHealthCheck\n ? [\n {\n handler: altTextHealthEndpoint(pluginConfig.healthCheckAccess),\n method: 'get' as const,\n path: `/${PLUGIN_SLUG}/health`,\n },\n ]\n : []),\n ],\n i18n: {\n ...config.i18n,\n translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}),\n },\n }\n }\n"],"names":["PLUGIN_SLUG","altTextHealthEndpoint","bulkGenerateAltTextsEndpoint","generateAltTextEndpoint","altTextField","keywordsField","createRevalidateAltTextHealthAfterChangeHook","createRevalidateAltTextHealthAfterDeleteHook","translations","normalizeCollectionsConfig","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","normalizedCollections","collections","access","req","user","healthCheckAccess","pluginConfig","fieldsOverride","getImageThumbnail","locale","maxBulkGenerateConcurrency","maxBulkGenerateIds","resolver","length","Error","collectionConfigBySlug","Map","entry","collectionConfig","altTextCollectionConfig","get","upload","console","warn","defaultFields","localized","Boolean","supportedMimeTypes","trackedMimeTypes","mimeTypes","validate","fields","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","some","widget","custom","altTextPluginConfig","endpoints","handler","method","i18n"],"mappings":"AAOA,SAASA,WAAW,QAAQ,iBAAgB;AAC5C,SAASC,qBAAqB,QAAQ,+BAA8B;AACpE,SAASC,4BAA4B,QAAQ,sCAAqC;AAClF,SAASC,uBAAuB,QAAQ,iCAAgC;AACxE,SAASC,YAAY,QAAQ,2BAA0B;AACvD,SAASC,aAAa,QAAQ,4BAA2B;AACzD,SACEC,4CAA4C,EAC5CC,4CAA4C,QACvC,qCAAoC;AAC3C,SAASC,YAAY,QAAQ,0BAAyB;AACtD,SAASC,0BAA0B,QAAQ,2BAA0B;AACrE,SAASC,eAAe,QAAQ,6BAA4B;AAE5D,MAAMC,gCAAgC;IACpCC,MAAM;IACN,uGAAuG;IACvGC,WAAW;IACXC,eAAe;IACfC,OAAO;QACLC,IAAI;QACJC,IAAI;IACN;IACAC,UAAU;IACVC,UAAU;AACZ;AAEA,OAAO,MAAMC,uBACX,CAACC,uBACD,CAACC;QACC,MAAMC,SAAS;YAAE,GAAGD,cAAc;QAAC;QAEnC,oEAAoE;QACpE,IAAID,qBAAqBG,OAAO,KAAK,OAAO;YAC1C,OAAOD;QACT;QAEA,MAAME,UAAUF,OAAOG,YAAY,GAC/BH,OAAOG,YAAY,CAACD,OAAO,CAACE,GAAG,CAAC,CAACC,eAC/B,OAAOA,iBAAiB,WAAWA,eAAeA,aAAaC,IAAI,IAErE,EAAE;QAEN,MAAMC,oBAAoBT,qBAAqBU,WAAW,KAAK;QAE/D,MAAMC,wBAAwBvB,2BAA2BY,qBAAqBY,WAAW;QAEzF,MAAMC,SAASb,qBAAqBa,MAAM,IAAK,CAAA,CAAC,EAAEC,GAAG,EAAE,GAAK,CAAC,CAACA,IAAIC,IAAI,AAAD;QAErE,yEAAyE;QACzE,wDAAwD;QACxD,MAAMC,oBACJ,OAAOhB,qBAAqBU,WAAW,KAAK,aACxCV,qBAAqBU,WAAW,GAChCG;QAEN,MAAMI,eAAoC;YACxCJ;YACAD,aAAaD;YACbR,SAASH,qBAAqBG,OAAO,IAAI;YACzCe,gBAAgBlB,qBAAqBkB,cAAc;YACnDC,mBAAmBnB,qBAAqBmB,iBAAiB;YACzDT,aAAaD;YACbO;YACAI,QAAQpB,qBAAqBoB,MAAM;YACnChB;YACAiB,4BAA4BrB,qBAAqBqB,0BAA0B,IAAI;YAC/EC,oBAAoBtB,qBAAqBsB,kBAAkB,IAAI;YAC/DC,UAAUvB,qBAAqBuB,QAAQ;QACzC;QAEA,qDAAqD;QACrD,IAAInB,QAAQoB,MAAM,KAAK,KAAK,CAACxB,qBAAqBoB,MAAM,EAAE;YACxD,MAAM,IAAIK,MACR,2FACE;QAEN;QAEA,MAAMC,yBAAyB,IAAIC,IACjChB,sBAAsBL,GAAG,CAAC,CAACsB,QAAU;gBAACA,MAAMrC,IAAI;gBAAEqC;aAAM;QAG1D,kCAAkC;QAClC1B,OAAOU,WAAW,GAAGV,OAAOU,WAAW,IAAI,EAAE;QAE7C,yEAAyE;QACzEV,OAAOU,WAAW,GAAGV,OAAOU,WAAW,CAACN,GAAG,CAAC,CAACuB;YAC3C,MAAMC,0BAA0BJ,uBAAuBK,GAAG,CAACF,iBAAiBtC,IAAI;YAEhF,IAAIuC,yBAAyB;gBAC3B,IAAI,CAACD,iBAAiBG,MAAM,EAAE;oBAC5BC,QAAQC,IAAI,CACV,CAAC,gCAAgC,EAAEL,iBAAiBtC,IAAI,CAAC,wDAAwD,CAAC;oBAEpH,OAAOsC;gBACT;gBAEA,MAAMM,gBAAgB;oBACpBpD,aAAa;wBACXqD,WAAWC,QAAQnC,OAAOG,YAAY;wBACtCiC,oBAAoBrB,aAAaM,QAAQ,CAACe,kBAAkB;wBAC5DC,kBAAkBT,wBAAwBU,SAAS;wBACnDC,UAAUX,wBAAwBW,QAAQ;oBAC5C;oBACAzD,cAAc;wBACZoD,WAAWC,QAAQnC,OAAOG,YAAY;oBACxC;iBACD;gBAED,MAAMqC,SACJ1C,qBAAqBkB,cAAc,IACnC,OAAOlB,qBAAqBkB,cAAc,KAAK,aAC3ClB,qBAAqBkB,cAAc,CAAC;oBAAEiB;gBAAc,KACpDA;gBAEN,OAAO;oBACL,GAAGN,gBAAgB;oBACnBc,OAAO;wBACL,GAAGd,iBAAiBc,KAAK;wBACzBC,YAAY;4BACV,GAAIf,iBAAiBc,KAAK,EAAEC,cAAc,CAAC,CAAC;4BAC5C,wHAAwH;4BACxHC,iBAAiB;mCACXhB,iBAAiBc,KAAK,EAAEC,YAAYC,mBAAmB,EAAE;gCAC7D;oCACEC,MAAM;oCACNC,OAAO;wCACLC,gBAAgBnB,iBAAiBtC,IAAI;oCACvC;gCACF;6BACD;wBACH;wBACA,mIAAmI;wBACnI0D,sBAAsBpB,iBAAiBc,KAAK,EAAEM,wBAAwB;4BACpE;4BACA;4BACA;yBACD;oBACH;oBACAP,QAAQ;2BAAKb,iBAAiBa,MAAM,IAAI,EAAE;2BAAMA;qBAAO;oBACvDQ,OAAO;wBACL,GAAGrB,iBAAiBqB,KAAK;wBACzB,GAAIzC,qBAAqB;4BACvB0C,aAAa;mCACPtB,iBAAiBqB,KAAK,EAAEC,eAAe,EAAE;gCAC7ClE,6CAA6C4C,iBAAiBtC,IAAI;6BACnE;4BACD6D,aAAa;mCACPvB,iBAAiBqB,KAAK,EAAEE,eAAe,EAAE;gCAC7ClE,6CAA6C2C,iBAAiBtC,IAAI;6BACnE;wBACH,CAAC;oBACH;gBACF;YACF;YAEA,OAAOsC;QACT;QAEA,MAAMwB,kBAAkBnD,OAAOyC,KAAK,EAAEW,WAAWC,WAAW,EAAE;QAC9D,MAAMA,UACJ,CAAC9C,qBAAqB4C,gBAAgBG,IAAI,CAAC,CAACC,SAAWA,OAAOlE,IAAI,KAAK,qBACnE8D,kBACA;eAAIA;YAAiB/D;SAA8B;QAEzD,OAAO;YACL,GAAGY,MAAM;YACTyC,OAAO;gBACL,GAAGzC,OAAOyC,KAAK;gBACfW,WAAW;oBACT,GAAGpD,OAAOyC,KAAK,EAAEW,SAAS;oBAC1BC;gBACF;YACF;YACAG,QAAQ;gBACN,GAAGxD,OAAOwD,MAAM;gBAChB,gDAAgD;gBAChDC,qBAAqB1C;YACvB;YACA2C,WAAW;mBACL1D,OAAO0D,SAAS,IAAI,EAAE;gBAC1B;oBACEC,SAAS/E,wBAAwBmC,aAAaJ,MAAM;oBACpDiD,QAAQ;oBACRhB,MAAM,CAAC,CAAC,EAAEnE,YAAY,SAAS,CAAC;gBAClC;gBACA;oBACEkF,SAAShF,6BAA6BoC,aAAaJ,MAAM;oBACzDiD,QAAQ;oBACRhB,MAAM,CAAC,CAAC,EAAEnE,YAAY,cAAc,CAAC;gBACvC;mBACI8B,oBACA;oBACE;wBACEoD,SAASjF,sBAAsBqC,aAAaD,iBAAiB;wBAC7D8C,QAAQ;wBACRhB,MAAM,CAAC,CAAC,EAAEnE,YAAY,OAAO,CAAC;oBAChC;iBACD,GACD,EAAE;aACP;YACDoF,MAAM;gBACJ,GAAG7D,OAAO6D,IAAI;gBACd5E,cAAcE,gBAAgBF,cAAcc,eAAe8D,IAAI,EAAE5E,gBAAgB,CAAC;YACpF;QACF;IACF,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { Config, Widget } from 'payload'\n\nimport type {\n AltTextPluginConfig,\n IncomingAltTextPluginConfig,\n} from './types/AltTextPluginConfig.js'\n\nimport { PLUGIN_SLUG } from './constants.js'\nimport { altTextHealthEndpoint } from './endpoints/altTextHealth.js'\nimport { bulkGenerateAltTextsEndpoint } from './endpoints/bulkGenerateAltTexts.js'\nimport { generateAltTextEndpoint } from './endpoints/generateAltText.js'\nimport { altTextField } from './fields/altTextField.js'\nimport { keywordsField } from './fields/keywordsField.js'\nimport {\n createRevalidateAltTextHealthAfterChangeHook,\n createRevalidateAltTextHealthAfterDeleteHook,\n} from './hooks/revalidateAltTextHealth.js'\nimport { translations } from './translations/index.js'\nimport { isValidMimeType, normalizeCollectionsConfig } from './utilities/mimeTypes.js'\nimport { deepMergeSimple } from './utils/deepMergeSimple.js'\n\nconst altTextHealthWidgetDefinition = {\n slug: 'alt-text-health',\n // `Component` was renamed from `ComponentPath` in Payload 3.79.0. Set both for backward compatibility.\n Component: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n ComponentPath: '@jhb.software/payload-alt-text-plugin/server#AltTextHealthWidget',\n label: {\n de: 'Alternativtexte Zustand',\n en: 'Alt text health',\n },\n maxWidth: 'full',\n minWidth: 'medium',\n} satisfies { ComponentPath: string } & Widget\n\nexport const payloadAltTextPlugin =\n (incomingPluginConfig: IncomingAltTextPluginConfig) =>\n (incomingConfig: Config): Config => {\n const config = { ...incomingConfig }\n\n // If the plugin is disabled, return the config without modifying it\n if (incomingPluginConfig.enabled === false) {\n return config\n }\n\n const locales = config.localization\n ? config.localization.locales.map((localeConfig) =>\n typeof localeConfig === 'string' ? localeConfig : localeConfig.code,\n )\n : []\n\n const enableHealthCheck = incomingPluginConfig.healthCheck !== false\n\n const normalizedCollections = normalizeCollectionsConfig(incomingPluginConfig.collections, {\n imageThumbnailMimeType: incomingPluginConfig.imageThumbnailMimeType,\n })\n\n // A declared thumbnail MIME type replaces the per-document source check, so a\n // wrong one fails at boot rather than as a silently missing guard or a 500 per\n // image.\n const supportedMimeTypes = incomingPluginConfig.resolver.supportedMimeTypes\n for (const collection of normalizedCollections) {\n const declared = collection.imageThumbnailMimeType\n if (declared === undefined) {\n continue\n }\n\n if (!isValidMimeType(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n 'but that is not a valid MIME type. Expected something like \"image/webp\".',\n )\n }\n\n if (supportedMimeTypes && !supportedMimeTypes.includes(declared)) {\n throw new Error(\n `The alt-text plugin is configured with imageThumbnailMimeType \"${declared}\" for the \"${collection.slug}\" collection, ` +\n `but the \"${incomingPluginConfig.resolver.key}\" resolver does not support it. ` +\n `Supported types: ${supportedMimeTypes.join(', ')}. ` +\n \"Either change the transformation in getImageThumbnail, or remove the declaration to fall back to checking each document's own mime type.\",\n )\n }\n }\n\n const access = incomingPluginConfig.access ?? (({ req }) => !!req.user)\n\n // A function form of `healthCheck` doubles as the health report's access\n // gate; otherwise it falls back to the shared `access`.\n const healthCheckAccess =\n typeof incomingPluginConfig.healthCheck === 'function'\n ? incomingPluginConfig.healthCheck\n : access\n\n const pluginConfig: AltTextPluginConfig = {\n access,\n collections: normalizedCollections,\n enabled: incomingPluginConfig.enabled ?? true,\n fieldsOverride: incomingPluginConfig.fieldsOverride,\n getImageThumbnail: incomingPluginConfig.getImageThumbnail,\n healthCheck: enableHealthCheck,\n healthCheckAccess,\n locale: incomingPluginConfig.locale,\n locales,\n maxBulkGenerateConcurrency: incomingPluginConfig.maxBulkGenerateConcurrency ?? 16,\n maxBulkGenerateIds: incomingPluginConfig.maxBulkGenerateIds ?? 100,\n resolver: incomingPluginConfig.resolver,\n }\n\n // Validate locale requirement for non-localized mode\n if (locales.length === 0 && !incomingPluginConfig.locale) {\n throw new Error(\n 'The alt-text plugin requires a \"locale\" option when Payload localization is disabled. ' +\n 'Please add { locale: \"en\" } (or your preferred locale) to your plugin configuration.',\n )\n }\n\n const collectionConfigBySlug = new Map<string, (typeof normalizedCollections)[number]>(\n normalizedCollections.map((entry) => [entry.slug, entry]),\n )\n\n // Ensure collections array exists\n config.collections = config.collections || []\n\n // Map over collections and inject AI alt text fields into specified ones\n config.collections = config.collections.map((collectionConfig) => {\n const altTextCollectionConfig = collectionConfigBySlug.get(collectionConfig.slug)\n\n if (altTextCollectionConfig) {\n if (!collectionConfig.upload) {\n console.warn(\n `AI Alt Text Plugin: Collection \"${collectionConfig.slug}\" is not an upload collection. Skipping field injection.`,\n )\n return collectionConfig\n }\n\n const defaultFields = [\n altTextField({\n localized: Boolean(config.localization),\n // When the collection declares what getImageThumbnail delivers, the\n // document's own mime type says nothing about whether generation can\n // succeed — so don't let the admin UI disable the button on it.\n supportedMimeTypes: altTextCollectionConfig.imageThumbnailMimeType\n ? undefined\n : pluginConfig.resolver.supportedMimeTypes,\n trackedMimeTypes: altTextCollectionConfig.mimeTypes,\n validate: altTextCollectionConfig.validate,\n }),\n keywordsField({\n localized: Boolean(config.localization),\n }),\n ]\n\n const fields =\n incomingPluginConfig.fieldsOverride &&\n typeof incomingPluginConfig.fieldsOverride === 'function'\n ? incomingPluginConfig.fieldsOverride({ defaultFields })\n : defaultFields\n\n return {\n ...collectionConfig,\n admin: {\n ...collectionConfig.admin,\n components: {\n ...(collectionConfig.admin?.components ?? {}),\n // TODO: use the beforeBulkAction custom component slot once available: https://github.com/payloadcms/payload/pull/11719\n beforeListTable: [\n ...(collectionConfig.admin?.components?.beforeListTable ?? []),\n {\n path: '@jhb.software/payload-alt-text-plugin/client#BulkGenerateAltTextsButton',\n props: {\n collectionSlug: collectionConfig.slug,\n },\n },\n ],\n },\n // enhance the search by adding the filename, keywords and alt fields (if the user has not provided their own listSearchableFields)\n listSearchableFields: collectionConfig.admin?.listSearchableFields ?? [\n 'filename',\n 'keywords',\n 'alt',\n ],\n },\n fields: [...(collectionConfig.fields ?? []), ...fields],\n hooks: {\n ...collectionConfig.hooks,\n ...(enableHealthCheck && {\n afterChange: [\n ...(collectionConfig.hooks?.afterChange ?? []),\n createRevalidateAltTextHealthAfterChangeHook(collectionConfig.slug),\n ],\n afterDelete: [\n ...(collectionConfig.hooks?.afterDelete ?? []),\n createRevalidateAltTextHealthAfterDeleteHook(collectionConfig.slug),\n ],\n }),\n },\n }\n }\n\n return collectionConfig\n })\n\n const existingWidgets = config.admin?.dashboard?.widgets ?? []\n const widgets =\n !enableHealthCheck || existingWidgets.some((widget) => widget.slug === 'alt-text-health')\n ? existingWidgets\n : [...existingWidgets, altTextHealthWidgetDefinition]\n\n return {\n ...config,\n admin: {\n ...config.admin,\n dashboard: {\n ...config.admin?.dashboard,\n widgets,\n },\n },\n custom: {\n ...config.custom,\n // Make plugin config available in hooks/actions\n altTextPluginConfig: pluginConfig,\n },\n endpoints: [\n ...(config.endpoints ?? []),\n {\n handler: generateAltTextEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate`,\n },\n {\n handler: bulkGenerateAltTextsEndpoint(pluginConfig.access),\n method: 'post',\n path: `/${PLUGIN_SLUG}/generate/bulk`,\n },\n ...(enableHealthCheck\n ? [\n {\n handler: altTextHealthEndpoint(pluginConfig.healthCheckAccess),\n method: 'get' as const,\n path: `/${PLUGIN_SLUG}/health`,\n },\n ]\n : []),\n ],\n i18n: {\n ...config.i18n,\n translations: deepMergeSimple(translations, incomingConfig.i18n?.translations ?? {}),\n },\n }\n }\n"],"names":["PLUGIN_SLUG","altTextHealthEndpoint","bulkGenerateAltTextsEndpoint","generateAltTextEndpoint","altTextField","keywordsField","createRevalidateAltTextHealthAfterChangeHook","createRevalidateAltTextHealthAfterDeleteHook","translations","isValidMimeType","normalizeCollectionsConfig","deepMergeSimple","altTextHealthWidgetDefinition","slug","Component","ComponentPath","label","de","en","maxWidth","minWidth","payloadAltTextPlugin","incomingPluginConfig","incomingConfig","config","enabled","locales","localization","map","localeConfig","code","enableHealthCheck","healthCheck","normalizedCollections","collections","imageThumbnailMimeType","supportedMimeTypes","resolver","collection","declared","undefined","Error","includes","key","join","access","req","user","healthCheckAccess","pluginConfig","fieldsOverride","getImageThumbnail","locale","maxBulkGenerateConcurrency","maxBulkGenerateIds","length","collectionConfigBySlug","Map","entry","collectionConfig","altTextCollectionConfig","get","upload","console","warn","defaultFields","localized","Boolean","trackedMimeTypes","mimeTypes","validate","fields","admin","components","beforeListTable","path","props","collectionSlug","listSearchableFields","hooks","afterChange","afterDelete","existingWidgets","dashboard","widgets","some","widget","custom","altTextPluginConfig","endpoints","handler","method","i18n"],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AltTextResolver } from './types.js';
|
|
2
|
+
export type MistralResolverConfig = {
|
|
3
|
+
/** Mistral API key for authentication */
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/**
|
|
6
|
+
* Base URL of the Mistral API.
|
|
7
|
+
* @default 'https://api.mistral.ai/v1'
|
|
8
|
+
*/
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* The vision-capable Mistral model to use for alt text generation.
|
|
12
|
+
*
|
|
13
|
+
* Must be able to read images — `mistral-medium-latest`,
|
|
14
|
+
* `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models
|
|
15
|
+
* all are.
|
|
16
|
+
*
|
|
17
|
+
* @default 'mistral-medium-latest'
|
|
18
|
+
*/
|
|
19
|
+
model?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Abort after this many milliseconds. Covers downloading the image and the
|
|
22
|
+
* completion call together.
|
|
23
|
+
* @default 30000
|
|
24
|
+
*/
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Creates a Mistral-based resolver for alt text generation.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
|
|
33
|
+
*
|
|
34
|
+
* mistralResolver({
|
|
35
|
+
* apiKey: process.env.MISTRAL_API_KEY,
|
|
36
|
+
* model: 'mistral-medium-latest', // optional, this is the default
|
|
37
|
+
* })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const mistralResolver: (config: MistralResolverConfig) => AltTextResolver;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Image formats the Mistral API accepts.
|
|
4
|
+
*
|
|
5
|
+
* Narrower than what an upload collection may hold — SVG and AVIF are missing,
|
|
6
|
+
* so the endpoint rejects those documents and their generate button stays
|
|
7
|
+
* disabled instead of failing at the provider.
|
|
8
|
+
*/ const SUPPORTED_MIME_TYPES = [
|
|
9
|
+
'image/jpeg',
|
|
10
|
+
'image/png',
|
|
11
|
+
'image/gif',
|
|
12
|
+
'image/webp'
|
|
13
|
+
];
|
|
14
|
+
/** Mistral rejects images above 20 MB. */ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
15
|
+
const altTextSchema = z.object({
|
|
16
|
+
altText: z.string().describe('A concise, descriptive alt text for the image'),
|
|
17
|
+
keywords: z.array(z.string()).describe('Keywords that describe the content of the image')
|
|
18
|
+
});
|
|
19
|
+
/** One schema entry per requested locale, so the model must answer for all of them. */ const schemaForLocales = (locales)=>z.object(Object.fromEntries(locales.map((locale)=>[
|
|
20
|
+
locale,
|
|
21
|
+
altTextSchema
|
|
22
|
+
])));
|
|
23
|
+
/**
|
|
24
|
+
* Downloads the image and returns it as a data URI.
|
|
25
|
+
*
|
|
26
|
+
* Mistral can fetch an image URL itself, but that path is not dependable for a
|
|
27
|
+
* CMS. It requires the file to be reachable from the public internet — never
|
|
28
|
+
* true in local development, and not true for private buckets — and some hosts
|
|
29
|
+
* refuse Mistral's fetcher outright, which surfaces as `File could not be
|
|
30
|
+
* fetched from url` (error 3310). Sending the bytes removes that whole class of
|
|
31
|
+
* failure for the price of one extra download.
|
|
32
|
+
*/ async function fetchImageAsDataUri(url, signal) {
|
|
33
|
+
let response;
|
|
34
|
+
try {
|
|
35
|
+
response = await fetch(url, {
|
|
36
|
+
signal
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return {
|
|
40
|
+
error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
return {
|
|
45
|
+
error: `Could not download the image from ${url}: status ${response.status}`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// The document's mime type is checked by the endpoint before the resolver
|
|
49
|
+
// runs, but `getImageThumbnail` may point at a derivative in a different
|
|
50
|
+
// format, so trust what was actually served.
|
|
51
|
+
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
|
|
52
|
+
if (!contentType || !SUPPORTED_MIME_TYPES.includes(contentType)) {
|
|
53
|
+
return {
|
|
54
|
+
error: `The image at ${url} was served as "${contentType ?? 'an unknown type'}", which Mistral cannot read. Supported types: ${SUPPORTED_MIME_TYPES.join(', ')}.`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
58
|
+
if (bytes.byteLength === 0) {
|
|
59
|
+
return {
|
|
60
|
+
error: `The image at ${url} was empty.`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES) {
|
|
64
|
+
return {
|
|
65
|
+
error: `The image at ${url} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB, above Mistral's 20 MB limit. Point getImageThumbnail at a smaller image size.`
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
dataUri: `data:${contentType};base64,${bytes.toString('base64')}`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function buildPrompt(locales) {
|
|
73
|
+
const languages = locales.join(', ');
|
|
74
|
+
return `
|
|
75
|
+
You are an expert at analyzing images and creating descriptive image alt text.
|
|
76
|
+
|
|
77
|
+
Please analyze the given image and provide the following in ${languages}:
|
|
78
|
+
- A concise, descriptive alt text (1-2 sentences) as "altText". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.
|
|
79
|
+
- A list of keywords that describe the content (e.g., ["Camel", "Palm trees", "Desert"]) as "keywords"
|
|
80
|
+
|
|
81
|
+
If a context is provided, use it to enhance the alt text.
|
|
82
|
+
|
|
83
|
+
Format your response as a JSON object with ${locales.map((locale)=>`"${locale}"`).join(', ')} keys, each containing "altText" and "keywords".
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reads the model's response.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately strict about a blank `altText`: the field is required on the
|
|
90
|
+
* collection, so an empty string would satisfy that requirement while telling a
|
|
91
|
+
* screen reader nothing — and nobody looks at an alt text again once it is set.
|
|
92
|
+
*/ function parseResults(content, locales) {
|
|
93
|
+
const parsed = schemaForLocales(locales).safeParse(content);
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const results = {};
|
|
98
|
+
for (const locale of locales){
|
|
99
|
+
const entry = parsed.data[locale];
|
|
100
|
+
if (entry.altText.trim().length === 0) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
results[locale] = {
|
|
104
|
+
altText: entry.altText.trim(),
|
|
105
|
+
keywords: entry.keywords
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* One request, one or more locales.
|
|
112
|
+
*
|
|
113
|
+
* All locales go into a single call rather than one call each: the image is
|
|
114
|
+
* uploaded and analyzed once — the expensive part — and every language ends up
|
|
115
|
+
* describing the same reading of it.
|
|
116
|
+
*/ async function generate({ apiKey, baseUrl, filename, imageThumbnailUrl, locales, model, timeoutMs }) {
|
|
117
|
+
if (!apiKey) {
|
|
118
|
+
return {
|
|
119
|
+
error: 'No Mistral API key configured',
|
|
120
|
+
success: false
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (locales.length === 0) {
|
|
124
|
+
return {
|
|
125
|
+
error: 'No locale requested',
|
|
126
|
+
success: false
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
130
|
+
const image = await fetchImageAsDataUri(imageThumbnailUrl, signal);
|
|
131
|
+
if ('error' in image) {
|
|
132
|
+
return {
|
|
133
|
+
error: image.error,
|
|
134
|
+
success: false
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
max_tokens: 150 * locales.length,
|
|
141
|
+
messages: [
|
|
142
|
+
{
|
|
143
|
+
content: buildPrompt(locales),
|
|
144
|
+
role: 'system'
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: 'image_url',
|
|
150
|
+
image_url: image.dataUri
|
|
151
|
+
},
|
|
152
|
+
...filename ? [
|
|
153
|
+
{
|
|
154
|
+
type: 'text',
|
|
155
|
+
text: filename
|
|
156
|
+
}
|
|
157
|
+
] : []
|
|
158
|
+
],
|
|
159
|
+
role: 'user'
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
model,
|
|
163
|
+
response_format: {
|
|
164
|
+
type: 'json_schema',
|
|
165
|
+
json_schema: {
|
|
166
|
+
name: 'data',
|
|
167
|
+
schema: z.toJSONSchema(schemaForLocales(locales), {
|
|
168
|
+
target: 'draft-7'
|
|
169
|
+
}),
|
|
170
|
+
strict: true
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
headers: {
|
|
175
|
+
Authorization: `Bearer ${apiKey}`,
|
|
176
|
+
'Content-Type': 'application/json'
|
|
177
|
+
},
|
|
178
|
+
method: 'POST',
|
|
179
|
+
signal
|
|
180
|
+
});
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const body = await response.text().catch(()=>'');
|
|
183
|
+
return {
|
|
184
|
+
error: `Mistral responded with status ${response.status}${body ? `: ${body}` : ''}`,
|
|
185
|
+
success: false
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const completion = await response.json();
|
|
189
|
+
const content = completion.choices?.[0]?.message?.content;
|
|
190
|
+
if (typeof content !== 'string') {
|
|
191
|
+
return {
|
|
192
|
+
error: 'No result from Mistral',
|
|
193
|
+
success: false
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const results = parseResults(JSON.parse(content), locales);
|
|
197
|
+
if (!results) {
|
|
198
|
+
return {
|
|
199
|
+
error: `Mistral did not return a usable alt text for every requested locale (${locales.join(', ')})`,
|
|
200
|
+
success: false
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
results,
|
|
205
|
+
success: true
|
|
206
|
+
};
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error('Error generating alt text:', error);
|
|
209
|
+
return {
|
|
210
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
211
|
+
success: false
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Creates a Mistral-based resolver for alt text generation.
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* ```typescript
|
|
220
|
+
* import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'
|
|
221
|
+
*
|
|
222
|
+
* mistralResolver({
|
|
223
|
+
* apiKey: process.env.MISTRAL_API_KEY,
|
|
224
|
+
* model: 'mistral-medium-latest', // optional, this is the default
|
|
225
|
+
* })
|
|
226
|
+
* ```
|
|
227
|
+
*/ export const mistralResolver = (config)=>{
|
|
228
|
+
const { apiKey, baseUrl = 'https://api.mistral.ai/v1', model = 'mistral-medium-latest', timeoutMs = 30_000 } = config;
|
|
229
|
+
return {
|
|
230
|
+
key: 'mistral',
|
|
231
|
+
resolve: async ({ filename, imageThumbnailUrl, locale })=>{
|
|
232
|
+
const result = await generate({
|
|
233
|
+
apiKey,
|
|
234
|
+
baseUrl,
|
|
235
|
+
filename,
|
|
236
|
+
imageThumbnailUrl,
|
|
237
|
+
locales: [
|
|
238
|
+
locale
|
|
239
|
+
],
|
|
240
|
+
model,
|
|
241
|
+
timeoutMs
|
|
242
|
+
});
|
|
243
|
+
if (!result.success) {
|
|
244
|
+
return {
|
|
245
|
+
error: result.error,
|
|
246
|
+
success: false
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
result: result.results[locale],
|
|
251
|
+
success: true
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
resolveBulk: async ({ filename, imageThumbnailUrl, locales })=>{
|
|
255
|
+
const result = await generate({
|
|
256
|
+
apiKey,
|
|
257
|
+
baseUrl,
|
|
258
|
+
filename,
|
|
259
|
+
imageThumbnailUrl,
|
|
260
|
+
locales,
|
|
261
|
+
model,
|
|
262
|
+
timeoutMs
|
|
263
|
+
});
|
|
264
|
+
if (!result.success) {
|
|
265
|
+
return {
|
|
266
|
+
error: result.error,
|
|
267
|
+
success: false
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
results: result.results,
|
|
272
|
+
success: true
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
// https://docs.mistral.ai/capabilities/vision/
|
|
276
|
+
supportedMimeTypes: SUPPORTED_MIME_TYPES
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
//# sourceMappingURL=mistral.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/resolvers/mistral.ts"],"sourcesContent":["import { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n AltTextResult,\n} from './types.js'\n\nexport type MistralResolverConfig = {\n /** Mistral API key for authentication */\n apiKey: string\n /**\n * Base URL of the Mistral API.\n * @default 'https://api.mistral.ai/v1'\n */\n baseUrl?: string\n /**\n * The vision-capable Mistral model to use for alt text generation.\n *\n * Must be able to read images — `mistral-medium-latest`,\n * `mistral-large-latest`, `mistral-small-latest` and the `ministral-*` models\n * all are.\n *\n * @default 'mistral-medium-latest'\n */\n model?: string\n /**\n * Abort after this many milliseconds. Covers downloading the image and the\n * completion call together.\n * @default 30000\n */\n timeoutMs?: number\n}\n\n/**\n * Image formats the Mistral API accepts.\n *\n * Narrower than what an upload collection may hold — SVG and AVIF are missing,\n * so the endpoint rejects those documents and their generate button stays\n * disabled instead of failing at the provider.\n */\nconst SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/** Mistral rejects images above 20 MB. */\nconst MAX_IMAGE_BYTES = 20 * 1024 * 1024\n\nconst altTextSchema = z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n})\n\n/** One schema entry per requested locale, so the model must answer for all of them. */\nconst schemaForLocales = (locales: string[]) =>\n z.object(Object.fromEntries(locales.map((locale) => [locale, altTextSchema])))\n\n/**\n * Downloads the image and returns it as a data URI.\n *\n * Mistral can fetch an image URL itself, but that path is not dependable for a\n * CMS. It requires the file to be reachable from the public internet — never\n * true in local development, and not true for private buckets — and some hosts\n * refuse Mistral's fetcher outright, which surfaces as `File could not be\n * fetched from url` (error 3310). Sending the bytes removes that whole class of\n * failure for the price of one extra download.\n */\nasync function fetchImageAsDataUri(\n url: string,\n signal: AbortSignal,\n): Promise<{ dataUri: string } | { error: string }> {\n let response: Response\n\n try {\n response = await fetch(url, { signal })\n } catch (error) {\n return {\n error: `Could not download the image from ${url}: ${error instanceof Error ? error.message : 'unknown error'}`,\n }\n }\n\n if (!response.ok) {\n return { error: `Could not download the image from ${url}: status ${response.status}` }\n }\n\n // The document's mime type is checked by the endpoint before the resolver\n // runs, but `getImageThumbnail` may point at a derivative in a different\n // format, so trust what was actually served.\n const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()\n\n if (!contentType || !SUPPORTED_MIME_TYPES.includes(contentType)) {\n return {\n error: `The image at ${url} was served as \"${contentType ?? 'an unknown type'}\", which Mistral cannot read. Supported types: ${SUPPORTED_MIME_TYPES.join(', ')}.`,\n }\n }\n\n const bytes = Buffer.from(await response.arrayBuffer())\n\n if (bytes.byteLength === 0) {\n return { error: `The image at ${url} was empty.` }\n }\n\n if (bytes.byteLength > MAX_IMAGE_BYTES) {\n return {\n error: `The image at ${url} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB, above Mistral's 20 MB limit. Point getImageThumbnail at a smaller image size.`,\n }\n }\n\n return { dataUri: `data:${contentType};base64,${bytes.toString('base64')}` }\n}\n\nfunction buildPrompt(locales: string[]): string {\n const languages = locales.join(', ')\n\n return `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following in ${languages}:\n - A concise, descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object with ${locales.map((locale) => `\"${locale}\"`).join(', ')} keys, each containing \"altText\" and \"keywords\".\n `\n}\n\n/**\n * Reads the model's response.\n *\n * Deliberately strict about a blank `altText`: the field is required on the\n * collection, so an empty string would satisfy that requirement while telling a\n * screen reader nothing — and nobody looks at an alt text again once it is set.\n */\nfunction parseResults(content: unknown, locales: string[]): null | Record<string, AltTextResult> {\n const parsed = schemaForLocales(locales).safeParse(content)\n\n if (!parsed.success) {\n return null\n }\n\n const results: Record<string, AltTextResult> = {}\n\n for (const locale of locales) {\n const entry = parsed.data[locale]\n\n if (entry.altText.trim().length === 0) {\n return null\n }\n\n results[locale] = { altText: entry.altText.trim(), keywords: entry.keywords }\n }\n\n return results\n}\n\n/**\n * One request, one or more locales.\n *\n * All locales go into a single call rather than one call each: the image is\n * uploaded and analyzed once — the expensive part — and every language ends up\n * describing the same reading of it.\n */\nasync function generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales,\n model,\n timeoutMs,\n}: {\n apiKey: string\n baseUrl: string\n filename?: string\n imageThumbnailUrl: string\n locales: string[]\n model: string\n timeoutMs: number\n}): Promise<\n { error: string; success: false } | { results: Record<string, AltTextResult>; success: true }\n> {\n if (!apiKey) {\n return { error: 'No Mistral API key configured', success: false }\n }\n\n if (locales.length === 0) {\n return { error: 'No locale requested', success: false }\n }\n\n const signal = AbortSignal.timeout(timeoutMs)\n const image = await fetchImageAsDataUri(imageThumbnailUrl, signal)\n\n if ('error' in image) {\n return { error: image.error, success: false }\n }\n\n try {\n const response = await fetch(`${baseUrl}/chat/completions`, {\n body: JSON.stringify({\n max_tokens: 150 * locales.length,\n messages: [\n { content: buildPrompt(locales), role: 'system' },\n {\n content: [\n { type: 'image_url', image_url: image.dataUri },\n ...(filename ? [{ type: 'text', text: filename }] : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: {\n type: 'json_schema',\n json_schema: {\n name: 'data',\n schema: z.toJSONSchema(schemaForLocales(locales), { target: 'draft-7' }),\n strict: true,\n },\n },\n }),\n headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n method: 'POST',\n signal,\n })\n\n if (!response.ok) {\n const body = await response.text().catch(() => '')\n\n return {\n error: `Mistral responded with status ${response.status}${body ? `: ${body}` : ''}`,\n success: false,\n }\n }\n\n const completion = (await response.json()) as {\n choices?: { message?: { content?: unknown } }[]\n }\n const content = completion.choices?.[0]?.message?.content\n\n if (typeof content !== 'string') {\n return { error: 'No result from Mistral', success: false }\n }\n\n const results = parseResults(JSON.parse(content), locales)\n\n if (!results) {\n return {\n error: `Mistral did not return a usable alt text for every requested locale (${locales.join(', ')})`,\n success: false,\n }\n }\n\n return { results, success: true }\n } catch (error) {\n console.error('Error generating alt text:', error)\n\n return { error: error instanceof Error ? error.message : 'Unknown error', success: false }\n }\n}\n\n/**\n * Creates a Mistral-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { mistralResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * mistralResolver({\n * apiKey: process.env.MISTRAL_API_KEY,\n * model: 'mistral-medium-latest', // optional, this is the default\n * })\n * ```\n */\nexport const mistralResolver = (config: MistralResolverConfig): AltTextResolver => {\n const {\n apiKey,\n baseUrl = 'https://api.mistral.ai/v1',\n model = 'mistral-medium-latest',\n timeoutMs = 30_000,\n } = config\n\n return {\n key: 'mistral',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n const result = await generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales: [locale],\n model,\n timeoutMs,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { result: result.results[locale], success: true }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n const result = await generate({\n apiKey,\n baseUrl,\n filename,\n imageThumbnailUrl,\n locales,\n model,\n timeoutMs,\n })\n\n if (!result.success) {\n return { error: result.error, success: false }\n }\n\n return { results: result.results, success: true }\n },\n // https://docs.mistral.ai/capabilities/vision/\n supportedMimeTypes: SUPPORTED_MIME_TYPES,\n }\n}\n"],"names":["z","SUPPORTED_MIME_TYPES","MAX_IMAGE_BYTES","altTextSchema","object","altText","string","describe","keywords","array","schemaForLocales","locales","Object","fromEntries","map","locale","fetchImageAsDataUri","url","signal","response","fetch","error","Error","message","ok","status","contentType","headers","get","split","trim","toLowerCase","includes","join","bytes","Buffer","from","arrayBuffer","byteLength","Math","round","dataUri","toString","buildPrompt","languages","parseResults","content","parsed","safeParse","success","results","entry","data","length","generate","apiKey","baseUrl","filename","imageThumbnailUrl","model","timeoutMs","AbortSignal","timeout","image","body","JSON","stringify","max_tokens","messages","role","type","image_url","text","response_format","json_schema","name","schema","toJSONSchema","target","strict","Authorization","method","catch","completion","json","choices","parse","console","mistralResolver","config","key","resolve","result","resolveBulk","supportedMimeTypes"],"mappings":"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"}
|
|
@@ -13,6 +13,16 @@ export type OpenAIResolverConfig = {
|
|
|
13
13
|
* @default 'gpt-4.1-nano'
|
|
14
14
|
*/
|
|
15
15
|
model?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The MIME types the provider accepts for the image URL.
|
|
18
|
+
*
|
|
19
|
+
* Defaults to the formats documented for OpenAI's vision models. Override it
|
|
20
|
+
* when pointing `baseUrl` at another provider whose accepted formats differ —
|
|
21
|
+
* the person choosing the provider is the one who knows.
|
|
22
|
+
*
|
|
23
|
+
* @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
|
24
|
+
*/
|
|
25
|
+
supportedMimeTypes?: string[];
|
|
16
26
|
};
|
|
17
27
|
/**
|
|
18
28
|
* Creates an OpenAI-based resolver for alt text generation.
|
package/dist/resolvers/openAI.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
2
|
import { makeParseableResponseFormat } from 'openai/lib/parser.mjs';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
/** @see https://platform.openai.com/docs/guides/images-vision */ const OPENAI_SUPPORTED_MIME_TYPES = [
|
|
5
|
+
'image/jpeg',
|
|
6
|
+
'image/png',
|
|
7
|
+
'image/gif',
|
|
8
|
+
'image/webp'
|
|
9
|
+
];
|
|
4
10
|
/**
|
|
5
11
|
* Creates a chat completion `JSONSchema` response format object from
|
|
6
12
|
* the given Zod schema.
|
|
@@ -181,13 +187,7 @@ import { z } from 'zod';
|
|
|
181
187
|
};
|
|
182
188
|
}
|
|
183
189
|
},
|
|
184
|
-
|
|
185
|
-
supportedMimeTypes: [
|
|
186
|
-
'image/jpeg',
|
|
187
|
-
'image/png',
|
|
188
|
-
'image/gif',
|
|
189
|
-
'image/webp'
|
|
190
|
-
]
|
|
190
|
+
supportedMimeTypes: config.supportedMimeTypes ?? OPENAI_SUPPORTED_MIME_TYPES
|
|
191
191
|
};
|
|
192
192
|
};
|
|
193
193
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/resolvers/openAI.ts"],"sourcesContent":["import type { AutoParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport type { ChatCompletionContentPartText } from 'openai/resources/chat/completions.mjs'\nimport type { ResponseFormatJSONSchema } from 'openai/resources/shared.mjs'\n\nimport OpenAI from 'openai'\nimport { makeParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n} from './types.js'\n\nexport type OpenAIResolverConfig = {\n /** OpenAI API key for authentication */\n apiKey: string\n /**\n * Base URL for the OpenAI-compatible API.\n * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).\n * @default undefined — the OpenAI SDK defaults to 'https://api.openai.com/v1'\n */\n baseUrl?: string\n /**\n * The OpenAI LLM model to use for alt text generation.\n * @default 'gpt-4.1-nano'\n */\n model?: string\n}\n\n/**\n * Creates a chat completion `JSONSchema` response format object from\n * the given Zod schema.\n *\n * This is a temporary drop in replacement for the zodResponseFormat from openai/helpers/zod.ts\n * because of issue https://github.com/openai/openai-node/issues/1576\n */\nfunction zodResponseFormat<ZodInput extends z.ZodType>(\n zodObject: ZodInput,\n name: string,\n props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'name' | 'schema' | 'strict'>,\n): AutoParseableResponseFormat<z.infer<ZodInput>> {\n return makeParseableResponseFormat(\n {\n type: 'json_schema',\n json_schema: {\n ...props,\n name,\n schema: z.toJSONSchema(zodObject, { target: 'draft-7' }),\n strict: true,\n },\n },\n (content) => zodObject.parse(JSON.parse(content)),\n )\n}\n\n/**\n * Creates an OpenAI-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { openAIResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * // OpenAI\n * openAIResolver({\n * apiKey: process.env.OPENAI_API_KEY,\n * model: 'gpt-4.1-mini', // optional, defaults to 'gpt-4.1-nano'\n * })\n *\n * // OpenAI-compatible provider (e.g. Nebius)\n * openAIResolver({\n * apiKey: process.env.NEBIUS_API_KEY,\n * baseUrl: 'https://api.tokenfactory.us-central1.nebius.com/v1',\n * model: 'Qwen/Qwen2.5-VL-72B-Instruct',\n * })\n * ```\n */\nexport const openAIResolver = (config: OpenAIResolverConfig): AltTextResolver => {\n const { apiKey, baseUrl, model = 'gpt-4.1-nano' } = config\n\n // Build the client lazily (once, on first use): the `resolver` argument is\n // evaluated even when the plugin is disabled, so eager construction would\n // throw on a keyless `enabled: !!process.env.OPENAI_API_KEY` setup.\n let openai: OpenAI | undefined\n const getClient = (): OpenAI => (openai ??= new OpenAI({ apiKey, baseURL: baseUrl }))\n\n return {\n key: 'openai',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n try {\n const modelResponseSchema = z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n })\n\n const response = await getClient().chat.completions.parse({\n max_completion_tokens: 150,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following:\n - A concise, descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object. You must respond in the ${locale} language.\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n try {\n const modelResponseSchema = z.object(\n Object.fromEntries(\n locales.map((locale) => [\n locale,\n z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z\n .array(z.string())\n .describe('Keywords that describe the content of the image'),\n }),\n ]),\n ),\n )\n\n const response = await getClient().chat.completions.parse({\n max_completion_tokens: 300,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following in ${locales.join(', ')}:\n - A concise, localized descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A localized list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object with ${locales.join(', ')} keys, each containing \"altText\" and \"keywords\".\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n results: result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating bulk alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n // https://platform.openai.com/docs/guides/images-vision\n supportedMimeTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],\n }\n}\n"],"names":["OpenAI","makeParseableResponseFormat","z","zodResponseFormat","zodObject","name","props","type","json_schema","schema","toJSONSchema","target","strict","content","parse","JSON","openAIResolver","config","apiKey","baseUrl","model","openai","getClient","baseURL","key","resolve","filename","imageThumbnailUrl","locale","modelResponseSchema","object","altText","string","describe","keywords","array","response","chat","completions","max_completion_tokens","messages","role","image_url","url","text","response_format","result","choices","message","parsed","error","success","console","Error","resolveBulk","locales","Object","fromEntries","map","join","results","supportedMimeTypes"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/resolvers/openAI.ts"],"sourcesContent":["import type { AutoParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport type { ChatCompletionContentPartText } from 'openai/resources/chat/completions.mjs'\nimport type { ResponseFormatJSONSchema } from 'openai/resources/shared.mjs'\n\nimport OpenAI from 'openai'\nimport { makeParseableResponseFormat } from 'openai/lib/parser.mjs'\nimport { z } from 'zod'\n\nimport type {\n AltTextBulkResolverArgs,\n AltTextBulkResolverResponse,\n AltTextResolver,\n AltTextResolverArgs,\n AltTextResolverResponse,\n} from './types.js'\n\nexport type OpenAIResolverConfig = {\n /** OpenAI API key for authentication */\n apiKey: string\n /**\n * Base URL for the OpenAI-compatible API.\n * Use this to point at alternative providers (e.g. Azure, Nebius, local inference).\n * @default undefined — the OpenAI SDK defaults to 'https://api.openai.com/v1'\n */\n baseUrl?: string\n /**\n * The OpenAI LLM model to use for alt text generation.\n * @default 'gpt-4.1-nano'\n */\n model?: string\n /**\n * The MIME types the provider accepts for the image URL.\n *\n * Defaults to the formats documented for OpenAI's vision models. Override it\n * when pointing `baseUrl` at another provider whose accepted formats differ —\n * the person choosing the provider is the one who knows.\n *\n * @default ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n */\n supportedMimeTypes?: string[]\n}\n\n/** @see https://platform.openai.com/docs/guides/images-vision */\nconst OPENAI_SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']\n\n/**\n * Creates a chat completion `JSONSchema` response format object from\n * the given Zod schema.\n *\n * This is a temporary drop in replacement for the zodResponseFormat from openai/helpers/zod.ts\n * because of issue https://github.com/openai/openai-node/issues/1576\n */\nfunction zodResponseFormat<ZodInput extends z.ZodType>(\n zodObject: ZodInput,\n name: string,\n props?: Omit<ResponseFormatJSONSchema.JSONSchema, 'name' | 'schema' | 'strict'>,\n): AutoParseableResponseFormat<z.infer<ZodInput>> {\n return makeParseableResponseFormat(\n {\n type: 'json_schema',\n json_schema: {\n ...props,\n name,\n schema: z.toJSONSchema(zodObject, { target: 'draft-7' }),\n strict: true,\n },\n },\n (content) => zodObject.parse(JSON.parse(content)),\n )\n}\n\n/**\n * Creates an OpenAI-based resolver for alt text generation.\n *\n * @example\n * ```typescript\n * import { openAIResolver } from '@jhb.software/payload-alt-text-plugin'\n *\n * // OpenAI\n * openAIResolver({\n * apiKey: process.env.OPENAI_API_KEY,\n * model: 'gpt-4.1-mini', // optional, defaults to 'gpt-4.1-nano'\n * })\n *\n * // OpenAI-compatible provider (e.g. Nebius)\n * openAIResolver({\n * apiKey: process.env.NEBIUS_API_KEY,\n * baseUrl: 'https://api.tokenfactory.us-central1.nebius.com/v1',\n * model: 'Qwen/Qwen2.5-VL-72B-Instruct',\n * })\n * ```\n */\nexport const openAIResolver = (config: OpenAIResolverConfig): AltTextResolver => {\n const { apiKey, baseUrl, model = 'gpt-4.1-nano' } = config\n\n // Build the client lazily (once, on first use): the `resolver` argument is\n // evaluated even when the plugin is disabled, so eager construction would\n // throw on a keyless `enabled: !!process.env.OPENAI_API_KEY` setup.\n let openai: OpenAI | undefined\n const getClient = (): OpenAI => (openai ??= new OpenAI({ apiKey, baseURL: baseUrl }))\n\n return {\n key: 'openai',\n resolve: async ({\n filename,\n imageThumbnailUrl,\n locale,\n }: AltTextResolverArgs): Promise<AltTextResolverResponse> => {\n try {\n const modelResponseSchema = z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z.array(z.string()).describe('Keywords that describe the content of the image'),\n })\n\n const response = await getClient().chat.completions.parse({\n max_completion_tokens: 150,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following:\n - A concise, descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object. You must respond in the ${locale} language.\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n resolveBulk: async ({\n filename,\n imageThumbnailUrl,\n locales,\n }: AltTextBulkResolverArgs): Promise<AltTextBulkResolverResponse> => {\n try {\n const modelResponseSchema = z.object(\n Object.fromEntries(\n locales.map((locale) => [\n locale,\n z.object({\n altText: z.string().describe('A concise, descriptive alt text for the image'),\n keywords: z\n .array(z.string())\n .describe('Keywords that describe the content of the image'),\n }),\n ]),\n ),\n )\n\n const response = await getClient().chat.completions.parse({\n max_completion_tokens: 300,\n messages: [\n {\n content: `\n You are an expert at analyzing images and creating descriptive image alt text.\n\n Please analyze the given image and provide the following in ${locales.join(', ')}:\n - A concise, localized descriptive alt text (1-2 sentences) as \"altText\". Focus on the subject, action, and setting. Avoid phrases like 'Image of', 'A picture of', or 'Photo showing'. Be specific and include relevant details like location or context if visible. Make no assumptions.\n - A localized list of keywords that describe the content (e.g., [\"Camel\", \"Palm trees\", \"Desert\"]) as \"keywords\"\n\n If a context is provided, use it to enhance the alt text.\n\n Format your response as a JSON object with ${locales.join(', ')} keys, each containing \"altText\" and \"keywords\".\n `,\n role: 'system',\n },\n {\n content: [\n {\n type: 'image_url',\n image_url: { url: imageThumbnailUrl },\n },\n ...(filename\n ? [\n {\n type: 'text',\n text: filename,\n } satisfies ChatCompletionContentPartText,\n ]\n : []),\n ],\n role: 'user',\n },\n ],\n model,\n response_format: zodResponseFormat(modelResponseSchema, 'data'),\n })\n\n const result = response.choices[0]?.message?.parsed\n\n if (!result) {\n return { error: 'No result from OpenAI', success: false }\n }\n\n return {\n results: result,\n success: true,\n }\n } catch (error) {\n console.error('Error generating bulk alt text:', error)\n return {\n error: error instanceof Error ? error.message : 'Unknown error',\n success: false,\n }\n }\n },\n supportedMimeTypes: config.supportedMimeTypes ?? OPENAI_SUPPORTED_MIME_TYPES,\n }\n}\n"],"names":["OpenAI","makeParseableResponseFormat","z","OPENAI_SUPPORTED_MIME_TYPES","zodResponseFormat","zodObject","name","props","type","json_schema","schema","toJSONSchema","target","strict","content","parse","JSON","openAIResolver","config","apiKey","baseUrl","model","openai","getClient","baseURL","key","resolve","filename","imageThumbnailUrl","locale","modelResponseSchema","object","altText","string","describe","keywords","array","response","chat","completions","max_completion_tokens","messages","role","image_url","url","text","response_format","result","choices","message","parsed","error","success","console","Error","resolveBulk","locales","Object","fromEntries","map","join","results","supportedMimeTypes"],"mappings":"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"}
|
|
@@ -14,6 +14,16 @@ export type AltTextResult = {
|
|
|
14
14
|
export type AltTextResolverArgs = {
|
|
15
15
|
/** Optional filename for additional context */
|
|
16
16
|
filename?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The format served at `imageThumbnailUrl`, when the collection declares one
|
|
19
|
+
* via the plugin's `imageThumbnailMimeType` option. Undefined otherwise.
|
|
20
|
+
*
|
|
21
|
+
* Resolvers that hand the URL to the provider can ignore this. Resolvers that
|
|
22
|
+
* inline the bytes need it — Anthropic image blocks require `media_type`,
|
|
23
|
+
* Gemini's `inline_data` requires `mime_type`, and neither can be sniffed from
|
|
24
|
+
* a URL.
|
|
25
|
+
*/
|
|
26
|
+
imageThumbnailMimeType?: string;
|
|
17
27
|
/** URL of the image thumbnail (must be publicly accessible) */
|
|
18
28
|
imageThumbnailUrl: string;
|
|
19
29
|
/** Target locale for the generated alt text */
|
|
@@ -27,6 +37,11 @@ export type AltTextResolverArgs = {
|
|
|
27
37
|
export type AltTextBulkResolverArgs = {
|
|
28
38
|
/** Optional filename for additional context */
|
|
29
39
|
filename?: string;
|
|
40
|
+
/**
|
|
41
|
+
* The format served at `imageThumbnailUrl`, when the collection declares one
|
|
42
|
+
* via `imageThumbnailMimeType`. See {@link AltTextResolverArgs.imageThumbnailMimeType}.
|
|
43
|
+
*/
|
|
44
|
+
imageThumbnailMimeType?: string;
|
|
30
45
|
/** URL of the image thumbnail (must be publicly accessible) */
|
|
31
46
|
imageThumbnailUrl: string;
|
|
32
47
|
/** Target locales for the generated alt texts */
|
|
@@ -65,6 +80,14 @@ export type AltTextResolver = {
|
|
|
65
80
|
resolve: (args: AltTextResolverArgs) => Promise<AltTextResolverResponse>;
|
|
66
81
|
/** Generate alt text for a single image in multiple locales (bulk operation) */
|
|
67
82
|
resolveBulk: (args: AltTextBulkResolverArgs) => Promise<AltTextBulkResolverResponse>;
|
|
68
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Formats the provider accepts for the bytes served at `imageThumbnailUrl`.
|
|
85
|
+
*
|
|
86
|
+
* When set, the endpoints reject documents whose stored `mimeType` is not in
|
|
87
|
+
* this list — a conservative proxy, since the resolver never sees the stored
|
|
88
|
+
* file. A project whose `getImageThumbnail` transcodes should declare the
|
|
89
|
+
* delivered format via the plugin's `imageThumbnailMimeType` option, which
|
|
90
|
+
* replaces the proxy with a one-time check against this list at config load.
|
|
91
|
+
*/
|
|
69
92
|
supportedMimeTypes?: string[];
|
|
70
93
|
};
|