@sanity/assist 1.0.12 → 1.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/assist",
3
- "version": "1.0.12",
3
+ "version": "1.1.1",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "sanity",
@@ -36,7 +36,7 @@ export function useInstructionToaster(documentId: string, documentSchemaType: Ob
36
36
  .filter((task) => task.ended && isAfter(addSeconds(new Date(task.ended), 30), new Date()))
37
37
 
38
38
  endedTasks?.forEach((task) => {
39
- const title = getInstructionTitle(task.instruction)
39
+ const title = task.title ?? getInstructionTitle(task.instruction)
40
40
  if (task.reason === 'error') {
41
41
  toast.push({
42
42
  title: `Failed: ${title}`,
@@ -111,7 +111,7 @@ export function InstructionTaskHistoryButton(props: InstructionTaskHistoryButton
111
111
  const instruction = instructions?.find((i) => i._key === task.instructionKey)
112
112
  return {
113
113
  ...task,
114
- title: showTitles ? getInstructionTitle(instruction) : undefined,
114
+ title: showTitles ? task.title ?? getInstructionTitle(instruction) : undefined,
115
115
  cancel: () => cancelRun(task._key),
116
116
  }
117
117
  }) ?? []
@@ -1,4 +1,4 @@
1
- import {ReactElement, ReactNode} from 'react'
1
+ import {forwardRef, ReactElement, ReactNode} from 'react'
2
2
  import styled, {keyframes} from 'styled-components'
3
3
 
4
4
  const fadeIn = keyframes`
@@ -21,13 +21,20 @@ const FadeInDiv = styled.div`
21
21
  animation-timing-function: ease-in-out;
22
22
  `
23
23
 
24
- export function FadeInContent({
25
- children,
26
- durationMs = 250,
27
- }: {
28
- children?: ReactNode
29
- ms?: number
30
- durationMs?: number
31
- }): ReactElement {
32
- return <FadeInDiv style={{animationDuration: `${durationMs}ms`}}>{children}</FadeInDiv>
33
- }
24
+ export const FadeInContent = forwardRef(function FadeInContent(
25
+ {
26
+ children,
27
+ durationMs = 250,
28
+ }: {
29
+ children?: ReactNode
30
+ ms?: number
31
+ durationMs?: number
32
+ },
33
+ ref: any
34
+ ): ReactElement {
35
+ return (
36
+ <FadeInDiv ref={ref} style={{animationDuration: `${durationMs}ms`}}>
37
+ {children}
38
+ </FadeInDiv>
39
+ )
40
+ })
@@ -0,0 +1,39 @@
1
+ import {createContext, useEffect, useMemo, useState} from 'react'
2
+ import {InputProps, pathToString} from 'sanity'
3
+ import {getCaptionFieldOption} from '../helpers/typeUtils'
4
+ import {useAssistDocumentContext} from '../assistDocument/AssistDocumentContext'
5
+ import {useApiClient, useGenerateCaption} from '../useApiClient'
6
+ import {useAiAssistanceConfig} from '../assistLayout/AiAssistanceConfigContext'
7
+
8
+ export interface ImageContextValue {
9
+ captionPath: string
10
+ assetRef?: string
11
+ }
12
+
13
+ export const ImageContext = createContext<ImageContextValue | undefined>(undefined)
14
+
15
+ export function ImageContextProvider(props: InputProps) {
16
+ const {schemaType, path, value} = props
17
+ const assetRef = (value as any)?.asset?._ref
18
+ const [assetRefState, setAssetRefState] = useState<string | undefined>(assetRef)
19
+
20
+ const {documentId} = useAssistDocumentContext()
21
+ const {config} = useAiAssistanceConfig()
22
+ const apiClient = useApiClient(config?.__customApiClient)
23
+ const {generateCaption} = useGenerateCaption(apiClient)
24
+
25
+ useEffect(() => {
26
+ const captionField = getCaptionFieldOption(schemaType)
27
+ if (assetRef && documentId && captionField && assetRef !== assetRefState) {
28
+ setAssetRefState(assetRef)
29
+ generateCaption({path: pathToString([...path, captionField]), documentId: documentId})
30
+ }
31
+ }, [schemaType, path, assetRef, assetRefState, documentId, generateCaption])
32
+
33
+ const context: ImageContextValue | undefined = useMemo(() => {
34
+ const captionField = getCaptionFieldOption(schemaType)
35
+ return captionField ? {captionPath: pathToString([...path, captionField]), assetRef} : undefined
36
+ }, [schemaType, path, assetRef])
37
+
38
+ return <ImageContext.Provider value={context}>{props.renderDefault(props)}</ImageContext.Provider>
39
+ }
@@ -20,6 +20,7 @@ import {
20
20
  useRequestRunInstruction,
21
21
  } from '../assistDocument/RequestRunInstructionProvider'
22
22
  import {PrivateIcon} from './PrivateIcon'
23
+ import {generateCaptionsActions} from './generateCaptionActions'
23
24
 
24
25
  function node(node: DocumentFieldActionItem | DocumentFieldActionGroup) {
25
26
  return node
@@ -73,6 +74,8 @@ export const assistFieldActions: DocumentFieldAction = {
73
74
  const isPathSelected = pathKey === selectedPath
74
75
  const isSelected = isInspectorOpen && isPathSelected
75
76
 
77
+ const imageCaptionAction = generateCaptionsActions.useAction(props)
78
+
76
79
  const manageInstructions = useCallback(
77
80
  () =>
78
81
  isSelected
@@ -116,21 +119,24 @@ export const assistFieldActions: DocumentFieldAction = {
116
119
  )
117
120
 
118
121
  const runInstructionsGroup = useMemo(() => {
119
- return instructions?.length
122
+ return instructions?.length || imageCaptionAction
120
123
  ? node({
121
124
  type: 'group',
122
125
  icon: () => null,
123
126
  title: 'Run instructions',
124
- children: instructions.map((instruction) =>
125
- instructionItem({
126
- instruction,
127
- isPrivate: Boolean(instruction.userId && instruction.userId === currentUser?.id),
128
- onInstructionAction,
129
- hidden: isHidden,
130
- documentIsNew: !!documentIsNew,
131
- assistSupported,
132
- })
133
- ),
127
+ children: [
128
+ ...instructions?.map((instruction) =>
129
+ instructionItem({
130
+ instruction,
131
+ isPrivate: Boolean(instruction.userId && instruction.userId === currentUser?.id),
132
+ onInstructionAction,
133
+ hidden: isHidden,
134
+ documentIsNew: !!documentIsNew,
135
+ assistSupported,
136
+ })
137
+ ),
138
+ imageCaptionAction,
139
+ ].filter(Boolean),
134
140
  expanded: true,
135
141
  })
136
142
  : undefined
@@ -141,6 +147,7 @@ export const assistFieldActions: DocumentFieldAction = {
141
147
  isHidden,
142
148
  documentIsNew,
143
149
  assistSupported,
150
+ imageCaptionAction,
144
151
  ])
145
152
 
146
153
  const instructionsLength = instructions?.length || 0
@@ -163,28 +170,19 @@ export const assistFieldActions: DocumentFieldAction = {
163
170
  type: 'group',
164
171
  icon: SparklesIcon,
165
172
  title: pluginTitleShort,
166
- children: [
167
- runInstructionsGroup,
168
- /* documentIsNew && {
169
- type: 'action',
170
- disabled: true,
171
- title: `Document is new. Make an edit to enable `,
172
- icon: WarningOutlineIcon,
173
- tone: 'caution',
174
- status: 'warning',
175
- onAction: () => {},
176
- },*/
177
- manageInstructionsItem,
178
- ].filter((c): c is DocumentFieldActionItem | DocumentFieldActionGroup => !!c),
173
+ children: [runInstructionsGroup, assistSupported && manageInstructionsItem].filter(
174
+ (c): c is DocumentFieldActionItem | DocumentFieldActionGroup => !!c
175
+ ),
179
176
  expanded: false,
180
177
  renderAsButton: true,
181
- hidden: !assistSupported,
178
+ hidden: !assistSupported && !imageCaptionAction,
182
179
  }),
183
180
  [
184
181
  //documentIsNew,
185
182
  runInstructionsGroup,
186
183
  manageInstructionsItem,
187
184
  assistSupported,
185
+ imageCaptionAction,
188
186
  ]
189
187
  )
190
188
 
@@ -203,7 +201,7 @@ export const assistFieldActions: DocumentFieldAction = {
203
201
  )
204
202
 
205
203
  // If there are no instructions, we don't want to render the group
206
- if (instructionsLength === 0) {
204
+ if (instructionsLength === 0 && !imageCaptionAction) {
207
205
  return emptyAction
208
206
  }
209
207
 
@@ -0,0 +1,58 @@
1
+ import {DocumentFieldAction, DocumentFieldActionGroup, DocumentFieldActionItem} from 'sanity'
2
+ import {ImageIcon} from '@sanity/icons'
3
+ import {useContext, useMemo} from 'react'
4
+ import {usePathKey} from '../helpers/misc'
5
+ import {useApiClient, useGenerateCaption} from '../useApiClient'
6
+ import {useAiAssistanceConfig} from '../assistLayout/AiAssistanceConfigContext'
7
+ import {useAssistDocumentContext} from '../assistDocument/AssistDocumentContext'
8
+ import {ImageContext} from '../components/ImageContext'
9
+ import {Box, Spinner} from '@sanity/ui'
10
+
11
+ function node(node: DocumentFieldActionItem | DocumentFieldActionGroup) {
12
+ return node
13
+ }
14
+
15
+ export const generateCaptionsActions: DocumentFieldAction = {
16
+ name: 'sanity-assist-generate-captions',
17
+ useAction(props) {
18
+ const pathKey = usePathKey(props.path)
19
+
20
+ const {config} = useAiAssistanceConfig()
21
+ const apiClient = useApiClient(config?.__customApiClient)
22
+ const {generateCaption, loading} = useGenerateCaption(apiClient)
23
+
24
+ const imageContext = useContext(ImageContext)
25
+
26
+ if (imageContext && pathKey === imageContext?.captionPath) {
27
+ //if this is true, it is stable, and not breaking rules of hooks
28
+ // eslint-disable-next-line react-hooks/rules-of-hooks
29
+ const {documentId} = useAssistDocumentContext()
30
+ // eslint-disable-next-line react-hooks/rules-of-hooks
31
+ return useMemo(() => {
32
+ return node({
33
+ type: 'action',
34
+ icon: loading
35
+ ? () => (
36
+ <Box style={{height: 17}}>
37
+ <Spinner style={{transform: 'translateY(6px)'}} />
38
+ </Box>
39
+ )
40
+ : ImageIcon,
41
+ title: 'Generate caption',
42
+ onAction: () => {
43
+ if (loading) {
44
+ return
45
+ }
46
+ generateCaption({path: pathKey, documentId: documentId ?? ''})
47
+ },
48
+ renderAsButton: true,
49
+ disabled: loading,
50
+ hidden: !imageContext.assetRef,
51
+ })
52
+ }, [generateCaption, pathKey, documentId, loading, imageContext])
53
+ }
54
+
55
+ // works but not supported by types
56
+ return undefined as unknown as DocumentFieldActionItem
57
+ },
58
+ }
@@ -1,4 +1,4 @@
1
- import {ArraySchemaType, SchemaType} from 'sanity'
1
+ import {ArraySchemaType, ImageOptions, SchemaType} from 'sanity'
2
2
 
3
3
  export function isPortableTextArray(type: ArraySchemaType) {
4
4
  return type.of.find((t) => isType(t, 'block'))
@@ -13,3 +13,18 @@ export function isType(schemaType: SchemaType, typeName: string): boolean {
13
13
  }
14
14
  return isType(schemaType.type, typeName)
15
15
  }
16
+
17
+ export function isImage(schemaType: SchemaType) {
18
+ return isType(schemaType, 'image')
19
+ }
20
+
21
+ export function getCaptionFieldOption(schemaType: SchemaType | undefined): string | undefined {
22
+ if (!schemaType) {
23
+ return undefined
24
+ }
25
+ const captionField = (schemaType.options as ImageOptions)?.captionField
26
+ if (captionField) {
27
+ return captionField
28
+ }
29
+ return getCaptionFieldOption(schemaType.type)
30
+ }
package/src/plugin.tsx CHANGED
@@ -13,6 +13,8 @@ import {packageName} from './constants'
13
13
  import {AssistDocumentInputWrapper} from './assistDocument/AssistDocumentInput'
14
14
  import {createAssistDocumentPresence} from './presence/AssistDocumentPresence'
15
15
  import {isSchemaAssistEnabled} from './helpers/assistSupported'
16
+ import {isImage} from './helpers/typeUtils'
17
+ import {ImageContextProvider} from './components/ImageContext'
16
18
 
17
19
  export interface AssistPluginConfig {
18
20
  /**
@@ -75,6 +77,22 @@ export const assist = definePlugin<AssistPluginConfig | void>((config) => {
75
77
  name: `${packageName}/safe-value-input`,
76
78
  form: {components: {input: SafeValueInput}},
77
79
  })(),
80
+
81
+ definePlugin({
82
+ name: `${packageName}/generate-caption`,
83
+ form: {
84
+ components: {
85
+ input: (props) => {
86
+ const {schemaType} = props
87
+
88
+ if (isImage(schemaType)) {
89
+ return <ImageContextProvider {...props} />
90
+ }
91
+ return props.renderDefault(props)
92
+ },
93
+ },
94
+ },
95
+ })(),
78
96
  ],
79
97
  }
80
98
  })
@@ -18,6 +18,7 @@ declare module 'sanity' {
18
18
  interface GeopointOptions extends AssistOptions {}
19
19
  interface ImageOptions extends AssistOptions {
20
20
  imagePromptField?: string
21
+ captionField?: string
21
22
  }
22
23
  interface NumberOptions extends AssistOptions {}
23
24
  interface ObjectOptions extends AssistOptions {}
package/src/types.ts CHANGED
@@ -125,6 +125,7 @@ export interface InstructionTask {
125
125
  _key: string
126
126
  _type: typeof instructionTaskTypeName
127
127
  instructionKey?: string
128
+ title?: string
128
129
  path?: string
129
130
  started?: string
130
131
  updated?: string
@@ -35,6 +35,59 @@ export function useApiClient(customApiClient?: (defaultClient: SanityClient) =>
35
35
  )
36
36
  }
37
37
 
38
+ export function useGenerateCaption(apiClient: SanityClient) {
39
+ const [loading, setLoading] = useState(false)
40
+ const user = useCurrentUser()
41
+ const schema = useSchema()
42
+ const types = useMemo(() => serializeSchema(schema, {leanFormat: true}), [schema])
43
+ const toast = useToast()
44
+
45
+ const generateCaption = useCallback(
46
+ ({path, documentId}: {path: string; documentId: string}) => {
47
+ setLoading(true)
48
+
49
+ return apiClient
50
+ .request({
51
+ method: 'POST',
52
+ url: `/assist/tasks/generate-caption/${apiClient.config().dataset}?projectId=${
53
+ apiClient.config().projectId
54
+ }`,
55
+ body: {
56
+ path,
57
+ documentId,
58
+ types,
59
+ userId: user?.id,
60
+ },
61
+ })
62
+ .catch((e) => {
63
+ toast.push({
64
+ status: 'error',
65
+ title: 'Generate caption failed',
66
+ description: e.message,
67
+ })
68
+ setLoading(false)
69
+ throw e
70
+ })
71
+ .finally(() => {
72
+ // adding some artificial delay here
73
+ // server responds with 201 then proceeds; we dont need to allow spamming the button
74
+ setTimeout(() => {
75
+ setLoading(false)
76
+ }, 2000)
77
+ })
78
+ },
79
+ [setLoading, apiClient, toast, user, types]
80
+ )
81
+
82
+ return useMemo(
83
+ () => ({
84
+ generateCaption,
85
+ loading,
86
+ }),
87
+ [generateCaption, loading]
88
+ )
89
+ }
90
+
38
91
  export function useGetInstructStatus(apiClient: SanityClient) {
39
92
  const [loading, setLoading] = useState(true)
40
93
  const projectClient = useClient({apiVersion: '2023-06-05'})