@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.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.esm.js +2792 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +2807 -0
- package/dist/index.js.map +1 -0
- package/package.json +93 -0
- package/sanity.json +8 -0
- package/src/api/embeddingsApi.ts +68 -0
- package/src/api/embeddingsApiHooks.ts +12 -0
- package/src/embeddingsIndexDashboard/EmbeddingsIndexTool.tsx +150 -0
- package/src/embeddingsIndexDashboard/IndexEditor.tsx +181 -0
- package/src/embeddingsIndexDashboard/IndexFormInput.tsx +75 -0
- package/src/embeddingsIndexDashboard/IndexInfo.tsx +116 -0
- package/src/embeddingsIndexDashboard/IndexList.tsx +83 -0
- package/src/embeddingsIndexDashboard/QueryIndex.tsx +107 -0
- package/src/embeddingsIndexDashboard/dashboardPlugin.ts +15 -0
- package/src/embeddingsIndexDashboard/hooks.ts +36 -0
- package/src/index.ts +9 -0
- package/src/preview/DocumentPreview.tsx +135 -0
- package/src/referenceInput/SemanticSearchReferenceInput.tsx +227 -0
- package/src/referenceInput/referencePlugin.tsx +21 -0
- package/src/referenceInput/types.ts +0 -0
- package/src/schemas/typeDefExtensions.ts +19 -0
- package/src/utils/id.ts +3 -0
- package/src/utils/types.ts +11 -0
- package/v2-incompatible.js +11 -0
|
@@ -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
|
+
}
|
package/src/utils/id.ts
ADDED
|
@@ -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
|
+
})
|