Skip to content

Commit ccd7037

Browse files
thatbeautifuldreamhaydenbleaselclaude
authored
feat: add stt and add to test and example apps (#112)
Co-authored-by: Hayden Bleasel <hello@haydenbleasel.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2db6495 commit ccd7037

File tree

3 files changed

+156
-12
lines changed

3 files changed

+156
-12
lines changed

apps/test/app/examples/prompt-input.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ import {
1717
PromptInputModelSelectTrigger,
1818
PromptInputModelSelectValue,
1919
PromptInputProvider,
20+
PromptInputSpeechButton,
2021
PromptInputSubmit,
2122
PromptInputTextarea,
2223
PromptInputToolbar,
2324
PromptInputTools,
2425
usePromptInputController,
2526
} from "@repo/elements/prompt-input";
2627
import { Button } from "@repo/shadcn-ui/components/ui/button";
27-
import { GlobeIcon, MicIcon } from "lucide-react";
28-
import { useState } from "react";
28+
import { GlobeIcon } from "lucide-react";
29+
import { useRef, useState } from "react";
2930

3031
const models = [
3132
{ id: "gpt-4", name: "GPT-4" },
@@ -88,6 +89,7 @@ const Example = () => {
8889
const [status, setStatus] = useState<
8990
"submitted" | "streaming" | "ready" | "error"
9091
>("ready");
92+
const textareaRef = useRef<HTMLTextAreaElement>(null);
9193

9294
const handleSubmit = (message: PromptInputMessage) => {
9395
const hasText = Boolean(message.text);
@@ -118,7 +120,7 @@ const Example = () => {
118120
<PromptInputAttachments>
119121
{(attachment) => <PromptInputAttachment data={attachment} />}
120122
</PromptInputAttachments>
121-
<PromptInputTextarea />
123+
<PromptInputTextarea ref={textareaRef} />
122124
</PromptInputBody>
123125
<PromptInputToolbar>
124126
<PromptInputTools>
@@ -128,9 +130,7 @@ const Example = () => {
128130
<PromptInputActionAddAttachments />
129131
</PromptInputActionMenuContent>
130132
</PromptInputActionMenu>
131-
<PromptInputButton>
132-
<MicIcon size={16} />
133-
</PromptInputButton>
133+
<PromptInputSpeechButton textareaRef={textareaRef} />
134134
<PromptInputButton>
135135
<GlobeIcon size={16} />
136136
<span>Search</span>

packages/elements/src/prompt-input.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { ChatStatus, FileUIPart } from "ai";
3232
import {
3333
ImageIcon,
3434
Loader2Icon,
35+
MicIcon,
3536
PaperclipIcon,
3637
PlusIcon,
3738
SendIcon,
@@ -961,6 +962,149 @@ export const PromptInputSubmit = ({
961962
);
962963
};
963964

965+
interface SpeechRecognition extends EventTarget {
966+
continuous: boolean;
967+
interimResults: boolean;
968+
lang: string;
969+
start(): void;
970+
stop(): void;
971+
onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
972+
onend: ((this: SpeechRecognition, ev: Event) => any) | null;
973+
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null;
974+
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null;
975+
}
976+
977+
interface SpeechRecognitionEvent extends Event {
978+
results: SpeechRecognitionResultList;
979+
}
980+
981+
interface SpeechRecognitionResultList {
982+
readonly length: number;
983+
item(index: number): SpeechRecognitionResult;
984+
[index: number]: SpeechRecognitionResult;
985+
}
986+
987+
interface SpeechRecognitionResult {
988+
readonly length: number;
989+
item(index: number): SpeechRecognitionAlternative;
990+
[index: number]: SpeechRecognitionAlternative;
991+
isFinal: boolean;
992+
}
993+
994+
interface SpeechRecognitionAlternative {
995+
transcript: string;
996+
confidence: number;
997+
}
998+
999+
interface SpeechRecognitionErrorEvent extends Event {
1000+
error: string;
1001+
}
1002+
1003+
declare global {
1004+
interface Window {
1005+
SpeechRecognition: {
1006+
new (): SpeechRecognition;
1007+
};
1008+
webkitSpeechRecognition: {
1009+
new (): SpeechRecognition;
1010+
};
1011+
}
1012+
}
1013+
1014+
export type PromptInputSpeechButtonProps = ComponentProps<typeof Button> & {
1015+
textareaRef?: RefObject<HTMLTextAreaElement | null>;
1016+
onTranscriptionChange?: (text: string) => void;
1017+
};
1018+
1019+
export const PromptInputSpeechButton = ({
1020+
className,
1021+
textareaRef,
1022+
onTranscriptionChange,
1023+
...props
1024+
}: PromptInputSpeechButtonProps) => {
1025+
const [isListening, setIsListening] = useState(false);
1026+
const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
1027+
const recognitionRef = useRef<SpeechRecognition | null>(null);
1028+
1029+
useEffect(() => {
1030+
if (typeof window !== "undefined" && ("SpeechRecognition" in window || "webkitSpeechRecognition" in window)) {
1031+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
1032+
const speechRecognition = new SpeechRecognition();
1033+
1034+
speechRecognition.continuous = true;
1035+
speechRecognition.interimResults = true;
1036+
speechRecognition.lang = "en-US";
1037+
1038+
speechRecognition.onstart = () => {
1039+
setIsListening(true);
1040+
};
1041+
1042+
speechRecognition.onend = () => {
1043+
setIsListening(false);
1044+
};
1045+
1046+
speechRecognition.onresult = (event) => {
1047+
let finalTranscript = "";
1048+
1049+
for (let i = 0; i < event.results.length; i++) {
1050+
if (event.results[i].isFinal) {
1051+
finalTranscript += event.results[i][0].transcript;
1052+
}
1053+
}
1054+
1055+
if (finalTranscript && textareaRef?.current) {
1056+
const textarea = textareaRef.current;
1057+
const currentValue = textarea.value;
1058+
const newValue = currentValue + (currentValue ? " " : "") + finalTranscript;
1059+
1060+
textarea.value = newValue;
1061+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
1062+
onTranscriptionChange?.(newValue);
1063+
}
1064+
};
1065+
1066+
speechRecognition.onerror = (event) => {
1067+
console.error("Speech recognition error:", event.error);
1068+
setIsListening(false);
1069+
};
1070+
1071+
recognitionRef.current = speechRecognition;
1072+
setRecognition(speechRecognition);
1073+
}
1074+
1075+
return () => {
1076+
if (recognitionRef.current) {
1077+
recognitionRef.current.stop();
1078+
}
1079+
};
1080+
}, [textareaRef, onTranscriptionChange]);
1081+
1082+
const toggleListening = useCallback(() => {
1083+
if (!recognition) return;
1084+
1085+
if (isListening) {
1086+
recognition.stop();
1087+
} else {
1088+
recognition.start();
1089+
}
1090+
}, [recognition, isListening]);
1091+
1092+
return (
1093+
<PromptInputButton
1094+
className={cn(
1095+
"relative transition-all duration-200",
1096+
isListening && "animate-pulse bg-accent text-accent-foreground",
1097+
className
1098+
)}
1099+
onClick={toggleListening}
1100+
disabled={!recognition}
1101+
{...props}
1102+
>
1103+
<MicIcon className="size-4" />
1104+
</PromptInputButton>
1105+
);
1106+
};
1107+
9641108
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
9651109

9661110
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (

packages/examples/src/prompt-input.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ import {
1717
PromptInputModelSelectTrigger,
1818
PromptInputModelSelectValue,
1919
PromptInputProvider,
20+
PromptInputSpeechButton,
2021
PromptInputSubmit,
2122
PromptInputTextarea,
2223
PromptInputToolbar,
2324
PromptInputTools,
2425
usePromptInputController,
2526
} from "@repo/elements/prompt-input";
2627
import { Button } from "@repo/shadcn-ui/components/ui/button";
27-
import { GlobeIcon, MicIcon } from "lucide-react";
28-
import { useState } from "react";
28+
import { GlobeIcon } from "lucide-react";
29+
import { useRef, useState } from "react";
2930

3031
const models = [
3132
{ id: "gpt-4", name: "GPT-4" },
@@ -88,6 +89,7 @@ const Example = () => {
8889
const [status, setStatus] = useState<
8990
"submitted" | "streaming" | "ready" | "error"
9091
>("ready");
92+
const textareaRef = useRef<HTMLTextAreaElement>(null);
9193

9294
const handleSubmit = (message: PromptInputMessage) => {
9395
const hasText = Boolean(message.text);
@@ -119,7 +121,7 @@ const Example = () => {
119121
<PromptInputAttachments>
120122
{(attachment) => <PromptInputAttachment data={attachment} />}
121123
</PromptInputAttachments>
122-
<PromptInputTextarea />
124+
<PromptInputTextarea ref={textareaRef} />
123125
</PromptInputBody>
124126
<PromptInputToolbar>
125127
<PromptInputTools>
@@ -129,9 +131,7 @@ const Example = () => {
129131
<PromptInputActionAddAttachments />
130132
</PromptInputActionMenuContent>
131133
</PromptInputActionMenu>
132-
<PromptInputButton>
133-
<MicIcon size={16} />
134-
</PromptInputButton>
134+
<PromptInputSpeechButton textareaRef={textareaRef} />
135135
<PromptInputButton>
136136
<GlobeIcon size={16} />
137137
<span>Search</span>

0 commit comments

Comments
 (0)