Skip to content

Commit

Permalink
speech interaction features
Browse files Browse the repository at this point in the history
  • Loading branch information
leonardogrig committed Feb 13, 2024
1 parent 6c5aa02 commit 14216a9
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 144 deletions.
12 changes: 12 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,16 @@
body {
@apply bg-background text-foreground;
}
}

.mendable-textarea::-webkit-scrollbar {
width: 3px;
}

.mendable-textarea::-webkit-scrollbar-track {
background: transparent;
}

.mendable-textarea::-webkit-scrollbar-thumb {
background-color: black;
}
42 changes: 8 additions & 34 deletions src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,17 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/components/ui/use-toast";
import { welcomeMessage } from "@/lib/strings";
import { useChat } from "ai/react";
import { Share } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import { useSpeechRecognition } from "react-speech-recognition";
import Bubble from "./chat/bubble";
import SendButton from "./chat/send-button";
import SendForm from "./chat/send-form";

export default function Chat() {
const formRef = useRef(null);
const { toast } = useToast();
const searchParams = useSearchParams();
const share = searchParams.get("share");
Expand All @@ -34,24 +31,10 @@ export default function Chat() {
? JSON.parse(lzstring.decompressFromEncodedURIComponent(share))
: [],
});
const { transcript } = useSpeechRecognition();


useEnsureRegeneratorRuntime();

useEffect(() => {
if (transcript) {
updateInputWithTranscript(transcript);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transcript]);

const updateInputWithTranscript = (transcriptValue: string) => {
const fakeEvent: any = {
target: { value: transcriptValue },
};
handleInputChange(fakeEvent as React.ChangeEvent<HTMLInputElement>);
};

const scrollAreaRef = useRef<null | HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -111,21 +94,12 @@ export default function Chat() {
</ScrollArea>
</CardContent>
<CardFooter>
<form
ref={formRef}
onSubmit={(event) => {
handleSubmit(event);
updateInputWithTranscript("");
}}
className="flex items-center justify-center w-full space-x-2"
>
<Input
placeholder="Type your message"
value={input}
onChange={handleInputChange}
/>
<SendButton isLoading={isLoading} formRef={formRef} />
</form>
<SendForm
input={input}
handleSubmit={handleSubmit}
isLoading={isLoading}
handleInputChange={handleInputChange}
/>
</CardFooter>
</Card>
);
Expand Down
110 changes: 0 additions & 110 deletions src/components/chat/send-button.tsx

This file was deleted.

141 changes: 141 additions & 0 deletions src/components/chat/send-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useEnsureRegeneratorRuntime } from "@/app/hooks/useEnsureRegeneratorRuntime";
import { Textarea } from "@/components/ui/textarea";
import { useEffect, useRef } from "react";
import { Grid } from "react-loader-spinner";
import SpeechRecognition, {
useSpeechRecognition,
} from "react-speech-recognition";
import { MicIcon } from "../icons/mic-icon";
import { XIcon } from "../icons/x-icon";
import { Button } from "../ui/button";
import { toast } from "../ui/use-toast";

interface SendForm {
input: string;
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
isLoading: boolean;
handleInputChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
}

export default function SendForm({
input,
handleSubmit,
isLoading,
handleInputChange,
}: SendForm) {
useEnsureRegeneratorRuntime();

const textareaRef = useRef(null);
const {
listening,
browserSupportsSpeechRecognition,
resetTranscript,
transcript,
} = useSpeechRecognition();

useEffect(() => {
if (!browserSupportsSpeechRecognition) {
toast({
description: "Your browser does not support speech recognition",
});
}
}, [browserSupportsSpeechRecognition]);

useEffect(() => {
if (listening) {
const textarea = document.querySelector(".mendable-textarea");
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
}
}, [listening, input]);

// This listener stops the speech recognition when the tab is not visible
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden" && listening) {
SpeechRecognition.stopListening();
}
};

document.addEventListener("visibilitychange", handleVisibilityChange);

return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [listening]);

useEffect(() => {
if (transcript) {
updateInputWithTranscript(transcript);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transcript]);

const updateInputWithTranscript = (transcriptValue: string) => {
const fakeEvent: any = {
target: { value: transcriptValue },
};
handleInputChange(fakeEvent);
};

function toggleSpeech() {
if (listening) {
SpeechRecognition.stopListening();
return;
} else {
SpeechRecognition.startListening({ continuous: true });
return;
}
}

return (
<form
onSubmit={(event) => {
handleSubmit(event);
}}
className="flex items-center justify-center w-full space-x-2"
>
<div className="relative w-full max-w-xs">
<MicIcon
onClick={toggleSpeech}
className={`absolute right-2 h-4 w-4 top-1/2 transform -translate-y-2 ${
listening ? "text-red-500 scale-125 animate-pulse" : "text-gray-500"
} dark:text-gray-400 hover:scale-125 cursor-pointer`}
/>
<XIcon
onClick={() => {
updateInputWithTranscript("");
resetTranscript();
}}
className="absolute right-8 h-4 w-4 top-1/2 transform -translate-y-2 text-gray-500 dark:text-gray-400 cursor-pointer hover:scale-125"
/>
<Textarea
value={input}
onChange={handleInputChange}
className="pr-12 resize-none mendable-textarea"
placeholder="Search"
ref={textareaRef}
/>
</div>

<Button className="h-full">
{isLoading ? (
<div className="flex gap-2 items-center">
<Grid
height={12}
width={12}
radius={5}
ariaLabel="grid-loading"
color="#fff"
visible={true}
/>
{"Loading..."}
</div>
) : (
<div className="flex flex-col w-16">Send</div>
)}
</Button>
</form>
);
}
22 changes: 22 additions & 0 deletions src/components/icons/mic-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function MicIcon(
props: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="30"
height="30"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
);
}
21 changes: 21 additions & 0 deletions src/components/icons/x-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function XIcon(
props: React.JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}

0 comments on commit 14216a9

Please sign in to comment.