@sanity/embeddings-index-ui 1.0.2 → 1.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.
@@ -1,32 +1,80 @@
1
- import {ObjectInputProps, ReferenceSchemaType, set, setIfMissing, typed, unset} from 'sanity'
2
- import {Autocomplete, Box, Button, Flex, Text, AutocompleteOpenButtonProps} from '@sanity/ui'
1
+ import {
2
+ ObjectInputProps,
3
+ ReferenceBaseOptions,
4
+ ReferenceSchemaType,
5
+ set,
6
+ setIfMissing,
7
+ unset,
8
+ } from 'sanity'
9
+ import {Box, Button, Flex, Spinner} from '@sanity/ui'
3
10
  import {EarthGlobeIcon, LinkIcon} from '@sanity/icons'
4
- import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react'
5
- import {DocumentPreview} from '../preview/DocumentPreview'
11
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
6
12
  import {useDocumentPane} from 'sanity/desk'
7
- import {queryIndex, QueryResult} from '../api/embeddingsApi'
13
+ import {QueryResult} from '../api/embeddingsApi'
8
14
  import {publicId} from '../utils/id'
9
- import {useApiClient} from '../api/embeddingsApiHooks'
15
+ import {FeatureDisabledNotice, useIsFeatureEnabledContext} from '../api/isEnabled'
16
+ import {EmbeddingsIndexConfig} from '../schemas/typeDefExtensions'
17
+ import {SemanticSearchAutocomplete} from './SemanticSearchAutocomplete'
18
+
19
+ function useEmeddingsConfig(
20
+ embeddingsIndexConfig: ReferenceBaseOptions['embeddingsIndex'],
21
+ defaultConfig: EmbeddingsIndexConfig | undefined,
22
+ ) {
23
+ return useMemo(() => {
24
+ if (embeddingsIndexConfig === true || !embeddingsIndexConfig) {
25
+ if (!defaultConfig?.indexName) {
26
+ throw new Error(
27
+ 'Default embeddingsIndex config is missing. When options.embeddingsIndex: true, embeddingsIndexReferenceInput plugin config is required.',
28
+ )
29
+ }
30
+ return defaultConfig
31
+ }
32
+
33
+ const finalConfig = {
34
+ ...defaultConfig,
35
+ ...embeddingsIndexConfig,
36
+ }
10
37
 
11
- interface Option {
12
- result: QueryResult
13
- value: string
38
+ if (!finalConfig?.indexName) {
39
+ throw new Error(
40
+ 'indexName is missing. Either set it in options.embeddingsIndex or configure defaults using plugin config.',
41
+ )
42
+ }
43
+
44
+ return finalConfig
45
+ }, [defaultConfig, embeddingsIndexConfig])
14
46
  }
15
47
 
16
- const NO_OPTIONS: Option[] = []
17
- const NO_FILTER = () => true
48
+ export function SemanticSearchReferenceInput(
49
+ props: ObjectInputProps & {defaultConfig?: EmbeddingsIndexConfig},
50
+ ) {
51
+ const embeddingsIndexConfig = (props.schemaType as ReferenceSchemaType)?.options?.embeddingsIndex
18
52
 
19
- export function SemanticSearchReferenceInput(props: ObjectInputProps) {
20
- const defaultEnabled =
21
- (props.schemaType as ReferenceSchemaType)?.options?.embeddingsIndex?.searchMode === 'embeddings'
53
+ const config = useEmeddingsConfig(embeddingsIndexConfig, props.defaultConfig)
54
+
55
+ const defaultEnabled = config.searchMode === 'embeddings'
56
+
57
+ const featureState = useIsFeatureEnabledContext()
22
58
 
23
59
  const [semantic, setSemantic] = useState<boolean>(defaultEnabled)
24
60
  const toggleSemantic = useCallback(() => setSemantic((current) => !current), [])
25
61
 
26
62
  return (
27
63
  <Flex gap={2} flex={1} style={{width: '100%'}}>
64
+ {semantic && featureState == 'loading' ? (
65
+ <Box padding={2}>
66
+ <Spinner />
67
+ </Box>
68
+ ) : null}
69
+
70
+ {semantic && featureState == 'disabled' ? <FeatureDisabledNotice /> : null}
71
+
28
72
  <Box flex={1} style={{maxHeight: 36, overflow: 'hidden'}}>
29
- {semantic ? <SemanticSearchInput {...props} /> : props.renderDefault(props)}
73
+ {semantic && featureState == 'enabled' ? (
74
+ <SemanticSearchInput {...props} indexConfig={config} />
75
+ ) : (
76
+ props.renderDefault(props)
77
+ )}
30
78
  </Box>
31
79
  <Button
32
80
  icon={semantic ? EarthGlobeIcon : LinkIcon}
@@ -40,38 +88,13 @@ export function SemanticSearchReferenceInput(props: ObjectInputProps) {
40
88
  )
41
89
  }
42
90
 
43
- function useDebouncedValue<T>(value: T, ms: number) {
44
- const [debouncedValue, setDebouncedValue] = useState(value)
45
-
46
- useEffect(() => {
47
- const timeoutId = setTimeout(() => {
48
- setDebouncedValue(value)
49
- }, ms)
50
- return () => clearTimeout(timeoutId)
51
- }, [value, ms])
52
-
53
- return debouncedValue
54
- }
55
-
56
- function SemanticSearchInput(props: ObjectInputProps) {
57
- const {onPathFocus, onChange, readOnly, schemaType, value} = props
91
+ function SemanticSearchInput(props: ObjectInputProps & {indexConfig: EmbeddingsIndexConfig}) {
92
+ const {indexConfig, onPathFocus, onChange, readOnly, schemaType, value} = props
58
93
 
59
94
  const {value: currentDocument} = useDocumentPane()
60
95
  const docRef = useRef(currentDocument)
61
96
  const autocompleteRef = useRef<HTMLInputElement>(null)
62
97
 
63
- const id = useId()
64
-
65
- const [query, setQuery] = useState('')
66
- const queryRef = useRef(query)
67
- const debouncedQuery = useDebouncedValue(query, 300)
68
- const prevDebouncedQuery = useRef(debouncedQuery)
69
-
70
- const [searching, setSearching] = useState(false)
71
- const [options, setOptions] = useState(NO_OPTIONS)
72
-
73
- const client = useApiClient()
74
-
75
98
  useEffect(() => {
76
99
  docRef.current = currentDocument
77
100
  }, [currentDocument])
@@ -88,58 +111,6 @@ function SemanticSearchInput(props: ObjectInputProps) {
88
111
  const handleFocus = useCallback(() => onPathFocus(['_ref']), [onPathFocus])
89
112
  const handleBlur = useCallback(() => onPathFocus([]), [onPathFocus])
90
113
 
91
- const runIndexQuery = useCallback(
92
- (queryString: string) => {
93
- setSearching(true)
94
- const refSchema = schemaType as ReferenceSchemaType
95
- const indexName = refSchema.options?.embeddingsIndex?.indexName
96
- const maxResults = refSchema.options?.embeddingsIndex?.maxResults
97
- const typeFilter = refSchema.to.map((ref) => ref.name)
98
-
99
- if (!indexName) {
100
- throw new Error(
101
- `Reference option embeddingsIndex.indexName is required, but was missing in type ${refSchema.name}`,
102
- )
103
- }
104
-
105
- queryIndex(
106
- {
107
- query: queryString.trim().length ? queryString : JSON.stringify(docRef.current) ?? '',
108
- indexName,
109
- maxResults,
110
- filter: {
111
- type: typeFilter,
112
- },
113
- },
114
- client,
115
- )
116
- .then((result: QueryResult[]) => {
117
- if (queryRef.current === queryString) {
118
- setSearching(false)
119
- setOptions(
120
- result
121
- .filter((r) => r.value.documentId !== publicId(docRef.current._id))
122
- .map((r) => typed<Option>({result: r, value: r.value.documentId})),
123
- )
124
- }
125
- })
126
- .catch((e) => {
127
- if (queryRef.current === queryString) {
128
- setSearching(false)
129
- }
130
- throw e
131
- })
132
- },
133
- [client, schemaType],
134
- )
135
-
136
- useEffect(() => {
137
- if (prevDebouncedQuery.current !== debouncedQuery) {
138
- runIndexQuery(debouncedQuery)
139
- }
140
- prevDebouncedQuery.current = debouncedQuery
141
- }, [debouncedQuery, runIndexQuery])
142
-
143
114
  const handleChange = useCallback(
144
115
  (nextId: string) => {
145
116
  if (!nextId) {
@@ -163,54 +134,28 @@ function SemanticSearchInput(props: ObjectInputProps) {
163
134
  [onChange, onPathFocus, schemaType.name],
164
135
  )
165
136
 
166
- const openButtonConfig: AutocompleteOpenButtonProps = useMemo(
167
- () => ({onClick: () => runIndexQuery(queryRef.current)}),
168
- [runIndexQuery, queryRef],
137
+ const filterResult = useCallback(
138
+ (r: QueryResult) => r.value.documentId !== publicId(docRef.current._id),
139
+ [docRef],
169
140
  )
170
141
 
171
- const handleQueryChange = useCallback(
172
- (newValue: string | null) => {
173
- const newQuery = newValue ?? ''
174
- queryRef.current = newQuery
175
- setQuery(newQuery)
176
- },
177
- [setQuery],
142
+ const getEmptySearchValue = useCallback(() => JSON.stringify(docRef.current), [docRef])
143
+ const typeFilter = useMemo(
144
+ () => (schemaType as ReferenceSchemaType).to.map((refType) => refType.name),
145
+ [schemaType],
178
146
  )
179
147
 
180
148
  return (
181
- <Autocomplete
182
- id={id}
149
+ <SemanticSearchAutocomplete
183
150
  ref={autocompleteRef}
184
- data-testid="semantic-autocomplete"
185
- placeholder="Type to search..."
186
- openButton={openButtonConfig}
187
- onFocus={handleFocus}
151
+ typeFilter={typeFilter}
152
+ indexConfig={indexConfig}
188
153
  onChange={handleChange}
189
- loading={searching}
154
+ onFocus={handleFocus}
190
155
  onBlur={handleBlur}
156
+ getEmptySearchValue={getEmptySearchValue}
157
+ filterResult={filterResult}
191
158
  readOnly={readOnly}
192
- filterOption={NO_FILTER}
193
- onQueryChange={handleQueryChange}
194
- options={options}
195
- renderOption={AutocompleteOption}
196
159
  />
197
160
  )
198
161
  }
199
-
200
- function AutocompleteOption(props: Option) {
201
- const value = props.result.value
202
- return (
203
- <Button mode="bleed" padding={1} style={{width: '100%'}}>
204
- <Flex gap={2} align="center">
205
- <Box flex={1}>
206
- <DocumentPreview documentId={value.documentId} schemaTypeName={value.type} />
207
- </Box>
208
- <Box padding={2}>
209
- <Text size={1} muted title={'Relevance'}>
210
- {Math.floor(props.result.score * 100)}%
211
- </Text>
212
- </Box>
213
- </Flex>
214
- </Button>
215
- )
216
- }
@@ -1,21 +1,43 @@
1
1
  import {definePlugin, isObjectInputProps, ObjectInputProps, ReferenceSchemaType} from 'sanity'
2
2
  import {SemanticSearchReferenceInput} from './SemanticSearchReferenceInput'
3
3
  import {isType} from '../utils/types'
4
+ import {FeatureEnabledProvider} from '../api/isEnabled'
5
+ import {EmbeddingsIndexConfig} from '../schemas/typeDefExtensions'
4
6
 
5
- export const embeddingsIndexReferenceInput = definePlugin({
6
- name: '@sanity/embeddings-index-reference-input',
7
- form: {
8
- components: {
9
- input: (props) => {
10
- if (
11
- isObjectInputProps(props) &&
12
- isType(props.schemaType, 'reference') &&
13
- (props.schemaType as ReferenceSchemaType).options?.embeddingsIndex?.indexName
14
- ) {
15
- return <SemanticSearchReferenceInput {...(props as ObjectInputProps)} />
16
- }
17
- return props.renderDefault(props)
7
+ export const embeddingsIndexReferenceInput = definePlugin<EmbeddingsIndexConfig | void>(
8
+ (defaultConfig) => {
9
+ const config = typeof defaultConfig === 'object' ? defaultConfig : undefined
10
+
11
+ return {
12
+ name: '@sanity/embeddings-index-reference-input',
13
+ studio: {
14
+ components: {
15
+ layout: (props) => {
16
+ return <FeatureEnabledProvider>{props.renderDefault(props)}</FeatureEnabledProvider>
17
+ },
18
+ },
19
+ },
20
+ form: {
21
+ components: {
22
+ input: (props) => {
23
+ const embeddingsIndexConfig = (props.schemaType as ReferenceSchemaType)?.options
24
+ ?.embeddingsIndex
25
+ if (
26
+ isObjectInputProps(props) &&
27
+ isType(props.schemaType, 'reference') &&
28
+ (embeddingsIndexConfig === true || embeddingsIndexConfig?.indexName)
29
+ ) {
30
+ return (
31
+ <SemanticSearchReferenceInput
32
+ {...(props as ObjectInputProps)}
33
+ defaultConfig={config}
34
+ />
35
+ )
36
+ }
37
+ return props.renderDefault(props)
38
+ },
39
+ },
18
40
  },
19
- },
41
+ }
20
42
  },
21
- })
43
+ )
@@ -1,19 +1,27 @@
1
1
  import 'sanity'
2
+
3
+ export interface EmbeddingsIndexConfig {
4
+ /**
5
+ * Name of the index
6
+ */
7
+ indexName: string
8
+ maxResults?: number
9
+ /**
10
+ * Determines if which search mode is enabled by default for the reference field.
11
+ * Default is the studio default search, while 'embeddings' enables
12
+ * Defaults to 'default' behaviour
13
+ */
14
+ searchMode?: 'embeddings' | 'default'
15
+ }
16
+
2
17
  /* eslint-disable no-unused-vars */
3
18
  declare module 'sanity' {
4
19
  interface ReferenceBaseOptions {
5
- embeddingsIndex?: {
6
- /**
7
- * Name of the index
8
- */
9
- indexName: string
10
- maxResults?: number
11
- /**
12
- * Determines if which search mode is enabled by default for the reference field.
13
- * Default is the studio default search, while 'embeddings' enables
14
- * Defaults to 'default' behaviour
15
- */
16
- searchMode?: 'embeddings' | 'default'
17
- }
20
+ /**
21
+ * Enables toggleable semantic search for a reference field.
22
+ *
23
+ * When `true`: will use default plugin configuration (if no config has been for the plugin provided ,this will throw an error)
24
+ */
25
+ embeddingsIndex?: true | EmbeddingsIndexConfig
18
26
  }
19
27
  }