@basementstudio/sanity-ai-image-plugin 0.0.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.
@@ -0,0 +1,172 @@
1
+ import type { ImageValue, SanityClient } from "sanity"
2
+ import {
3
+ resolveAllowedAiImageModel,
4
+ type SupportedAiImageModelId,
5
+ } from "../utils/models"
6
+ import {
7
+ MAX_REFERENCE_IMAGES,
8
+ type SettingsTargetConfigValue,
9
+ type SettingsValue,
10
+ type StoredImage,
11
+ } from "../utils/shared"
12
+ import { convertImageFileToPng, fileExtensionFromMimeType } from "./files"
13
+
14
+ export type ToolReferenceImage = {
15
+ assetId?: string
16
+ id: string
17
+ name: string
18
+ previewUrl?: string
19
+ sourceFile?: File
20
+ }
21
+
22
+ export function createImageReferenceValue(assetId: string): ImageValue {
23
+ return {
24
+ _type: "image",
25
+ asset: {
26
+ _type: "reference",
27
+ _ref: assetId,
28
+ },
29
+ }
30
+ }
31
+
32
+ export function getErrorMessage(
33
+ error: unknown,
34
+ fallback = "Unable to generate an image right now."
35
+ ): string {
36
+ if (error instanceof Error && error.message.trim()) {
37
+ return error.message
38
+ }
39
+
40
+ return fallback
41
+ }
42
+
43
+ export function findTargetSettings(
44
+ settings: SettingsValue | null,
45
+ targetId: string
46
+ ): SettingsTargetConfigValue | undefined {
47
+ return settings?.targetConfigs?.find((target) => target.targetId === targetId)
48
+ }
49
+
50
+ export function resolveSettingsModel(options: {
51
+ allowedModels: SupportedAiImageModelId[]
52
+ settings: SettingsValue | null
53
+ }): SupportedAiImageModelId {
54
+ return resolveAllowedAiImageModel(
55
+ options.settings?.globalModel,
56
+ options.allowedModels
57
+ )
58
+ }
59
+
60
+ export async function fetchSettingsDocument(
61
+ client: SanityClient,
62
+ documentId: string,
63
+ schemaType: string
64
+ ): Promise<SettingsValue | null> {
65
+ return client.fetch<SettingsValue | null>(
66
+ `*[_type == $type && _id == $id][0]{
67
+ globalModel,
68
+ globalPrompt,
69
+ globalReferenceImages[]{
70
+ "asset": {
71
+ "_ref": asset._ref,
72
+ "_id": asset->_id,
73
+ "originalFilename": asset->originalFilename,
74
+ "url": asset->url
75
+ }
76
+ },
77
+ targetConfigs[]{
78
+ targetId,
79
+ prompt,
80
+ referenceImages[]{
81
+ "asset": {
82
+ "_ref": asset._ref,
83
+ "_id": asset->_id,
84
+ "originalFilename": asset->originalFilename,
85
+ "url": asset->url
86
+ }
87
+ }
88
+ }
89
+ }`,
90
+ {
91
+ id: documentId,
92
+ type: schemaType,
93
+ }
94
+ )
95
+ }
96
+
97
+ export async function downloadReferenceImageAsPng(
98
+ image: StoredImage,
99
+ index: number
100
+ ): Promise<File | null> {
101
+ if (!image.asset?.url) {
102
+ return null
103
+ }
104
+
105
+ const response = await fetch(image.asset.url)
106
+
107
+ if (!response.ok) {
108
+ throw new Error("Failed to download AI Image Plugin reference images.")
109
+ }
110
+
111
+ const blob = await response.blob()
112
+ const fallbackName = `reference-${index + 1}.${fileExtensionFromMimeType(
113
+ blob.type || "image/png"
114
+ )}`
115
+ const filename = image.asset.originalFilename || fallbackName
116
+ const file = new File([blob], filename, { type: blob.type || "image/png" })
117
+
118
+ return convertImageFileToPng(file)
119
+ }
120
+
121
+ export async function buildSettingsReferenceImages(
122
+ settings: SettingsValue | null,
123
+ targetId?: string,
124
+ maxReferenceImages = MAX_REFERENCE_IMAGES
125
+ ): Promise<File[]> {
126
+ const targetSettings = targetId ? findTargetSettings(settings, targetId) : null
127
+ const images = [
128
+ ...(settings?.globalReferenceImages || []),
129
+ ...(targetSettings?.referenceImages || []),
130
+ ].slice(0, maxReferenceImages)
131
+
132
+ return (
133
+ await Promise.all(images.map(downloadReferenceImageAsPng))
134
+ ).filter((image): image is File => image instanceof File)
135
+ }
136
+
137
+ export function createToolReferenceImageFromStoredImage(
138
+ image: StoredImage,
139
+ index: number
140
+ ): ToolReferenceImage | null {
141
+ const asset = image.asset
142
+ const assetId = asset?._ref || asset?._id
143
+
144
+ if (!assetId) {
145
+ return null
146
+ }
147
+
148
+ return {
149
+ id: assetId,
150
+ assetId,
151
+ name: asset?.originalFilename || `reference-${index + 1}.png`,
152
+ ...(asset?.url ? { previewUrl: asset.url } : {}),
153
+ }
154
+ }
155
+
156
+ export function createToolReferenceImageFromFile(file: File): ToolReferenceImage {
157
+ return {
158
+ id: `${file.name}-${file.size}-${file.lastModified}`,
159
+ name: file.name,
160
+ sourceFile: file,
161
+ }
162
+ }
163
+
164
+ export function appendReferenceImages(
165
+ currentImages: ToolReferenceImage[],
166
+ nextFiles: File[]
167
+ ): ToolReferenceImage[] {
168
+ return [...currentImages, ...nextFiles.map(createToolReferenceImageFromFile)].slice(
169
+ 0,
170
+ MAX_REFERENCE_IMAGES
171
+ )
172
+ }
@@ -0,0 +1,55 @@
1
+ import { normalizeAllowedAiImageModels } from "./models"
2
+ import {
3
+ DEFAULT_API_ENDPOINT,
4
+ DEFAULT_ASSET_SOURCE_TARGET_ID,
5
+ SETTINGS_TOOL_NAME,
6
+ SETTINGS_TOOL_TITLE,
7
+ type PluginOptions,
8
+ type ResolvedOptions,
9
+ } from "./shared"
10
+
11
+ export function normalizeOptions(options: PluginOptions): ResolvedOptions {
12
+ const assetSource =
13
+ options.assetSource === false ||
14
+ (typeof options.assetSource === "object" &&
15
+ options.assetSource.enabled === false)
16
+ ? null
17
+ : {
18
+ id:
19
+ typeof options.assetSource === "object"
20
+ ? options.assetSource.id || DEFAULT_ASSET_SOURCE_TARGET_ID
21
+ : DEFAULT_ASSET_SOURCE_TARGET_ID,
22
+ type: "assetSource" as const,
23
+ title:
24
+ typeof options.assetSource === "object"
25
+ ? options.assetSource.title || "Image Asset Source"
26
+ : "Image Asset Source",
27
+ ...(typeof options.assetSource === "object" &&
28
+ options.assetSource.description
29
+ ? { description: options.assetSource.description }
30
+ : {
31
+ description: "Adds AI Image Plugin to the image asset picker.",
32
+ }),
33
+ promptLabel:
34
+ typeof options.assetSource === "object"
35
+ ? options.assetSource.promptLabel || "Prompt"
36
+ : "Prompt",
37
+ promptPlaceholder:
38
+ typeof options.assetSource === "object"
39
+ ? options.assetSource.promptPlaceholder ||
40
+ "Turn these references into a clean homepage hero image with warm daylight and subtle depth."
41
+ : "Turn these references into a clean homepage hero image with warm daylight and subtle depth.",
42
+ }
43
+
44
+ return {
45
+ apiEndpoint: options.apiEndpoint || DEFAULT_API_ENDPOINT,
46
+ apiVersion: options.apiVersion,
47
+ allowedModels: normalizeAllowedAiImageModels(options.allowedModels),
48
+ assetSourceTarget: assetSource,
49
+ registerSettingsSchemaType:
50
+ options.settingsTool?.registerSchemaType === true,
51
+ settingsToolName: options.settingsTool?.name || SETTINGS_TOOL_NAME,
52
+ settingsToolTitle: options.settingsTool?.title || SETTINGS_TOOL_TITLE,
53
+ targets: options.targets || [],
54
+ }
55
+ }
@@ -0,0 +1,172 @@
1
+ import { getValueAtPath } from "./document-paths"
2
+
3
+ const SUPPORTED_CONTEXT_FIELD_TYPES = new Set([
4
+ "string",
5
+ "text",
6
+ "number",
7
+ "boolean",
8
+ "date",
9
+ "datetime",
10
+ "slug",
11
+ ])
12
+
13
+ type DocumentSchemaFieldMember = {
14
+ name?: string
15
+ type?: {
16
+ name?: string
17
+ }
18
+ }
19
+
20
+ type DocumentSchemaField = {
21
+ name?: string
22
+ title?: string
23
+ type?: {
24
+ name?: string
25
+ of?: DocumentSchemaFieldMember[]
26
+ }
27
+ }
28
+
29
+ export type SelectableContextField = {
30
+ label: string
31
+ path: string
32
+ typeName: string
33
+ }
34
+
35
+ function getDocumentSchemaFields(documentSchema: unknown): DocumentSchemaField[] {
36
+ if (!documentSchema || typeof documentSchema !== "object") {
37
+ return []
38
+ }
39
+
40
+ const fields = (documentSchema as { fields?: unknown }).fields
41
+
42
+ return Array.isArray(fields) ? (fields as DocumentSchemaField[]) : []
43
+ }
44
+
45
+ function formatFieldNameAsLabel(fieldName: string): string {
46
+ const normalizedFieldName = fieldName
47
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
48
+ .replace(/[_-]+/g, " ")
49
+ .trim()
50
+ .replace(/\s+/g, " ")
51
+
52
+ if (!normalizedFieldName) {
53
+ return fieldName
54
+ }
55
+
56
+ return (
57
+ normalizedFieldName.charAt(0).toUpperCase() +
58
+ normalizedFieldName.slice(1).toLowerCase()
59
+ )
60
+ }
61
+
62
+ function isSimpleStringArrayField(field: DocumentSchemaField): boolean {
63
+ if (field.type?.name !== "array" || !Array.isArray(field.type.of)) {
64
+ return false
65
+ }
66
+
67
+ if (field.type.of.length === 0) {
68
+ return false
69
+ }
70
+
71
+ return field.type.of.every((member) => {
72
+ const memberTypeName = member?.type?.name || member?.name
73
+
74
+ return memberTypeName === "string"
75
+ })
76
+ }
77
+
78
+ export function getSelectableContextFields(
79
+ documentSchema: unknown
80
+ ): SelectableContextField[] {
81
+ return getDocumentSchemaFields(documentSchema).flatMap((field) => {
82
+ if (!field?.name || !field.type?.name) {
83
+ return []
84
+ }
85
+
86
+ const isSupportedScalarField = SUPPORTED_CONTEXT_FIELD_TYPES.has(
87
+ field.type.name
88
+ )
89
+
90
+ if (!isSupportedScalarField && !isSimpleStringArrayField(field)) {
91
+ return []
92
+ }
93
+
94
+ return [
95
+ {
96
+ label: field.title || formatFieldNameAsLabel(field.name),
97
+ path: field.name,
98
+ typeName: field.type.name,
99
+ },
100
+ ]
101
+ })
102
+ }
103
+
104
+ export function getDefaultSelectedContextFieldPaths(options: {
105
+ selectableContextFields: SelectableContextField[]
106
+ suggestedContextFieldPaths: string[] | undefined
107
+ }): string[] {
108
+ const suggestedFieldPaths = new Set(
109
+ (options.suggestedContextFieldPaths || []).filter(Boolean)
110
+ )
111
+
112
+ return options.selectableContextFields
113
+ .filter((field) => suggestedFieldPaths.has(field.path))
114
+ .map((field) => field.path)
115
+ }
116
+
117
+ export function serializeContextFieldValue(value: unknown): string | null {
118
+ if (typeof value === "string") {
119
+ const trimmedValue = value.trim()
120
+
121
+ return trimmedValue || null
122
+ }
123
+
124
+ if (typeof value === "number" || typeof value === "boolean") {
125
+ return String(value)
126
+ }
127
+
128
+ if (Array.isArray(value)) {
129
+ const serializedValues = value
130
+ .map((item) => (typeof item === "string" ? item.trim() : null))
131
+ .filter((item): item is string => Boolean(item))
132
+
133
+ return serializedValues.length > 0 ? serializedValues.join(", ") : null
134
+ }
135
+
136
+ if (value && typeof value === "object") {
137
+ const currentValue = (value as Record<string, unknown>).current
138
+
139
+ if (typeof currentValue === "string") {
140
+ const trimmedValue = currentValue.trim()
141
+
142
+ return trimmedValue || null
143
+ }
144
+ }
145
+
146
+ return null
147
+ }
148
+
149
+ export function buildContextFieldPrompt(options: {
150
+ contextFieldPaths: string[]
151
+ documentValue: Record<string, unknown> | undefined
152
+ }): string | null {
153
+ if (!options.documentValue || options.contextFieldPaths.length === 0) {
154
+ return null
155
+ }
156
+
157
+ const promptLines = options.contextFieldPaths
158
+ .map((fieldPath) => {
159
+ const serializedValue = serializeContextFieldValue(
160
+ getValueAtPath(options.documentValue, fieldPath)
161
+ )
162
+
163
+ if (!serializedValue) {
164
+ return `The field titled "${fieldPath}" is empty.`
165
+ }
166
+
167
+ return `The value of field "${fieldPath}" is '${serializedValue}'.`
168
+ })
169
+ .filter((line): line is string => Boolean(line))
170
+
171
+ return promptLines.length > 0 ? promptLines.join("\n") : null
172
+ }
@@ -0,0 +1,51 @@
1
+ import type { Path } from "sanity"
2
+ import type { GenerateButtonTarget } from "./shared"
3
+
4
+ export function pathToFieldPath(path: Path): string {
5
+ return path
6
+ .map((segment) => {
7
+ if (typeof segment === "string" || typeof segment === "number") {
8
+ return String(segment)
9
+ }
10
+
11
+ if (segment && typeof segment === "object" && "_key" in segment) {
12
+ return String(segment._key)
13
+ }
14
+
15
+ return ""
16
+ })
17
+ .filter(Boolean)
18
+ .join(".")
19
+ }
20
+
21
+ export function getValueAtPath(
22
+ value: Record<string, unknown> | undefined,
23
+ path: string | undefined
24
+ ): unknown {
25
+ if (!value || !path) {
26
+ return undefined
27
+ }
28
+
29
+ return path.split(".").reduce<unknown>((currentValue, segment) => {
30
+ if (!currentValue || typeof currentValue !== "object") {
31
+ return undefined
32
+ }
33
+
34
+ return (currentValue as Record<string, unknown>)[segment]
35
+ }, value)
36
+ }
37
+
38
+ export function doesTargetMatchField(options: {
39
+ documentType: string | undefined
40
+ path: Path
41
+ target: GenerateButtonTarget
42
+ }): boolean {
43
+ if (!options.documentType) {
44
+ return false
45
+ }
46
+
47
+ return (
48
+ options.documentType === options.target.documentType &&
49
+ pathToFieldPath(options.path) === options.target.fieldPath
50
+ )
51
+ }
@@ -0,0 +1,126 @@
1
+ export const SUPPORTED_AI_IMAGE_MODELS = [
2
+ "gemini-2.5-flash-image",
3
+ "gemini-3.1-flash-image-preview",
4
+ "gpt-image-1",
5
+ ] as const
6
+
7
+ export type SupportedAiImageModelId = (typeof SUPPORTED_AI_IMAGE_MODELS)[number]
8
+
9
+ export type SupportedAiImageProvider = "google" | "openai"
10
+
11
+ export type SupportedAiImageModelDefinition = {
12
+ defaultAspectRatio?: string
13
+ defaultSize?: string
14
+ id: SupportedAiImageModelId
15
+ provider: SupportedAiImageProvider
16
+ supportsReferenceImages: boolean
17
+ title: string
18
+ }
19
+
20
+ export const DEFAULT_SUPPORTED_AI_IMAGE_MODEL: SupportedAiImageModelId =
21
+ "gemini-2.5-flash-image"
22
+
23
+ const SUPPORTED_AI_IMAGE_MODEL_DEFINITIONS = [
24
+ {
25
+ defaultAspectRatio: "1:1",
26
+ id: "gemini-2.5-flash-image",
27
+ provider: "google",
28
+ supportsReferenceImages: true,
29
+ title: "Google Gemini 2.5 Flash Image",
30
+ },
31
+ {
32
+ defaultAspectRatio: "1:1",
33
+ id: "gemini-3.1-flash-image-preview",
34
+ provider: "google",
35
+ supportsReferenceImages: true,
36
+ title: "Google Gemini 3.1 Flash Image Preview",
37
+ },
38
+ {
39
+ defaultSize: "1024x1024",
40
+ id: "gpt-image-1",
41
+ provider: "openai",
42
+ supportsReferenceImages: true,
43
+ title: "OpenAI GPT Image 1",
44
+ },
45
+ ] as const satisfies readonly SupportedAiImageModelDefinition[]
46
+
47
+ const SUPPORTED_AI_IMAGE_MODEL_DEFINITION_MAP = Object.fromEntries(
48
+ SUPPORTED_AI_IMAGE_MODEL_DEFINITIONS.map((modelDefinition) => [
49
+ modelDefinition.id,
50
+ modelDefinition,
51
+ ])
52
+ ) as Record<SupportedAiImageModelId, SupportedAiImageModelDefinition>
53
+
54
+ export function getSupportedAiImageModelDefinition(
55
+ modelId: SupportedAiImageModelId
56
+ ): SupportedAiImageModelDefinition {
57
+ return SUPPORTED_AI_IMAGE_MODEL_DEFINITION_MAP[modelId]
58
+ }
59
+
60
+ export function getSupportedAiImageModelOptions(
61
+ modelIds: readonly SupportedAiImageModelId[]
62
+ ): Array<{ title: string; value: SupportedAiImageModelId }> {
63
+ return modelIds.map((modelId) => {
64
+ const modelDefinition = getSupportedAiImageModelDefinition(modelId)
65
+
66
+ return {
67
+ title: modelDefinition.title,
68
+ value: modelDefinition.id,
69
+ }
70
+ })
71
+ }
72
+
73
+ export function isSupportedAiImageModelId(
74
+ value: string
75
+ ): value is SupportedAiImageModelId {
76
+ return (
77
+ SUPPORTED_AI_IMAGE_MODELS as readonly string[]
78
+ ).includes(value)
79
+ }
80
+
81
+ export function normalizeAllowedAiImageModels(
82
+ allowedModels?: readonly string[]
83
+ ): SupportedAiImageModelId[] {
84
+ if (!allowedModels) {
85
+ return [DEFAULT_SUPPORTED_AI_IMAGE_MODEL]
86
+ }
87
+
88
+ if (allowedModels.length === 0) {
89
+ throw new Error(
90
+ "AI Image Plugin requires at least one allowed model in allowedModels."
91
+ )
92
+ }
93
+
94
+ const normalizedAllowedModels: SupportedAiImageModelId[] = []
95
+
96
+ for (const allowedModel of allowedModels) {
97
+ if (!isSupportedAiImageModelId(allowedModel)) {
98
+ throw new Error(
99
+ `AI Image Plugin does not support the model "${allowedModel}".`
100
+ )
101
+ }
102
+
103
+ if (!normalizedAllowedModels.includes(allowedModel)) {
104
+ normalizedAllowedModels.push(allowedModel)
105
+ }
106
+ }
107
+
108
+ return normalizedAllowedModels
109
+ }
110
+
111
+ export function getDefaultAllowedAiImageModel(
112
+ allowedModels: readonly SupportedAiImageModelId[]
113
+ ): SupportedAiImageModelId {
114
+ return allowedModels[0] ?? DEFAULT_SUPPORTED_AI_IMAGE_MODEL
115
+ }
116
+
117
+ export function resolveAllowedAiImageModel(
118
+ modelId: string | null | undefined,
119
+ allowedModels: readonly SupportedAiImageModelId[]
120
+ ): SupportedAiImageModelId {
121
+ if (modelId && allowedModels.includes(modelId as SupportedAiImageModelId)) {
122
+ return modelId as SupportedAiImageModelId
123
+ }
124
+
125
+ return getDefaultAllowedAiImageModel(allowedModels)
126
+ }
@@ -0,0 +1,6 @@
1
+ export function composePrompt(parts: Array<string | null | undefined>): string {
2
+ return parts
3
+ .map((part) => part?.trim())
4
+ .filter((part): part is string => Boolean(part))
5
+ .join("\n\n")
6
+ }
@@ -0,0 +1,88 @@
1
+ import type { SupportedAiImageModelId } from "./models"
2
+
3
+ export const SOURCE_NAME = "ai-image-plugin"
4
+ export const SOURCE_TITLE = "AI Image Plugin"
5
+ export const SETTINGS_DOCUMENT_ID = "aiImagePlugin.settings"
6
+ export const SETTINGS_SCHEMA_TYPE = "aiImagePluginSettings"
7
+ export const SETTINGS_TOOL_NAME = "ai-image-plugin-settings"
8
+ export const SETTINGS_TOOL_TITLE = "AI Image Plugin"
9
+ export const DEFAULT_API_ENDPOINT = "/api/ai-image-plugin"
10
+ export const DEFAULT_ASSET_SOURCE_TARGET_ID = "ai-image-plugin-asset-source"
11
+ export const MAX_REFERENCE_IMAGES = 5
12
+
13
+ export type StoredImage = {
14
+ asset?: {
15
+ _id?: string
16
+ _ref?: string
17
+ originalFilename?: string
18
+ url?: string
19
+ }
20
+ }
21
+
22
+ export type SettingsTargetConfigValue = {
23
+ prompt?: string
24
+ referenceImages?: StoredImage[]
25
+ targetId?: string
26
+ }
27
+
28
+ export type SettingsValue = {
29
+ globalModel?: SupportedAiImageModelId
30
+ globalPrompt?: string
31
+ globalReferenceImages?: StoredImage[]
32
+ targetConfigs?: SettingsTargetConfigValue[]
33
+ }
34
+
35
+ export type GenerateButtonTarget = {
36
+ description?: string
37
+ dialogTitle?: string
38
+ documentType: string
39
+ fieldPath: string
40
+ id: string
41
+ promptLabel?: string
42
+ promptPlaceholder?: string
43
+ suggestedContextFieldPaths?: string[]
44
+ title?: string
45
+ type: "generateButton"
46
+ }
47
+
48
+ export type AssetSourceTarget = {
49
+ description?: string
50
+ id: string
51
+ promptLabel?: string
52
+ promptPlaceholder?: string
53
+ title?: string
54
+ type: "assetSource"
55
+ }
56
+
57
+ export type PluginOptions = {
58
+ apiEndpoint?: string
59
+ apiVersion: string
60
+ allowedModels?: SupportedAiImageModelId[]
61
+ assetSource?:
62
+ | boolean
63
+ | {
64
+ description?: string
65
+ enabled?: boolean
66
+ id?: string
67
+ promptLabel?: string
68
+ promptPlaceholder?: string
69
+ title?: string
70
+ }
71
+ settingsTool?: {
72
+ name?: string
73
+ registerSchemaType?: boolean
74
+ title?: string
75
+ }
76
+ targets?: GenerateButtonTarget[]
77
+ }
78
+
79
+ export type ResolvedOptions = {
80
+ apiEndpoint: string
81
+ apiVersion: string
82
+ allowedModels: SupportedAiImageModelId[]
83
+ assetSourceTarget: AssetSourceTarget | null
84
+ registerSettingsSchemaType: boolean
85
+ settingsToolName: string
86
+ settingsToolTitle: string
87
+ targets: GenerateButtonTarget[]
88
+ }