@sanity/embeddings-index-ui 1.0.3 → 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/README.md +33 -1
- package/dist/index.d.ts +21 -14
- package/dist/index.esm.js +289 -276
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +285 -272
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/embeddingsIndexDashboard/IndexEditor.tsx +8 -6
- package/src/embeddingsIndexDashboard/IndexFormInput.tsx +13 -3
- package/src/embeddingsIndexDashboard/IndexList.tsx +6 -3
- package/src/embeddingsIndexDashboard/QueryIndex.tsx +8 -103
- package/src/referenceInput/SemanticSearchAutocomplete.tsx +205 -0
- package/src/referenceInput/SemanticSearchReferenceInput.tsx +62 -140
- package/src/referenceInput/referencePlugin.tsx +35 -21
- package/src/schemas/typeDefExtensions.ts +21 -13
package/package.json
CHANGED
|
@@ -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="
|
|
145
|
-
|
|
144
|
+
label="Filter"
|
|
145
|
+
description="Must be a valid GROQ filter"
|
|
146
|
+
placeholder={defaultIndex.filter}
|
|
146
147
|
index={index}
|
|
147
|
-
prop="
|
|
148
|
+
prop="filter"
|
|
148
149
|
onChange={setIndex}
|
|
149
150
|
readOnly={readOnly}
|
|
150
151
|
type="textarea"
|
|
151
152
|
/>
|
|
152
153
|
<IndexFormInput
|
|
153
|
-
label="
|
|
154
|
-
|
|
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="
|
|
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' : '
|
|
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}>
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
13
|
-
const
|
|
14
|
-
|
|
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
|
+
}
|