@smart-cloud/ai-kit-ui 1.3.0 → 1.3.3

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.
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ActionIcon,
3
3
  Anchor,
4
+ Box,
4
5
  Button,
5
6
  Group,
6
7
  Input,
@@ -13,12 +14,14 @@ import {
13
14
  import {
14
15
  IconMaximize,
15
16
  IconMessage,
17
+ IconMicrophone,
16
18
  IconMinimize,
17
19
  IconPaperclip,
18
20
  IconPencil,
19
21
  IconPlayerStop,
20
22
  IconSend,
21
23
  IconTrash,
24
+ IconX,
22
25
  } from "@tabler/icons-react";
23
26
 
24
27
  import {
@@ -57,6 +60,8 @@ I18n.putVocabularies(translations);
57
60
 
58
61
  const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
59
62
 
63
+ const USE_AUDIO = false; // Set to true to enable audio recording feature (requires backend support for audio input)
64
+
60
65
  // New: history storage support
61
66
  const DEFAULT_PRESERVATION_TIME_DAYS = 1;
62
67
  const DEFAULT_HISTORY_STORAGE: HistoryStorageMode = "localstorage";
@@ -133,6 +138,8 @@ type ChatMessageAttachment = {
133
138
  blobId?: string;
134
139
  objectUrl?: string | null;
135
140
  blob?: Blob;
141
+ duration?: number; // For audio/video attachments
142
+ mediaType?: "image" | "audio"; // Distinguish media types
136
143
  };
137
144
 
138
145
  type ChatMessage = {
@@ -152,6 +159,13 @@ type ComposerImage = {
152
159
  objectUrl: string;
153
160
  };
154
161
 
162
+ type ComposerAudio = {
163
+ id: string;
164
+ blob: Blob;
165
+ objectUrl: string;
166
+ duration: number;
167
+ };
168
+
155
169
  type PersistedAttachment = Omit<ChatMessageAttachment, "objectUrl" | "blob">;
156
170
 
157
171
  type PersistedChatMessage = Omit<ChatMessage, "attachments"> & {
@@ -288,6 +302,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
288
302
  onClose,
289
303
 
290
304
  // AiChatbotProps
305
+ context,
291
306
  placeholder,
292
307
  maxImages,
293
308
  maxImageBytes,
@@ -312,6 +327,16 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
312
327
 
313
328
  const [question, setQuestion] = useState("");
314
329
  const [composerImages, setComposerImages] = useState<ComposerImage[]>([]);
330
+ const [composerAudio, setComposerAudio] = useState<ComposerAudio | null>(
331
+ null,
332
+ );
333
+ const [recording, setRecording] = useState<boolean>(false);
334
+ const [audioLevel, setAudioLevel] = useState<number>(0);
335
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
336
+ const audioChunksRef = useRef<Blob[]>([]);
337
+ const audioContextRef = useRef<AudioContext | null>(null);
338
+ const analyserRef = useRef<AnalyserNode | null>(null);
339
+ const animationFrameRef = useRef<number | null>(null);
315
340
  const [messages, setMessages] = useState<ChatMessage[]>([]);
316
341
  const [statusLineError, setStatusLineError] = useState<string | null>(null);
317
342
  const [resetDialogOpen, setResetDialogOpen] = useState(false);
@@ -355,6 +380,125 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
355
380
  if (fileInputRef.current) fileInputRef.current.value = "";
356
381
  }, [disposeComposerImageList]);
357
382
 
383
+ const clearComposerAudio = useCallback(() => {
384
+ if (composerAudioRef.current) {
385
+ revokeObjectUrlSafe(composerAudioRef.current.objectUrl);
386
+ }
387
+ setComposerAudio(null);
388
+ audioChunksRef.current = [];
389
+ setAudioLevel(0);
390
+ }, []);
391
+
392
+ const startRecording = useCallback(async () => {
393
+ try {
394
+ // Clear question input when starting audio recording
395
+ setQuestion("");
396
+ // Clear any existing audio
397
+ clearComposerAudio();
398
+
399
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
400
+ const mediaRecorder = new MediaRecorder(stream, {
401
+ mimeType: "audio/webm",
402
+ });
403
+
404
+ // Setup audio analysis for visual feedback
405
+ const audioContext = new AudioContext();
406
+ const source = audioContext.createMediaStreamSource(stream);
407
+ const analyser = audioContext.createAnalyser();
408
+ analyser.fftSize = 2048;
409
+ analyser.smoothingTimeConstant = 0.8;
410
+ source.connect(analyser);
411
+
412
+ audioContextRef.current = audioContext;
413
+ analyserRef.current = analyser;
414
+
415
+ // Monitor audio level
416
+ const dataArray = new Uint8Array(analyser.fftSize);
417
+ const updateLevel = () => {
418
+ if (!analyserRef.current) return;
419
+ analyserRef.current.getByteTimeDomainData(dataArray);
420
+
421
+ let sum = 0;
422
+ for (let i = 0; i < dataArray.length; i++) {
423
+ const normalized = (dataArray[i]! - 128) / 128;
424
+ sum += normalized * normalized;
425
+ }
426
+ const rms = Math.sqrt(sum / dataArray.length);
427
+ const level = Math.min(100, rms * 200);
428
+ setAudioLevel(level);
429
+
430
+ animationFrameRef.current = requestAnimationFrame(updateLevel);
431
+ };
432
+ updateLevel();
433
+
434
+ audioChunksRef.current = [];
435
+ const recordStartTime = Date.now();
436
+
437
+ mediaRecorder.ondataavailable = (event) => {
438
+ if (event.data.size > 0) {
439
+ audioChunksRef.current.push(event.data);
440
+ }
441
+ };
442
+
443
+ mediaRecorder.onstop = () => {
444
+ const audioBlob = new Blob(audioChunksRef.current, {
445
+ type: "audio/webm",
446
+ });
447
+ const duration = (Date.now() - recordStartTime) / 1000; // duration in seconds
448
+ const objectUrl = createObjectUrl(audioBlob);
449
+
450
+ if (objectUrl) {
451
+ setComposerAudio({
452
+ id: createMessageId("composer-audio"),
453
+ blob: audioBlob,
454
+ objectUrl,
455
+ duration,
456
+ });
457
+ }
458
+
459
+ stream.getTracks().forEach((track) => track.stop());
460
+
461
+ // Cleanup audio analysis
462
+ if (animationFrameRef.current) {
463
+ cancelAnimationFrame(animationFrameRef.current);
464
+ animationFrameRef.current = null;
465
+ }
466
+ if (audioContextRef.current) {
467
+ audioContextRef.current.close();
468
+ audioContextRef.current = null;
469
+ }
470
+ analyserRef.current = null;
471
+ setAudioLevel(0);
472
+ };
473
+
474
+ mediaRecorderRef.current = mediaRecorder;
475
+ mediaRecorder.start();
476
+ setRecording(true);
477
+ } catch (error) {
478
+ console.error("Failed to start recording:", error);
479
+ }
480
+ }, [clearComposerAudio]);
481
+
482
+ const stopRecording = useCallback(() => {
483
+ if (mediaRecorderRef.current && recording) {
484
+ mediaRecorderRef.current.stop();
485
+ setRecording(false);
486
+ }
487
+ }, [recording]);
488
+
489
+ // Cleanup on unmount
490
+ useEffect(() => {
491
+ return () => {
492
+ if (animationFrameRef.current) {
493
+ cancelAnimationFrame(animationFrameRef.current);
494
+ }
495
+ if (audioContextRef.current) {
496
+ audioContextRef.current.close();
497
+ }
498
+ clearComposerAudio();
499
+ };
500
+ }, [clearComposerAudio]);
501
+
358
502
  // New: persist timestamp of last actually-sent user message
359
503
  const [lastUserSentAt, setLastUserSentAt] = useState<number | null>(null);
360
504
 
@@ -363,6 +507,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
363
507
  const messagesRef = useRef(messages);
364
508
  const lastUserSentAtRef = useRef(lastUserSentAt);
365
509
  const composerImagesRef = useRef(composerImages);
510
+ const composerAudioRef = useRef(composerAudio);
366
511
 
367
512
  useEffect(() => {
368
513
  questionRef.current = question;
@@ -370,6 +515,9 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
370
515
  useEffect(() => {
371
516
  composerImagesRef.current = composerImages;
372
517
  }, [composerImages]);
518
+ useEffect(() => {
519
+ composerAudioRef.current = composerAudio;
520
+ }, [composerAudio]);
373
521
  useEffect(() => {
374
522
  messagesRef.current = messages;
375
523
  }, [messages]);
@@ -406,8 +554,9 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
406
554
 
407
555
  const canSend = useMemo(() => {
408
556
  if (isChatBusy) return false;
409
- return question.trim().length > 0;
410
- }, [question, isChatBusy]);
557
+ // Can send if we have text OR audio
558
+ return question.trim().length > 0 || composerAudio !== null;
559
+ }, [question, isChatBusy, composerAudio]);
411
560
 
412
561
  const openButtonLabel = useMemo(() => {
413
562
  const raw = openButtonTitle ? openButtonTitle : labels.askMeLabel;
@@ -657,47 +806,90 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
657
806
  }, [statusLineError]);
658
807
 
659
808
  const buildUserAttachments = useCallback(
660
- async (sources: ComposerImage[]): Promise<ChatMessageAttachment[]> => {
661
- if (!sources.length) return [];
662
-
809
+ async (
810
+ images: ComposerImage[],
811
+ audio?: ComposerAudio | null,
812
+ ): Promise<ChatMessageAttachment[]> => {
663
813
  const shouldPersist = historyStorage !== "nostorage";
664
-
665
- const built = await Promise.all(
666
- sources.map(async (img) => {
667
- const attachmentId = createMessageId("attachment");
668
- let blobId: string | undefined;
669
- if (shouldPersist) {
670
- try {
671
- const persisted = await persistAttachmentBlob(
672
- attachmentId,
673
- img.file,
674
- {
675
- name: img.file.name,
676
- type: img.file.type,
677
- size: img.file.size,
678
- },
679
- );
680
- blobId = persisted ?? undefined;
681
- } catch (error) {
682
- console.warn("[AiChatbot] Failed to persist attachment", error);
814
+ const attachments: ChatMessageAttachment[] = [];
815
+
816
+ // Build image attachments
817
+ if (images.length > 0) {
818
+ const imageAttachments = await Promise.all(
819
+ images.map(async (img) => {
820
+ const attachmentId = createMessageId("attachment-image");
821
+ let blobId: string | undefined;
822
+ if (shouldPersist) {
823
+ try {
824
+ const persisted = await persistAttachmentBlob(
825
+ attachmentId,
826
+ img.file,
827
+ {
828
+ name: img.file.name,
829
+ type: img.file.type,
830
+ size: img.file.size,
831
+ },
832
+ );
833
+ blobId = persisted ?? undefined;
834
+ } catch (error) {
835
+ console.warn("[AiChatbot] Failed to persist image", error);
836
+ }
683
837
  }
838
+
839
+ const objectUrl = createObjectUrl(img.file);
840
+
841
+ return {
842
+ id: attachmentId,
843
+ name: img.file.name,
844
+ type: img.file.type || "application/octet-stream",
845
+ size: img.file.size,
846
+ blobId,
847
+ objectUrl: objectUrl ?? undefined,
848
+ blob: img.file,
849
+ mediaType: "image" as const,
850
+ } satisfies ChatMessageAttachment;
851
+ }),
852
+ );
853
+ attachments.push(...imageAttachments.filter(Boolean));
854
+ }
855
+
856
+ // Build audio attachment
857
+ if (audio) {
858
+ const attachmentId = createMessageId("attachment-audio");
859
+ let blobId: string | undefined;
860
+ if (shouldPersist) {
861
+ try {
862
+ const persisted = await persistAttachmentBlob(
863
+ attachmentId,
864
+ audio.blob,
865
+ {
866
+ name: `audio-${Date.now()}.webm`,
867
+ type: audio.blob.type,
868
+ size: audio.blob.size,
869
+ },
870
+ );
871
+ blobId = persisted ?? undefined;
872
+ } catch (error) {
873
+ console.warn("[AiChatbot] Failed to persist audio", error);
684
874
  }
875
+ }
685
876
 
686
- const objectUrl = createObjectUrl(img.file);
687
-
688
- return {
689
- id: attachmentId,
690
- name: img.file.name,
691
- type: img.file.type || "application/octet-stream",
692
- size: img.file.size,
693
- blobId,
694
- objectUrl: objectUrl ?? undefined,
695
- blob: img.file,
696
- } satisfies ChatMessageAttachment;
697
- }),
698
- );
877
+ const objectUrl = createObjectUrl(audio.blob);
878
+
879
+ attachments.push({
880
+ id: attachmentId,
881
+ name: `audio-${Date.now()}.webm`,
882
+ type: audio.blob.type || "audio/webm",
883
+ size: audio.blob.size,
884
+ blobId,
885
+ objectUrl: objectUrl ?? undefined,
886
+ blob: audio.blob,
887
+ duration: audio.duration,
888
+ mediaType: "audio" as const,
889
+ } satisfies ChatMessageAttachment);
890
+ }
699
891
 
700
- return built.filter(Boolean);
892
+ return attachments;
701
893
  },
702
894
  [historyStorage],
703
895
  );
@@ -818,6 +1010,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
818
1010
  const resetConversation = useCallback(() => {
819
1011
  disposeMessagesAttachments(messagesRef.current);
820
1012
  clearComposerImages();
1013
+ clearComposerAudio();
821
1014
  setMessages([]);
822
1015
  setStatusLineError(null);
823
1016
  sessionRef.current = null;
@@ -835,7 +1028,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
835
1028
  }
836
1029
 
837
1030
  void clearAllAttachments();
838
- }, [clearComposerImages, historyStorage]);
1031
+ }, [clearComposerImages, clearComposerAudio, historyStorage]);
839
1032
 
840
1033
  const handleResetClick = useCallback(() => {
841
1034
  // Open confirmation dialog
@@ -881,6 +1074,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
881
1074
  {
882
1075
  signal,
883
1076
  onStatus,
1077
+ context,
884
1078
  },
885
1079
  );
886
1080
  return null;
@@ -918,20 +1112,26 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
918
1112
 
919
1113
  const ask = useCallback(async () => {
920
1114
  const trimmed = questionRef.current.trim();
921
- if (!trimmed || ai.busy) return;
1115
+ const selectedAudio = composerAudioRef.current;
1116
+
1117
+ // Can send if we have text OR audio
1118
+ if ((!trimmed && !selectedAudio) || ai.busy) return;
922
1119
 
923
1120
  cancelRequestedRef.current = false;
924
1121
  setStatusLineError(null);
925
1122
  setActiveOp("chat");
926
1123
  const selectedImages = [...composerImagesRef.current];
927
- const userAttachments = await buildUserAttachments(selectedImages);
1124
+ const userAttachments = await buildUserAttachments(
1125
+ selectedImages,
1126
+ selectedAudio,
1127
+ );
928
1128
 
929
1129
  const userMessageId = createMessageId("user");
930
1130
  const userMessageCreatedAt = Date.now();
931
1131
  const userMessage: ChatMessage = {
932
1132
  id: userMessageId,
933
1133
  role: "user",
934
- content: trimmed,
1134
+ content: trimmed || (selectedAudio ? "[Audio message]" : ""),
935
1135
  createdAt: userMessageCreatedAt,
936
1136
  clientStatus: "pending",
937
1137
  attachments: userAttachments.length ? userAttachments : undefined,
@@ -940,6 +1140,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
940
1140
  // optimistic UI
941
1141
  setQuestion("");
942
1142
  clearComposerImages();
1143
+ clearComposerAudio();
943
1144
  setMessages((prev) => [...prev, userMessage]);
944
1145
 
945
1146
  if (!opened) setOpened(true);
@@ -957,12 +1158,14 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
957
1158
  const out = await sendChatMessage(
958
1159
  {
959
1160
  sessionId: activeSessionId,
960
- message: trimmed,
1161
+ message: trimmed || undefined,
1162
+ audio: selectedAudio?.blob,
961
1163
  images: selectedImages.map((img) => img.file),
962
1164
  },
963
1165
  {
964
1166
  signal,
965
1167
  onStatus,
1168
+ context,
966
1169
  },
967
1170
  );
968
1171
  return out;
@@ -1044,6 +1247,7 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1044
1247
  ai,
1045
1248
  buildUserAttachments,
1046
1249
  clearComposerImages,
1250
+ clearComposerAudio,
1047
1251
  opened,
1048
1252
  scrollToBottom,
1049
1253
  markLastPendingAs,
@@ -1563,40 +1767,110 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1563
1767
  </Stack>
1564
1768
 
1565
1769
  {msg.attachments && msg.attachments.length > 0 && (
1566
- <Group className="ai-thumbs ai-message-thumbs" gap="xs">
1567
- {msg.attachments.map((attachment) => (
1568
- <button
1569
- key={attachment.id}
1570
- type="button"
1571
- className="thumb"
1572
- style={{
1573
- backgroundImage: attachment.objectUrl
1574
- ? `url(${attachment.objectUrl})`
1575
- : undefined,
1576
- backgroundSize: "cover",
1577
- backgroundPosition: "center",
1578
- backgroundRepeat: "no-repeat",
1579
- }}
1580
- onClick={() =>
1581
- openAttachmentPreview(
1582
- attachment.objectUrl,
1583
- attachment.name,
1584
- )
1585
- }
1586
- disabled={!attachment.objectUrl}
1587
- title={attachment.name || I18n.get("View image")}
1588
- aria-label={
1589
- attachment.name || I18n.get("View image")
1590
- }
1770
+ <Stack
1771
+ gap="xs"
1772
+ style={{ maxWidth: "min(400px, 100%)" }}
1773
+ >
1774
+ {/* Image attachments */}
1775
+ {msg.attachments.filter(
1776
+ (att) =>
1777
+ att.mediaType === "image" ||
1778
+ (!att.mediaType && att.type.startsWith("image/")),
1779
+ ).length > 0 && (
1780
+ <Group
1781
+ className="ai-thumbs ai-message-thumbs"
1782
+ gap="xs"
1591
1783
  >
1592
- {!attachment.objectUrl && (
1593
- <Text size="xs" c="dimmed">
1594
- {I18n.get("Loading image...")}
1595
- </Text>
1596
- )}
1597
- </button>
1598
- ))}
1599
- </Group>
1784
+ {msg.attachments
1785
+ .filter(
1786
+ (att) =>
1787
+ att.mediaType === "image" ||
1788
+ (!att.mediaType &&
1789
+ att.type.startsWith("image/")),
1790
+ )
1791
+ .map((attachment) => (
1792
+ <button
1793
+ key={attachment.id}
1794
+ type="button"
1795
+ className="thumb"
1796
+ style={{
1797
+ backgroundImage: attachment.objectUrl
1798
+ ? `url(${attachment.objectUrl})`
1799
+ : undefined,
1800
+ backgroundSize: "cover",
1801
+ backgroundPosition: "center",
1802
+ backgroundRepeat: "no-repeat",
1803
+ }}
1804
+ onClick={() =>
1805
+ openAttachmentPreview(
1806
+ attachment.objectUrl,
1807
+ attachment.name,
1808
+ )
1809
+ }
1810
+ disabled={!attachment.objectUrl}
1811
+ title={
1812
+ attachment.name || I18n.get("View image")
1813
+ }
1814
+ aria-label={
1815
+ attachment.name || I18n.get("View image")
1816
+ }
1817
+ >
1818
+ {!attachment.objectUrl && (
1819
+ <Text size="xs" c="dimmed">
1820
+ {I18n.get("Image no longer available")}
1821
+ </Text>
1822
+ )}
1823
+ </button>
1824
+ ))}
1825
+ </Group>
1826
+ )}
1827
+
1828
+ {/* Audio attachments */}
1829
+ {msg.attachments
1830
+ .filter(
1831
+ (att) =>
1832
+ att.mediaType === "audio" ||
1833
+ (!att.mediaType &&
1834
+ att.type.startsWith("audio/")),
1835
+ )
1836
+ .map((attachment) => (
1837
+ <Box
1838
+ key={attachment.id}
1839
+ p="sm"
1840
+ style={{
1841
+ backgroundColor:
1842
+ "var(--ai-kit-chat-surface-subtle)",
1843
+ borderRadius: "var(--ai-kit-radius-sm)",
1844
+ border:
1845
+ "1px solid var(--ai-kit-chat-border-color)",
1846
+ }}
1847
+ >
1848
+ {attachment.objectUrl ? (
1849
+ <Stack gap="xs">
1850
+ <audio
1851
+ className="ai-kit-audio-player"
1852
+ controls
1853
+ src={attachment.objectUrl}
1854
+ preload="metadata"
1855
+ />
1856
+ {attachment.duration && (
1857
+ <Text
1858
+ size="xs"
1859
+ c="dimmed"
1860
+ style={{ textAlign: "right" }}
1861
+ >
1862
+ {Math.round(attachment.duration)}s
1863
+ </Text>
1864
+ )}
1865
+ </Stack>
1866
+ ) : (
1867
+ <Text size="xs" c="dimmed">
1868
+ {I18n.get("Audio no longer available")}
1869
+ </Text>
1870
+ )}
1871
+ </Box>
1872
+ ))}
1873
+ </Stack>
1600
1874
  )}
1601
1875
 
1602
1876
  {isLastCanceled && (
@@ -1759,12 +2033,61 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1759
2033
  value={question}
1760
2034
  onChange={(e) => {
1761
2035
  setQuestion(e.target.value);
2036
+ // Clear audio when typing
2037
+ if (composerAudio) {
2038
+ clearComposerAudio();
2039
+ }
1762
2040
  }}
1763
2041
  onKeyDown={handleQuestionKeyDown}
1764
2042
  rows={3}
2043
+ disabled={recording || !!composerAudio}
1765
2044
  />
1766
2045
  </Group>
1767
2046
 
2047
+ {/* Audio level indicator when recording */}
2048
+ {USE_AUDIO && recording && (
2049
+ <Stack gap="xs" mt="xs">
2050
+ <Text size="xs" c="dimmed">
2051
+ <em>{I18n.get("Recording...")}</em>
2052
+ </Text>
2053
+ <div
2054
+ style={{
2055
+ width: "100%",
2056
+ height: "4px",
2057
+ backgroundColor: "var(--mantine-color-gray-3)",
2058
+ borderRadius: "2px",
2059
+ overflow: "hidden",
2060
+ }}
2061
+ >
2062
+ <div
2063
+ style={{
2064
+ width: `${audioLevel}%`,
2065
+ height: "100%",
2066
+ backgroundColor: "var(--mantine-color-red-6)",
2067
+ transition: "width 0.1s ease",
2068
+ }}
2069
+ />
2070
+ </div>
2071
+ </Stack>
2072
+ )}
2073
+
2074
+ {/* Audio playback when recorded */}
2075
+ {USE_AUDIO && composerAudio && !recording && (
2076
+ <Stack gap="xs" mt="xs">
2077
+ <Text size="xs" c="dimmed">
2078
+ <em>
2079
+ {I18n.get("Audio recorded")} (
2080
+ {Math.round(composerAudio.duration)}s)
2081
+ </em>
2082
+ </Text>
2083
+ <audio
2084
+ className="ai-kit-audio-player"
2085
+ controls
2086
+ src={composerAudio.objectUrl}
2087
+ />
2088
+ </Stack>
2089
+ )}
2090
+
1768
2091
  <Group className="ai-actions" justify="space-between" w="100%">
1769
2092
  <Group justify="flex-start">
1770
2093
  <Button
@@ -1779,6 +2102,40 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1779
2102
  </Group>
1780
2103
 
1781
2104
  <Group justify="flex-end">
2105
+ {/* Microphone button */}
2106
+ {USE_AUDIO && (
2107
+ <>
2108
+ {composerAudio ? (
2109
+ <Button
2110
+ variant="outline"
2111
+ leftSection={<IconX size={18} />}
2112
+ onClick={clearComposerAudio}
2113
+ disabled={isChatBusy}
2114
+ title={I18n.get("Clear audio")}
2115
+ data-ai-kit-clear-audio-button
2116
+ >
2117
+ {I18n.get("Clear")}
2118
+ </Button>
2119
+ ) : (
2120
+ <Button
2121
+ variant="outline"
2122
+ leftSection={<IconMicrophone size={18} />}
2123
+ onClick={recording ? stopRecording : startRecording}
2124
+ disabled={isChatBusy}
2125
+ title={
2126
+ recording
2127
+ ? I18n.get("Stop recording")
2128
+ : I18n.get("Record audio")
2129
+ }
2130
+ color={recording ? "red" : undefined}
2131
+ data-ai-kit-microphone-button
2132
+ >
2133
+ {recording ? I18n.get("Stop") : I18n.get("Record")}
2134
+ </Button>
2135
+ )}
2136
+ </>
2137
+ )}
2138
+
1782
2139
  {resolvedMaxImages > 0 && (
1783
2140
  <>
1784
2141
  <Button
@@ -1787,7 +2144,9 @@ const AiChatbotBase: FC<AiChatbotProps & AiKitShellInjectedProps> = (props) => {
1787
2144
  onClick={() => fileInputRef.current?.click()}
1788
2145
  disabled={
1789
2146
  composerImages.length >= resolvedMaxImages ||
1790
- isChatBusy
2147
+ isChatBusy ||
2148
+ recording ||
2149
+ !!composerAudio
1791
2150
  }
1792
2151
  title={I18n.get(labels.addImageLabel)}
1793
2152
  data-ai-kit-add-image-button