@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/embeddings-index-ui",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Various Sanity Studio plugins for integrating with the embeddings index API",
5
5
  "keywords": [
6
6
  "sanity",
@@ -0,0 +1,53 @@
1
+ import {useClient} from 'sanity'
2
+ import {createContext, PropsWithChildren, useContext, useEffect, useState} from 'react'
3
+ import {Card, Text} from '@sanity/ui'
4
+
5
+ const featureName = 'embeddingsIndexApi'
6
+
7
+ export type FeatureStatus = 'enabled' | 'disabled' | 'loading'
8
+ export const FeatureEnabledContext = createContext<FeatureStatus>('loading')
9
+
10
+ export function useIsFeatureEnabled() {
11
+ const client = useClient({apiVersion: '2023-09-01'})
12
+ const [status, setStatus] = useState<FeatureStatus>('loading')
13
+
14
+ useEffect(() => {
15
+ client
16
+ .request<string | boolean>({
17
+ method: 'GET',
18
+ url: `/projects/${client.config().projectId}/features/${featureName}`,
19
+ })
20
+ .then((isEnabled) => {
21
+ setStatus(isEnabled === 'true' || isEnabled === true ? 'enabled' : 'disabled')
22
+ })
23
+ .catch((err) => {
24
+ console.error(err)
25
+ setStatus('disabled')
26
+ })
27
+ }, [client])
28
+
29
+ return status
30
+ }
31
+
32
+ export function FeatureEnabledProvider(props: PropsWithChildren<{}>) {
33
+ const status = useIsFeatureEnabled()
34
+ return (
35
+ <FeatureEnabledContext.Provider value={status}>{props.children}</FeatureEnabledContext.Provider>
36
+ )
37
+ }
38
+
39
+ export function useIsFeatureEnabledContext(): FeatureStatus {
40
+ return useContext(FeatureEnabledContext)
41
+ }
42
+
43
+ export function FeatureDisabledNotice() {
44
+ return (
45
+ <Card tone="primary" border padding={2}>
46
+ <Text size={1}>
47
+ Embeddings index APIs are only available on the{' '}
48
+ <a href="https://sanity.io/pricing">Team tier and above</a>. Please upgrade to enable
49
+ access.
50
+ </Text>
51
+ </Card>
52
+ )
53
+ }
@@ -6,13 +6,23 @@ import {EditIndexDialog} from './IndexEditor'
6
6
  import {IndexList} from './IndexList'
7
7
  import {IndexInfo} from './IndexInfo'
8
8
  import {useApiClient} from '../api/embeddingsApiHooks'
9
+ import {FeatureDisabledNotice, useIsFeatureEnabled} from '../api/isEnabled'
9
10
 
10
11
  export function EmbeddingsIndexTool() {
12
+ const featureState = useIsFeatureEnabled()
11
13
  return (
12
14
  <Card>
13
15
  <Flex justify="center" flex={1}>
14
16
  <Card flex={1} style={{maxWidth: 1200}} padding={5}>
15
- <Indexes />
17
+ {featureState == 'loading' ? (
18
+ <Box padding={2}>
19
+ <Spinner />
20
+ </Box>
21
+ ) : null}
22
+
23
+ {featureState == 'disabled' ? <FeatureDisabledNotice /> : null}
24
+
25
+ {featureState == 'enabled' ? <Indexes /> : null}
16
26
  </Card>
17
27
  </Flex>
18
28
  </Card>
@@ -141,19 +141,21 @@ export function IndexEditor(props: {
141
141
  />
142
142
  <IndexFormInput label="Dataset" index={index} prop="dataset" onChange={setIndex} readOnly />
143
143
  <IndexFormInput
144
- label="Projection"
145
- placeholder={defaultIndex.projection}
144
+ label="Filter"
145
+ description="Must be a valid GROQ filter"
146
+ placeholder={defaultIndex.filter}
146
147
  index={index}
147
- prop="projection"
148
+ prop="filter"
148
149
  onChange={setIndex}
149
150
  readOnly={readOnly}
150
151
  type="textarea"
151
152
  />
152
153
  <IndexFormInput
153
- label="Filter"
154
- placeholder={defaultIndex.filter}
154
+ label="Projection"
155
+ description="Must be a valid GROQ projection, starting { and ending with }"
156
+ placeholder={defaultIndex.projection}
155
157
  index={index}
156
- prop="filter"
158
+ prop="projection"
157
159
  onChange={setIndex}
158
160
  readOnly={readOnly}
159
161
  type="textarea"
@@ -1,11 +1,12 @@
1
1
  import {NamedIndex} from '../api/embeddingsApi'
2
2
  import {Dispatch, FormEvent, SetStateAction, useCallback, useId} from 'react'
3
- import {Label, Stack, TextArea, TextInput} from '@sanity/ui'
3
+ import {Box, Label, Stack, TextArea, TextInput, Text} from '@sanity/ui'
4
4
 
5
5
  export interface IndexFormInputProps {
6
6
  index: Partial<NamedIndex>
7
7
  prop: keyof NamedIndex
8
8
  label: string
9
+ description?: string
9
10
  onChange: Dispatch<SetStateAction<Partial<NamedIndex>>>
10
11
  readOnly: boolean
11
12
  placeholder?: string
@@ -13,7 +14,7 @@ export interface IndexFormInputProps {
13
14
  }
14
15
 
15
16
  export function IndexFormInput(props: IndexFormInputProps) {
16
- const {label, index, prop, onChange, readOnly, placeholder, type} = props
17
+ const {label, description, index, prop, onChange, readOnly, placeholder, type} = props
17
18
  const handleChange = useCallback(
18
19
  (propValue: string) => onChange((current) => ({...current, [prop]: propValue})),
19
20
  [onChange, prop],
@@ -21,6 +22,7 @@ export function IndexFormInput(props: IndexFormInputProps) {
21
22
  return (
22
23
  <FormInput
23
24
  label={label}
25
+ description={description}
24
26
  onChange={handleChange}
25
27
  value={index[prop] ?? ''}
26
28
  readOnly={readOnly}
@@ -32,6 +34,7 @@ export function IndexFormInput(props: IndexFormInputProps) {
32
34
 
33
35
  interface FormInputProps {
34
36
  label: string
37
+ description?: string
35
38
  onChange: (value: string) => void
36
39
  value: string
37
40
  readOnly: boolean
@@ -40,7 +43,7 @@ interface FormInputProps {
40
43
  }
41
44
 
42
45
  function FormInput(props: FormInputProps) {
43
- const {label, onChange, value, readOnly, placeholder, type = 'text'} = props
46
+ const {label, description, onChange, value, readOnly, placeholder, type = 'text'} = props
44
47
  const id = useId()
45
48
  const handleChange = useCallback(
46
49
  (e: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange(e.currentTarget.value),
@@ -51,6 +54,13 @@ function FormInput(props: FormInputProps) {
51
54
  <Label muted htmlFor={id}>
52
55
  <label htmlFor={id}>{label}</label>
53
56
  </Label>
57
+ {description && (
58
+ <Box>
59
+ <Text size={1} muted>
60
+ {description}
61
+ </Text>
62
+ </Box>
63
+ )}
54
64
  {type === 'text' ? (
55
65
  <TextInput
56
66
  id={id}
@@ -14,7 +14,7 @@ export function IndexList(props: IndexListProps) {
14
14
  return (
15
15
  <Card tone="default" style={{opacity: loading ? 0.5 : 1}}>
16
16
  <Stack space={2}>
17
- <Card borderBottom flex={1} paddingBottom={2}>
17
+ <Card borderBottom flex={1} paddingBottom={2} padding={3}>
18
18
  <Flex>
19
19
  <Box flex={1}>
20
20
  <Label muted>Index name</Label>
@@ -59,12 +59,15 @@ function IndexRow(props: {
59
59
  return (
60
60
  <Button
61
61
  tone={selectedIndex?.indexName === index.indexName ? 'primary' : 'default'}
62
- mode={selectedIndex?.indexName === index.indexName ? 'default' : 'bleed'}
62
+ mode={selectedIndex?.indexName === index.indexName ? 'default' : 'ghost'}
63
63
  onClick={onSelect}
64
64
  key={index.indexName}
65
+ padding={3}
65
66
  >
66
67
  <Flex>
67
- <Box flex={1}>{index.indexName}</Box>
68
+ <Box flex={1}>
69
+ <strong>{index.indexName}</strong>
70
+ </Box>
68
71
  <Box flex={1}>{index.dataset}</Box>
69
72
  <Box flex={1}>{index.status}</Box>
70
73
  <Box flex={1}>
@@ -1,108 +1,13 @@
1
- import {SearchIcon} from '@sanity/icons'
2
- import {Box, Card, Flex, Spinner, Stack, Text, TextInput} from '@sanity/ui'
3
- import {FormEvent, useCallback, useState, KeyboardEvent} from 'react'
4
- import {DocumentPreview} from '../preview/DocumentPreview'
5
- import {queryIndex, QueryResult} from '../api/embeddingsApi'
6
- import {useApiClient} from '../api/embeddingsApiHooks'
7
-
8
- const NO_RESULTS: QueryResult[] = []
1
+ import {useCallback, useMemo} from 'react'
2
+ import {SemanticSearchAutocomplete} from '../referenceInput/SemanticSearchAutocomplete'
3
+ import {EmbeddingsIndexConfig} from '../schemas/typeDefExtensions'
9
4
 
10
5
  export function QueryIndex(props: {indexName: string}) {
11
6
  const {indexName} = props
12
- const [query, setQuery] = useState('')
13
- const [searching, setSearching] = useState(false)
14
- const [results, setResults] = useState(NO_RESULTS)
15
-
16
- const client = useApiClient()
17
-
18
- const search = useCallback(
19
- (queryString: string) => {
20
- setSearching(true)
21
- return queryIndex(
22
- {
23
- query: queryString,
24
- indexName,
25
- maxResults: 5,
26
- },
27
- client,
28
- )
29
- .then(setResults)
30
- .finally(() => setSearching(false))
31
- },
32
- [client, indexName],
33
- )
34
-
35
- const onInputChange = useCallback((e: FormEvent<HTMLInputElement>) => {
36
- setQuery(e.currentTarget.value)
37
- }, [])
38
-
39
- const onKeyDown = useCallback(
40
- (e: KeyboardEvent<HTMLInputElement>) => {
41
- if (e.key === 'Enter') {
42
- search(query).catch(console.error)
43
- }
44
- },
45
- [search, query],
46
- )
47
-
48
- return (
49
- <Stack space={3} flex={1}>
50
- <Flex flex={1}>
51
- <Card flex={1}>
52
- <TextInput
53
- iconRight={
54
- searching ? (
55
- <Box style={{marginTop: 5}}>
56
- <Spinner />
57
- </Box>
58
- ) : (
59
- SearchIcon
60
- )
61
- }
62
- placeholder={'Find documents'}
63
- value={query}
64
- disabled={searching}
65
- onChange={onInputChange}
66
- onKeyDown={onKeyDown}
67
- />
68
- </Card>
69
- </Flex>
70
- <Flex gap={4} style={{opacity: searching ? 0.5 : 1}}>
71
- <Box flex={1}>
72
- <ResultList results={results} query={query} />
73
- </Box>
74
- </Flex>
75
- </Stack>
76
- )
77
- }
78
-
79
- export function ResultList(props: {results: QueryResult[]; query: string}) {
80
- const {results, query} = props
81
-
82
- return (
83
- <Stack space={4} height="fill">
84
- <Stack space={2}>
85
- {results.map((r) => (
86
- <ResultEntry result={r} key={r.value.documentId} />
87
- ))}
88
- {!results.length && query ? 'No results.' : null}
89
- </Stack>
90
- </Stack>
91
- )
92
- }
93
-
94
- function ResultEntry(props: {result: QueryResult}) {
95
- const value = props.result.value
96
- return (
97
- <Flex gap={4} align="center">
98
- <Box flex={1}>
99
- <DocumentPreview documentId={value.documentId} schemaTypeName={value.type} button />
100
- </Box>
101
- <Box>
102
- <Text muted size={1}>
103
- {Math.floor(props.result.score * 100)} %
104
- </Text>
105
- </Box>
106
- </Flex>
7
+ const getEmpty = useCallback(() => 'anything', [])
8
+ const indexConfig: EmbeddingsIndexConfig = useMemo(
9
+ () => ({indexName, maxResults: 8}),
10
+ [indexName],
107
11
  )
12
+ return <SemanticSearchAutocomplete getEmptySearchValue={getEmpty} indexConfig={indexConfig} />
108
13
  }
@@ -0,0 +1,205 @@
1
+ /* eslint-disable max-nested-callbacks */
2
+ import {Autocomplete, AutocompleteOpenButtonProps, Box, Button, Flex, Text} from '@sanity/ui'
3
+ import {DocumentPreview} from '../preview/DocumentPreview'
4
+ import {
5
+ FocusEventHandler,
6
+ forwardRef,
7
+ useCallback,
8
+ useEffect,
9
+ useId,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react'
14
+ import {EmbeddingsIndexConfig} from '../schemas/typeDefExtensions'
15
+ import {queryIndex, QueryResult} from '../api/embeddingsApi'
16
+ import {typed} from 'sanity'
17
+ import {useApiClient} from '../api/embeddingsApiHooks'
18
+
19
+ export interface SemanticSearchAutocompleteProps {
20
+ indexConfig: EmbeddingsIndexConfig
21
+ getEmptySearchValue: () => string
22
+ typeFilter?: string[]
23
+ filterResult?: (hit: QueryResult) => boolean
24
+ onChange?: (value: string) => void
25
+ onFocus?: FocusEventHandler<HTMLInputElement>
26
+ onBlur?: FocusEventHandler<HTMLInputElement>
27
+ readOnly?: boolean
28
+ }
29
+
30
+ interface Option {
31
+ result: QueryResult
32
+ value: string
33
+ }
34
+
35
+ const NO_RESULTS_VALUE = '' as const
36
+
37
+ interface NoResultOption {
38
+ value: string
39
+ }
40
+
41
+ const NO_OPTIONS: NoResultOption[] = []
42
+ const NO_FILTER = () => true
43
+
44
+ export const SemanticSearchAutocomplete = forwardRef(function SemanticSearchAutocomplete(
45
+ props: SemanticSearchAutocompleteProps,
46
+ ref: any,
47
+ ) {
48
+ const {
49
+ indexConfig,
50
+ filterResult,
51
+ getEmptySearchValue,
52
+ readOnly,
53
+ onFocus,
54
+ onBlur,
55
+ onChange,
56
+ typeFilter,
57
+ } = props
58
+ const id = useId()
59
+ const [query, setQuery] = useState('')
60
+ const queryRef = useRef(query)
61
+ const debouncedQuery = useDebouncedValue(query, 300)
62
+ const prevDebouncedQuery = useRef(debouncedQuery)
63
+
64
+ const [searching, setSearching] = useState(false)
65
+ const [options, setOptions] = useState<Option[] | NoResultOption[]>(NO_OPTIONS)
66
+
67
+ const client = useApiClient()
68
+
69
+ const runIndexQuery = useCallback(
70
+ (queryString: string) => {
71
+ setSearching(true)
72
+ const indexName = indexConfig?.indexName
73
+ const maxResults = indexConfig?.maxResults
74
+
75
+ if (!indexName) {
76
+ throw new Error(`Reference option embeddingsIndex.indexName is required, but was missing`)
77
+ }
78
+
79
+ queryIndex(
80
+ {
81
+ query: queryString.trim().length ? queryString : getEmptySearchValue() ?? '',
82
+ indexName,
83
+ maxResults,
84
+ filter: {
85
+ type: typeFilter,
86
+ },
87
+ },
88
+ client,
89
+ )
90
+ .then((result: QueryResult[]) => {
91
+ if (queryRef.current === queryString) {
92
+ setSearching(false)
93
+ setOptions([])
94
+ const resultOptions = result
95
+ .filter((hit) => (filterResult ? filterResult(hit) : true))
96
+ .map((r) => typed<Option>({result: r, value: r.value.documentId}))
97
+ if (resultOptions.length) {
98
+ setOptions(resultOptions)
99
+ } else {
100
+ setOptions([{value: NO_RESULTS_VALUE}])
101
+ }
102
+ }
103
+ })
104
+ .catch((e) => {
105
+ if (queryRef.current === queryString) {
106
+ setSearching(false)
107
+ }
108
+ throw e
109
+ })
110
+ },
111
+ [client, indexConfig, getEmptySearchValue, filterResult, typeFilter],
112
+ )
113
+
114
+ useEffect(() => {
115
+ if (prevDebouncedQuery.current !== debouncedQuery) {
116
+ runIndexQuery(debouncedQuery)
117
+ }
118
+ prevDebouncedQuery.current = debouncedQuery
119
+ }, [debouncedQuery, runIndexQuery])
120
+
121
+ const openButtonConfig: AutocompleteOpenButtonProps = useMemo(
122
+ () => ({onClick: () => runIndexQuery(queryRef.current)}),
123
+ [runIndexQuery, queryRef],
124
+ )
125
+
126
+ const handleQueryChange = useCallback(
127
+ (newValue: string | null) => {
128
+ const newQuery = newValue ?? ''
129
+ queryRef.current = newQuery
130
+ setQuery(newQuery)
131
+ },
132
+ [setQuery],
133
+ )
134
+
135
+ const handleChange = useCallback(
136
+ (value: string) => {
137
+ if (value === NO_RESULTS_VALUE) {
138
+ setOptions(NO_OPTIONS)
139
+ return
140
+ }
141
+ onChange?.(value)
142
+ },
143
+ [onChange],
144
+ )
145
+
146
+ return (
147
+ <Autocomplete
148
+ id={id}
149
+ ref={ref}
150
+ data-testid="semantic-autocomplete"
151
+ placeholder="Type to search..."
152
+ openButton={openButtonConfig}
153
+ onFocus={onFocus}
154
+ onChange={handleChange}
155
+ loading={searching}
156
+ onBlur={onBlur}
157
+ readOnly={readOnly}
158
+ filterOption={NO_FILTER}
159
+ onQueryChange={handleQueryChange}
160
+ options={options}
161
+ renderOption={AutocompleteOption}
162
+ />
163
+ )
164
+ })
165
+
166
+ function AutocompleteOption(props: Option | NoResultOption) {
167
+ if ('result' in props) {
168
+ const value = props.result.value
169
+ return (
170
+ <Button mode="bleed" padding={1} style={{width: '100%'}}>
171
+ <Flex gap={2} align="center">
172
+ <Box flex={1}>
173
+ <DocumentPreview documentId={value.documentId} schemaTypeName={value.type} />
174
+ </Box>
175
+ <Box padding={2}>
176
+ <Text size={1} muted title={'Relevance'}>
177
+ {Math.floor(props.result.score * 100)}%
178
+ </Text>
179
+ </Box>
180
+ </Flex>
181
+ </Button>
182
+ )
183
+ }
184
+
185
+ return (
186
+ <Button mode="bleed" padding={1} style={{width: '100%'}} disabled>
187
+ <Flex gap={2} align="center">
188
+ No results.
189
+ </Flex>
190
+ </Button>
191
+ )
192
+ }
193
+
194
+ function useDebouncedValue<T>(value: T, ms: number) {
195
+ const [debouncedValue, setDebouncedValue] = useState(value)
196
+
197
+ useEffect(() => {
198
+ const timeoutId = setTimeout(() => {
199
+ setDebouncedValue(value)
200
+ }, ms)
201
+ return () => clearTimeout(timeoutId)
202
+ }, [value, ms])
203
+
204
+ return debouncedValue
205
+ }