@basementstudio/sanity-ai-image-plugin 0.0.1 → 0.1.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.
Files changed (110) hide show
  1. package/README.md +281 -257
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +3 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/presets/article-featured-image.d.ts +3 -0
  7. package/dist/presets/article-featured-image.d.ts.map +1 -0
  8. package/dist/presets/article-featured-image.js +17 -0
  9. package/dist/presets/article-featured-image.js.map +1 -0
  10. package/dist/server/constants.d.ts +6 -0
  11. package/dist/server/constants.d.ts.map +1 -0
  12. package/{src/server/constants.ts → dist/server/constants.js} +17 -20
  13. package/dist/server/constants.js.map +1 -0
  14. package/dist/server/handle-request.d.ts +5 -0
  15. package/dist/server/handle-request.d.ts.map +1 -0
  16. package/dist/server/handle-request.js +122 -0
  17. package/dist/server/handle-request.js.map +1 -0
  18. package/dist/server/types.d.ts +28 -0
  19. package/dist/server/types.d.ts.map +1 -0
  20. package/dist/server/types.js +2 -0
  21. package/dist/server/types.js.map +1 -0
  22. package/dist/server/utils.d.ts +50 -0
  23. package/dist/server/utils.d.ts.map +1 -0
  24. package/dist/server/utils.js +274 -0
  25. package/dist/server/utils.js.map +1 -0
  26. package/dist/server.d.ts +3 -0
  27. package/dist/server.d.ts.map +1 -0
  28. package/dist/server.js +3 -0
  29. package/dist/server.js.map +1 -0
  30. package/dist/studio/components/asset-source.d.ts +4 -0
  31. package/dist/studio/components/asset-source.d.ts.map +1 -0
  32. package/dist/studio/components/asset-source.js +163 -0
  33. package/dist/studio/components/asset-source.js.map +1 -0
  34. package/dist/studio/components/generate-button-input.d.ts +8 -0
  35. package/dist/studio/components/generate-button-input.d.ts.map +1 -0
  36. package/dist/studio/components/generate-button-input.js +186 -0
  37. package/dist/studio/components/generate-button-input.js.map +1 -0
  38. package/dist/studio/components/input-router.d.ts +7 -0
  39. package/dist/studio/components/input-router.d.ts.map +1 -0
  40. package/dist/studio/components/input-router.js +21 -0
  41. package/dist/studio/components/input-router.js.map +1 -0
  42. package/dist/studio/files.d.ts +9 -0
  43. package/dist/studio/files.d.ts.map +1 -0
  44. package/dist/studio/files.js +86 -0
  45. package/dist/studio/files.js.map +1 -0
  46. package/dist/studio/plugin.d.ts +3 -0
  47. package/dist/studio/plugin.d.ts.map +1 -0
  48. package/dist/studio/plugin.js +47 -0
  49. package/dist/studio/plugin.js.map +1 -0
  50. package/dist/studio/settings/schema.d.ts +8 -0
  51. package/dist/studio/settings/schema.d.ts.map +1 -0
  52. package/dist/studio/settings/schema.js +110 -0
  53. package/dist/studio/settings/schema.js.map +1 -0
  54. package/dist/studio/settings/tool.d.ts +3 -0
  55. package/dist/studio/settings/tool.d.ts.map +1 -0
  56. package/dist/studio/settings/tool.js +292 -0
  57. package/dist/studio/settings/tool.js.map +1 -0
  58. package/dist/studio/settings-data.d.ts +24 -0
  59. package/dist/studio/settings-data.d.ts.map +1 -0
  60. package/dist/studio/settings-data.js +99 -0
  61. package/dist/studio/settings-data.js.map +1 -0
  62. package/dist/studio/shared-secret.d.ts +9 -0
  63. package/dist/studio/shared-secret.d.ts.map +1 -0
  64. package/dist/studio/shared-secret.js +37 -0
  65. package/dist/studio/shared-secret.js.map +1 -0
  66. package/dist/utils/config.d.ts +3 -0
  67. package/dist/utils/config.d.ts.map +1 -0
  68. package/dist/utils/config.js +41 -0
  69. package/dist/utils/config.js.map +1 -0
  70. package/dist/utils/context-fields.d.ts +16 -0
  71. package/dist/utils/context-fields.d.ts.map +1 -0
  72. package/dist/utils/context-fields.js +104 -0
  73. package/dist/utils/context-fields.js.map +1 -0
  74. package/dist/utils/document-paths.d.ts +10 -0
  75. package/dist/utils/document-paths.d.ts.map +1 -0
  76. package/dist/utils/document-paths.js +33 -0
  77. package/dist/utils/document-paths.js.map +1 -0
  78. package/dist/utils/models.d.ts +22 -0
  79. package/dist/utils/models.d.ts.map +1 -0
  80. package/dist/utils/models.js +76 -0
  81. package/dist/utils/models.js.map +1 -0
  82. package/dist/utils/prompts.d.ts +2 -0
  83. package/dist/utils/prompts.d.ts.map +1 -0
  84. package/dist/utils/prompts.js +7 -0
  85. package/dist/utils/prompts.js.map +1 -0
  86. package/dist/utils/shared.d.ts +82 -0
  87. package/dist/utils/shared.d.ts.map +1 -0
  88. package/dist/utils/shared.js +13 -0
  89. package/dist/utils/shared.js.map +1 -0
  90. package/package.json +73 -67
  91. package/src/index.ts +0 -23
  92. package/src/presets/article-featured-image.ts +0 -23
  93. package/src/server/handle-request.ts +0 -207
  94. package/src/server/types.ts +0 -30
  95. package/src/server/utils.ts +0 -395
  96. package/src/server.ts +0 -14
  97. package/src/studio/components/asset-source.tsx +0 -297
  98. package/src/studio/components/generate-button-input.tsx +0 -380
  99. package/src/studio/components/input-router.tsx +0 -41
  100. package/src/studio/files.ts +0 -114
  101. package/src/studio/plugin.tsx +0 -54
  102. package/src/studio/settings/schema.ts +0 -122
  103. package/src/studio/settings/tool.tsx +0 -587
  104. package/src/studio/settings-data.ts +0 -172
  105. package/src/utils/config.ts +0 -55
  106. package/src/utils/context-fields.ts +0 -172
  107. package/src/utils/document-paths.ts +0 -51
  108. package/src/utils/models.ts +0 -126
  109. package/src/utils/prompts.ts +0 -6
  110. package/src/utils/shared.ts +0 -88
@@ -1,395 +0,0 @@
1
- import { Buffer } from "node:buffer";
2
- import { createGoogleGenerativeAI } from "@ai-sdk/google";
3
- import { createOpenAI } from "@ai-sdk/openai";
4
- import * as aiSdk from "ai";
5
- import {
6
- DEFAULT_SUPPORTED_AI_IMAGE_MODEL,
7
- getDefaultAllowedAiImageModel,
8
- getSupportedAiImageModelDefinition,
9
- normalizeAllowedAiImageModels,
10
- type SupportedAiImageModelId,
11
- } from "../utils/models";
12
- import { MAX_REFERENCE_IMAGES } from "../utils/shared";
13
- import {
14
- DEFAULT_MAX_REFERENCE_FILE_BYTES,
15
- OPENAI_SUPPORTED_REFERENCE_IMAGE_TYPES,
16
- SUPPORTED_REFERENCE_IMAGE_TYPES,
17
- } from "./constants";
18
- import type {
19
- GenerateImageFunction,
20
- GeneratedImagePayload,
21
- RequestOptions,
22
- } from "./types";
23
-
24
- type ResolvedRequestOptions = {
25
- allowedModels: SupportedAiImageModelId[];
26
- defaultModel: SupportedAiImageModelId;
27
- enforceSameOrigin: boolean;
28
- maxReferenceFileBytes: number;
29
- maxReferences: number;
30
- maxTotalReferenceBytes: number;
31
- };
32
-
33
- type ValidationError = {
34
- error: string;
35
- status: number;
36
- };
37
-
38
- // Small request-parsing helpers used directly by the main request handler.
39
- export function jsonResponse(
40
- body: Record<string, unknown>,
41
- status = 200,
42
- ): Response {
43
- return Response.json(body, { status });
44
- }
45
-
46
- export function isFileEntry(value: FormDataEntryValue): value is File {
47
- return typeof value !== "string";
48
- }
49
-
50
- export function isMultipartFormData(contentType: string | null): boolean {
51
- return typeof contentType === "string"
52
- ? contentType.toLowerCase().startsWith("multipart/form-data")
53
- : false;
54
- }
55
-
56
- export function parseHeaderByteLength(value: string | null): number | null {
57
- if (!value) {
58
- return null;
59
- }
60
-
61
- const parsedValue = Number(value);
62
-
63
- return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : null;
64
- }
65
-
66
- // Request-level validation helpers keep the handler readable while still
67
- // returning precise HTTP errors for common failure modes.
68
- export function getSameOriginValidationError(
69
- request: Request,
70
- ): ValidationError | null {
71
- const requestOrigin = getRequestOrigin(request);
72
- const normalizedOrigin = getNormalizedOrigin(request.headers.get("origin"));
73
-
74
- if (!requestOrigin) {
75
- return {
76
- error: "AI Image Plugin could not determine the request origin.",
77
- status: 400,
78
- };
79
- }
80
-
81
- if (!normalizedOrigin || normalizedOrigin !== requestOrigin) {
82
- return {
83
- error:
84
- "AI Image Plugin only accepts same-origin browser requests from Sanity Studio.",
85
- status: 403,
86
- };
87
- }
88
-
89
- return null;
90
- }
91
-
92
- export function resolveRequestOptions(
93
- options: RequestOptions,
94
- ): ResolvedRequestOptions {
95
- const allowedModels = normalizeAllowedModels(options);
96
- const defaultModel = getDefaultModel(options, allowedModels);
97
- const enforceSameOrigin = options.enforceSameOrigin !== false;
98
- const maxReferenceFileBytes =
99
- options.maxReferenceFileBytes ?? DEFAULT_MAX_REFERENCE_FILE_BYTES;
100
- const maxReferences = options.maxReferences ?? MAX_REFERENCE_IMAGES;
101
- const maxTotalReferenceBytes =
102
- options.maxTotalReferenceBytes ?? maxReferences * maxReferenceFileBytes;
103
-
104
- return {
105
- allowedModels,
106
- defaultModel,
107
- enforceSameOrigin,
108
- maxReferenceFileBytes,
109
- maxReferences,
110
- maxTotalReferenceBytes,
111
- };
112
- }
113
-
114
- export function getFormRequestPayload(
115
- formData: FormData,
116
- maxReferences: number,
117
- ): {
118
- prompt: string;
119
- referenceImages: File[];
120
- requestedModel: string;
121
- } {
122
- return {
123
- prompt: String(formData.get("prompt") || "").trim(),
124
- requestedModel: String(formData.get("model") || "").trim(),
125
- referenceImages: formData
126
- .getAll("references")
127
- .filter(isFileEntry)
128
- .slice(0, maxReferences),
129
- };
130
- }
131
-
132
- // Reference image rules depend on both configured byte limits and the
133
- // capabilities of the selected model provider.
134
- export function getReferenceImageValidationError(options: {
135
- maxReferenceFileBytes: number;
136
- maxTotalReferenceBytes: number;
137
- model: SupportedAiImageModelId;
138
- referenceImages: File[];
139
- }): ValidationError | null {
140
- const totalReferenceBytes = options.referenceImages.reduce(
141
- (totalBytes, referenceImage) => totalBytes + referenceImage.size,
142
- 0,
143
- );
144
-
145
- if (totalReferenceBytes > options.maxTotalReferenceBytes) {
146
- return {
147
- error: `Reference images cannot exceed ${options.maxTotalReferenceBytes} bytes in total.`,
148
- status: 413,
149
- };
150
- }
151
-
152
- const modelDefinition = getSupportedAiImageModelDefinition(options.model);
153
-
154
- for (const referenceImage of options.referenceImages) {
155
- if (referenceImage.size > options.maxReferenceFileBytes) {
156
- return {
157
- error: `Reference images cannot exceed ${options.maxReferenceFileBytes} bytes each.`,
158
- status: 413,
159
- };
160
- }
161
-
162
- if (!SUPPORTED_REFERENCE_IMAGE_TYPES.has(referenceImage.type)) {
163
- return {
164
- error: "Reference images must be PNG, JPEG, WEBP, HEIC, or HEIF files.",
165
- status: 400,
166
- };
167
- }
168
-
169
- if (
170
- modelDefinition.provider === "openai" &&
171
- !OPENAI_SUPPORTED_REFERENCE_IMAGE_TYPES.has(referenceImage.type)
172
- ) {
173
- return {
174
- error:
175
- "Reference images for OpenAI models must be PNG, JPEG, or WEBP files.",
176
- status: 400,
177
- };
178
- }
179
- }
180
-
181
- return null;
182
- }
183
-
184
- // Provider helpers centralize API-key selection and user-facing provider names
185
- // so the request handler stays focused on control flow.
186
- export function getProviderApiKey(
187
- model: SupportedAiImageModelId,
188
- options: RequestOptions,
189
- ): string | null {
190
- const modelDefinition = getSupportedAiImageModelDefinition(model);
191
-
192
- if (modelDefinition.provider === "google") {
193
- return options.googleApiKey || options.apiKey || null;
194
- }
195
-
196
- return options.openAiApiKey || null;
197
- }
198
-
199
- export function getProviderDisplayName(model: SupportedAiImageModelId): string {
200
- const modelDefinition = getSupportedAiImageModelDefinition(model);
201
-
202
- return modelDefinition.provider === "google" ? "Google" : "OpenAI";
203
- }
204
-
205
- export function getMissingApiKeyErrorMessage(
206
- model: SupportedAiImageModelId,
207
- ): string {
208
- const modelDefinition = getSupportedAiImageModelDefinition(model);
209
-
210
- if (modelDefinition.provider === "google") {
211
- return "Missing Google API key. Add googleApiKey (or apiKey for backwards compatibility) before using AI Image Plugin.";
212
- }
213
-
214
- return "Missing OpenAI API key. Add openAiApiKey before using AI Image Plugin.";
215
- }
216
-
217
- export function getErrorMessage(error: unknown): string | null {
218
- if (!(error instanceof Error) || !error.message.trim()) {
219
- return null;
220
- }
221
-
222
- return error.message;
223
- }
224
-
225
- // Provider SDKs do not share a single error shape, so we look through a few
226
- // common status locations before falling back to a generic 502.
227
- export function getErrorStatusCode(error: unknown): number | null {
228
- if (!error || typeof error !== "object") {
229
- return null;
230
- }
231
-
232
- if ("statusCode" in error && typeof error.statusCode === "number") {
233
- return error.statusCode;
234
- }
235
-
236
- if ("status" in error && typeof error.status === "number") {
237
- return error.status;
238
- }
239
-
240
- if (
241
- "response" in error &&
242
- error.response &&
243
- typeof error.response === "object" &&
244
- "status" in error.response &&
245
- typeof error.response.status === "number"
246
- ) {
247
- return error.response.status;
248
- }
249
-
250
- return null;
251
- }
252
-
253
- // This wraps the AI SDK differences so the request handler can ask for "an
254
- // image from model X" without caring which provider implementation is behind it.
255
- export async function generateImageWithModel(options: {
256
- apiKey: string;
257
- geminiApiUrl?: string;
258
- model: SupportedAiImageModelId;
259
- prompt: string;
260
- referenceImages: File[];
261
- }): Promise<{ data: string; mimeType: string } | null> {
262
- const generateImage = getGenerateImage();
263
- const modelDefinition = getSupportedAiImageModelDefinition(options.model);
264
- const prompt =
265
- options.referenceImages.length > 0
266
- ? {
267
- images: await Promise.all(options.referenceImages.map(toModelInput)),
268
- text: options.prompt,
269
- }
270
- : options.prompt;
271
-
272
- const model =
273
- modelDefinition.provider === "google"
274
- ? createGoogleGenerativeAI({
275
- apiKey: options.apiKey,
276
- ...(options.geminiApiUrl ? { baseURL: options.geminiApiUrl } : {}),
277
- }).image(options.model)
278
- : createOpenAI({
279
- apiKey: options.apiKey,
280
- }).image(options.model);
281
-
282
- const result = await generateImage({
283
- aspectRatio: modelDefinition.defaultAspectRatio ?? null,
284
- model,
285
- prompt,
286
- size: modelDefinition.defaultSize ?? null,
287
- });
288
-
289
- return getGeneratedImagePayload(result.image);
290
- }
291
-
292
- // Internal helpers below support the exported request/provider helpers without
293
- // needing to leak implementation details across the server module boundary.
294
- function getGenerateImage(): GenerateImageFunction {
295
- const aiModule = aiSdk as Record<string, unknown>;
296
- const generateImage = (
297
- "generateImage" in aiModule
298
- ? aiModule.generateImage
299
- : aiModule.experimental_generateImage
300
- ) as GenerateImageFunction | undefined;
301
-
302
- if (typeof generateImage !== "function") {
303
- throw new Error(
304
- 'AI Image Plugin could not find generateImage in the installed "ai" package.',
305
- );
306
- }
307
-
308
- return generateImage;
309
- }
310
-
311
- function getNormalizedOrigin(value: string | null): string | null {
312
- if (!value || value === "null") {
313
- return null;
314
- }
315
-
316
- try {
317
- return new URL(value).origin;
318
- } catch {
319
- return null;
320
- }
321
- }
322
-
323
- function getRequestOrigin(request: Request): string | null {
324
- try {
325
- return new URL(request.url).origin;
326
- } catch {
327
- return null;
328
- }
329
- }
330
-
331
- function normalizeAllowedModels(
332
- options: RequestOptions,
333
- ): SupportedAiImageModelId[] {
334
- if (options.allowedModels) {
335
- return normalizeAllowedAiImageModels(options.allowedModels);
336
- }
337
-
338
- if (options.model) {
339
- return normalizeAllowedAiImageModels([options.model]);
340
- }
341
-
342
- return normalizeAllowedAiImageModels();
343
- }
344
-
345
- function getDefaultModel(
346
- options: RequestOptions,
347
- allowedModels: SupportedAiImageModelId[],
348
- ): SupportedAiImageModelId {
349
- if (!options.model) {
350
- return getDefaultAllowedAiImageModel(allowedModels);
351
- }
352
-
353
- if (!allowedModels.includes(options.model)) {
354
- throw new Error(
355
- `AI Image Plugin server configuration must include "${options.model}" in allowedModels.`,
356
- );
357
- }
358
-
359
- return options.model;
360
- }
361
-
362
- function getGeneratedImagePayload(
363
- image: unknown,
364
- ): { data: string; mimeType: string } | null {
365
- if (!image || typeof image !== "object") {
366
- return null;
367
- }
368
-
369
- const generatedImage = image as GeneratedImagePayload;
370
- const mimeType =
371
- typeof generatedImage.mediaType === "string" &&
372
- generatedImage.mediaType.trim()
373
- ? generatedImage.mediaType
374
- : "image/png";
375
-
376
- if (typeof generatedImage.base64 === "string" && generatedImage.base64) {
377
- return {
378
- data: generatedImage.base64.replace(/^data:[^;]+;base64,/, ""),
379
- mimeType,
380
- };
381
- }
382
-
383
- if (generatedImage.uint8Array instanceof Uint8Array) {
384
- return {
385
- data: Buffer.from(generatedImage.uint8Array).toString("base64"),
386
- mimeType,
387
- };
388
- }
389
-
390
- return null;
391
- }
392
-
393
- async function toModelInput(file: File): Promise<Buffer> {
394
- return Buffer.from(await file.arrayBuffer());
395
- }
package/src/server.ts DELETED
@@ -1,14 +0,0 @@
1
- export {
2
- DEFAULT_MODEL,
3
- DEFAULT_MAX_REFERENCE_FILE_BYTES,
4
- handleAiImageRequest,
5
- } from "./server/handle-request";
6
- export {
7
- DEFAULT_PROVIDER_API_URL,
8
- SUPPORTED_REFERENCE_IMAGE_TYPES,
9
- } from "./server/constants";
10
- export type { RequestOptions, SuccessResponse } from "./server/types";
11
- export {
12
- SUPPORTED_AI_IMAGE_MODELS,
13
- type SupportedAiImageModelId,
14
- } from "./utils/models";
@@ -1,297 +0,0 @@
1
- "use client"
2
-
3
- import { Box, Button, Card, Flex, Stack, Text, TextArea } from "@sanity/ui"
4
- import { type ChangeEvent, useState } from "react"
5
- import {
6
- type AssetSource,
7
- type AssetSourceComponentProps,
8
- useClient,
9
- } from "sanity"
10
- import {
11
- MAX_REFERENCE_IMAGES,
12
- SETTINGS_DOCUMENT_ID,
13
- SETTINGS_SCHEMA_TYPE,
14
- SOURCE_NAME,
15
- SOURCE_TITLE,
16
- type ResolvedOptions,
17
- } from "../../utils/shared"
18
- import { composePrompt } from "../../utils/prompts"
19
- import {
20
- buildSettingsReferenceImages,
21
- fetchSettingsDocument,
22
- findTargetSettings,
23
- getErrorMessage,
24
- resolveSettingsModel,
25
- } from "../settings-data"
26
- import {
27
- convertImageFileToPng,
28
- createGeneratedFile,
29
- formatFileSize,
30
- } from "../files"
31
-
32
- type GenerateResponse = {
33
- data: string
34
- mimeType: string
35
- model: string
36
- }
37
-
38
- type GenerationPhase = "idle" | "preparing" | "generating" | "uploading" | "updating"
39
-
40
- function getGenerationPhaseLabel(phase: GenerationPhase): string {
41
- switch (phase) {
42
- case "preparing":
43
- return "Preparing..."
44
- case "generating":
45
- return "Generating..."
46
- case "uploading":
47
- return "Uploading to Sanity..."
48
- case "updating":
49
- return "Updating field..."
50
- default:
51
- return "Generate with AI Image Plugin"
52
- }
53
- }
54
-
55
- export function createAssetSource(options: ResolvedOptions): AssetSource {
56
- const assetSourceTarget = options.assetSourceTarget
57
-
58
- function AssetSourceComponent({
59
- onClose,
60
- onSelect,
61
- }: AssetSourceComponentProps) {
62
- const client = useClient({ apiVersion: options.apiVersion })
63
- const [prompt, setPrompt] = useState("")
64
- const [referenceImages, setReferenceImages] = useState<File[]>([])
65
- const [error, setError] = useState<string | null>(null)
66
- const [generationPhase, setGenerationPhase] = useState<GenerationPhase>("idle")
67
- const isGenerating = generationPhase !== "idle"
68
-
69
- function handleReferenceImageChange(event: ChangeEvent<HTMLInputElement>) {
70
- const nextFiles = Array.from(event.currentTarget.files ?? [])
71
-
72
- if (nextFiles.length === 0) {
73
- return
74
- }
75
-
76
- setReferenceImages((currentFiles) =>
77
- [...currentFiles, ...nextFiles].slice(0, MAX_REFERENCE_IMAGES)
78
- )
79
- event.currentTarget.value = ""
80
- }
81
-
82
- function handleRemoveReferenceImage(indexToRemove: number) {
83
- setReferenceImages((currentFiles) =>
84
- currentFiles.filter((_, index) => index !== indexToRemove)
85
- )
86
- }
87
-
88
- async function handleGenerate() {
89
- setError(null)
90
- setGenerationPhase("preparing")
91
-
92
- try {
93
- const settings = await fetchSettingsDocument(
94
- client,
95
- SETTINGS_DOCUMENT_ID,
96
- SETTINGS_SCHEMA_TYPE
97
- )
98
- const targetSettings = assetSourceTarget
99
- ? findTargetSettings(settings, assetSourceTarget.id)
100
- : null
101
- const selectedModel = resolveSettingsModel({
102
- allowedModels: options.allowedModels,
103
- settings,
104
- })
105
- const composedPrompt = composePrompt([
106
- settings?.globalPrompt
107
- ? `Global direction:\n${settings.globalPrompt}`
108
- : null,
109
- targetSettings?.prompt
110
- ? `Asset source direction:\n${targetSettings.prompt}`
111
- : null,
112
- prompt.trim()
113
- ? `Editor prompt:\n${prompt.trim()}`
114
- : null,
115
- ])
116
-
117
- if (!composedPrompt) {
118
- throw new Error("Add a prompt or configure a default prompt first.")
119
- }
120
-
121
- const formData = new FormData()
122
- const normalizedReferenceImages = await Promise.all(
123
- referenceImages.map(convertImageFileToPng)
124
- )
125
- const settingsReferenceImages = await buildSettingsReferenceImages(
126
- settings,
127
- assetSourceTarget?.id
128
- )
129
- const allReferenceImages = [
130
- ...settingsReferenceImages,
131
- ...normalizedReferenceImages,
132
- ].slice(0, MAX_REFERENCE_IMAGES)
133
-
134
- formData.set("prompt", composedPrompt)
135
- formData.set("model", selectedModel)
136
-
137
- for (const referenceImage of allReferenceImages) {
138
- formData.append("references", referenceImage)
139
- }
140
-
141
- setGenerationPhase("generating")
142
- const response = await fetch(options.apiEndpoint, {
143
- method: "POST",
144
- body: formData,
145
- })
146
- const payload = (await response.json()) as
147
- | GenerateResponse
148
- | { error?: string }
149
-
150
- if (!response.ok) {
151
- throw new Error(
152
- "error" in payload && payload.error
153
- ? payload.error
154
- : "AI image generation failed."
155
- )
156
- }
157
-
158
- if (
159
- !("data" in payload) ||
160
- typeof payload.data !== "string" ||
161
- typeof payload.mimeType !== "string"
162
- ) {
163
- throw new Error("AI Image Plugin returned an invalid image payload.")
164
- }
165
-
166
- const generatedFile = createGeneratedFile(
167
- payload,
168
- prompt,
169
- "ai-image-plugin"
170
- )
171
- setGenerationPhase("uploading")
172
- const uploadedAsset = await client.assets.upload("image", generatedFile, {
173
- filename: generatedFile.name,
174
- })
175
-
176
- setGenerationPhase("updating")
177
- onSelect([{ kind: "assetDocumentId", value: uploadedAsset._id }])
178
- onClose()
179
- } catch (nextError) {
180
- setError(getErrorMessage(nextError))
181
- } finally {
182
- setGenerationPhase("idle")
183
- }
184
- }
185
-
186
- return (
187
- <Card padding={4}>
188
- <Stack space={4}>
189
- <Stack space={2}>
190
- <Text size={2} weight="semibold">
191
- Generate an image with AI Image Plugin and drop it straight into this
192
- field.
193
- </Text>
194
- <Text muted size={1}>
195
- Project defaults from the AI Image Plugin settings tool are included
196
- automatically when configured.
197
- </Text>
198
- </Stack>
199
-
200
- <Box>
201
- <Text size={1} weight="medium">
202
- {assetSourceTarget?.promptLabel || "Prompt"}
203
- </Text>
204
- <Box marginTop={2}>
205
- <TextArea
206
- onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
207
- setPrompt(event.currentTarget.value)
208
- }
209
- placeholder={
210
- assetSourceTarget?.promptPlaceholder ||
211
- "Turn these references into a clean homepage hero image with warm daylight and subtle depth."
212
- }
213
- rows={6}
214
- value={prompt}
215
- />
216
- </Box>
217
- </Box>
218
-
219
- <Box>
220
- <Text size={1} weight="medium">
221
- Reference images
222
- </Text>
223
- <Box marginTop={2}>
224
- <input
225
- accept="image/*"
226
- multiple
227
- onChange={handleReferenceImageChange}
228
- type="file"
229
- />
230
- </Box>
231
- {referenceImages.length > 0 ? (
232
- <Box marginTop={3}>
233
- <Stack space={2}>
234
- {referenceImages.map((referenceImage, index) => (
235
- <Card
236
- key={`${referenceImage.name}-${index}`}
237
- padding={3}
238
- radius={2}
239
- tone="transparent"
240
- >
241
- <Flex align="center" gap={3} justify="space-between">
242
- <Box flex={1}>
243
- <Text size={1} weight="medium">
244
- {referenceImage.name}
245
- </Text>
246
- <Text muted size={1}>
247
- {formatFileSize(referenceImage.size)}
248
- </Text>
249
- </Box>
250
- <Button
251
- mode="bleed"
252
- onClick={() => handleRemoveReferenceImage(index)}
253
- text="Remove"
254
- />
255
- </Flex>
256
- </Card>
257
- ))}
258
- </Stack>
259
- </Box>
260
- ) : null}
261
- </Box>
262
-
263
- {error ? (
264
- <Card padding={3} radius={2} tone="critical">
265
- <Text size={1}>{error}</Text>
266
- </Card>
267
- ) : null}
268
-
269
- {isGenerating ? (
270
- <Card padding={3} radius={2} tone="transparent">
271
- <Text size={1}>
272
- {getGenerationPhaseLabel(generationPhase)} This can take a minute
273
- or two for slower models.
274
- </Text>
275
- </Card>
276
- ) : null}
277
-
278
- <Flex gap={3} justify="flex-end">
279
- <Button mode="ghost" onClick={onClose} text="Cancel" />
280
- <Button
281
- disabled={isGenerating}
282
- onClick={() => void handleGenerate()}
283
- text={getGenerationPhaseLabel(generationPhase)}
284
- tone="primary"
285
- />
286
- </Flex>
287
- </Stack>
288
- </Card>
289
- )
290
- }
291
-
292
- return {
293
- name: SOURCE_NAME,
294
- title: SOURCE_TITLE,
295
- component: AssetSourceComponent,
296
- }
297
- }