@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/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@sanity/embeddings-index-ui",
3
+ "version": "1.0.0",
4
+ "description": "Various Sanity Studio plugins for integrating with the embeddings index API",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin"
8
+ ],
9
+ "homepage": "https://github.com/sanity-io/embeddings-index-ui#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/sanity-io/embeddings-index-ui/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+ssh://git@github.com/sanity-io/embeddings-index-ui.git"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Sanity <hello@sanity.io>",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "source": "./src/index.ts",
23
+ "require": "./dist/index.js",
24
+ "import": "./dist/index.esm.js",
25
+ "default": "./dist/index.esm.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "main": "./dist/index.js",
30
+ "module": "./dist/index.esm.js",
31
+ "source": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "sanity.json",
36
+ "src",
37
+ "v2-incompatible.js"
38
+ ],
39
+ "scripts": {
40
+ "build": "run-s clean && plugin-kit verify-package --silent && pkg-utils build --strict && pkg-utils --strict",
41
+ "clean": "rimraf dist",
42
+ "format": "prettier --write --cache --ignore-unknown .",
43
+ "link-watch": "plugin-kit link-watch",
44
+ "lint": "eslint .",
45
+ "prepublishOnly": "run-s build",
46
+ "watch": "pkg-utils watch --strict",
47
+ "prepare": "husky install"
48
+ },
49
+ "dependencies": {
50
+ "@sanity/icons": "^2.4.1",
51
+ "@sanity/incompatible-plugin": "^1.0.4",
52
+ "@sanity/ui": "^1.7.11"
53
+ },
54
+ "devDependencies": {
55
+ "@commitlint/cli": "^17.7.1",
56
+ "@commitlint/config-conventional": "^17.7.0",
57
+ "@sanity/pkg-utils": "^2.4.8",
58
+ "@sanity/plugin-kit": "^3.1.10",
59
+ "@sanity/semantic-release-preset": "^4.1.4",
60
+ "@types/react": "^18.2.21",
61
+ "@typescript-eslint/eslint-plugin": "^6.5.0",
62
+ "@typescript-eslint/parser": "^6.5.0",
63
+ "eslint": "^8.48.0",
64
+ "eslint-config-prettier": "^9.0.0",
65
+ "eslint-config-sanity": "^6.0.0",
66
+ "eslint-plugin-prettier": "^5.0.0",
67
+ "eslint-plugin-react": "^7.33.2",
68
+ "eslint-plugin-react-hooks": "^4.6.0",
69
+ "husky": "^8.0.3",
70
+ "lint-staged": "^14.0.1",
71
+ "npm-run-all": "^4.1.5",
72
+ "prettier": "^3.0.3",
73
+ "prettier-plugin-packagejson": "^2.4.5",
74
+ "react": "^18.2.0",
75
+ "react-dom": "^18.2.0",
76
+ "react-is": "^18.2.0",
77
+ "rimraf": "^5.0.1",
78
+ "sanity": "^3.16.1",
79
+ "semantic-release": "^21.1.1",
80
+ "styled-components": "^5.3.11",
81
+ "typescript": "^5.2.2"
82
+ },
83
+ "peerDependencies": {
84
+ "react": "^18",
85
+ "sanity": "^3.14"
86
+ },
87
+ "engines": {
88
+ "node": ">=14"
89
+ },
90
+ "publishConfig": {
91
+ "access": "public"
92
+ }
93
+ }
package/sanity.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "parts": [
3
+ {
4
+ "implements": "part:@sanity/base/sanity-root",
5
+ "path": "./v2-incompatible.js"
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,68 @@
1
+ import {SanityClient} from 'sanity'
2
+
3
+ export interface NamedIndex {
4
+ indexName: string
5
+ dataset: string
6
+ project: string
7
+ projection: string
8
+ filter: string
9
+ }
10
+
11
+ export interface IndexState extends NamedIndex {
12
+ status: string
13
+ startDocumentCount: number
14
+ remainingDocumentCount: number
15
+ failedDocumentCount: number
16
+ }
17
+
18
+ export interface QueryResult {
19
+ score: number
20
+ value: {
21
+ documentId: string
22
+ type?: string
23
+ }
24
+ }
25
+
26
+ export interface QueryConfig {
27
+ query: string
28
+ indexName: string
29
+ maxResults?: number
30
+ filter?: {
31
+ type?: string | string[]
32
+ }
33
+ }
34
+
35
+ export function queryIndex(queryConfig: QueryConfig, client: SanityClient): Promise<QueryResult[]> {
36
+ const {query, indexName, maxResults, filter} = queryConfig
37
+ const projectId = client.config().projectId
38
+ const dataset = client.config().dataset
39
+ const queryString = query?.trim()
40
+
41
+ return client.request<QueryResult[]>({
42
+ method: 'POST',
43
+ url: `/embeddings-index/query/${dataset}/${indexName}?projectId=${projectId}`,
44
+ body: {
45
+ query: queryString,
46
+ maxResults,
47
+ filter,
48
+ },
49
+ })
50
+ }
51
+
52
+ export function getIndexes(client: SanityClient): Promise<IndexState[]> {
53
+ const projectId = client.config().projectId
54
+ const dataset = client.config().dataset
55
+ return client.request<IndexState[]>({
56
+ method: 'GET',
57
+ url: `/embeddings-index/${dataset}?projectId=${projectId}`,
58
+ })
59
+ }
60
+
61
+ export function deleteIndex(indexName: string, client: SanityClient): Promise<IndexState> {
62
+ const projectId = client.config().projectId
63
+ const dataset = client.config().dataset
64
+ return client.request<IndexState>({
65
+ method: 'DELETE',
66
+ url: `/embeddings-index/${dataset}/${indexName}?projectId=${projectId}`,
67
+ })
68
+ }
@@ -0,0 +1,12 @@
1
+ import {SanityClient, useClient} from 'sanity'
2
+ import {useMemo} from 'react'
3
+
4
+ export function useApiClient(
5
+ customApiClient?: (defaultClient: SanityClient) => SanityClient,
6
+ ): SanityClient {
7
+ const client = useClient({apiVersion: 'vX'})
8
+ return useMemo(
9
+ () => (customApiClient ? customApiClient(client) : client),
10
+ [client, customApiClient],
11
+ )
12
+ }
@@ -0,0 +1,150 @@
1
+ import {Box, Button, Card, Flex, Heading, Spinner, Stack} from '@sanity/ui'
2
+ import {useClient} from 'sanity'
3
+ import {useCallback, useEffect, useMemo, useState} from 'react'
4
+ import {AddIcon, UndoIcon} from '@sanity/icons'
5
+ import {deleteIndex, getIndexes, IndexState, NamedIndex} from '../api/embeddingsApi'
6
+ import {EditIndexDialog} from './IndexEditor'
7
+ import {IndexList} from './IndexList'
8
+ import {IndexInfo} from './IndexInfo'
9
+
10
+ function useApiClient() {
11
+ const client = useClient({apiVersion: 'vX'})
12
+ return useMemo(() => client, [client])
13
+ }
14
+
15
+ export function EmbeddingsIndexTool() {
16
+ return (
17
+ <Card>
18
+ <Flex justify="center" flex={1}>
19
+ <Card flex={1} style={{maxWidth: 1200}} padding={5}>
20
+ <Indexes />
21
+ </Card>
22
+ </Flex>
23
+ </Card>
24
+ )
25
+ }
26
+
27
+ const NO_INDEXES: IndexState[] = []
28
+
29
+ function Indexes() {
30
+ const client = useApiClient()
31
+ const [indexes, setIndexes] = useState<IndexState[]>(NO_INDEXES)
32
+ const [loading, setLoading] = useState(false)
33
+ const [error, setError] = useState(false)
34
+ const [createIndexOpen, setCreateIndexOpen] = useState(false)
35
+ const [selectedIndex, setSelectedIndex] = useState<IndexState | undefined>(undefined)
36
+
37
+ const onCreateIndexClose = useCallback(() => setCreateIndexOpen(false), [])
38
+
39
+ useEffect(() => {
40
+ setSelectedIndex(indexes.find((i) => i.indexName === selectedIndex?.indexName))
41
+ }, [indexes, selectedIndex])
42
+
43
+ const updateIndexes = useCallback(() => {
44
+ setLoading(true)
45
+ setError(false)
46
+ getIndexes(client)
47
+ .then((response: IndexState[]) => {
48
+ setLoading(false)
49
+ setIndexes(response)
50
+ })
51
+ .catch((e) => {
52
+ // eslint-disable-next-line no-unused-expressions
53
+ console.error(e)
54
+ setError(true)
55
+ })
56
+ .finally(() => {
57
+ setLoading(false)
58
+ })
59
+ }, [client])
60
+
61
+ const deleteNamedIndex = useCallback(
62
+ (index: NamedIndex) => {
63
+ if (
64
+ // eslint-disable-next-line no-alert
65
+ !confirm(`Are you sure you want to delete ${index.indexName} for dataset ${index.dataset}?`)
66
+ ) {
67
+ return
68
+ }
69
+ setLoading(true)
70
+ setError(false)
71
+ deleteIndex(index.indexName, client)
72
+ .then(() => {
73
+ setTimeout(() => updateIndexes())
74
+ })
75
+ .catch((e) => {
76
+ // eslint-disable-next-line no-unused-expressions
77
+ console.error(e)
78
+ setError(true)
79
+ })
80
+ .finally(() => {
81
+ setLoading(false)
82
+ })
83
+ },
84
+ [client, updateIndexes],
85
+ )
86
+
87
+ const onSelectIndex = useCallback(
88
+ (index: IndexState) => {
89
+ setSelectedIndex(index)
90
+ updateIndexes()
91
+ },
92
+ [setSelectedIndex, updateIndexes],
93
+ )
94
+
95
+ useEffect(() => {
96
+ updateIndexes()
97
+ }, [updateIndexes])
98
+
99
+ const openCreate = useCallback(() => setCreateIndexOpen(true), [])
100
+ const onSubmit = useCallback(
101
+ (index: IndexState) => {
102
+ setIndexes((current) => [...current, index])
103
+ setSelectedIndex(index)
104
+ updateIndexes()
105
+ },
106
+ [updateIndexes],
107
+ )
108
+ return (
109
+ <Stack space={4}>
110
+ <Flex gap={2} align="center" style={{height: 30}}>
111
+ <Box flex={1}>
112
+ <Heading size={1}>Embeddings indexes</Heading>
113
+ </Box>
114
+ <Box style={{justifySelf: 'flex-end'}}>
115
+ <Button
116
+ icon={AddIcon}
117
+ text={'New index'}
118
+ tone="default"
119
+ mode="ghost"
120
+ onClick={openCreate}
121
+ />
122
+ </Box>
123
+ <Button
124
+ size={1}
125
+ icon={loading ? <Spinner /> : UndoIcon}
126
+ title={'Refresh index list'}
127
+ tone="default"
128
+ mode="bleed"
129
+ onClick={updateIndexes}
130
+ disabled={loading}
131
+ />
132
+ </Flex>
133
+ {error ? (
134
+ <Card tone="critical" padding={2} border>
135
+ An error occurred. See console for details.
136
+ </Card>
137
+ ) : null}
138
+ <IndexList
139
+ loading={loading}
140
+ indexes={indexes}
141
+ selectedIndex={selectedIndex}
142
+ onIndexSelected={onSelectIndex}
143
+ />
144
+ {selectedIndex && (
145
+ <IndexInfo selectedIndex={selectedIndex} onDeleteIndex={deleteNamedIndex} />
146
+ )}
147
+ <EditIndexDialog open={createIndexOpen} onClose={onCreateIndexClose} onSubmit={onSubmit} />
148
+ </Stack>
149
+ )
150
+ }
@@ -0,0 +1,181 @@
1
+ import {IndexState, NamedIndex} from '../api/embeddingsApi'
2
+ import {useSchema} from 'sanity'
3
+ import {FormEvent, useCallback, useEffect, useId, useRef, useState} from 'react'
4
+ import {Box, Button, Card, Dialog, Spinner, Stack, Text} from '@sanity/ui'
5
+ import {AddIcon} from '@sanity/icons'
6
+ import {useApiClient} from '../api/embeddingsApiHooks'
7
+ import {useDefaultIndex} from './hooks'
8
+ import {IndexFormInput} from './IndexFormInput'
9
+
10
+ export function EditIndexDialog(props: {
11
+ open: boolean
12
+ onClose: () => void
13
+ onSubmit: (index: IndexState) => void
14
+ }) {
15
+ const {open, onClose, onSubmit} = props
16
+ const id = useId()
17
+ const ref = useRef<HTMLDivElement>(null)
18
+
19
+ useEffect(() => {
20
+ if (!open) {
21
+ return
22
+ }
23
+ setTimeout(() => ref.current?.querySelector('input')?.focus())
24
+ }, [ref, open])
25
+
26
+ const handleSubmit = useCallback(
27
+ (index: IndexState) => {
28
+ onSubmit(index)
29
+ onClose()
30
+ },
31
+ [onSubmit, onClose],
32
+ )
33
+
34
+ return open ? (
35
+ <Dialog id={id} width={1} ref={ref} onClose={onClose} header="Create embeddings index">
36
+ <Stack padding={4} space={5}>
37
+ <IndexEditor readOnly={false} onSubmit={handleSubmit} />
38
+ </Stack>
39
+ </Dialog>
40
+ ) : null
41
+ }
42
+
43
+ export function IndexEditor(props: {
44
+ index?: Partial<NamedIndex>
45
+ readOnly: boolean
46
+ onSubmit?: (index: IndexState) => void
47
+ }) {
48
+ const {readOnly, index: selectedIndex, onSubmit} = props
49
+ const client = useApiClient()
50
+ const schema = useSchema()
51
+ const defaultIndex = useDefaultIndex(schema, client.config().dataset ?? '')
52
+ const [errors, setErrors] = useState<string[] | undefined>()
53
+ const [loading, setLoading] = useState<boolean>()
54
+ const [index, setIndex] = useState<Partial<NamedIndex>>(() => ({
55
+ ...defaultIndex,
56
+ ...selectedIndex,
57
+ }))
58
+
59
+ useEffect(() => setIndex(selectedIndex ?? {...defaultIndex}), [selectedIndex, defaultIndex])
60
+
61
+ const handleSubmit = useCallback(
62
+ (e: FormEvent) => {
63
+ e.preventDefault()
64
+ if (readOnly) {
65
+ return
66
+ }
67
+
68
+ const validationErrors: string[] = []
69
+
70
+ if (!index.indexName) {
71
+ validationErrors.push('Index name is required')
72
+ } else if (!index.indexName.match(/^[a-zA-Z0-9-_]+$/g)) {
73
+ validationErrors.push('Index name can only contain the letters a-z, numbers - and _')
74
+ }
75
+
76
+ if (!index.dataset) {
77
+ validationErrors.push('Dataset is required')
78
+ }
79
+
80
+ if (!index.filter) {
81
+ validationErrors.push('Filter is required')
82
+ }
83
+
84
+ if (!index.projection) {
85
+ validationErrors.push('Projection is required')
86
+ }
87
+
88
+ if (validationErrors.length) {
89
+ setErrors(validationErrors)
90
+ return
91
+ }
92
+
93
+ const {projectId} = client.config()
94
+ setLoading(true)
95
+ client
96
+ .request({
97
+ method: 'POST',
98
+ url: `/embeddings-index/${index.dataset}?projectId=${projectId}`,
99
+ body: {
100
+ indexName: index.indexName,
101
+ projection: index.projection,
102
+ filter: index.filter,
103
+ },
104
+ })
105
+ .then((response: {index: IndexState}) => {
106
+ if (onSubmit) {
107
+ onSubmit(response.index)
108
+ }
109
+ })
110
+ .catch((err: any) => {
111
+ console.error(err)
112
+ setErrors([err.message])
113
+ })
114
+ .finally(() => {
115
+ setLoading(false)
116
+ })
117
+ },
118
+ [index, readOnly, onSubmit, client],
119
+ )
120
+
121
+ return (
122
+ <form onSubmit={handleSubmit}>
123
+ <Stack space={4}>
124
+ {errors?.length ? (
125
+ <Card tone="critical" border padding={2}>
126
+ <Text>
127
+ <ul style={{marginLeft: -10}}>
128
+ {/* eslint-disable-next-line react/no-array-index-key */}
129
+ {errors?.map((error, i) => <li key={`${error}-${i}`}>{error}</li>)}
130
+ </ul>
131
+ </Text>
132
+ </Card>
133
+ ) : null}
134
+ <IndexFormInput
135
+ label="Index name"
136
+ placeholder={'Name of index without spaces...'}
137
+ index={index}
138
+ prop="indexName"
139
+ onChange={setIndex}
140
+ readOnly={readOnly}
141
+ />
142
+ <IndexFormInput label="Dataset" index={index} prop="dataset" onChange={setIndex} readOnly />
143
+ <IndexFormInput
144
+ label="Projection"
145
+ placeholder={defaultIndex.projection}
146
+ index={index}
147
+ prop="projection"
148
+ onChange={setIndex}
149
+ readOnly={readOnly}
150
+ type="textarea"
151
+ />
152
+ <IndexFormInput
153
+ label="Filter"
154
+ placeholder={defaultIndex.filter}
155
+ index={index}
156
+ prop="filter"
157
+ onChange={setIndex}
158
+ readOnly={readOnly}
159
+ type="textarea"
160
+ />
161
+ {onSubmit && (
162
+ <Button
163
+ type="submit"
164
+ text="Create index"
165
+ icon={
166
+ loading ? (
167
+ <Box style={{marginTop: 5}}>
168
+ <Spinner />
169
+ </Box>
170
+ ) : (
171
+ AddIcon
172
+ )
173
+ }
174
+ disabled={readOnly || loading}
175
+ tone="primary"
176
+ />
177
+ )}
178
+ </Stack>
179
+ </form>
180
+ )
181
+ }
@@ -0,0 +1,75 @@
1
+ import {NamedIndex} from '../api/embeddingsApi'
2
+ import {Dispatch, FormEvent, SetStateAction, useCallback, useId} from 'react'
3
+ import {Label, Stack, TextArea, TextInput} from '@sanity/ui'
4
+
5
+ export interface IndexFormInputProps {
6
+ index: Partial<NamedIndex>
7
+ prop: keyof NamedIndex
8
+ label: string
9
+ onChange: Dispatch<SetStateAction<Partial<NamedIndex>>>
10
+ readOnly: boolean
11
+ placeholder?: string
12
+ type?: 'text' | 'textarea'
13
+ }
14
+
15
+ export function IndexFormInput(props: IndexFormInputProps) {
16
+ const {label, index, prop, onChange, readOnly, placeholder, type} = props
17
+ const handleChange = useCallback(
18
+ (propValue: string) => onChange((current) => ({...current, [prop]: propValue})),
19
+ [onChange, prop],
20
+ )
21
+ return (
22
+ <FormInput
23
+ label={label}
24
+ onChange={handleChange}
25
+ value={index[prop] ?? ''}
26
+ readOnly={readOnly}
27
+ placeholder={placeholder}
28
+ type={type}
29
+ />
30
+ )
31
+ }
32
+
33
+ interface FormInputProps {
34
+ label: string
35
+ onChange: (value: string) => void
36
+ value: string
37
+ readOnly: boolean
38
+ placeholder?: string
39
+ type?: 'text' | 'textarea'
40
+ }
41
+
42
+ function FormInput(props: FormInputProps) {
43
+ const {label, onChange, value, readOnly, placeholder, type = 'text'} = props
44
+ const id = useId()
45
+ const handleChange = useCallback(
46
+ (e: FormEvent<HTMLInputElement | HTMLTextAreaElement>) => onChange(e.currentTarget.value),
47
+ [onChange],
48
+ )
49
+ return (
50
+ <Stack space={3}>
51
+ <Label muted htmlFor={id}>
52
+ <label htmlFor={id}>{label}</label>
53
+ </Label>
54
+ {type === 'text' ? (
55
+ <TextInput
56
+ id={id}
57
+ value={value}
58
+ onChange={handleChange}
59
+ readOnly={readOnly}
60
+ placeholder={placeholder}
61
+ />
62
+ ) : (
63
+ <TextArea
64
+ id={id}
65
+ value={value}
66
+ rows={3}
67
+ onChange={handleChange}
68
+ readOnly={readOnly}
69
+ placeholder={placeholder}
70
+ style={{resize: 'vertical'}}
71
+ />
72
+ )}
73
+ </Stack>
74
+ )
75
+ }
@@ -0,0 +1,116 @@
1
+ import {
2
+ Box,
3
+ Button,
4
+ Flex,
5
+ Heading,
6
+ Label,
7
+ Menu,
8
+ MenuButton,
9
+ MenuItem,
10
+ Stack,
11
+ Text,
12
+ } from '@sanity/ui'
13
+ import {EllipsisVerticalIcon, TrashIcon} from '@sanity/icons'
14
+ import {IndexEditor} from './IndexEditor'
15
+ import {QueryIndex} from './QueryIndex'
16
+ import {IndexState} from '../api/embeddingsApi'
17
+ import {useCallback} from 'react'
18
+
19
+ export interface IndexInfoProps {
20
+ selectedIndex: IndexState
21
+ onDeleteIndex: (index: IndexState) => void
22
+ }
23
+
24
+ export function IndexInfo({selectedIndex, onDeleteIndex}: IndexInfoProps) {
25
+ const handleDelete = useCallback(
26
+ () => onDeleteIndex(selectedIndex),
27
+ [selectedIndex, onDeleteIndex],
28
+ )
29
+ return (
30
+ <Stack space={4} flex={1}>
31
+ <Flex align="center" flex={1} gap={2}>
32
+ <Box flex={1}>
33
+ <Heading>Index: {selectedIndex?.indexName ?? 'Untitled'}</Heading>
34
+ </Box>
35
+ <Box>
36
+ <MenuButton
37
+ button={
38
+ <Button
39
+ title="Open index actions"
40
+ icon={EllipsisVerticalIcon}
41
+ padding={2}
42
+ mode="ghost"
43
+ />
44
+ }
45
+ id={`button-${selectedIndex.indexName}`}
46
+ menu={
47
+ <Menu>
48
+ <MenuItem
49
+ text="Delete index"
50
+ icon={TrashIcon}
51
+ tone="critical"
52
+ onClick={handleDelete}
53
+ />
54
+ </Menu>
55
+ }
56
+ popover={{placement: 'right'}}
57
+ />
58
+ </Box>
59
+ </Flex>
60
+
61
+ <Flex gap={6}>
62
+ <Stack space={4} flex={1} style={{maxWidth: 600}}>
63
+ <Box>
64
+ <IndexEditor index={selectedIndex} readOnly />
65
+ </Box>
66
+ <IndexStatus selectedIndex={selectedIndex} />
67
+ </Stack>
68
+
69
+ <Stack space={3} flex={1}>
70
+ <Label muted>Query index</Label>
71
+ <QueryIndex indexName={selectedIndex.indexName} key={selectedIndex.indexName} />
72
+ </Stack>
73
+ </Flex>
74
+ </Stack>
75
+ )
76
+ }
77
+
78
+ function IndexStatus({selectedIndex}: {selectedIndex: IndexState}) {
79
+ return (
80
+ <Stack space={4} flex={1}>
81
+ <Flex gap={2} align="center">
82
+ <Box flex={1}>
83
+ <Label size={1} muted>
84
+ Status
85
+ </Label>
86
+ </Box>
87
+ <Stack space={2}>
88
+ <Text>{selectedIndex.status}</Text>
89
+ </Stack>
90
+ </Flex>
91
+ <Flex gap={5} align="center">
92
+ <Box flex={1}>
93
+ <Label size={1} muted>
94
+ Indexing progress
95
+ </Label>
96
+ </Box>
97
+ <Stack space={2}>
98
+ <Text>
99
+ {selectedIndex.startDocumentCount - selectedIndex.remainingDocumentCount} /{' '}
100
+ {selectedIndex.startDocumentCount}
101
+ </Text>
102
+ </Stack>
103
+ </Flex>
104
+ <Flex gap={5} align="center">
105
+ <Box flex={1}>
106
+ <Label size={1} muted>
107
+ Failed documents
108
+ </Label>
109
+ </Box>
110
+ <Stack space={2}>
111
+ <Text>{selectedIndex.failedDocumentCount}</Text>
112
+ </Stack>
113
+ </Flex>
114
+ </Stack>
115
+ )
116
+ }