Skip to content

Commit b20aab1

Browse files
committed
feat: added voice model and save chat
1 parent 7876054 commit b20aab1

7 files changed

Lines changed: 404 additions & 24 deletions

File tree

bun.lock

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@
4646
"@trpc/react-query": "^11.0.0",
4747
"@trpc/server": "^11.0.0",
4848
"@types/nodemailer": "^6.4.17",
49+
"@types/react-speech-kit": "^3.0.0",
50+
"@types/react-speech-recognition": "^3.9.6",
4951
"@types/react-syntax-highlighter": "^15.5.13",
5052
"@upstash/ratelimit": "^2.0.5",
5153
"ai": "^4.3.16",
@@ -64,7 +66,10 @@
6466
"react-dom": "^19.0.0",
6567
"react-hook-form": "^7.57.0",
6668
"react-markdown": "^10.1.0",
69+
"react-speech-kit": "^3.0.1",
70+
"react-speech-recognition": "^4.0.1",
6771
"react-syntax-highlighter": "^15.6.1",
72+
"react-text-to-speech": "^2.0.3",
6873
"react-turnstile": "^1.1.4",
6974
"remark-gfm": "^4.0.1",
7075
"server-only": "^0.0.1",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Chat" ADD COLUMN "isSaved" BOOLEAN NOT NULL DEFAULT false;

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ model Transaction {
9090
model Chat {
9191
id String @id @default(cuid())
9292
userId String
93+
isSaved Boolean @default(false)
9394
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
9495
messages Message[]
9596
createdAt DateTime @default(now())

src/components/ui/ui-input.tsx

Lines changed: 140 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
CopyIcon,
1313
ThumbsDownIcon,
1414
ThumbsUpIcon,
15+
SpeakerHighIcon,
16+
SpeakerXIcon,
1517
} from "@phosphor-icons/react";
1618
import ReactMarkdown from "react-markdown";
1719
import SyntaxHighlighter from "react-syntax-highlighter";
@@ -23,6 +25,9 @@ import TabsSuggestion from "./tabs-suggestion";
2325
import { useFont } from "@/contexts/font-context";
2426
import { ModelSelector } from "@/components/ui/model-selector";
2527
import { DEFAULT_MODEL_ID } from "@/models/constants";
28+
import SpeechRecognition, { useSpeechRecognition } from 'react-speech-recognition';
29+
import { useSpeechSynthesis } from 'react-speech-kit';
30+
import { toast } from "sonner";
2631

2732
const geistMono = Geist_Mono({
2833
subsets: ["latin"],
@@ -40,12 +45,50 @@ interface Message {
4045
const UIInput = () => {
4146
const session = useSession();
4247
const [model, setModel] = useState<string>(DEFAULT_MODEL_ID);
48+
const [modeOfChatting, setModeOfChatting] = useState<"text" | "voice">("text");
4349
const [query, setQuery] = useState<string>("");
4450
const [messages, setMessages] = useState<Message[]>([]);
4551
const [showWelcome, setShowWelcome] = useState(true);
4652
const [isLoading, setIsLoading] = useState(false);
4753
const messagesEndRef = useRef<HTMLDivElement>(null);
4854
const abortControllerRef = useRef<AbortController | null>(null);
55+
const welcomeSpokenRef = useRef(false);
56+
57+
const {
58+
transcript,
59+
listening,
60+
resetTranscript,
61+
browserSupportsSpeechRecognition
62+
} = useSpeechRecognition();
63+
64+
const { speak, cancel, speaking, supported: ttsSupported, voices } = useSpeechSynthesis();
65+
const [selectedVoice, setSelectedVoice] = useState<SpeechSynthesisVoice | null>(null);
66+
67+
useEffect(() => {
68+
if (!browserSupportsSpeechRecognition) {
69+
toast.error("Your browser doesn't support speech recognition.");
70+
}
71+
}, [browserSupportsSpeechRecognition]);
72+
73+
useEffect(() => {
74+
if (modeOfChatting === "voice" && !ttsSupported) {
75+
toast.error("Text-to-speech not supported in your browser");
76+
setModeOfChatting("text");
77+
}
78+
}, [modeOfChatting, ttsSupported]);
79+
80+
useEffect(() => {
81+
if (ttsSupported && voices.length > 0) {
82+
const defaultVoice = voices.find(v => v.default) || voices[0];
83+
setSelectedVoice(defaultVoice!);
84+
}
85+
}, [voices, ttsSupported]);
86+
87+
useEffect(() => {
88+
if (listening) {
89+
setQuery(transcript);
90+
}
91+
}, [listening, transcript]);
4992

5093
const scrollToBottom = () => {
5194
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
@@ -55,6 +98,16 @@ const UIInput = () => {
5598
scrollToBottom();
5699
}, [messages]);
57100

101+
useEffect(() => {
102+
if (showWelcome && messages.length === 0 && modeOfChatting === "voice" && ttsSupported && selectedVoice && !welcomeSpokenRef.current) {
103+
welcomeSpokenRef.current = true;
104+
speak({
105+
text: `Hello mate, how may I help you today?`,
106+
voice: selectedVoice
107+
});
108+
}
109+
}, [showWelcome, messages.length, modeOfChatting, ttsSupported, selectedVoice, speak]);
110+
58111
const createChat = api.chat.createChat.useMutation({
59112
onError: (error) => {
60113
console.error("Error saving chat:", error);
@@ -105,7 +158,6 @@ const UIInput = () => {
105158
const { done, value } = await reader.read();
106159

107160
if (done) {
108-
// Final update with complete content
109161
setMessages((prev) =>
110162
prev.map((msg) =>
111163
msg.id === tempMessageId
@@ -118,6 +170,13 @@ const UIInput = () => {
118170
clearTimeout(updateTimeout);
119171
}
120172

173+
if (modeOfChatting === "voice" && ttsSupported && selectedVoice) {
174+
speak({
175+
text: accumulatedContent,
176+
voice: selectedVoice
177+
});
178+
}
179+
121180
break;
122181
}
123182

@@ -150,7 +209,6 @@ const UIInput = () => {
150209
error?: string;
151210
};
152211

153-
// Handle error responses
154212
if (parsedData.error) {
155213
console.error("Stream error:", parsedData.error);
156214
setMessages((prev) =>
@@ -180,7 +238,6 @@ const UIInput = () => {
180238
}
181239
} catch (error) {
182240
console.error("Error processing stream:", error);
183-
// Update message with error state
184241
setMessages((prev) =>
185242
prev.map((msg) =>
186243
msg.id === tempMessageId
@@ -209,9 +266,7 @@ const UIInput = () => {
209266
};
210267

211268
setQuery("");
212-
213269
setMessages((prev) => [...prev, userMessage]);
214-
215270
setIsLoading(true);
216271

217272
if (abortControllerRef.current) {
@@ -253,6 +308,29 @@ const UIInput = () => {
253308
}
254309
};
255310

311+
const handleStartListening = () => {
312+
resetTranscript();
313+
SpeechRecognition.startListening({ continuous: true });
314+
toast.success("Listening...", {
315+
description: "Speak now...",
316+
duration: 5000,
317+
});
318+
};
319+
320+
const handleStopListening = () => {
321+
SpeechRecognition.stopListening();
322+
toast.success("Stopped listening", {
323+
description: "Processing your voice input...",
324+
});
325+
};
326+
327+
const toggleMode = () => {
328+
if (modeOfChatting === "voice" && speaking) {
329+
cancel();
330+
}
331+
setModeOfChatting(modeOfChatting === "text" ? "voice" : "text");
332+
};
333+
256334
const { selectedFont } = useFont();
257335

258336
return (
@@ -406,16 +484,37 @@ const UIInput = () => {
406484
</div>
407485
<div className="font-medium">
408486
{message.role === "assistant" && (
409-
<div className="flex w-fit items-center text-base font-semibold">
410-
<div className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
487+
<div className="flex w-fit items-center gap-2 text-base font-semibold">
488+
<button className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
411489
<ThumbsUpIcon weight="bold" />
412-
</div>
413-
<div className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
490+
</button>
491+
<button className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
414492
<ThumbsDownIcon weight="bold" />
415-
</div>
416-
<div className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
493+
</button>
494+
<button className="hover:bg-accent flex size-7 items-center justify-center rounded-lg">
417495
<CopyIcon weight="bold" />
418-
</div>
496+
</button>
497+
{modeOfChatting === "voice" && (
498+
<button
499+
className="hover:bg-accent flex size-7 items-center justify-center rounded-lg"
500+
onClick={() => {
501+
if (speaking) {
502+
cancel();
503+
} else if (ttsSupported && selectedVoice) {
504+
speak({
505+
text: message.content,
506+
voice: selectedVoice
507+
});
508+
}
509+
}}
510+
>
511+
{speaking ? (
512+
<SpeakerXIcon weight="bold" />
513+
) : (
514+
<SpeakerHighIcon weight="bold" />
515+
)}
516+
</button>
517+
)}
419518
</div>
420519
)}
421520
</div>
@@ -447,15 +546,39 @@ const UIInput = () => {
447546
void handleCreateChat(e as any);
448547
}
449548
}}
450-
placeholder="Ask whatever you want to be"
549+
placeholder={
550+
modeOfChatting === "voice"
551+
? "Or type here..."
552+
: "Ask whatever you want to be"
553+
}
451554
className="h-[2rem] resize-none rounded-none border-none bg-transparent px-0 py-1 shadow-none ring-0 focus-visible:ring-0 dark:bg-transparent"
452555
disabled={isLoading}
453556
/>
454557
<div className="mt-2 flex items-center justify-between">
455558
<div className="flex items-center gap-2">
456-
<div className="bg-accent flex size-8 items-center justify-center rounded-lg border">
457-
<MicrophoneIcon />
458-
</div>
559+
<Button
560+
variant="ghost"
561+
size="sm"
562+
onClick={toggleMode}
563+
className="text-xs"
564+
>
565+
{modeOfChatting === "text" ? "Switch to Voice" : "Switch to Text"}
566+
</Button>
567+
{modeOfChatting === "voice" && (
568+
<div className="bg-accent flex size-8 items-center justify-center rounded-lg border">
569+
<button
570+
onClick={listening ? handleStopListening : handleStartListening}
571+
disabled={!browserSupportsSpeechRecognition}
572+
>
573+
<MicrophoneIcon
574+
weight="bold"
575+
className={`text-foreground size-4 hover:text-primary cursor-pointer ${
576+
listening ? "text-red-500 animate-pulse" : ""
577+
}`}
578+
/>
579+
</button>
580+
</div>
581+
)}
459582
<ModelSelector
460583
value={model}
461584
onValueChange={setModel}
@@ -482,4 +605,4 @@ const UIInput = () => {
482605
);
483606
};
484607

485-
export default UIInput;
608+
export default UIInput;

0 commit comments

Comments
 (0)