ai-design-system 0.1.94 → 0.1.95

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.
@@ -39,6 +39,7 @@ import type { ChatStatus, FileUIPart } from "ai";
39
39
  import {
40
40
  FileIcon,
41
41
  Loader2Icon,
42
+ MusicIcon,
42
43
  SendIcon,
43
44
  SquareIcon,
44
45
  XIcon,
@@ -248,6 +249,13 @@ export const usePromptInputAttachments = () => {
248
249
  return context;
249
250
  };
250
251
 
252
+ /** Optional variant of usePromptInputAttachments (does NOT throw). */
253
+ export const useOptionalPromptInputAttachments = (): AttachmentsContext | null => {
254
+ const provider = useOptionalProviderAttachments();
255
+ const local = useContext(LocalAttachmentsContext);
256
+ return provider ?? local;
257
+ };
258
+
251
259
  export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
252
260
  data: FileUIPart & { id: string };
253
261
  className?: string;
@@ -265,6 +273,7 @@ export function PromptInputAttachment({
265
273
  const mediaType =
266
274
  data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
267
275
  const isImage = mediaType === "image";
276
+ const isAudio = data.mediaType?.startsWith("audio/") ?? false;
268
277
 
269
278
  // For non-image files, truncate filename to max 20 characters followed by '..'
270
279
  const attachmentLabel = filename
@@ -273,6 +282,8 @@ export function PromptInputAttachment({
273
282
  : filename
274
283
  : isImage
275
284
  ? "Image"
285
+ : isAudio
286
+ ? "Voice note"
276
287
  : "Attachment";
277
288
 
278
289
  if (isImage) {
@@ -321,7 +332,7 @@ export function PromptInputAttachment({
321
332
  <div className="relative size-5 shrink-0">
322
333
  <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
323
334
  <div className="flex size-5 items-center justify-center text-muted-foreground">
324
- <FileIcon className="size-3.5" />
335
+ {isAudio ? <MusicIcon className="size-3.5" /> : <FileIcon className="size-3.5" />}
325
336
  </div>
326
337
  </div>
327
338
  <Button
@@ -473,11 +484,16 @@ export const PromptInput = ({
473
484
  if (!accept || accept.trim() === "") {
474
485
  return true;
475
486
  }
476
- if (accept.includes("image/*")) {
477
- return f.type.startsWith("image/");
478
- }
479
- // NOTE: keep simple; expand as needed
480
- return true;
487
+ // Comma-separated tokens: "image/*", "audio/webm", ".txt", "*".
488
+ return accept.split(",").some((raw) => {
489
+ const token = raw.trim();
490
+ if (!token) return false;
491
+ if (token === "*" || token === "*/*") return true;
492
+ if (token.endsWith("/*")) return f.type.startsWith(token.slice(0, -1));
493
+ if (token.startsWith("."))
494
+ return f.name.toLowerCase().endsWith(token.toLowerCase());
495
+ return f.type === token;
496
+ });
481
497
  },
482
498
  [accept]
483
499
  );
@@ -628,15 +644,26 @@ export const PromptInput = ({
628
644
  };
629
645
  }, [add, globalDrop]);
630
646
 
647
+ // Latest-ref for the local file list. The unmount cleanup below must NOT
648
+ // list `files` in its deps: that runs the cleanup after EVERY files change,
649
+ // revoking the previous list's blob URLs while they are still live — and
650
+ // submit does `fetch(blobUrl)`, so adding a second attachment (e.g. image
651
+ // + voice note) made the first file's conversion throw "TypeError: Failed
652
+ // to fetch" and the message was never submitted. Revoke only on unmount.
653
+ const filesRef = useRef(files);
654
+ useEffect(() => {
655
+ filesRef.current = files;
656
+ }, [files]);
657
+
631
658
  useEffect(
632
659
  () => () => {
633
660
  if (!usingProvider) {
634
- for (const f of files) {
661
+ for (const f of filesRef.current) {
635
662
  if (f.url) URL.revokeObjectURL(f.url);
636
663
  }
637
664
  }
638
665
  },
639
- [usingProvider, files]
666
+ [usingProvider]
640
667
  );
641
668
 
642
669
  const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
@@ -0,0 +1,228 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { MicIcon } from "lucide-react";
6
+ import { useOptionalPromptInputAttachments } from "@/components/ai-elements/prompt-input";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ /** Hard cap on a single recording, so a voice note stays far under the 5MB chat-attachment limit. */
10
+ const MAX_RECORDING_MS = 5_000;
11
+ /** Mirrors the SDK's per-attachment cap (chat-attachments.ts). UX-only; the SDK re-checks. */
12
+ const MAX_AUDIO_BYTES = 5 * 1024 * 1024;
13
+
14
+ export interface SpeechInputProps
15
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
16
+ /**
17
+ * Receives the recording only when no attachments context is available.
18
+ * Inside a PromptInput the recording attaches to the composer as a chip
19
+ * instead — that is the default path.
20
+ */
21
+ onAudioRecorded?: (blob: Blob) => void;
22
+ className?: string;
23
+ variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link";
24
+ size?: "default" | "sm" | "lg" | "icon";
25
+ disabled?: boolean;
26
+ }
27
+
28
+ /**
29
+ * SpeechInput — voice-note recorder.
30
+ *
31
+ * Records up to MAX_RECORDING_MS from the microphone with MediaRecorder and
32
+ * attaches the result (audio/webm in Chrome/Firefox, audio/mp4 in Safari) to
33
+ * the composer's attachments, where it uploads to S3 with the rest of the
34
+ * message's chat attachments. No live transcription: the message text is typed.
35
+ */
36
+ export const SpeechInput = React.memo<SpeechInputProps>(
37
+ ({
38
+ onAudioRecorded,
39
+ variant = "ghost",
40
+ size = "icon",
41
+ disabled = false,
42
+ className,
43
+ ...props
44
+ }) => {
45
+ const [isRecording, setIsRecording] = React.useState(false);
46
+ const [error, setError] = React.useState<string | null>(null);
47
+ const mediaRecorderRef = React.useRef<MediaRecorder | null>(null);
48
+ const streamRef = React.useRef<MediaStream | null>(null);
49
+ const chunksRef = React.useRef<Blob[]>([]);
50
+ const timerRef = React.useRef<number | null>(null);
51
+ const disposedRef = React.useRef(false);
52
+ const attachments = useOptionalPromptInputAttachments();
53
+
54
+ const clearTimer = React.useCallback(() => {
55
+ if (timerRef.current !== null) {
56
+ window.clearTimeout(timerRef.current);
57
+ timerRef.current = null;
58
+ }
59
+ }, []);
60
+
61
+ const releaseStream = React.useCallback(() => {
62
+ streamRef.current?.getTracks().forEach((track) => track.stop());
63
+ streamRef.current = null;
64
+ }, []);
65
+
66
+ const finalize = React.useCallback(
67
+ (blob: Blob) => {
68
+ if (blob.size === 0) return;
69
+ if (blob.size > MAX_AUDIO_BYTES) {
70
+ setError("Recording is too large — record again.");
71
+ if (process.env.NODE_ENV === "development") {
72
+ console.warn("[SpeechInput] recording exceeds 5MB after encoding");
73
+ }
74
+ return;
75
+ }
76
+ // Strip codec parameters: MediaRecorder reports
77
+ // `audio/webm;codecs=opus`, and every downstream allowlist
78
+ // (frontend + SDK) matches on the base type.
79
+ const baseType = (blob.type || "audio/webm").split(";")[0];
80
+ const subtype = baseType.split("/")[1] ?? "webm";
81
+ const file = new File([blob], `voice-${Date.now()}.${subtype}`, {
82
+ type: baseType,
83
+ });
84
+ if (attachments) {
85
+ attachments.add([file]);
86
+ } else {
87
+ onAudioRecorded?.(blob);
88
+ }
89
+ },
90
+ [attachments, onAudioRecorded]
91
+ );
92
+
93
+ const stopRecording = React.useCallback(() => {
94
+ clearTimer();
95
+ const recorder = mediaRecorderRef.current;
96
+ if (recorder && recorder.state !== "inactive") {
97
+ recorder.stop();
98
+ }
99
+ }, [clearTimer]);
100
+
101
+ const startRecording = React.useCallback(async () => {
102
+ if (
103
+ typeof MediaRecorder === "undefined" ||
104
+ !navigator.mediaDevices?.getUserMedia
105
+ ) {
106
+ setError("Voice notes are not supported in this browser.");
107
+ return;
108
+ }
109
+ try {
110
+ const stream = await navigator.mediaDevices.getUserMedia({
111
+ audio: true,
112
+ });
113
+ if (disposedRef.current) {
114
+ stream.getTracks().forEach((track) => track.stop());
115
+ return;
116
+ }
117
+ streamRef.current = stream;
118
+ chunksRef.current = [];
119
+
120
+ const recorder = new MediaRecorder(stream);
121
+ recorder.ondataavailable = (event) => {
122
+ if (event.data && event.data.size > 0) {
123
+ chunksRef.current.push(event.data);
124
+ }
125
+ };
126
+ recorder.onstop = () => {
127
+ const blob = new Blob(chunksRef.current, {
128
+ // Base type only — recorder.mimeType carries `;codecs=…`.
129
+ type: (recorder.mimeType || "audio/webm").split(";")[0],
130
+ });
131
+ chunksRef.current = [];
132
+ releaseStream();
133
+ if (disposedRef.current) return;
134
+ mediaRecorderRef.current = null;
135
+ setIsRecording(false);
136
+ finalize(blob);
137
+ };
138
+
139
+ mediaRecorderRef.current = recorder;
140
+ recorder.start();
141
+ setError(null);
142
+ setIsRecording(true);
143
+ timerRef.current = window.setTimeout(
144
+ () => stopRecording(),
145
+ MAX_RECORDING_MS
146
+ );
147
+ } catch (err) {
148
+ releaseStream();
149
+ mediaRecorderRef.current = null;
150
+ setIsRecording(false);
151
+ setError("Microphone access denied.");
152
+ if (process.env.NODE_ENV === "development") {
153
+ console.warn("[SpeechInput] failed to start recording:", err);
154
+ }
155
+ }
156
+ }, [finalize, releaseStream, stopRecording]);
157
+
158
+ const toggleListening = React.useCallback(
159
+ (e: React.MouseEvent<HTMLButtonElement>) => {
160
+ e.preventDefault();
161
+ e.stopPropagation();
162
+ if (isRecording) {
163
+ stopRecording();
164
+ } else {
165
+ void startRecording();
166
+ }
167
+ },
168
+ [isRecording, startRecording, stopRecording]
169
+ );
170
+
171
+ React.useEffect(() => {
172
+ return () => {
173
+ disposedRef.current = true;
174
+ if (timerRef.current !== null) {
175
+ window.clearTimeout(timerRef.current);
176
+ timerRef.current = null;
177
+ }
178
+ const recorder = mediaRecorderRef.current;
179
+ if (recorder && recorder.state !== "inactive") {
180
+ try {
181
+ recorder.stop();
182
+ } catch {
183
+ // already stopped
184
+ }
185
+ }
186
+ releaseStream();
187
+ };
188
+ }, [releaseStream]);
189
+
190
+ const status = error
191
+ ? error
192
+ : isRecording
193
+ ? `Recording\u2026 (5s max)`
194
+ : "Record a 5-second voice note";
195
+
196
+ return (
197
+ <Button
198
+ type="button"
199
+ variant={variant}
200
+ size={size}
201
+ disabled={disabled}
202
+ onClick={toggleListening}
203
+ className={cn(
204
+ "relative h-8 w-8 rounded-full text-muted-foreground hover:text-foreground transition-all duration-200",
205
+ isRecording &&
206
+ "bg-red-500/15 text-red-500 hover:bg-red-500/20 animate-pulse",
207
+ error && "text-red-500",
208
+ className
209
+ )}
210
+ aria-label={isRecording ? "Stop recording" : status}
211
+ title={status}
212
+ {...props}
213
+ >
214
+ <MicIcon
215
+ className={cn("h-4 w-4", isRecording && "text-red-500")}
216
+ />
217
+ {isRecording && (
218
+ <span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
219
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
220
+ <span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
221
+ </span>
222
+ )}
223
+ </Button>
224
+ );
225
+ }
226
+ );
227
+
228
+ SpeechInput.displayName = "SpeechInput";
@@ -336,3 +336,29 @@ export const WithNearLimitContext: Story = {
336
336
  },
337
337
  };
338
338
 
339
+ /**
340
+ * With Speech Input
341
+ * Matches the design with paperclip attachment, speech-input mic, and blue enter button.
342
+ */
343
+ export const WithSpeechInput: Story = {
344
+ args: {
345
+ placeholder: "What would you like to know?",
346
+ enableSpeech: true,
347
+ attachIcon: "paperclip",
348
+ onSubmit: (message) => {
349
+ console.log("Submitted:", message);
350
+ alert(`Submitted: ${message.text}`);
351
+ },
352
+ },
353
+ parameters: {
354
+ docs: {
355
+ description: {
356
+ story:
357
+ "PromptInput featuring file attachment (paperclip), voice-note SpeechInput (microphone, 5s max), and blue enter return button.",
358
+ },
359
+ },
360
+ },
361
+ };
362
+
363
+
364
+
@@ -31,9 +31,11 @@ import {
31
31
  ContextReasoningUsage,
32
32
  ContextCacheUsage,
33
33
  } from "@/components/ai-elements/context";
34
+ import { SpeechInput, type SpeechInputProps } from "@/components/ai-elements/speech-input";
34
35
  import type { FormEvent } from "react";
35
36
  import { Button } from "@/components/primitives/Button";
36
37
  import { Icon } from "@/components/primitives/Icon";
38
+ import { cn } from "@/lib/utils";
37
39
 
38
40
  export interface PromptInputContextProps {
39
41
  usedTokens: number;
@@ -60,6 +62,9 @@ export interface PromptInputBlockProps
60
62
  onStop?: () => void;
61
63
  context?: PromptInputContextProps | ReactNode;
62
64
  tools?: ReactNode;
65
+ enableSpeech?: boolean;
66
+ speechProps?: SpeechInputProps;
67
+ attachIcon?: "paperclip" | "plus";
63
68
  }
64
69
 
65
70
  export const PromptInput = React.memo<PromptInputBlockProps>(
@@ -74,7 +79,10 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
74
79
  onStop,
75
80
  context,
76
81
  tools,
77
- accept = "image/*",
82
+ enableSpeech = true,
83
+ speechProps,
84
+ attachIcon = "paperclip",
85
+ accept = "image/*,audio/*",
78
86
  multiple = true,
79
87
  maxFiles,
80
88
  maxFileSize,
@@ -134,7 +142,13 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
134
142
  </PromptInputBody>
135
143
  <PromptInputFooter>
136
144
  <PromptInputTools>
137
- <AttachButton disabled={disabled || loading} />
145
+ <AttachButton disabled={disabled || loading} icon={attachIcon} />
146
+ {enableSpeech && (
147
+ <SpeechInput
148
+ disabled={disabled || loading}
149
+ {...speechProps}
150
+ />
151
+ )}
138
152
  {tools}
139
153
  {context ? (
140
154
  <PromptInputContextIndicator
@@ -147,7 +161,13 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
147
161
  disabled={disabled || (loading && !onStop)}
148
162
  status={loading ? (onStop ? "streaming" : "submitted") : undefined}
149
163
  onClick={isStopping ? (e: React.MouseEvent) => { e.preventDefault(); onStop?.(); } : undefined}
150
- />
164
+ className={cn(
165
+ "rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors",
166
+ isStopping && "bg-transparent hover:bg-accent"
167
+ )}
168
+ >
169
+ {loading ? undefined : <Icon name="corner-down-left" size="sm" />}
170
+ </PromptInputSubmit>
151
171
  </PromptInputFooter>
152
172
  </AIPromptInput>
153
173
  );
@@ -174,26 +194,35 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
174
194
  );
175
195
 
176
196
  /**
177
- * The "+" toolbar button — opens the native file picker via the attachments
197
+ * The attach toolbar button — opens the native file picker via the attachments
178
198
  * context. Must render inside <AIPromptInput> (or a PromptInputProvider),
179
199
  * since usePromptInputAttachments() reads that context.
180
200
  */
181
- function AttachButton({ disabled }: { disabled?: boolean }) {
201
+ function AttachButton({
202
+ disabled,
203
+ icon = "paperclip",
204
+ }: {
205
+ disabled?: boolean;
206
+ icon?: "paperclip" | "plus";
207
+ }) {
182
208
  const attachments = usePromptInputAttachments();
183
209
  return (
184
210
  <Button
185
211
  variant="ghost"
186
212
  size="icon"
187
- className="h-8 w-8 rounded-full"
213
+ className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground transition-colors"
188
214
  type="button"
189
215
  disabled={disabled}
190
216
  onClick={() => attachments.openFileDialog()}
217
+ aria-label="Attach files"
218
+ title="Attach files"
191
219
  >
192
- <Icon name="plus" size="sm" />
220
+ <Icon name={icon} size="sm" />
193
221
  </Button>
194
222
  );
195
223
  }
196
224
 
225
+
197
226
  /** Renders a preview chip (thumbnail + remove) per attached file. */
198
227
  function AttachmentPreviews() {
199
228
  return (
@@ -1,6 +1,9 @@
1
1
  export { PromptInput } from "./PromptInput";
2
2
  export type { PromptInputBlockProps, PromptInputContextProps } from "./PromptInput";
3
3
 
4
+ export { SpeechInput } from "@/components/ai-elements/speech-input";
5
+ export type { SpeechInputProps } from "@/components/ai-elements/speech-input";
6
+
4
7
  export {
5
8
  PromptInputProvider,
6
9
  usePromptInputController,
@@ -24,6 +24,51 @@ export interface SystemMessageProps {
24
24
  renderContent?: (content: string) => React.ReactNode
25
25
  }
26
26
 
27
+ /**
28
+ * Renders the player/viewer for a known url + mediaType (video, audio,
29
+ * image), or null when the pair matches none of them. Shared by
30
+ * SystemMessage (job notifications) and UserMessage (chat attachments) so
31
+ * in-chat media rendering lives in one place — an audio voice note gets the
32
+ * same `<audio controls>` player wherever it appears.
33
+ */
34
+ export function renderMediaOutput(
35
+ url: string,
36
+ mediaType?: string,
37
+ title?: string
38
+ ): React.ReactNode {
39
+ if (mediaType?.startsWith('audio/')) {
40
+ return (
41
+ <div className="flex flex-col items-center gap-2 w-full">
42
+ {title && <span className="text-xs text-muted-foreground">{title}</span>}
43
+ <audio controls className="w-full min-w-[240px] max-w-[360px] h-8">
44
+ <source src={url} type={mediaType} />
45
+ Your browser does not support the audio element.
46
+ </audio>
47
+ </div>
48
+ )
49
+ }
50
+ if (mediaType?.startsWith('video/')) {
51
+ return (
52
+ <div className="flex flex-col items-center gap-2">
53
+ {title && <span className="text-xs text-muted-foreground">{title}</span>}
54
+ <video controls className="w-full max-w-[360px] rounded-md shadow-sm">
55
+ <source src={url} type={mediaType} />
56
+ Your browser does not support the video element.
57
+ </video>
58
+ </div>
59
+ )
60
+ }
61
+ if (mediaType?.startsWith('image/')) {
62
+ return (
63
+ <div className="flex flex-col items-center gap-2">
64
+ {title && <span className="text-xs text-muted-foreground">{title}</span>}
65
+ <img src={url} alt={title || 'Image output'} className="max-w-[360px] rounded-md shadow-sm" />
66
+ </div>
67
+ )
68
+ }
69
+ return null
70
+ }
71
+
27
72
  function parseSystemMessageContent(content: string): React.ReactNode {
28
73
  try {
29
74
  const parsed = JSON.parse(content)
@@ -33,36 +78,8 @@ function parseSystemMessageContent(content: string): React.ReactNode {
33
78
  const title = typeof parsed.title === 'string' ? parsed.title : typeof parsed.message === 'string' ? parsed.message : undefined
34
79
 
35
80
  if (url) {
36
- if (mediaType?.startsWith('video/') || /\.(mp4|webm|mov)(\?.*)?$/i.test(url)) {
37
- return (
38
- <div className="flex flex-col items-center gap-2">
39
- {title && <span className="text-xs text-muted-foreground">{title}</span>}
40
- <video controls className="w-full max-w-[360px] rounded-md shadow-sm">
41
- <source src={url} type={mediaType || 'video/mp4'} />
42
- Your browser does not support the video element.
43
- </video>
44
- </div>
45
- )
46
- }
47
- if (mediaType?.startsWith('audio/') || /\.(wav|mp3|ogg|m4a)(\?.*)?$/i.test(url)) {
48
- return (
49
- <div className="flex flex-col items-center gap-2">
50
- {title && <span className="text-xs text-muted-foreground">{title}</span>}
51
- <audio controls className="w-full max-w-[360px] h-8">
52
- <source src={url} type={mediaType || 'audio/wav'} />
53
- Your browser does not support the audio element.
54
- </audio>
55
- </div>
56
- )
57
- }
58
- if (mediaType?.startsWith('image/') || /\.(png|jpg|jpeg|webp|gif)(\?.*)?$/i.test(url)) {
59
- return (
60
- <div className="flex flex-col items-center gap-2">
61
- {title && <span className="text-xs text-muted-foreground">{title}</span>}
62
- <img src={url} alt={title || 'Image output'} className="max-w-[360px] rounded-md shadow-sm" />
63
- </div>
64
- )
65
- }
81
+ const media = renderMediaOutput(url, mediaType, title)
82
+ if (media) return media
66
83
  }
67
84
  if (title) return <span>{title}</span>
68
85
  }
@@ -1,2 +1,2 @@
1
- export { SystemMessage } from "./SystemMessage"
1
+ export { SystemMessage, renderMediaOutput } from "./SystemMessage"
2
2
  export type { SystemMessageProps, SystemMessageData } from "./SystemMessage"
@@ -57,3 +57,29 @@ export const ShortQuestion: Story = {
57
57
  showAvatar: true,
58
58
  },
59
59
  }
60
+
61
+ // 50ms of 8kHz 16-bit mono silence — a real, playable WAV so the audio
62
+ // branch renders an actual player (same data: URI shape a sent voice note
63
+ // has before upload resolution).
64
+ const SILENT_WAV =
65
+ 'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='
66
+
67
+ export const WithVoiceNote: Story = {
68
+ args: {
69
+ message: {
70
+ id: '5',
71
+ content: 'Here is the voice note',
72
+ avatarSrc: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=32&h=32&fit=crop&crop=face',
73
+ avatarName: 'User',
74
+ attachments: [
75
+ {
76
+ kind: 'audio',
77
+ mime: 'audio/wav',
78
+ filename: 'voice-note.wav',
79
+ source: { type: 'data', value: SILENT_WAV },
80
+ },
81
+ ],
82
+ },
83
+ showAvatar: true,
84
+ },
85
+ }
@@ -9,6 +9,7 @@ import {
9
9
  MessageContent,
10
10
  MessageAvatar,
11
11
  } from "@/components/ai-elements/message"
12
+ import { renderMediaOutput } from "@/components/composites/SystemMessage"
12
13
  import { cn } from "@/lib/utils"
13
14
 
14
15
  /**
@@ -66,7 +67,8 @@ export interface UserMessageData {
66
67
 
67
68
  function UserMessageAttachments({ attachments }: { attachments: UserMessageAttachment[] }) {
68
69
  const images = attachments.filter((a) => a.mime.startsWith("image/"))
69
- if (images.length === 0) return null
70
+ const audio = attachments.filter((a) => a.mime.startsWith("audio/"))
71
+ if (images.length === 0 && audio.length === 0) return null
70
72
 
71
73
  return (
72
74
  <div className="flex flex-wrap justify-end gap-2">
@@ -88,6 +90,18 @@ function UserMessageAttachments({ attachments }: { attachments: UserMessageAttac
88
90
  />
89
91
  </a>
90
92
  ))}
93
+ {/* Voice notes: the same `<audio controls>` player SystemMessage uses
94
+ for job notifications — `source.value` works as src for both the
95
+ optimistic data: URI and the resolved object-store URL, so no
96
+ branching on source type at render time. */}
97
+ {audio.map((a, i) => (
98
+ <div
99
+ key={a.id ?? `${a.filename ?? "voice-note"}-${i}`}
100
+ className="shrink-0 rounded-md border border-border p-1.5 w-[280px] sm:w-[320px] max-w-full"
101
+ >
102
+ {renderMediaOutput(a.source.value, a.mime)}
103
+ </div>
104
+ ))}
91
105
  </div>
92
106
  )
93
107
  }
@@ -227,9 +227,11 @@ export {
227
227
  ContextOutputUsage,
228
228
  ContextReasoningUsage,
229
229
  ContextCacheUsage,
230
+ SpeechInput,
230
231
  } from './PromptInput'
231
232
  export type {
232
233
  PromptInputBlockProps,
234
+ SpeechInputProps,
233
235
  PromptInputContextProps,
234
236
  PromptInputControllerProps,
235
237
  AttachmentsContext,