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.
- package/components/ai-elements/prompt-input.tsx +35 -8
- package/components/ai-elements/speech-input.tsx +228 -0
- package/components/composites/PromptInput/PromptInput.stories.tsx +26 -0
- package/components/composites/PromptInput/PromptInput.tsx +36 -7
- package/components/composites/PromptInput/index.ts +3 -0
- package/components/composites/SystemMessage/SystemMessage.tsx +47 -30
- package/components/composites/SystemMessage/index.ts +1 -1
- package/components/composites/UserMessage/UserMessage.stories.tsx +26 -0
- package/components/composites/UserMessage/UserMessage.tsx +15 -1
- package/components/composites/index.ts +2 -0
- package/components/features/ChatPanel/ChatPanel.mocks.ts +49 -0
- package/components/features/ChatPanel/ChatPanel.stories.tsx +140 -1
- package/components/features/ChatPanel/ChatPanel.tsx +7 -0
- package/components/index.ts +2 -0
- package/dist/index.cjs +480 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +49 -0
- package/dist/index.d.ts +42 -34
- package/dist/index.js +481 -108
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/utils/markdown-formatter.ts +143 -3
|
@@ -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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
661
|
+
for (const f of filesRef.current) {
|
|
635
662
|
if (f.url) URL.revokeObjectURL(f.url);
|
|
636
663
|
}
|
|
637
664
|
}
|
|
638
665
|
},
|
|
639
|
-
[usingProvider
|
|
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
|
-
|
|
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
|
|
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({
|
|
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=
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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,
|