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

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.13",
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 () => {
@@ -416,7 +443,7 @@ const DocSearchBase: FC<Props> = (props) => {
416
443
  "Reusing cached audio (no re-upload needed within",
417
444
  Math.round(
418
445
  (AUDIO_CACHE_TTL - (now - audioCacheRef.current!.uploadTimestamp)) /
419
- 1000,
446
+ 1000,
420
447
  ),
421
448
  "seconds)",
422
449
  );
@@ -438,8 +465,8 @@ const DocSearchBase: FC<Props> = (props) => {
438
465
  }),
439
466
  ...(enableUserFilters &&
440
467
  selectedSubcategories.length > 0 && {
441
- userSelectedSubcategories: selectedSubcategories,
442
- }),
468
+ userSelectedSubcategories: selectedSubcategories,
469
+ }),
443
470
  ...(enableUserFilters &&
444
471
  selectedTags.length > 0 && { userSelectedTags: selectedTags }),
445
472
  },
@@ -565,14 +592,24 @@ const DocSearchBase: FC<Props> = (props) => {
565
592
  }
566
593
 
567
594
  const safeEnd = Math.min(end, summaryText.length);
568
- segments.push(summaryText.slice(cursor, safeEnd));
569
- segments.push(`<sup>${refs.join(",")}</sup>`);
570
- cursor = safeEnd;
595
+ const snappedEnd = snapEndToBoundary(summaryText, safeEnd);
596
+
597
+ segments.push(summaryText.slice(cursor, snappedEnd));
598
+
599
+ const supHtml = refs
600
+ .map(
601
+ (n) =>
602
+ `<a href="#docsearch-source-${n}" data-docsearch-cite="${n}" style="color: inherit; text-decoration: none;">${n}</a>`,
603
+ )
604
+ .join(",");
605
+
606
+ segments.push(`<sup>${supHtml}</sup>`);
607
+ cursor = snappedEnd;
571
608
  }
572
609
 
573
610
  segments.push(summaryText.slice(cursor));
574
611
  return segments.join("");
575
- }, [citationAnchors, summaryText]);
612
+ }, [citationAnchors, summaryText, docNumberMap, chunkDocMap]);
576
613
 
577
614
  const RootComponent: typeof Modal.Root | typeof Group =
578
615
  variation === "modal" ? Modal.Root : Group;
@@ -731,49 +768,48 @@ const DocSearchBase: FC<Props> = (props) => {
731
768
  }}
732
769
  />
733
770
 
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
- ? {
771
+ {/* Microphone button */}
772
+ {USE_AUDIO && (
773
+ <>
774
+ {audioBlob ? (
775
+ <Button
776
+ variant="outline"
777
+ size="sm"
778
+ color="red"
779
+ onClick={clearAudio}
780
+ disabled={busy}
781
+ title={I18n.get("Clear audio")}
782
+ >
783
+ <IconMicrophoneOff size={18} />
784
+ </Button>
785
+ ) : (
786
+ <Button
787
+ variant={recording ? "filled" : "outline"}
788
+ size="sm"
789
+ color={recording ? "red" : "gray"}
790
+ onClick={
791
+ recording ? stopRecording : startRecording
792
+ }
793
+ disabled={busy}
794
+ title={
795
+ recording
796
+ ? I18n.get("Stop recording")
797
+ : I18n.get("Record audio")
798
+ }
799
+ style={
800
+ recording
801
+ ? {
765
802
  transform: `scale(${1 + audioLevel / 300})`,
766
803
  transition: "transform 0.1s ease-out",
767
804
  }
768
- : undefined
769
- }
770
- >
771
- <IconMicrophone size={18} />
772
- </Button>
773
- )}
774
- </>
775
- )
776
- }
805
+ : undefined
806
+ }
807
+ >
808
+ <IconMicrophone size={18} />
809
+ </Button>
810
+ )}
811
+ </>
812
+ )}
777
813
 
778
814
  <Button
779
815
  variant="filled"
@@ -830,51 +866,51 @@ const DocSearchBase: FC<Props> = (props) => {
830
866
  {/* Main categories as checkboxes */}
831
867
  {Object.keys(metadataOptions.allowedCategories)
832
868
  .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[
869
+ <div>
870
+ <Text size="sm" fw={500} mb="xs">
871
+ {I18n.get("Categories")}
872
+ </Text>
873
+ <Group gap="md">
874
+ {Object.keys(
875
+ metadataOptions.allowedCategories,
876
+ ).map((category) => (
877
+ <Checkbox
878
+ key={category}
879
+ label={I18n.get(category)}
880
+ checked={selectedCategories.includes(
881
+ category,
882
+ )}
883
+ onChange={(e) => {
884
+ if (e.currentTarget.checked) {
885
+ setSelectedCategories([
886
+ ...selectedCategories,
887
+ category,
888
+ ]);
889
+ } else {
890
+ setSelectedCategories(
891
+ selectedCategories.filter(
892
+ (c) => c !== category,
893
+ ),
894
+ );
895
+ // Remove subcategories of unchecked category
896
+ const subcatsToRemove =
897
+ metadataOptions.allowedCategories[
862
898
  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
- )}
899
+ ] || [];
900
+ setSelectedSubcategories(
901
+ selectedSubcategories.filter(
902
+ (sc) =>
903
+ !subcatsToRemove.includes(sc),
904
+ ),
905
+ );
906
+ }
907
+ }}
908
+ disabled={busy || loadingMetadata}
909
+ />
910
+ ))}
911
+ </Group>
912
+ </div>
913
+ )}
878
914
 
879
915
  {/* Subcategories for selected categories */}
880
916
  {subcategories.length > 0 && (
@@ -883,31 +919,30 @@ const DocSearchBase: FC<Props> = (props) => {
883
919
  {I18n.get("Subcategories")}
884
920
  </Text>
885
921
  <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
- ))}
922
+ {subcategories.map((subcat) => (
923
+ <Checkbox
924
+ key={subcat}
925
+ label={I18n.get(subcat)}
926
+ checked={selectedSubcategories.includes(
927
+ subcat,
928
+ )}
929
+ onChange={(e) => {
930
+ if (e.currentTarget.checked) {
931
+ setSelectedSubcategories([
932
+ ...selectedSubcategories,
933
+ subcat,
934
+ ]);
935
+ } else {
936
+ setSelectedSubcategories(
937
+ selectedSubcategories.filter(
938
+ (sc) => sc !== subcat,
939
+ ),
940
+ );
941
+ }
942
+ }}
943
+ disabled={busy || loadingMetadata}
944
+ />
945
+ ))}
911
946
  </Group>
912
947
  </div>
913
948
  )}
@@ -941,40 +976,39 @@ const DocSearchBase: FC<Props> = (props) => {
941
976
  </Stack>
942
977
  )}
943
978
 
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
- )}
979
+ {/* Audio level indicator when recording */}
980
+ {USE_AUDIO && (
981
+ <>
982
+ {recording && (
983
+ <Stack gap="xs">
984
+ <Text size="xs" c="dimmed">
985
+ {I18n.get("Recording...")} 🎤
986
+ </Text>
987
+ <Progress
988
+ value={audioLevel}
989
+ size="sm"
990
+ color="red"
991
+ animated
992
+ striped
993
+ />
994
+ </Stack>
995
+ )}
961
996
 
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
- }
997
+ {/* Audio playback when recorded */}
998
+ {audioBlob && !recording && (
999
+ <Stack gap="xs">
1000
+ <Text size="xs" c="dimmed">
1001
+ {I18n.get("Recorded audio:")}
1002
+ </Text>
1003
+ <audio
1004
+ controls
1005
+ src={URL.createObjectURL(audioBlob)}
1006
+ className="ai-kit-audio-player"
1007
+ />
1008
+ </Stack>
1009
+ )}
1010
+ </>
1011
+ )}
978
1012
 
979
1013
  {error ? (
980
1014
  <Alert color="red" title={I18n.get("Error")}>
@@ -1017,6 +1051,44 @@ const DocSearchBase: FC<Props> = (props) => {
1017
1051
  <ReactMarkdown
1018
1052
  remarkPlugins={[remarkGfm]}
1019
1053
  rehypePlugins={[rehypeRaw]}
1054
+ components={{
1055
+ a: ({ href, children, ...rest }) => {
1056
+ const isCite =
1057
+ typeof href === "string" &&
1058
+ href.startsWith("#docsearch-source-");
1059
+
1060
+ return (
1061
+ <a
1062
+ href={href}
1063
+ {...rest}
1064
+ onClick={(e) => {
1065
+ if (!isCite) return;
1066
+ e.preventDefault();
1067
+
1068
+ const id = href!.slice(1);
1069
+ const selector = `#${escapeCssId(id)}`;
1070
+
1071
+ const scope: ParentNode =
1072
+ (rootElement as unknown as ParentNode) ??
1073
+ document;
1074
+
1075
+ const el = (
1076
+ scope as Document | Element
1077
+ ).querySelector?.(
1078
+ selector,
1079
+ ) as HTMLElement | null;
1080
+
1081
+ el?.scrollIntoView({
1082
+ block: "start",
1083
+ behavior: "smooth",
1084
+ });
1085
+ }}
1086
+ >
1087
+ {children}
1088
+ </a>
1089
+ );
1090
+ },
1091
+ }}
1020
1092
  data-doc-search-result-content
1021
1093
  >
1022
1094
  {annotatedSummary || summaryText}
@@ -1026,8 +1098,8 @@ const DocSearchBase: FC<Props> = (props) => {
1026
1098
  ) : null}
1027
1099
 
1028
1100
  {showSources &&
1029
- (result?.citations?.docs?.length ||
1030
- result?.citations?.chunks?.length) ? (
1101
+ (result?.citations?.docs?.length ||
1102
+ result?.citations?.chunks?.length) ? (
1031
1103
  <>
1032
1104
  <Divider />
1033
1105
  <Stack gap="sm" data-doc-search-sources>
@@ -1051,12 +1123,18 @@ const DocSearchBase: FC<Props> = (props) => {
1051
1123
  {titleText}
1052
1124
  </Text>
1053
1125
  );
1126
+
1054
1127
  return (
1055
1128
  <Paper
1056
1129
  key={doc.docId}
1057
1130
  withBorder
1058
1131
  radius="md"
1059
1132
  p="sm"
1133
+ id={
1134
+ docNumber
1135
+ ? `docsearch-source-${docNumber}`
1136
+ : undefined
1137
+ }
1060
1138
  >
1061
1139
  <Stack gap="xs">
1062
1140
  <Group
@@ -1086,6 +1164,7 @@ const DocSearchBase: FC<Props> = (props) => {
1086
1164
  ) : (
1087
1165
  titleNode
1088
1166
  )}
1167
+
1089
1168
  <Anchor
1090
1169
  href={href}
1091
1170
  target="_blank"
@@ -1100,6 +1179,7 @@ const DocSearchBase: FC<Props> = (props) => {
1100
1179
  >
1101
1180
  {doc.sourceUrl}
1102
1181
  </Anchor>
1182
+
1103
1183
  {doc.author ? (
1104
1184
  <Text
1105
1185
  size="xs"
@@ -1109,6 +1189,7 @@ const DocSearchBase: FC<Props> = (props) => {
1109
1189
  {doc.author}
1110
1190
  </Text>
1111
1191
  ) : null}
1192
+
1112
1193
  {doc.description ? (
1113
1194
  <Text
1114
1195
  size="sm"
@@ -1128,6 +1209,7 @@ const DocSearchBase: FC<Props> = (props) => {
1128
1209
  </Stack>
1129
1210
  </>
1130
1211
  ) : null}
1212
+
1131
1213
  <PoweredBy variation={variation} />
1132
1214
  </Stack>
1133
1215
  </Paper>