@smart-cloud/ai-kit-ui 1.1.39 → 1.1.41

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,498 @@
1
+ import {
2
+ Alert,
3
+ Anchor,
4
+ Button,
5
+ Divider,
6
+ Group,
7
+ Loader,
8
+ Modal,
9
+ Paper,
10
+ Stack,
11
+ Text,
12
+ TextInput,
13
+ Title,
14
+ } from "@mantine/core";
15
+ import {
16
+ AiKitDocSearchIcon,
17
+ sendSearchMessage,
18
+ type AiKitStatusEvent,
19
+ type DocSearchProps,
20
+ type SearchResult,
21
+ } from "@smart-cloud/ai-kit-core";
22
+ import { I18n } from "aws-amplify/utils";
23
+ import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
24
+ import ReactMarkdown from "react-markdown";
25
+ import rehypeRaw from "rehype-raw";
26
+ import remarkGfm from "remark-gfm";
27
+
28
+ import { IconSearch } from "@tabler/icons-react";
29
+
30
+ import { AiFeatureBorder } from "../ai-feature/AiFeatureBorder";
31
+ import { translations } from "../i18n";
32
+ import { useAiRun } from "../useAiRun";
33
+ import { AiKitShellInjectedProps, withAiKitShell } from "../withAiKitShell";
34
+ import { PoweredBy } from "../poweredBy";
35
+
36
+ I18n.putVocabularies(translations);
37
+
38
+ type Props = DocSearchProps & AiKitShellInjectedProps;
39
+
40
+ function groupChunksByDoc(result: SearchResult | null) {
41
+ const docs = result?.citations?.docs ?? [];
42
+ const chunks = result?.citations?.chunks ?? [];
43
+ const byDoc = new Map<
44
+ string,
45
+ { doc: (typeof docs)[number]; chunks: typeof chunks }
46
+ >();
47
+
48
+ for (const doc of docs) {
49
+ byDoc.set(doc.docId, { doc, chunks: [] });
50
+ }
51
+ for (const ch of chunks) {
52
+ const cur = byDoc.get(ch.docId);
53
+ if (cur) {
54
+ cur.chunks.push(ch);
55
+ } else {
56
+ // fallback if doc list is incomplete
57
+ byDoc.set(ch.docId, { doc: { docId: ch.docId }, chunks: [ch] });
58
+ }
59
+ }
60
+ return Array.from(byDoc.values());
61
+ }
62
+
63
+ const DocSearchBase: FC<Props> = (props) => {
64
+ const {
65
+ autoRun = true,
66
+ context,
67
+ title,
68
+ searchButtonIcon,
69
+ showSearchButtonTitle = true,
70
+ showSearchButtonIcon = true,
71
+ showSources = true,
72
+ topK = 10,
73
+ getSearchText,
74
+
75
+ variation,
76
+ rootElement,
77
+ colorMode,
78
+ language,
79
+ onClose,
80
+ onClickDoc,
81
+ } = props;
82
+
83
+ const [query, setQuery] = useState<string>("");
84
+ const { busy, error, statusEvent, result, run, cancel, reset } =
85
+ useAiRun<SearchResult>();
86
+
87
+ const autoRunOnceRef = useRef(false);
88
+
89
+ const sessionId = result?.sessionId;
90
+ const citationDocs = result?.citations?.docs ?? [];
91
+ const citationChunks = result?.citations?.chunks ?? [];
92
+ const citationAnchors = result?.citations?.anchors ?? [];
93
+ const summaryText = result?.result ?? "";
94
+
95
+ const buttonLeftIcon = useMemo(() => {
96
+ if (!showSearchButtonIcon) return undefined;
97
+
98
+ if (searchButtonIcon?.trim()) {
99
+ return (
100
+ <img
101
+ src={searchButtonIcon}
102
+ alt=""
103
+ style={{ width: 18, height: 18, objectFit: "contain" }}
104
+ />
105
+ );
106
+ }
107
+ return <IconSearch size={18} />;
108
+ }, [searchButtonIcon, showSearchButtonIcon]);
109
+
110
+ const defaultTitle = useMemo(() => {
111
+ if (language) {
112
+ I18n.setLanguage(language || "en");
113
+ }
114
+ return I18n.get(title || "Search with AI-Kit");
115
+ }, [language]);
116
+
117
+ const statusText = useMemo(() => {
118
+ const e: AiKitStatusEvent | null = statusEvent;
119
+ if (!e) return null;
120
+ // Keep this generic and short (backend / on-device messages differ).
121
+ return I18n.get("Searching…");
122
+ }, [language, statusEvent]);
123
+
124
+ const inputText = useMemo(() => {
125
+ return query || getSearchText;
126
+ }, [query, getSearchText]);
127
+
128
+ const canSearch = useMemo(() => {
129
+ if (busy) return false;
130
+ const text = typeof inputText === "function" ? inputText() : inputText;
131
+ return Boolean(text && text.trim().length > 0);
132
+ }, [inputText, busy]);
133
+
134
+ const onSearch = useCallback(async () => {
135
+ const q =
136
+ typeof inputText === "function"
137
+ ? (inputText as () => string)()
138
+ : inputText;
139
+ if (!q) return;
140
+ setQuery(q);
141
+ reset();
142
+ await run(async ({ signal, onStatus }) => {
143
+ return await sendSearchMessage(
144
+ { sessionId, query: q, topK },
145
+ { signal, onStatus, context },
146
+ );
147
+ });
148
+ }, [context, inputText, run, reset, topK, sessionId]);
149
+
150
+ const close = useCallback(async () => {
151
+ reset();
152
+ onClose();
153
+ autoRunOnceRef.current = false;
154
+ }, [onClose, reset, autoRunOnceRef]);
155
+
156
+ useEffect(() => {
157
+ if (!autoRun || !canSearch || busy || autoRunOnceRef.current) {
158
+ return;
159
+ }
160
+ autoRunOnceRef.current = true;
161
+ queueMicrotask(() => {
162
+ void onSearch();
163
+ });
164
+ }, [busy, autoRunOnceRef, canSearch, autoRun, onSearch]);
165
+
166
+ useEffect(() => {
167
+ if (!canSearch) {
168
+ autoRunOnceRef.current = true;
169
+ }
170
+ }, [canSearch]);
171
+
172
+ const grouped = useMemo(() => groupChunksByDoc(result), [result]);
173
+
174
+ const docNumberMap = useMemo(() => {
175
+ const map = new Map<string, number>();
176
+ citationDocs.forEach((doc, index) => {
177
+ if (doc?.docId) {
178
+ map.set(doc.docId, index + 1);
179
+ }
180
+ });
181
+ return map;
182
+ }, [citationDocs]);
183
+
184
+ const chunkDocMap = useMemo(() => {
185
+ const map = new Map<string, string>();
186
+ citationChunks.forEach((chunk) => {
187
+ if (chunk?.chunkId && chunk?.docId) {
188
+ map.set(chunk.chunkId, chunk.docId);
189
+ }
190
+ });
191
+ return map;
192
+ }, [citationChunks]);
193
+
194
+ const annotatedSummary = useMemo(() => {
195
+ if (!summaryText) return "";
196
+ if (!citationAnchors.length || docNumberMap.size === 0) {
197
+ return summaryText;
198
+ }
199
+
200
+ const sortedAnchors = [...citationAnchors]
201
+ .filter(
202
+ (anchor) =>
203
+ Array.isArray(anchor?.chunkIds) && anchor?.span?.end !== undefined,
204
+ )
205
+ .sort((a, b) => {
206
+ const aEnd = a.span?.end ?? 0;
207
+ const bEnd = b.span?.end ?? 0;
208
+ return aEnd - bEnd;
209
+ });
210
+
211
+ let cursor = 0;
212
+ const segments: string[] = [];
213
+
214
+ for (const anchor of sortedAnchors) {
215
+ const end = anchor.span?.end;
216
+ if (typeof end !== "number" || end < cursor) {
217
+ continue;
218
+ }
219
+
220
+ const refs = Array.from(
221
+ new Set(
222
+ (anchor.chunkIds ?? [])
223
+ .map((chunkId) => (chunkId ? chunkDocMap.get(chunkId) : undefined))
224
+ .map((docId) => (docId ? docNumberMap.get(docId) : undefined))
225
+ .filter((num): num is number => typeof num === "number"),
226
+ ),
227
+ );
228
+
229
+ if (!refs.length) {
230
+ continue;
231
+ }
232
+
233
+ const safeEnd = Math.min(end, summaryText.length);
234
+ segments.push(summaryText.slice(cursor, safeEnd));
235
+ segments.push(`<sup>${refs.join(",")}</sup>`);
236
+ cursor = safeEnd;
237
+ }
238
+
239
+ segments.push(summaryText.slice(cursor));
240
+ return segments.join("");
241
+ }, [citationAnchors, summaryText]);
242
+
243
+ const RootComponent: typeof Modal.Root | typeof Group =
244
+ variation === "modal" ? Modal.Root : Group;
245
+ const ContentComponent: typeof Modal.Content | typeof Group =
246
+ variation === "modal" ? Modal.Content : Group;
247
+ const BodyComponent: typeof Modal.Body | typeof Group =
248
+ variation === "modal" ? Modal.Body : Group;
249
+
250
+ useEffect(() => {
251
+ if (variation !== "modal") {
252
+ return;
253
+ }
254
+ document.body.style.overflow = "hidden";
255
+ document.body.onkeydown = (e: KeyboardEvent) => {
256
+ if (e.key === "Escape") {
257
+ e.preventDefault();
258
+ close();
259
+ }
260
+ };
261
+ return () => {
262
+ // remove overflow: hidden; from body element
263
+ document.body.style.overflow = "";
264
+ document.body.onkeydown = null;
265
+ };
266
+ }, [close, variation]);
267
+
268
+ return (
269
+ <RootComponent
270
+ opened={true}
271
+ className="doc-search-root"
272
+ onClose={close}
273
+ padding="md"
274
+ gap="md"
275
+ size="md"
276
+ portalProps={
277
+ variation === "modal"
278
+ ? { target: rootElement, reuseTargetNode: true }
279
+ : undefined
280
+ }
281
+ data-ai-kit-theme={colorMode}
282
+ data-ai-kit-variation={variation}
283
+ >
284
+ {variation === "modal" && <Modal.Overlay />}
285
+ <ContentComponent
286
+ w="100%"
287
+ style={{
288
+ left: 0,
289
+ }}
290
+ >
291
+ {variation === "modal" && (
292
+ <Modal.Header style={{ zIndex: 1000 }}>
293
+ <AiKitDocSearchIcon className="doc-search-title-icon" />
294
+ <Modal.Title>{I18n.get(defaultTitle)}</Modal.Title>
295
+ <Modal.CloseButton />
296
+ </Modal.Header>
297
+ )}
298
+ <BodyComponent w="100%" style={{ zIndex: 1001 }}>
299
+ <AiFeatureBorder
300
+ enabled={variation !== "modal"}
301
+ working={busy}
302
+ variation={variation}
303
+ >
304
+ <Paper shadow="sm" radius="md" p="md">
305
+ <Stack gap="sm">
306
+ {variation !== "modal" && (
307
+ <Title order={4} style={{ margin: 0 }}>
308
+ {I18n.get(defaultTitle)}
309
+ </Title>
310
+ )}
311
+
312
+ <Group gap="sm" align="flex-end" wrap="nowrap">
313
+ <TextInput
314
+ style={{ flex: 1 }}
315
+ value={query}
316
+ onChange={(e) => setQuery(e.currentTarget.value)}
317
+ placeholder={I18n.get("Search the documentation…")}
318
+ disabled={busy}
319
+ onKeyDown={(e) => {
320
+ if (e.key === "Enter" && canSearch) {
321
+ e.preventDefault();
322
+ void onSearch();
323
+ }
324
+ }}
325
+ />
326
+
327
+ <Button
328
+ leftSection={buttonLeftIcon}
329
+ onClick={() => void onSearch()}
330
+ disabled={!canSearch}
331
+ className={
332
+ showSearchButtonTitle
333
+ ? "doc-search-button"
334
+ : "doc-search-button-no-title"
335
+ }
336
+ >
337
+ {showSearchButtonTitle ? I18n.get("Search") : null}
338
+ </Button>
339
+
340
+ {busy ? (
341
+ <Button variant="light" color="red" onClick={cancel}>
342
+ {I18n.get("Stop")}
343
+ </Button>
344
+ ) : null}
345
+ </Group>
346
+
347
+ {error ? (
348
+ <Alert color="red" title={I18n.get("Error")}>
349
+ {error}
350
+ </Alert>
351
+ ) : null}
352
+
353
+ {busy && statusText && (
354
+ <AiFeatureBorder
355
+ enabled={variation === "modal"}
356
+ working={true}
357
+ variation={variation}
358
+ >
359
+ <Group
360
+ justify="center"
361
+ align="center"
362
+ gap="sm"
363
+ m="sm"
364
+ pr="lg"
365
+ >
366
+ <Loader size="sm" />
367
+ <Text size="sm" c="dimmed">
368
+ {statusText}
369
+ </Text>
370
+ </Group>
371
+ </AiFeatureBorder>
372
+ )}
373
+
374
+ {result?.result ? (
375
+ <>
376
+ <Divider />
377
+ <Stack gap="xs" data-doc-search-result>
378
+ <Text size="sm" c="dimmed" data-doc-search-result-title>
379
+ {I18n.get("AI Summary")}
380
+ </Text>
381
+ <ReactMarkdown
382
+ remarkPlugins={[remarkGfm]}
383
+ rehypePlugins={[rehypeRaw]}
384
+ data-doc-search-result-content
385
+ >
386
+ {annotatedSummary || summaryText}
387
+ </ReactMarkdown>
388
+ </Stack>
389
+ </>
390
+ ) : null}
391
+
392
+ {showSources &&
393
+ (result?.citations?.docs?.length ||
394
+ result?.citations?.chunks?.length) ? (
395
+ <>
396
+ <Divider />
397
+ <Stack gap="sm" data-doc-search-sources>
398
+ <Text size="sm" c="dimmed" data-doc-search-sources-title>
399
+ {I18n.get("Sources")}
400
+ </Text>
401
+
402
+ {grouped.map(({ doc }) => {
403
+ const href = doc.sourceUrl?.trim() || undefined;
404
+ const docNumber = doc.docId
405
+ ? docNumberMap.get(doc.docId)
406
+ : undefined;
407
+ const titleText = doc.title?.trim() || doc.docId;
408
+ const titleNode = (
409
+ <Text fw={600} style={{ display: "inline" }}>
410
+ {docNumber ? `${docNumber}. ` : ""}
411
+ {titleText}
412
+ </Text>
413
+ );
414
+ return (
415
+ <Paper key={doc.docId} withBorder radius="md" p="sm">
416
+ <Stack gap="xs">
417
+ <Group justify="space-between" align="flex-start">
418
+ <Stack
419
+ gap={2}
420
+ style={{ flex: 1 }}
421
+ data-doc-search-source
422
+ >
423
+ {href ? (
424
+ <Anchor
425
+ href={href}
426
+ target="_blank"
427
+ rel="noreferrer"
428
+ style={{ textDecoration: "none" }}
429
+ onClick={(e) => {
430
+ if (!onClickDoc) return;
431
+ e.preventDefault();
432
+ onClickDoc?.(doc);
433
+ }}
434
+ data-doc-search-source-title
435
+ >
436
+ {titleNode}
437
+ </Anchor>
438
+ ) : (
439
+ titleNode
440
+ )}
441
+ <Anchor
442
+ href={href}
443
+ target="_blank"
444
+ rel="noreferrer"
445
+ style={{ textDecoration: "none" }}
446
+ onClick={(e) => {
447
+ if (!onClickDoc) return;
448
+ e.preventDefault();
449
+ onClickDoc?.(doc);
450
+ }}
451
+ data-doc-search-source-url
452
+ >
453
+ {doc.sourceUrl}
454
+ </Anchor>
455
+ {doc.author ? (
456
+ <Text
457
+ size="xs"
458
+ c="dimmed"
459
+ data-doc-search-source-author
460
+ >
461
+ {doc.author}
462
+ </Text>
463
+ ) : null}
464
+ {doc.description ? (
465
+ <Text
466
+ size="sm"
467
+ c="dimmed"
468
+ fs="italic"
469
+ data-doc-search-source-description
470
+ >
471
+ {doc.description}
472
+ </Text>
473
+ ) : null}
474
+ </Stack>
475
+ </Group>
476
+ </Stack>
477
+ </Paper>
478
+ );
479
+ })}
480
+ </Stack>
481
+ </>
482
+ ) : null}
483
+ {!busy && !error && !result?.result ? (
484
+ <Text size="sm" c="dimmed" data-doc-search-no-results>
485
+ {I18n.get("Enter a search query to start.")}
486
+ </Text>
487
+ ) : null}
488
+ <PoweredBy variation={variation} />
489
+ </Stack>
490
+ </Paper>
491
+ </AiFeatureBorder>
492
+ </BodyComponent>
493
+ </ContentComponent>
494
+ </RootComponent>
495
+ );
496
+ };
497
+
498
+ export const DocSearch = withAiKitShell(DocSearchBase);
@@ -0,0 +1 @@
1
+ export { DocSearch } from "./DocSearch";