added examples back
This commit is contained in:
90
examples/storytelling-chatbot/frontend/components/App.tsx
Normal file
90
examples/storytelling-chatbot/frontend/components/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useDaily } from "@daily-co/daily-react";
|
||||
import Setup from "./Setup";
|
||||
import Story from "./Story";
|
||||
|
||||
type State =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "started"
|
||||
| "finished"
|
||||
| "error";
|
||||
|
||||
export default function Call() {
|
||||
const daily = useDaily();
|
||||
|
||||
const [state, setState] = useState<State>("idle");
|
||||
const [room, setRoom] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
setState("connecting");
|
||||
|
||||
if (!daily) return;
|
||||
|
||||
// Create a new room for the story session
|
||||
try {
|
||||
const response = await fetch("/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_url: process.env.NEXT_PUBLIC_ROOM_URL || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const { room_url, token } = await response.json();
|
||||
|
||||
// Keep a reference to the room url for later
|
||||
setRoom(room_url);
|
||||
|
||||
// Join the WebRTC session
|
||||
await daily.join({
|
||||
url: room_url,
|
||||
token,
|
||||
videoSource: false,
|
||||
startAudioOff: true,
|
||||
});
|
||||
|
||||
setState("connected");
|
||||
|
||||
// Disable local audio, the bot will say hello first
|
||||
daily.setLocalAudio(false);
|
||||
|
||||
// Start the bot
|
||||
const resp = await fetch("/start", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
room_url,
|
||||
}),
|
||||
});
|
||||
|
||||
setState("started");
|
||||
} catch (error) {
|
||||
setState("error");
|
||||
leave();
|
||||
}
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
await daily?.leave();
|
||||
setState("finished");
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return <div>An Error occured</div>;
|
||||
}
|
||||
|
||||
if (state === "started") {
|
||||
return <Story handleLeave={() => leave()} />;
|
||||
}
|
||||
|
||||
return <Setup handleStart={() => start()} />;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
useAudioLevel,
|
||||
useAudioTrack,
|
||||
useLocalSessionId,
|
||||
} from "@daily-co/daily-react";
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
export const AudioIndicator: React.FC = () => {
|
||||
const localSessionId = useLocalSessionId();
|
||||
const audioTrack = useAudioTrack(localSessionId);
|
||||
const volRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useAudioLevel(
|
||||
audioTrack?.persistentTrack,
|
||||
useCallback((volume) => {
|
||||
// this volume number will be between 0 and 1
|
||||
// give it a minimum scale of 0.15 to not completely disappear 👻
|
||||
if (volRef.current) {
|
||||
const v = volume * 1.75;
|
||||
volRef.current.style.transform = `scale(${Math.max(0.1, v)})`;
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
// Your audio track's audio volume visualized in a small circle,
|
||||
// whose size changes depending on the volume level
|
||||
return (
|
||||
<>
|
||||
<div className="vol bg-teal-700" ref={volRef} />
|
||||
<style jsx>{`
|
||||
.vol {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
inset: 0px;
|
||||
z-index: 0;
|
||||
border-radius: 999px;
|
||||
transition: all 0.1s ease;
|
||||
transform: scale(0);
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AudioIndicatorBar: React.FC = () => {
|
||||
const localSessionId = useLocalSessionId();
|
||||
const audioTrack = useAudioTrack(localSessionId);
|
||||
|
||||
const volRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useAudioLevel(
|
||||
audioTrack?.persistentTrack,
|
||||
useCallback((volume) => {
|
||||
if (volRef.current)
|
||||
volRef.current.style.width = Math.max(2, volume * 100) + "%";
|
||||
}, [])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-200 h-[8px] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-green-500 h-[8px] w-[0] rounded-full transition-all duration-100 ease"
|
||||
ref={volRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioIndicator;
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { DailyMeetingState } from "@daily-co/daily-js";
|
||||
import { useDaily, useDevices } from "@daily-co/daily-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { IconMicrophone, IconDeviceSpeaker } from "@tabler/icons-react";
|
||||
import { AudioIndicatorBar } from "../AudioIndicator";
|
||||
|
||||
interface Props {}
|
||||
|
||||
export default function DevicePicker({}: Props) {
|
||||
const daily = useDaily();
|
||||
const {
|
||||
currentMic,
|
||||
hasMicError,
|
||||
micState,
|
||||
microphones,
|
||||
setMicrophone,
|
||||
currentSpeaker,
|
||||
speakers,
|
||||
setSpeaker,
|
||||
} = useDevices();
|
||||
|
||||
const handleMicrophoneChange = (value: string) => {
|
||||
setMicrophone(value);
|
||||
};
|
||||
|
||||
const handleSpeakerChange = (value: string) => {
|
||||
setSpeaker(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (microphones.length > 0 || !daily || daily.isDestroyed()) return;
|
||||
const meetingState = daily.meetingState();
|
||||
const meetingStatesBeforeJoin: DailyMeetingState[] = [
|
||||
"new",
|
||||
"loading",
|
||||
"loaded",
|
||||
];
|
||||
if (meetingStatesBeforeJoin.includes(meetingState)) {
|
||||
daily.startCamera({ startVideoOff: true, startAudioOff: false });
|
||||
}
|
||||
}, [daily, microphones]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<section>
|
||||
<label className="uppercase text-sm tracking-wider text-gray-500">
|
||||
Microphone:
|
||||
</label>
|
||||
<div className="flex flex-row gap-4 items-center mt-2">
|
||||
<IconMicrophone size={24} />
|
||||
<div className="flex flex-col flex-1 gap-3">
|
||||
<Select onValueChange={handleMicrophoneChange}>
|
||||
<SelectTrigger className="">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
hasMicError ? "error" : currentMic?.device?.label
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasMicError && (
|
||||
<option value="error" disabled>
|
||||
No microphone access.
|
||||
</option>
|
||||
)}
|
||||
|
||||
{microphones.map((m) => (
|
||||
<SelectItem key={m.device.deviceId} value={m.device.deviceId}>
|
||||
{m.device.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AudioIndicatorBar />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label className="uppercase text-sm tracking-wider text-gray-500">
|
||||
Speakers:
|
||||
</label>
|
||||
<div className="flex flex-row gap-4 items-center mt-2">
|
||||
<IconDeviceSpeaker size={24} />
|
||||
<Select onValueChange={handleSpeakerChange}>
|
||||
<SelectTrigger className="">
|
||||
<SelectValue placeholder={currentSpeaker?.device?.label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{speakers.map((m) => (
|
||||
<SelectItem key={m.device.deviceId} value={m.device.deviceId}>
|
||||
{m.device.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</section>
|
||||
{hasMicError && (
|
||||
<div className="error">
|
||||
{micState === "blocked" ? (
|
||||
<p>
|
||||
Please check your browser and system permissions. Make sure that
|
||||
this app is allowed to access your microphone.
|
||||
</p>
|
||||
) : micState === "in-use" ? (
|
||||
<p>
|
||||
Your microphone is being used by another app. Please close any
|
||||
other apps using your microphone and restart this app.
|
||||
</p>
|
||||
) : micState === "not-found" ? (
|
||||
<p>
|
||||
No microphone seems to be connected. Please connect a microphone.
|
||||
</p>
|
||||
) : micState === "not-supported" ? (
|
||||
<p>
|
||||
This app is not supported on your device. Please update your
|
||||
software or use a different device.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
There seems to be an issue accessing your microphone. Try
|
||||
restarting the app or consult a system administrator.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
useDaily,
|
||||
useLocalSessionId,
|
||||
useMediaTrack,
|
||||
} from "@daily-co/daily-react";
|
||||
import { useCallback } from "react";
|
||||
import { IconMicrophone, IconMicrophoneOff } from "@tabler/icons-react";
|
||||
|
||||
export const MicToggle: React.FC = () => {
|
||||
const daily = useDaily();
|
||||
const localSessionId = useLocalSessionId();
|
||||
const audioTrack = useMediaTrack(localSessionId, "audio");
|
||||
const isMicMuted =
|
||||
audioTrack.state === "blocked" || audioTrack.state === "off";
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!daily) return;
|
||||
daily.setLocalAudio(isMicMuted);
|
||||
}, [daily, isMicMuted]);
|
||||
|
||||
const text = isMicMuted ? (
|
||||
<IconMicrophone size={21} />
|
||||
) : (
|
||||
<IconMicrophoneOff size={21} />
|
||||
);
|
||||
|
||||
return (
|
||||
<button className="MicToggle UIButton" onClick={handleClick}>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MicToggle;
|
||||
93
examples/storytelling-chatbot/frontend/components/Setup.tsx
Normal file
93
examples/storytelling-chatbot/frontend/components/Setup.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DevicePicker from "@/components/DevicePicker";
|
||||
import { IconEar, IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
type SetupProps = {
|
||||
handleStart: () => void;
|
||||
};
|
||||
|
||||
const buttonLabel = {
|
||||
intro: "Next",
|
||||
setup: "Let's begin!",
|
||||
loading: "Joining...",
|
||||
};
|
||||
export const Setup: React.FC<SetupProps> = ({ handleStart }) => {
|
||||
const [state, setState] = React.useState<"intro" | "setup" | "loading">(
|
||||
"intro"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center justify-between">
|
||||
<div className="bg-white rounded-3xl cardAnim cardShadow p-9 max-w-screen-sm mx-auto outline outline-[5px] outline-gray-600/10 my-auto">
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-4xl font-bold text-pretty tracking-tighter mb-4">
|
||||
Welcome to <span className="text-sky-500">Storytime</span>
|
||||
</h1>
|
||||
|
||||
{state === "intro" ? (
|
||||
<>
|
||||
<p className="text-gray-600 leading-relaxed text-pretty">
|
||||
This app demos a voice-controlled storytelling chatbot. It will
|
||||
start with the bot asking you what kind of story you'd like
|
||||
to hear (e.g. a fairy tale, a mystery, etc.). After each scene,
|
||||
the bot will pause to ask for your input. Direct the story any
|
||||
way you choose!
|
||||
</p>
|
||||
<p className="flex flex-row gap-2 text-gray-600 font-medium">
|
||||
<IconEar size={24} /> For best results, try in a quiet
|
||||
environment!
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 leading-relaxed text-pretty">
|
||||
Since you'll be talking to Storybot, we need to make sure
|
||||
it can hear you! Please configure your microphone and speakers
|
||||
below.
|
||||
</p>
|
||||
<DevicePicker />
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr className="border-gray-150 my-2" />
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
disabled={state === "loading"}
|
||||
onClick={() => {
|
||||
if (state === "intro") {
|
||||
setState("setup");
|
||||
} else {
|
||||
setState("loading");
|
||||
handleStart();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{state === "loading" && (
|
||||
<IconLoader2
|
||||
size={21}
|
||||
stroke={2}
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
)}
|
||||
{buttonLabel[state]}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="flex-0 text-center font-mono text-sm text-gray-100 py-6">
|
||||
<span className="bg-gray-800/70 px-3 py-1 rounded-md">
|
||||
Created with{" "}
|
||||
<a
|
||||
href="https://git.new/ai"
|
||||
className="text-violet-300 underline decoration-violet-400 hover:text-violet-100"
|
||||
>
|
||||
git.new/ai
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
79
examples/storytelling-chatbot/frontend/components/Story.tsx
Normal file
79
examples/storytelling-chatbot/frontend/components/Story.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
useDaily,
|
||||
useParticipantIds,
|
||||
useAppMessage,
|
||||
DailyAudio,
|
||||
} from "@daily-co/daily-react";
|
||||
import { IconLogout, IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
import VideoTile from "@/components/VideoTile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import UserInputIndicator from "@/components/UserInputIndicator";
|
||||
import WaveText from "@/components/WaveText";
|
||||
|
||||
interface StoryProps {
|
||||
handleLeave: () => void;
|
||||
}
|
||||
|
||||
const Story: React.FC<StoryProps> = ({ handleLeave }) => {
|
||||
const daily = useDaily();
|
||||
const participantIds = useParticipantIds({ filter: "remote" });
|
||||
const [storyState, setStoryState] = useState<"user" | "assistant">(
|
||||
"assistant"
|
||||
);
|
||||
|
||||
useAppMessage({
|
||||
onAppMessage: (e) => {
|
||||
if (!daily || !e.data?.cue) return;
|
||||
|
||||
// Determine the UI state from the cue sent by the bot
|
||||
if (e.data?.cue === "user_turn") {
|
||||
// Delay enabling local mic input to avoid feedback from LLM
|
||||
setTimeout(() => daily.setLocalAudio(true), 500);
|
||||
setStoryState("user");
|
||||
} else {
|
||||
daily.setLocalAudio(false);
|
||||
setStoryState("assistant");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col flex-1 self-stretch">
|
||||
{/* Absolute elements */}
|
||||
<div className="absolute top-20 w-full text-center z-50">
|
||||
<WaveText active={storyState === "user"} />
|
||||
</div>
|
||||
<header className="flex absolute top-0 w-full z-50 p-6 justify-end">
|
||||
<Button variant="secondary" onClick={() => handleLeave()}>
|
||||
<IconLogout size={21} className="mr-2" />
|
||||
Exit
|
||||
</Button>
|
||||
</header>
|
||||
<div className="absolute inset-0 bg-gray-800 bg-opacity-90 z-10 fade-in"></div>
|
||||
|
||||
{/* Static elements */}
|
||||
<div className="relative z-20 flex-1 flex items-center justify-center">
|
||||
{participantIds.length >= 1 ? (
|
||||
<VideoTile
|
||||
sessionId={participantIds[0]}
|
||||
inactive={storyState === "user"}
|
||||
/>
|
||||
) : (
|
||||
<span className="p-3 rounded-full bg-gray-900/60 animate-pulse">
|
||||
<IconLoader2
|
||||
size={42}
|
||||
stroke={2}
|
||||
className="animate-spin text-white z-20 self-center"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<DailyAudio />
|
||||
</div>
|
||||
<UserInputIndicator active={storyState === "user"} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Story;
|
||||
@@ -0,0 +1,42 @@
|
||||
.container{
|
||||
position: absolute;
|
||||
color:white;
|
||||
z-index: 50;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@apply gap-4 inset-6;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.transcript{
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.transcript span{
|
||||
box-decoration-break: clone;
|
||||
@apply bg-gray-900/80 rounded-md px-4 py-2;
|
||||
}
|
||||
|
||||
.sentence{
|
||||
opacity: 1;
|
||||
animation: fadeOut 2.5s linear forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.8) translateY(-50%);
|
||||
filter: blur(25px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useAppMessage } from "@daily-co/daily-react";
|
||||
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
|
||||
|
||||
import styles from "./StoryTranscript.module.css";
|
||||
|
||||
export default function StoryTranscript() {
|
||||
const [partialText, setPartialText] = useState<string>("");
|
||||
const [sentences, setSentences] = useState<string[]>([]);
|
||||
const intervalRef = useRef<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
clearInterval(intervalRef.current);
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (sentences.length > 2) {
|
||||
setSentences((s) => s.slice(1));
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [sentences]);
|
||||
|
||||
useAppMessage({
|
||||
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
|
||||
if (e.fromId && e.fromId === "transcription") {
|
||||
// Check for LLM transcripts only
|
||||
if (e.data.user_id !== "") {
|
||||
setPartialText(e.data.text);
|
||||
if (e.data.is_final) {
|
||||
setPartialText("");
|
||||
setSentences((s) => [...s, e.data.text]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{sentences.map((sentence, index) => (
|
||||
<p key={index} className={`${styles.transcript} ${styles.sentence}`}>
|
||||
<span>{sentence}</span>
|
||||
</p>
|
||||
))}
|
||||
{partialText && (
|
||||
<p className={`${styles.transcript}`}>
|
||||
<span>{partialText}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.panel{
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
@apply pb-8;
|
||||
}
|
||||
|
||||
.panel::after{
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
@apply bg-gradient-to-t from-gray-950 to-transparent;
|
||||
}
|
||||
|
||||
.active::after{ opacity: 1;}
|
||||
|
||||
.micIcon{
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 120px;
|
||||
border: 6px solid;
|
||||
outline: 6px solid;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 auto;
|
||||
z-index: 20;
|
||||
transition: all 0.5s ease;
|
||||
@apply bg-gray-700/60 border-gray-600 outline-gray-900/20;
|
||||
|
||||
}
|
||||
|
||||
.micIcon svg{
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
opacity: 0.25;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
outline-width: 6px;
|
||||
@apply outline-teal-500/10;
|
||||
}
|
||||
50% {
|
||||
outline-width: 24px;
|
||||
@apply outline-teal-500/50;
|
||||
}
|
||||
100% {
|
||||
outline-width: 6px;
|
||||
@apply outline-teal-500/10;
|
||||
}
|
||||
}
|
||||
|
||||
.micIconActive{
|
||||
@apply bg-teal-950 border-teal-500 outline-teal-500/20;
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.micIconActive svg{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.transcript{
|
||||
flex: 0;
|
||||
align-self: center;
|
||||
opacity: 0.25;
|
||||
transition: opacity 1s ease;
|
||||
transition-delay: 2.5s;
|
||||
@apply bg-gray-900/90 font-medium py-1 px-2 rounded-sm mt-4;
|
||||
}
|
||||
|
||||
.active .transcript{
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { useAppMessage } from "@daily-co/daily-react";
|
||||
import { DailyEventObjectAppMessage } from "@daily-co/daily-js";
|
||||
import styles from "./UserInputIndicator.module.css";
|
||||
import { IconMicrophone } from "@tabler/icons-react";
|
||||
import { TypewriterEffect } from "../ui/typewriter";
|
||||
import AudioIndicator from "../AudioIndicator";
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function UserInputIndicator({ active }: Props) {
|
||||
const [transcription, setTranscription] = useState<string[]>([]);
|
||||
|
||||
useAppMessage({
|
||||
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
|
||||
if (e.fromId && e.fromId === "transcription") {
|
||||
if (e.data.user_id === "" && e.data.is_final) {
|
||||
setTranscription((t) => [...t, ...e.data.text.split(" ")]);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
const t = setTimeout(() => setTranscription([]), 4000);
|
||||
return () => clearTimeout(t);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.panel} ${active ? styles.active : ""}`}>
|
||||
<div className="relative z-20 flex flex-col">
|
||||
<div
|
||||
className={`${styles.micIcon} ${active ? styles.micIconActive : ""}`}
|
||||
>
|
||||
<IconMicrophone size={42} />
|
||||
{active && <AudioIndicator />}
|
||||
</div>
|
||||
<footer className={styles.transcript}>
|
||||
<TypewriterEffect words={transcription} />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.container{
|
||||
position: relative;
|
||||
animation: fadeIn 3s ease;
|
||||
transition: all 3s ease-out;
|
||||
}
|
||||
|
||||
.videoTile{
|
||||
@apply bg-gray-950;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
mask-image: url('/alpha-mask.gif');
|
||||
mask-size: cover;
|
||||
mask-repeat: no-repeat;
|
||||
margin:0 auto;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inactive{
|
||||
opacity: 0.7;
|
||||
filter:blur(3px);
|
||||
transform: scale(0.95)
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {
|
||||
filter: blur(100px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { DailyVideo } from "@daily-co/daily-react";
|
||||
import StoryTranscript from "@/components/StoryTranscript";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
inactive: boolean;
|
||||
}
|
||||
|
||||
const VideoTile = ({ sessionId, inactive }: Props) => {
|
||||
return (
|
||||
<div className={`${styles.container} ${inactive ? styles.inactive : ""} `}>
|
||||
<StoryTranscript />
|
||||
|
||||
<div className={styles.videoTile}>
|
||||
<DailyVideo
|
||||
sessionId={sessionId}
|
||||
type={"video"}
|
||||
className="aspect-square"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoTile;
|
||||
@@ -0,0 +1,68 @@
|
||||
.waveText{
|
||||
color: white;
|
||||
text-shadow: 3px 3px 0px rgba(0,0,0,0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 2s ease;
|
||||
position: relative;
|
||||
@apply text-4xl font-bold;
|
||||
}
|
||||
|
||||
.active{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
transform:translateY(0px)
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform:translateY(-30px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
transform:translateY(0px)
|
||||
}
|
||||
}
|
||||
|
||||
.waveText span{
|
||||
display:inline-block;
|
||||
animation:jump 2s infinite ease-in-out;
|
||||
|
||||
}
|
||||
|
||||
.waveText span:nth-child(1) {
|
||||
animation-delay:0s;
|
||||
}
|
||||
|
||||
.waveText span:nth-child(1) {
|
||||
animation-delay:0.1s;
|
||||
}
|
||||
.waveText span:nth-child(2) {
|
||||
animation-delay:0.2s;
|
||||
}
|
||||
.waveText span:nth-child(3) {
|
||||
animation-delay:0.3s;
|
||||
}
|
||||
.waveText span:nth-child(4) {
|
||||
animation-delay:0.4s;
|
||||
}
|
||||
.waveText span:nth-child(5) {
|
||||
animation-delay:0.5s;
|
||||
}
|
||||
.waveText span:nth-child(6) {
|
||||
animation-delay:0.6s;
|
||||
}
|
||||
.waveText span:nth-child(7) {
|
||||
animation-delay:0.7s;
|
||||
}
|
||||
.waveText span:nth-child(8) {
|
||||
animation-delay:0.8s;
|
||||
}
|
||||
.waveText span:nth-child(9) {
|
||||
animation-delay:0.9s;
|
||||
}
|
||||
.waveText span:nth-child(10) {
|
||||
animation-delay:1s;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import styles from "./WaveText.module.css";
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function WaveText({ active }: Props) {
|
||||
return (
|
||||
<div className={`${styles.waveText} ${active ? styles.active : ""}`}>
|
||||
<span>W</span>
|
||||
<span>h</span>
|
||||
<span>a</span>
|
||||
<span>t</span>
|
||||
<span> </span>
|
||||
<span>n</span>
|
||||
<span>e</span>
|
||||
<span>x</span>
|
||||
<span>t</span>
|
||||
<span>?</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/app/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-md font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 rounded-lg px-6 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-12 rounded-xl px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
160
examples/storytelling-chatbot/frontend/components/ui/select.tsx
Normal file
160
examples/storytelling-chatbot/frontend/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { cn } from "@/app/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex gap-3 h-12 w-full items-center justify-between rounded-xl border border-input bg-background px-4 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<IconChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<IconCheck className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/app/utils";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export const TypewriterEffect = ({
|
||||
words,
|
||||
className,
|
||||
}: {
|
||||
words: string[];
|
||||
className?: string;
|
||||
cursorClassName?: string;
|
||||
}) => {
|
||||
const renderWords = () => {
|
||||
return (
|
||||
<div>
|
||||
{words.map((word, idx) => {
|
||||
return (
|
||||
<div key={`word-${idx}`} className="inline-block">
|
||||
{word.split("").map((char, index) => (
|
||||
<span key={`char-${index}`}>{char}</span>
|
||||
))}
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex", className)}>
|
||||
{words.length < 1 ? (
|
||||
<span>...</span>
|
||||
) : (
|
||||
<motion.div
|
||||
className="overflow-hidden"
|
||||
initial={{
|
||||
width: "0%",
|
||||
}}
|
||||
whileInView={{
|
||||
width: "fit-content",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "linear",
|
||||
delay: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{renderWords()}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user