@sanity/embeddings-index-ui 1.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,83 @@
1
+ import {Box, Button, Card, Flex, Label, Stack, Text} from '@sanity/ui'
2
+ import {IndexState} from '../api/embeddingsApi'
3
+ import {useCallback} from 'react'
4
+
5
+ export interface IndexListProps {
6
+ loading: boolean
7
+ selectedIndex?: IndexState
8
+ indexes: IndexState[]
9
+ onIndexSelected: (index: IndexState) => void
10
+ }
11
+
12
+ export function IndexList(props: IndexListProps) {
13
+ const {loading, selectedIndex, indexes, onIndexSelected} = props
14
+ return (
15
+ <Card tone="default" style={{opacity: loading ? 0.5 : 1}}>
16
+ <Stack space={2}>
17
+ <Card borderBottom flex={1} paddingBottom={2}>
18
+ <Flex>
19
+ <Box flex={1}>
20
+ <Label muted>Index name</Label>
21
+ </Box>
22
+ <Box flex={1}>
23
+ <Label muted>Dataset</Label>
24
+ </Box>
25
+ <Box flex={1}>
26
+ <Label muted>Status</Label>
27
+ </Box>
28
+ <Box flex={1}>
29
+ <Label muted>Progress</Label>
30
+ </Box>
31
+ </Flex>
32
+ </Card>
33
+ {indexes.length ? (
34
+ <Stack space={2} style={{maxHeight: 200, overflow: 'auto'}}>
35
+ {indexes.map((index) => (
36
+ <IndexRow
37
+ selectedIndex={selectedIndex}
38
+ index={index}
39
+ onIndexSelected={onIndexSelected}
40
+ key={index.indexName}
41
+ />
42
+ ))}
43
+ </Stack>
44
+ ) : (
45
+ <Text muted>No indexes found.</Text>
46
+ )}
47
+ </Stack>
48
+ </Card>
49
+ )
50
+ }
51
+
52
+ function IndexRow(props: {
53
+ selectedIndex?: IndexState
54
+ index: IndexState
55
+ onIndexSelected: (index: IndexState) => void
56
+ }) {
57
+ const {selectedIndex, index, onIndexSelected} = props
58
+ const onSelect = useCallback(() => onIndexSelected(index), [onIndexSelected, index])
59
+ return (
60
+ <Button
61
+ tone={selectedIndex?.indexName === index.indexName ? 'primary' : 'default'}
62
+ mode={selectedIndex?.indexName === index.indexName ? 'default' : 'bleed'}
63
+ onClick={onSelect}
64
+ key={index.indexName}
65
+ >
66
+ <Flex>
67
+ <Box flex={1}>{index.indexName}</Box>
68
+ <Box flex={1}>{index.dataset}</Box>
69
+ <Box flex={1}>{index.status}</Box>
70
+ <Box flex={1}>
71
+ {index.startDocumentCount
72
+ ? Math.floor(
73
+ ((index.startDocumentCount - index.remainingDocumentCount) /
74
+ index.startDocumentCount) *
75
+ 100,
76
+ )
77
+ : '?'}
78
+ %
79
+ </Box>
80
+ </Flex>
81
+ </Button>
82
+ )
83
+ }
@@ -0,0 +1,107 @@
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[] = []
9
+
10
+ export function QueryIndex(props: {indexName: string}) {
11
+ 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
+ return (
96
+ <Flex gap={4} align="center">
97
+ <Box flex={1}>
98
+ <DocumentPreview documentId={props.result.value.documentId} button />
99
+ </Box>
100
+ <Box>
101
+ <Text muted size={1}>
102
+ {Math.floor(props.result.score * 100)} %
103
+ </Text>
104
+ </Box>
105
+ </Flex>
106
+ )
107
+ }
@@ -0,0 +1,15 @@
1
+ import {definePlugin, Tool} from 'sanity'
2
+ import {EarthGlobeIcon} from '@sanity/icons'
3
+ import {EmbeddingsIndexTool} from './EmbeddingsIndexTool'
4
+
5
+ export const embeddingsIndexTool: Tool = {
6
+ name: 'embeddings-index',
7
+ title: 'Embeddings',
8
+ icon: EarthGlobeIcon,
9
+ component: EmbeddingsIndexTool,
10
+ }
11
+
12
+ export const embeddingsIndexDashboard = definePlugin({
13
+ name: '@sanity/embeddings-index-dashboard',
14
+ tools: [embeddingsIndexTool],
15
+ })
@@ -0,0 +1,36 @@
1
+ import {ObjectSchemaType, Schema} from 'sanity'
2
+ import {useMemo} from 'react'
3
+ import {isType} from '../utils/types'
4
+
5
+ const defaultProjection = '{...}'
6
+
7
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
8
+ export function useDefaultIndex(schema: Schema, dataset: string) {
9
+ const defaultFilter = useMemo(
10
+ () =>
11
+ `_type in [${schema
12
+ .getTypeNames()
13
+ .map((n) => schema.get(n))
14
+ .filter((schemaType): schemaType is ObjectSchemaType =>
15
+ Boolean(schemaType && isType(schemaType, 'document')),
16
+ )
17
+ .filter(
18
+ (documentType) =>
19
+ !documentType.name.startsWith('sanity.') &&
20
+ !documentType.name.startsWith('assist.') &&
21
+ documentType.name !== 'document',
22
+ )
23
+ .map((documentType) => `"${documentType.name}"`)
24
+ .join(',\n')}]`,
25
+ [schema],
26
+ )
27
+
28
+ return useMemo(
29
+ () => ({
30
+ dataset,
31
+ projection: defaultProjection,
32
+ filter: defaultFilter,
33
+ }),
34
+ [defaultFilter, dataset],
35
+ )
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import './schemas/typeDefExtensions'
2
+ import {embeddingsIndexReferenceInput} from './referenceInput/referencePlugin'
3
+ import {embeddingsIndexDashboard} from './embeddingsIndexDashboard/dashboardPlugin'
4
+
5
+ export {embeddingsIndexReferenceInput}
6
+
7
+ export {embeddingsIndexDashboard}
8
+
9
+ export * from './api/embeddingsApi'
@@ -0,0 +1,135 @@
1
+ import {CSSProperties, useEffect, useMemo, useState} from 'react'
2
+ import {useMemoObservable} from 'react-rx'
3
+ import {
4
+ DefaultPreview,
5
+ getPreviewStateObservable,
6
+ getPreviewValueWithFallback,
7
+ SanityDefaultPreview,
8
+ SanityDocument,
9
+ SchemaType,
10
+ useClient,
11
+ useDocumentPreviewStore,
12
+ useSchema,
13
+ } from 'sanity'
14
+ import {useIntentLink} from 'sanity/router'
15
+ import {Box, Button, ButtonProps, Card} from '@sanity/ui'
16
+ import {ErrorOutlineIcon} from '@sanity/icons'
17
+
18
+ interface ResultPreviewProps {
19
+ documentId: string
20
+ button?: boolean
21
+ style?: CSSProperties
22
+ }
23
+
24
+ export function DocumentPreview({
25
+ documentId,
26
+ style,
27
+ ...buttonProps
28
+ }: ResultPreviewProps & ButtonProps) {
29
+ const client = useClient({apiVersion: '2023-06-06'})
30
+ const [type, setType] = useState<string | undefined>(undefined)
31
+ const schema = useSchema()
32
+ const schemaType = type ? schema.get(type) : undefined
33
+
34
+ useEffect(() => {
35
+ client.fetch<string>(`* [_id==$id]._type`, {id: documentId}).then(setType)
36
+ }, [documentId, client])
37
+
38
+ if (!type) {
39
+ return (
40
+ <Card style={{minHeight: '36px'}}>
41
+ <DefaultPreview
42
+ withShadow={false}
43
+ withBorder={false}
44
+ title={'Loading...'}
45
+ schemaType={schemaType}
46
+ isPlaceholder
47
+ />
48
+ </Card>
49
+ )
50
+ }
51
+
52
+ if (!schemaType) {
53
+ return (
54
+ <Card>
55
+ <DefaultPreview
56
+ withShadow={false}
57
+ withBorder={false}
58
+ media={() => <ErrorOutlineIcon />}
59
+ title={
60
+ <>
61
+ Unknown type <code>{type ?? 'N/A'}</code> for {documentId}
62
+ </>
63
+ }
64
+ />
65
+ </Card>
66
+ )
67
+ }
68
+
69
+ return (
70
+ <DocumentPreviewInner
71
+ documentId={documentId}
72
+ schemaType={schemaType}
73
+ style={style}
74
+ {...buttonProps}
75
+ />
76
+ )
77
+ }
78
+
79
+ function DocumentPreviewInner({
80
+ documentId,
81
+ schemaType,
82
+ style,
83
+ button,
84
+ }: ResultPreviewProps & {schemaType: SchemaType} & ButtonProps) {
85
+ const documentPreviewStore = useDocumentPreviewStore()
86
+
87
+ // NOTE: this emits sync so can never be null
88
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
89
+ const {draft, published, isLoading} = useMemoObservable(
90
+ () => getPreviewStateObservable(documentPreviewStore, schemaType, documentId, ''),
91
+ [documentId, documentPreviewStore, schemaType],
92
+ )!
93
+
94
+ const sanityDocument = useMemo(() => {
95
+ return {
96
+ _id: documentId,
97
+ _type: schemaType?.name,
98
+ } as SanityDocument
99
+ }, [documentId, schemaType?.name])
100
+
101
+ const {onClick: onIntentClick, href} = useIntentLink({
102
+ intent: 'edit',
103
+ params: {
104
+ id: documentId,
105
+ type: schemaType?.name,
106
+ },
107
+ })
108
+
109
+ const preview = (
110
+ <SanityDefaultPreview
111
+ {...getPreviewValueWithFallback({
112
+ draft,
113
+ published,
114
+ value: sanityDocument,
115
+ })}
116
+ isPlaceholder={isLoading ?? true}
117
+ layout="default"
118
+ icon={schemaType?.icon}
119
+ />
120
+ )
121
+ if (button) {
122
+ return (
123
+ <Button
124
+ as={'a'}
125
+ href={href}
126
+ onClick={onIntentClick}
127
+ mode="ghost"
128
+ style={{width: '100%', ...style}}
129
+ >
130
+ {preview}
131
+ </Button>
132
+ )
133
+ }
134
+ return <Box style={{width: '100%'}}>{preview}</Box>
135
+ }
@@ -0,0 +1,227 @@
1
+ import {
2
+ ObjectInputProps,
3
+ ReferenceSchemaType,
4
+ set,
5
+ setIfMissing,
6
+ typed,
7
+ unset,
8
+ useClient,
9
+ } from 'sanity'
10
+ import {Autocomplete, Box, Button, Flex, Text, AutocompleteOpenButtonProps} from '@sanity/ui'
11
+ import {EarthGlobeIcon, LinkIcon} from '@sanity/icons'
12
+ import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react'
13
+ import {DocumentPreview} from '../preview/DocumentPreview'
14
+ import {useDocumentPane} from 'sanity/desk'
15
+ import {queryIndex, QueryResult} from '../api/embeddingsApi'
16
+ import {publicId} from '../utils/id'
17
+
18
+ interface Option {
19
+ result: QueryResult
20
+ value: string
21
+ }
22
+
23
+ const NO_OPTIONS: Option[] = []
24
+ const NO_FILTER = () => true
25
+
26
+ export function SemanticSearchReferenceInput(props: ObjectInputProps) {
27
+ const defaultEnabled =
28
+ (props.schemaType as ReferenceSchemaType)?.options?.embeddingsIndex?.searchMode === 'embeddings'
29
+
30
+ const [semantic, setSemantic] = useState<boolean>(defaultEnabled)
31
+ const toggleSemantic = useCallback(() => setSemantic((current) => !current), [])
32
+
33
+ return (
34
+ <Flex gap={2} flex={1} style={{width: '100%'}}>
35
+ <Box flex={1} style={{maxHeight: 36, overflow: 'hidden'}}>
36
+ {semantic ? <SemanticSearchInput {...props} /> : props.renderDefault(props)}
37
+ </Box>
38
+ <Button
39
+ icon={semantic ? EarthGlobeIcon : LinkIcon}
40
+ onClick={toggleSemantic}
41
+ mode="bleed"
42
+ title={
43
+ semantic ? 'Switch to standard reference search' : 'Switch to semantic reference search'
44
+ }
45
+ />
46
+ </Flex>
47
+ )
48
+ }
49
+
50
+ function useDebouncedValue<T>(value: T, ms: number) {
51
+ const [debouncedValue, setDebouncedValue] = useState(value)
52
+
53
+ useEffect(() => {
54
+ const timeoutId = setTimeout(() => {
55
+ setDebouncedValue(value)
56
+ }, ms)
57
+ return () => clearTimeout(timeoutId)
58
+ }, [value, ms])
59
+
60
+ return debouncedValue
61
+ }
62
+
63
+ function useApiClient() {
64
+ const client = useClient({apiVersion: 'vX'})
65
+ return useMemo(() => client, [client])
66
+ }
67
+
68
+ function SemanticSearchInput(props: ObjectInputProps) {
69
+ const {onPathFocus, onChange, readOnly, schemaType, value} = props
70
+
71
+ const {value: currentDocument} = useDocumentPane()
72
+ const docRef = useRef(currentDocument)
73
+ const autocompleteRef = useRef<HTMLInputElement>(null)
74
+
75
+ const id = useId()
76
+
77
+ const [query, setQuery] = useState('')
78
+ const queryRef = useRef(query)
79
+ const debouncedQuery = useDebouncedValue(query, 300)
80
+ const prevDebouncedQuery = useRef(debouncedQuery)
81
+
82
+ const [searching, setSearching] = useState(false)
83
+ const [options, setOptions] = useState(NO_OPTIONS)
84
+
85
+ const client = useApiClient()
86
+
87
+ useEffect(() => {
88
+ docRef.current = currentDocument
89
+ }, [currentDocument])
90
+
91
+ useEffect(() => {
92
+ // if this component is rendered, and there is a value, replace was selected
93
+ if (value?._ref) {
94
+ autocompleteRef.current?.focus()
95
+ }
96
+ // intentional empty deps
97
+ // eslint-disable-next-line react-hooks/exhaustive-deps
98
+ }, [])
99
+
100
+ const handleFocus = useCallback(() => onPathFocus(['_ref']), [onPathFocus])
101
+ const handleBlur = useCallback(() => onPathFocus([]), [onPathFocus])
102
+
103
+ const runIndexQuery = useCallback(
104
+ (queryString: string) => {
105
+ setSearching(true)
106
+ const refSchema = schemaType as ReferenceSchemaType
107
+ const indexName = refSchema.options?.embeddingsIndex?.indexName
108
+ const maxResults = refSchema.options?.embeddingsIndex?.maxResults
109
+ const typeFilter = refSchema.to.map((ref) => ref.name)
110
+
111
+ if (!indexName) {
112
+ throw new Error(
113
+ `Reference option embeddingsIndex.indexName is required, but was missing in type ${refSchema.name}`,
114
+ )
115
+ }
116
+
117
+ queryIndex(
118
+ {
119
+ query: queryString,
120
+ indexName,
121
+ maxResults,
122
+ filter: {
123
+ type: typeFilter,
124
+ },
125
+ },
126
+ client,
127
+ )
128
+ .then((result: QueryResult[]) => {
129
+ if (queryRef.current === queryString) {
130
+ setSearching(false)
131
+ setOptions(
132
+ result
133
+ .filter((r) => r.value.documentId !== publicId(docRef.current._id))
134
+ .map((r) => typed<Option>({result: r, value: r.value.documentId})),
135
+ )
136
+ }
137
+ })
138
+ .catch((e) => {
139
+ if (queryRef.current === queryString) {
140
+ setSearching(false)
141
+ }
142
+ throw e
143
+ })
144
+ },
145
+ [client, schemaType],
146
+ )
147
+
148
+ useEffect(() => {
149
+ if (prevDebouncedQuery.current !== debouncedQuery) {
150
+ runIndexQuery(debouncedQuery)
151
+ }
152
+ prevDebouncedQuery.current = debouncedQuery
153
+ }, [debouncedQuery, runIndexQuery])
154
+
155
+ const handleChange = useCallback(
156
+ (nextId: string) => {
157
+ if (!nextId) {
158
+ onChange(unset())
159
+ onPathFocus([])
160
+ return
161
+ }
162
+
163
+ const patches = [
164
+ setIfMissing({}),
165
+ set(schemaType.name, ['_type']),
166
+ set(publicId(nextId), ['_ref']),
167
+ unset(['_weak']),
168
+ unset(['_strengthenOnPublish']),
169
+ ]
170
+
171
+ onChange(patches)
172
+ // Move focus away from _ref and one level up
173
+ onPathFocus([])
174
+ },
175
+ [onChange, onPathFocus, schemaType.name],
176
+ )
177
+
178
+ const openButtonConfig: AutocompleteOpenButtonProps = useMemo(
179
+ () => ({onClick: () => runIndexQuery(queryRef.current)}),
180
+ [runIndexQuery, queryRef],
181
+ )
182
+
183
+ const handleQueryChange = useCallback(
184
+ (newValue: string | null) => {
185
+ const newQuery = newValue ?? ''
186
+ queryRef.current = newQuery
187
+ setQuery(newQuery)
188
+ },
189
+ [setQuery],
190
+ )
191
+
192
+ return (
193
+ <Autocomplete
194
+ id={id}
195
+ ref={autocompleteRef}
196
+ data-testid="semantic-autocomplete"
197
+ placeholder="Type to search"
198
+ openButton={openButtonConfig}
199
+ onFocus={handleFocus}
200
+ onChange={handleChange}
201
+ loading={searching}
202
+ onBlur={handleBlur}
203
+ readOnly={readOnly}
204
+ filterOption={NO_FILTER}
205
+ onQueryChange={handleQueryChange}
206
+ options={options}
207
+ renderOption={AutocompleteOption}
208
+ />
209
+ )
210
+ }
211
+
212
+ function AutocompleteOption(props: Option) {
213
+ return (
214
+ <Button mode="bleed" padding={1} style={{width: '100%'}}>
215
+ <Flex gap={2} align="center">
216
+ <Box flex={1}>
217
+ <DocumentPreview documentId={props.result.value.documentId} />
218
+ </Box>
219
+ <Box padding={2}>
220
+ <Text size={1} muted title={'Relevance'}>
221
+ {Math.floor(props.result.score * 100)}%
222
+ </Text>
223
+ </Box>
224
+ </Flex>
225
+ </Button>
226
+ )
227
+ }
@@ -0,0 +1,21 @@
1
+ import {definePlugin, isObjectInputProps, ObjectInputProps, ReferenceSchemaType} from 'sanity'
2
+ import {SemanticSearchReferenceInput} from './SemanticSearchReferenceInput'
3
+ import {isType} from '../utils/types'
4
+
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)
18
+ },
19
+ },
20
+ },
21
+ })
File without changes
@@ -0,0 +1,19 @@
1
+ import 'sanity'
2
+ /* eslint-disable no-unused-vars */
3
+ declare module 'sanity' {
4
+ 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
+ }
18
+ }
19
+ }
@@ -0,0 +1,3 @@
1
+ export function publicId(id: string): string {
2
+ return id.replace('drafts.', '')
3
+ }
@@ -0,0 +1,11 @@
1
+ import {SchemaType} from 'sanity'
2
+
3
+ export function isType(schemaType: SchemaType, typeName: string): boolean {
4
+ if (schemaType.name === typeName) {
5
+ return true
6
+ }
7
+ if (!schemaType.type) {
8
+ return false
9
+ }
10
+ return isType(schemaType.type, typeName)
11
+ }
@@ -0,0 +1,11 @@
1
+ const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
2
+ const {name, version, sanityExchangeUrl} = require('./package.json')
3
+
4
+ export default showIncompatiblePluginDialog({
5
+ name: name,
6
+ versions: {
7
+ v3: version,
8
+ v2: undefined,
9
+ },
10
+ sanityExchangeUrl,
11
+ })