@smart-cloud/ai-kit-ui 1.3.12 → 1.3.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smart-cloud/ai-kit-ui",
3
- "version": "1.3.12",
3
+ "version": "1.3.14",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -76,6 +76,41 @@ function groupChunksByDoc(result: SearchResult | null) {
76
76
  return Array.from(byDoc.values());
77
77
  }
78
78
 
79
+ function isWordChar(ch: string) {
80
+ // Unicode letter/number + underscore; good for Cyrillic too
81
+ return Boolean(ch) && /[\p{L}\p{N}_]/u.test(ch);
82
+ }
83
+
84
+ /**
85
+ * Backend sometimes returns anchor `end` that lands inside a word.
86
+ * Snap the insertion point to a word boundary (end of current word).
87
+ */
88
+ function snapEndToBoundary(text: string, end: number) {
89
+ const len = text.length;
90
+ if (end <= 0) return 0;
91
+ if (end >= len) return len;
92
+
93
+ const prev = text[end - 1] ?? "";
94
+ const cur = text[end] ?? "";
95
+
96
+ // If we're inside a word, walk forward until word ends.
97
+ if (isWordChar(prev) && isWordChar(cur)) {
98
+ let i = end;
99
+ while (i < len && isWordChar(text[i] ?? "")) i++;
100
+ return i;
101
+ }
102
+
103
+ return end;
104
+ }
105
+
106
+ function escapeCssId(id: string) {
107
+ // Prefer native CSS.escape, fallback to a minimal safe escape.
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ const esc = (globalThis as any)?.CSS?.escape;
110
+ if (typeof esc === "function") return esc(id);
111
+ return id.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
112
+ }
113
+
79
114
  const DocSearchBase: FC<Props> = (props) => {
80
115
  const {
81
116
  autoRun = true,
@@ -202,16 +237,8 @@ const DocSearchBase: FC<Props> = (props) => {
202
237
  return [];
203
238
  }
204
239
  return selectedCategories
205
- .flatMap(
206
- (cat) =>
207
- metadataOptions.allowedCategories[
208
- cat
209
- ] || [],
210
- )
211
- .filter(
212
- (subcat, index, self) =>
213
- self.indexOf(subcat) === index,
214
- )
240
+ .flatMap((cat) => metadataOptions.allowedCategories[cat] || [])
241
+ .filter((subcat, index, self) => self.indexOf(subcat) === index);
215
242
  }, [selectedCategories, metadataOptions]);
216
243
 
217
244
  const startRecording = useCallback(async () => {
@@ -395,8 +422,6 @@ const DocSearchBase: FC<Props> = (props) => {
395
422
  setQuery(q);
396
423
  }
397
424
 
398
- console.log("Starting search with query:", q, "and audio:", audioBlob);
399
-
400
425
  // Check if we can reuse cached audio (same blob within TTL)
401
426
  const now = Date.now();
402
427
  const isSameAudio =
@@ -410,16 +435,6 @@ const DocSearchBase: FC<Props> = (props) => {
410
435
  blob: audioBlob,
411
436
  uploadTimestamp: now,
412
437
  };
413
- console.log("Audio cache updated for new recording");
414
- } else if (isSameAudio) {
415
- console.log(
416
- "Reusing cached audio (no re-upload needed within",
417
- Math.round(
418
- (AUDIO_CACHE_TTL - (now - audioCacheRef.current!.uploadTimestamp)) /
419
- 1000,
420
- ),
421
- "seconds)",
422
- );
423
438
  }
424
439
 
425
440
  reset();
@@ -438,8 +453,8 @@ const DocSearchBase: FC<Props> = (props) => {
438
453
  }),
439
454
  ...(enableUserFilters &&
440
455
  selectedSubcategories.length > 0 && {
441
- userSelectedSubcategories: selectedSubcategories,
442
- }),
456
+ userSelectedSubcategories: selectedSubcategories,
457
+ }),
443
458
  ...(enableUserFilters &&
444
459
  selectedTags.length > 0 && { userSelectedTags: selectedTags }),
445
460
  },
@@ -565,14 +580,24 @@ const DocSearchBase: FC<Props> = (props) => {
565
580
  }
566
581
 
567
582
  const safeEnd = Math.min(end, summaryText.length);
568
- segments.push(summaryText.slice(cursor, safeEnd));
569
- segments.push(`<sup>${refs.join(",")}</sup>`);
570
- cursor = safeEnd;
583
+ const snappedEnd = snapEndToBoundary(summaryText, safeEnd);
584
+
585
+ segments.push(summaryText.slice(cursor, snappedEnd));
586
+
587
+ const supHtml = refs
588
+ .map(
589
+ (n) =>
590
+ `<a href="#docsearch-source-${n}" data-docsearch-cite="${n}" style="color: inherit; text-decoration: none;">${n}</a>`,
591
+ )
592
+ .join(",");
593
+
594
+ segments.push(`<sup>${supHtml}</sup>`);
595
+ cursor = snappedEnd;
571
596
  }
572
597
 
573
598
  segments.push(summaryText.slice(cursor));
574
599
  return segments.join("");
575
- }, [citationAnchors, summaryText]);
600
+ }, [citationAnchors, summaryText, docNumberMap, chunkDocMap]);
576
601
 
577
602
  const RootComponent: typeof Modal.Root | typeof Group =
578
603
  variation === "modal" ? Modal.Root : Group;
@@ -731,49 +756,48 @@ const DocSearchBase: FC<Props> = (props) => {
731
756
  }}
732
757
  />
733
758
 
734
- {
735
- /* Microphone button */ USE_AUDIO && (
736
- <>
737
- {audioBlob ? (
738
- <Button
739
- variant="outline"
740
- size="sm"
741
- color="red"
742
- onClick={clearAudio}
743
- disabled={busy}
744
- title={I18n.get("Clear audio")}
745
- >
746
- <IconMicrophoneOff size={18} />
747
- </Button>
748
- ) : (
749
- <Button
750
- variant={recording ? "filled" : "outline"}
751
- size="sm"
752
- color={recording ? "red" : "gray"}
753
- onClick={
754
- recording ? stopRecording : startRecording
755
- }
756
- disabled={busy}
757
- title={
758
- recording
759
- ? I18n.get("Stop recording")
760
- : I18n.get("Record audio")
761
- }
762
- style={
763
- recording
764
- ? {
759
+ {/* Microphone button */}
760
+ {USE_AUDIO && (
761
+ <>
762
+ {audioBlob ? (
763
+ <Button
764
+ variant="outline"
765
+ size="sm"
766
+ color="red"
767
+ onClick={clearAudio}
768
+ disabled={busy}
769
+ title={I18n.get("Clear audio")}
770
+ >
771
+ <IconMicrophoneOff size={18} />
772
+ </Button>
773
+ ) : (
774
+ <Button
775
+ variant={recording ? "filled" : "outline"}
776
+ size="sm"
777
+ color={recording ? "red" : "gray"}
778
+ onClick={
779
+ recording ? stopRecording : startRecording
780
+ }
781
+ disabled={busy}
782
+ title={
783
+ recording
784
+ ? I18n.get("Stop recording")
785
+ : I18n.get("Record audio")
786
+ }
787
+ style={
788
+ recording
789
+ ? {
765
790
  transform: `scale(${1 + audioLevel / 300})`,
766
791
  transition: "transform 0.1s ease-out",
767
792
  }
768
- : undefined
769
- }
770
- >
771
- <IconMicrophone size={18} />
772
- </Button>
773
- )}
774
- </>
775
- )
776
- }
793
+ : undefined
794
+ }
795
+ >
796
+ <IconMicrophone size={18} />
797
+ </Button>
798
+ )}
799
+ </>
800
+ )}
777
801
 
778
802
  <Button
779
803
  variant="filled"
@@ -830,51 +854,51 @@ const DocSearchBase: FC<Props> = (props) => {
830
854
  {/* Main categories as checkboxes */}
831
855
  {Object.keys(metadataOptions.allowedCategories)
832
856
  .length > 0 && (
833
- <div>
834
- <Text size="sm" fw={500} mb="xs">
835
- {I18n.get("Categories")}
836
- </Text>
837
- <Group gap="md">
838
- {Object.keys(
839
- metadataOptions.allowedCategories,
840
- ).map((category) => (
841
- <Checkbox
842
- key={category}
843
- label={I18n.get(category)}
844
- checked={selectedCategories.includes(
845
- category,
846
- )}
847
- onChange={(e) => {
848
- if (e.currentTarget.checked) {
849
- setSelectedCategories([
850
- ...selectedCategories,
851
- category,
852
- ]);
853
- } else {
854
- setSelectedCategories(
855
- selectedCategories.filter(
856
- (c) => c !== category,
857
- ),
858
- );
859
- // Remove subcategories of unchecked category
860
- const subcatsToRemove =
861
- metadataOptions.allowedCategories[
857
+ <div>
858
+ <Text size="sm" fw={500} mb="xs">
859
+ {I18n.get("Categories")}
860
+ </Text>
861
+ <Group gap="md">
862
+ {Object.keys(
863
+ metadataOptions.allowedCategories,
864
+ ).map((category) => (
865
+ <Checkbox
866
+ key={category}
867
+ label={I18n.get(category)}
868
+ checked={selectedCategories.includes(
869
+ category,
870
+ )}
871
+ onChange={(e) => {
872
+ if (e.currentTarget.checked) {
873
+ setSelectedCategories([
874
+ ...selectedCategories,
875
+ category,
876
+ ]);
877
+ } else {
878
+ setSelectedCategories(
879
+ selectedCategories.filter(
880
+ (c) => c !== category,
881
+ ),
882
+ );
883
+ // Remove subcategories of unchecked category
884
+ const subcatsToRemove =
885
+ metadataOptions.allowedCategories[
862
886
  category
863
- ] || [];
864
- setSelectedSubcategories(
865
- selectedSubcategories.filter(
866
- (sc) =>
867
- !subcatsToRemove.includes(sc),
868
- ),
869
- );
870
- }
871
- }}
872
- disabled={busy || loadingMetadata}
873
- />
874
- ))}
875
- </Group>
876
- </div>
877
- )}
887
+ ] || [];
888
+ setSelectedSubcategories(
889
+ selectedSubcategories.filter(
890
+ (sc) =>
891
+ !subcatsToRemove.includes(sc),
892
+ ),
893
+ );
894
+ }
895
+ }}
896
+ disabled={busy || loadingMetadata}
897
+ />
898
+ ))}
899
+ </Group>
900
+ </div>
901
+ )}
878
902
 
879
903
  {/* Subcategories for selected categories */}
880
904
  {subcategories.length > 0 && (
@@ -883,31 +907,30 @@ const DocSearchBase: FC<Props> = (props) => {
883
907
  {I18n.get("Subcategories")}
884
908
  </Text>
885
909
  <Group gap="md">
886
- {subcategories
887
- .map((subcat) => (
888
- <Checkbox
889
- key={subcat}
890
- label={I18n.get(subcat)}
891
- checked={selectedSubcategories.includes(
892
- subcat,
893
- )}
894
- onChange={(e) => {
895
- if (e.currentTarget.checked) {
896
- setSelectedSubcategories([
897
- ...selectedSubcategories,
898
- subcat,
899
- ]);
900
- } else {
901
- setSelectedSubcategories(
902
- selectedSubcategories.filter(
903
- (sc) => sc !== subcat,
904
- ),
905
- );
906
- }
907
- }}
908
- disabled={busy || loadingMetadata}
909
- />
910
- ))}
910
+ {subcategories.map((subcat) => (
911
+ <Checkbox
912
+ key={subcat}
913
+ label={I18n.get(subcat)}
914
+ checked={selectedSubcategories.includes(
915
+ subcat,
916
+ )}
917
+ onChange={(e) => {
918
+ if (e.currentTarget.checked) {
919
+ setSelectedSubcategories([
920
+ ...selectedSubcategories,
921
+ subcat,
922
+ ]);
923
+ } else {
924
+ setSelectedSubcategories(
925
+ selectedSubcategories.filter(
926
+ (sc) => sc !== subcat,
927
+ ),
928
+ );
929
+ }
930
+ }}
931
+ disabled={busy || loadingMetadata}
932
+ />
933
+ ))}
911
934
  </Group>
912
935
  </div>
913
936
  )}
@@ -941,40 +964,39 @@ const DocSearchBase: FC<Props> = (props) => {
941
964
  </Stack>
942
965
  )}
943
966
 
944
- {
945
- /* Audio level indicator when recording */ USE_AUDIO && (
946
- <>
947
- {recording && (
948
- <Stack gap="xs">
949
- <Text size="xs" c="dimmed">
950
- {I18n.get("Recording...")} 🎤
951
- </Text>
952
- <Progress
953
- value={audioLevel}
954
- size="sm"
955
- color="red"
956
- animated
957
- striped
958
- />
959
- </Stack>
960
- )}
967
+ {/* Audio level indicator when recording */}
968
+ {USE_AUDIO && (
969
+ <>
970
+ {recording && (
971
+ <Stack gap="xs">
972
+ <Text size="xs" c="dimmed">
973
+ {I18n.get("Recording...")} 🎤
974
+ </Text>
975
+ <Progress
976
+ value={audioLevel}
977
+ size="sm"
978
+ color="red"
979
+ animated
980
+ striped
981
+ />
982
+ </Stack>
983
+ )}
961
984
 
962
- {/* Audio playback when recorded */}
963
- {audioBlob && !recording && (
964
- <Stack gap="xs">
965
- <Text size="xs" c="dimmed">
966
- {I18n.get("Recorded audio:")}
967
- </Text>
968
- <audio
969
- controls
970
- src={URL.createObjectURL(audioBlob)}
971
- className="ai-kit-audio-player"
972
- />
973
- </Stack>
974
- )}
975
- </>
976
- )
977
- }
985
+ {/* Audio playback when recorded */}
986
+ {audioBlob && !recording && (
987
+ <Stack gap="xs">
988
+ <Text size="xs" c="dimmed">
989
+ {I18n.get("Recorded audio:")}
990
+ </Text>
991
+ <audio
992
+ controls
993
+ src={URL.createObjectURL(audioBlob)}
994
+ className="ai-kit-audio-player"
995
+ />
996
+ </Stack>
997
+ )}
998
+ </>
999
+ )}
978
1000
 
979
1001
  {error ? (
980
1002
  <Alert color="red" title={I18n.get("Error")}>
@@ -1017,6 +1039,44 @@ const DocSearchBase: FC<Props> = (props) => {
1017
1039
  <ReactMarkdown
1018
1040
  remarkPlugins={[remarkGfm]}
1019
1041
  rehypePlugins={[rehypeRaw]}
1042
+ components={{
1043
+ a: ({ href, children, ...rest }) => {
1044
+ const isCite =
1045
+ typeof href === "string" &&
1046
+ href.startsWith("#docsearch-source-");
1047
+
1048
+ return (
1049
+ <a
1050
+ href={href}
1051
+ {...rest}
1052
+ onClick={(e) => {
1053
+ if (!isCite) return;
1054
+ e.preventDefault();
1055
+
1056
+ const id = href!.slice(1);
1057
+ const selector = `#${escapeCssId(id)}`;
1058
+
1059
+ const scope: ParentNode =
1060
+ (rootElement as unknown as ParentNode) ??
1061
+ document;
1062
+
1063
+ const el = (
1064
+ scope as Document | Element
1065
+ ).querySelector?.(
1066
+ selector,
1067
+ ) as HTMLElement | null;
1068
+
1069
+ el?.scrollIntoView({
1070
+ block: "start",
1071
+ behavior: "smooth",
1072
+ });
1073
+ }}
1074
+ >
1075
+ {children}
1076
+ </a>
1077
+ );
1078
+ },
1079
+ }}
1020
1080
  data-doc-search-result-content
1021
1081
  >
1022
1082
  {annotatedSummary || summaryText}
@@ -1026,8 +1086,8 @@ const DocSearchBase: FC<Props> = (props) => {
1026
1086
  ) : null}
1027
1087
 
1028
1088
  {showSources &&
1029
- (result?.citations?.docs?.length ||
1030
- result?.citations?.chunks?.length) ? (
1089
+ (result?.citations?.docs?.length ||
1090
+ result?.citations?.chunks?.length) ? (
1031
1091
  <>
1032
1092
  <Divider />
1033
1093
  <Stack gap="sm" data-doc-search-sources>
@@ -1051,12 +1111,18 @@ const DocSearchBase: FC<Props> = (props) => {
1051
1111
  {titleText}
1052
1112
  </Text>
1053
1113
  );
1114
+
1054
1115
  return (
1055
1116
  <Paper
1056
1117
  key={doc.docId}
1057
1118
  withBorder
1058
1119
  radius="md"
1059
1120
  p="sm"
1121
+ id={
1122
+ docNumber
1123
+ ? `docsearch-source-${docNumber}`
1124
+ : undefined
1125
+ }
1060
1126
  >
1061
1127
  <Stack gap="xs">
1062
1128
  <Group
@@ -1086,6 +1152,7 @@ const DocSearchBase: FC<Props> = (props) => {
1086
1152
  ) : (
1087
1153
  titleNode
1088
1154
  )}
1155
+
1089
1156
  <Anchor
1090
1157
  href={href}
1091
1158
  target="_blank"
@@ -1100,6 +1167,7 @@ const DocSearchBase: FC<Props> = (props) => {
1100
1167
  >
1101
1168
  {doc.sourceUrl}
1102
1169
  </Anchor>
1170
+
1103
1171
  {doc.author ? (
1104
1172
  <Text
1105
1173
  size="xs"
@@ -1109,6 +1177,7 @@ const DocSearchBase: FC<Props> = (props) => {
1109
1177
  {doc.author}
1110
1178
  </Text>
1111
1179
  ) : null}
1180
+
1112
1181
  {doc.description ? (
1113
1182
  <Text
1114
1183
  size="sm"
@@ -1128,6 +1197,7 @@ const DocSearchBase: FC<Props> = (props) => {
1128
1197
  </Stack>
1129
1198
  </>
1130
1199
  ) : null}
1200
+
1131
1201
  <PoweredBy variation={variation} />
1132
1202
  </Stack>
1133
1203
  </Paper>