This commit is contained in:
lukasIO
2024-01-09 15:05:20 +01:00
commit eae180722e
40 changed files with 7568 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import { Button } from "./button/Button";
import { useRef } from "react";
type PlaygroundConnectProps = {
accentColor: string;
onConnectClicked: (url: string, roomToken: string) => void;
};
export const PlaygroundConnect = ({
accentColor,
onConnectClicked,
}: PlaygroundConnectProps) => {
const urlInput = useRef<HTMLInputElement>(null);
const tokenInput = useRef<HTMLTextAreaElement>(null);
return (
<div className="flex left-0 top-0 w-full h-full bg-black/80 items-center justify-center text-center">
<div className="flex flex-col gap-4 p-8 bg-gray-950 w-full max-w-[400px] rounded-lg text-white border border-gray-900">
<div className="flex flex-col gap-2">
<h1 className="text-xl">Connect to playground</h1>
<p className="text-sm text-gray-500">
Connect LiveKit Agent Playground with a custom server using LiveKit
Cloud or LiveKit Server.
</p>
</div>
<div className="flex flex-col gap-2 my-4">
<input
ref={urlInput}
className="text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-2"
placeholder="wss://url"
></input>
<textarea
ref={tokenInput}
className="text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-2"
placeholder="room token..."
></textarea>
</div>
<Button
accentColor={accentColor}
className="w-full"
onClick={() => {
if (urlInput.current && tokenInput.current) {
onConnectClicked(
urlInput.current.value,
tokenInput.current.value
);
}
}}
>
Connect
</Button>
<a
href="https://kitt.livekit.io/"
className={`text-xs text-${accentColor}-500 hover:underline`}
>
Dont have a URL or token? Try out our KITT example to see agents in
action!
</a>
</div>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React, { ButtonHTMLAttributes, ReactNode } from "react";
type ButtonProps = {
accentColor: string;
children: ReactNode;
className?: string;
disabled?: boolean;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const Button: React.FC<ButtonProps> = ({
accentColor,
children,
className,
disabled,
...allProps
}) => {
return (
<button
className={`flex flex-row ${
disabled ? "pointer-events-none" : ""
} text-gray-950 text-sm justify-center border border-transparent bg-${accentColor}-500 px-3 py-1 rounded-md transition ease-out duration-250 hover:bg-transparent hover:shadow-${accentColor} hover:border-${accentColor}-500 hover:text-${accentColor}-500 active:scale-[0.98] ${className}`}
{...allProps}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,31 @@
export const LoadingSVG = ({
diameter = 20,
strokeWidth = 4,
}: {
diameter?: number;
strokeWidth?: number;
}) => (
<svg
className="animate-spin"
fill="none"
viewBox="0 0 24 24"
style={{
width: `${diameter}px`,
height: `${diameter}px`,
}}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth={strokeWidth}
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);

View File

@@ -0,0 +1,32 @@
type ChatMessageProps = {
message: string;
accentColor: string;
name: string;
isSelf: boolean;
};
export const ChatMessage = ({
name,
message,
accentColor,
isSelf,
}: ChatMessageProps) => {
return (
<div className="flex flex-col gap-1">
<div
className={`text-${
isSelf ? "gray-700" : accentColor + "-800 text-ts-" + accentColor
} uppercase text-xs`}
>
{name}
</div>
<div
className={`pr-4 text-${
isSelf ? "gray-300" : accentColor + "-500"
} text-sm ${isSelf ? "" : "drop-shadow-" + accentColor}`}
>
{message}
</div>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import { useWindowResize } from "@/hooks/useWindowResize";
import { useEffect, useRef, useState } from "react";
type ChatMessageInput = {
placeholder: string;
accentColor: string;
height: number;
onSend: (message: string) => void;
};
export const ChatMessageInput = ({
placeholder,
accentColor,
height,
onSend,
}: ChatMessageInput) => {
const [message, setMessage] = useState("");
const [inputTextWidth, setInputTextWidth] = useState(0);
const [inputWidth, setInputWidth] = useState(0);
const hiddenInputRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const windowSize = useWindowResize();
const [isTyping, setIsTyping] = useState(false);
const [inputHasFocus, setInputHasFocus] = useState(false);
const handleSend = () => {
if (message === "") {
return;
}
onSend(message);
setMessage("");
};
useEffect(() => {
setIsTyping(true);
const timeout = setTimeout(() => {
setIsTyping(false);
}, 500);
return () => clearTimeout(timeout);
}, [message]);
useEffect(() => {
if (hiddenInputRef.current) {
setInputTextWidth(hiddenInputRef.current.clientWidth);
}
}, [hiddenInputRef, message]);
useEffect(() => {
if (inputRef.current) {
setInputWidth(inputRef.current.clientWidth);
}
}, [hiddenInputRef, message, windowSize.width]);
return (
<div
className="flex flex-col gap-2 border-t border-t-gray-800"
style={{ height: height }}
>
<div className="flex flex-row pt-3 gap-2 items-center relative">
<div
className={`w-2 h-4 bg-${inputHasFocus ? accentColor : "gray"}-${
inputHasFocus ? 500 : 800
} ${inputHasFocus ? "shadow-" + accentColor : ""} absolute left-2 ${
!isTyping && inputHasFocus ? "cursor-animation" : ""
}`}
style={{
transform:
"translateX(" +
(message.length > 0
? Math.min(inputTextWidth, inputWidth - 20) - 4
: 0) +
"px)",
}}
></div>
<input
ref={inputRef}
className={`w-full text-xs caret-transparent bg-transparent opacity-25 text-gray-300 p-2 pr-6 rounded-sm focus:opacity-100 focus:outline-none focus:border-${accentColor}-700 focus:ring-1 focus:ring-${accentColor}-700`}
style={{
paddingLeft: message.length > 0 ? "12px" : "24px",
caretShape: "block",
}}
placeholder={placeholder}
value={message}
onChange={(e) => {
setMessage(e.target.value);
}}
onFocus={() => {
setInputHasFocus(true);
}}
onBlur={() => {
setInputHasFocus(false);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSend();
}
}}
></input>
<span
ref={hiddenInputRef}
className="absolute top-0 left-0 text-xs pl-3 text-amber-500 pointer-events-none opacity-0"
>
{message.replaceAll(" ", "\u00a0")}
</span>
<button
onClick={handleSend}
className={`text-xs uppercase text-${accentColor}-500 hover:bg-${accentColor}-950 p-2 rounded-md opacity-${
message.length > 0 ? 100 : 25
} pointer-events-${message.length > 0 ? "auto" : "none"}`}
>
Send
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from "react";
import { ChatMessage } from "@/components/chat/ChatMessage";
import { ChatMessageInput } from "@/components/chat/ChatMessageInput";
const inputHeight = 48;
export type ChatMessageType = {
name: string;
message: string;
isSelf: boolean;
};
type ChatTileProps = {
messages: ChatMessageType[];
accentColor: string;
onSend: (message: string) => void;
};
export const ChatTile = ({ messages, accentColor, onSend }: ChatTileProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [containerRef, messages]);
return (
<div className="flex flex-col gap-4 w-full h-full">
<div
ref={containerRef}
className="overflow-y-auto"
style={{
height: `calc(100% - ${inputHeight}px)`,
}}
>
<div className="flex flex-col min-h-full justify-end gap-6">
{messages.map((message, index) => (
<ChatMessage
key={index}
name={message.name}
message={message.message}
isSelf={message.isSelf}
accentColor={accentColor}
/>
))}
</div>
</div>
<ChatMessageInput
height={inputHeight}
placeholder="Type a message"
accentColor={accentColor}
onSend={onSend}
/>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { useState } from "react";
type ColorPickerProps = {
colors: string[];
selectedColor: string;
onSelect: (color: string) => void;
};
export const ColorPicker = ({
colors,
selectedColor,
onSelect,
}: ColorPickerProps) => {
const [isHovering, setIsHovering] = useState(false);
const onMouseEnter = () => {
setIsHovering(true);
};
const onMouseLeave = () => {
setIsHovering(false);
};
return (
<div
className="flex flex-row gap-1 py-2 flex-wrap"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{colors.map((color) => {
const isSelected = color === selectedColor;
const saturation = !isHovering && !isSelected ? "saturate-[0.25]" : "";
const borderColor = isSelected
? `border border-${color}-800`
: "border-transparent";
const opacity = isSelected ? `opacity-100` : "opacity-20";
return (
<div
key={color}
className={`${saturation} rounded-md p-1 border-2 ${borderColor} cursor-pointer hover:opacity-100 transition transition-all duration-200 ${opacity} hover:scale-[1.05]`}
onClick={() => {
onSelect(color);
}}
>
<div className={`w-5 h-5 bg-${color}-500 rounded-sm`}></div>
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { useRef } from "react";
import { AgentMultibandAudioVisualizer } from "../visualization/AgentMultibandAudioVisualizer";
type AudioInputTileProps = {
frequencies: Float32Array[];
};
export const AudioInputTile = ({ frequencies }: AudioInputTileProps) => {
return (
<div
className={`flex flex-row gap-2 h-[100px] items-center w-full justify-center border rounded-sm border-gray-800 bg-gray-900`}
>
<AgentMultibandAudioVisualizer
state="speaking"
barWidth={4}
minBarHeight={2}
maxBarHeight={50}
accentColor={"gray"}
accentShade={400}
frequencies={frequencies}
borderRadius={2}
gap={4}
/>
</div>
);
};

View File

@@ -0,0 +1,40 @@
import { ReactNode } from "react";
import { PlaygroundDeviceSelector } from "@/components/playground/PlaygroundDeviceSelector";
import { TrackToggle } from "@livekit/components-react";
import { Track } from "livekit-client";
type ConfigurationPanelItemProps = {
title: string;
children?: ReactNode;
deviceSelectorKind?: MediaDeviceKind;
};
export const ConfigurationPanelItem: React.FC<ConfigurationPanelItemProps> = ({
children,
title,
deviceSelectorKind,
}) => {
return (
<div className="w-full text-gray-300 py-4 border-b border-b-gray-800 relative">
<div className="flex flex-row justify-between items-center px-4 text-xs uppercase tracking-wider">
<h3>{title}</h3>
{deviceSelectorKind && (
<span className="flex flex-row gap-2">
<TrackToggle
className="px-2 py-1 bg-gray-900 text-gray-300 border border-gray-800 rounded-sm hover:bg-gray-800"
source={
deviceSelectorKind === "audioinput"
? Track.Source.Microphone
: Track.Source.Camera
}
/>
<PlaygroundDeviceSelector kind={deviceSelectorKind} />
</span>
)}
</div>
<div className="px-4 py-2 text-xs text-gray-500 leading-normal">
{children}
</div>
</div>
);
};

View File

@@ -0,0 +1,22 @@
import { ReactNode } from "react";
type NameValueRowProps = {
name: string;
value?: ReactNode;
valueColor?: string;
};
export const NameValueRow: React.FC<NameValueRowProps> = ({
name,
value,
valueColor = "gray-300",
}) => {
return (
<div className="flex flex-row w-full items-baseline text-sm">
<div className="grow shrink-0 text-gray-500">{name}</div>
<div className={`text-xs uppercase shrink text-${valueColor} text-right`}>
{value}
</div>
</div>
);
};

View File

@@ -0,0 +1,487 @@
"use client";
import {
VideoTrack,
useConnectionState,
useDataChannel,
useLocalParticipant,
useRemoteParticipants,
useRoomContext,
useTracks,
} from "@livekit/components-react";
import {
ConnectionState,
DataPacket_Kind,
LocalParticipant,
Track,
} from "livekit-client";
import { ColorPicker } from "@/components/colorPicker/ColorPicker";
import { ConfigurationPanelItem } from "@/components/config/ConfigurationPanelItem";
import { LoadingSVG } from "@/components/button/LoadingSVG";
import { NameValueRow } from "@/components/config/NameValueRow";
import { PlaygroundHeader } from "@/components/playground/PlaygroundHeader";
import {
PlaygroundTab,
PlaygroundTabbedTile,
PlaygroundTile,
} from "@/components/playground/PlaygroundTile";
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { useMultibandTrackVolume } from "@/hooks/useTrackVolume";
import { QRCodeSVG } from "qrcode.react";
import { AudioInputTile } from "@/components/config/AudioInputTile";
import { ChatMessageType, ChatTile } from "@/components/chat/ChatTile";
import { AgentMultibandAudioVisualizer } from "@/components/visualization/AgentMultibandAudioVisualizer";
export enum PlaygroundOutputs {
Video,
Audio,
Chat,
}
export interface PlaygroundProps {
logo?: ReactNode;
title?: string;
githubLink?: string;
description?: ReactNode;
themeColors: string[];
defaultColor: string;
outputs?: PlaygroundOutputs[];
showQR?: boolean;
onConnect: (connect: boolean, opts?: { token: string; url: string }) => void;
metadata?: { name: string; value: string }[];
}
const headerHeight = 56;
export default function Playground({
logo,
title,
githubLink,
description,
outputs,
showQR,
themeColors,
defaultColor,
onConnect,
metadata,
}: PlaygroundProps) {
const [agentState, setAgentState] = useState<
"listening" | "speaking" | "thinking" | "offline"
>("offline");
const [userState, setUserState] = useState<"silent" | "speaking">("silent");
const [themeColor, setThemeColor] = useState(defaultColor);
const [messages, setMessages] = useState<ChatMessageType[]>([]);
const localParticipant = useLocalParticipant();
const roomContext = useRoomContext();
const visualizerState = useMemo(() => {
if (agentState === "thinking") {
return "thinking";
} else if (agentState === "speaking") {
return "talking";
}
return "idle";
}, [agentState]);
const roomState = useConnectionState();
const tracks = useTracks();
const agentAudioTrack = tracks.find(
(trackRef) =>
trackRef.publication.kind === Track.Kind.Audio &&
trackRef.participant.isAgent
);
const agentVideoTrack = tracks.find(
(trackRef) =>
trackRef.publication.kind === Track.Kind.Video &&
trackRef.participant.isAgent
);
const subscribedVolumes = useMultibandTrackVolume(
agentAudioTrack?.publication.track,
5
);
const localTracks = tracks.filter(
({ participant }) => participant instanceof LocalParticipant
);
const localVideoTrack = localTracks.find(
({ source }) => source === Track.Source.Camera
);
const localMicTrack = localTracks.find(
({ source }) => source === Track.Source.Microphone
);
const localMultibandVolume = useMultibandTrackVolume(
localMicTrack?.publication.track,
20
);
const isAgentConnected = !!useRemoteParticipants().find((p) => p.isAgent);
useEffect(() => {
if (isAgentConnected && agentState === "offline") {
setAgentState("listening");
} else if (!isAgentConnected) {
setAgentState("offline");
}
}, [isAgentConnected, agentState]);
const onDataReceived = useCallback(
(msg: any) => {
const decoded = JSON.parse(new TextDecoder("utf-8").decode(msg.payload));
if (decoded.type === "state") {
const { agent_state, user_state } = decoded;
setAgentState(agent_state);
setUserState(user_state);
} else if (decoded.type === "transcription") {
setMessages([
...messages,
{ name: "You", message: decoded.text, isSelf: true },
]);
} else if (decoded.type === "agent_chat_message") {
setMessages([
...messages,
{ name: "Agent", message: decoded.text, isSelf: false },
]);
}
console.log("data received", decoded, msg.from);
},
[messages]
);
useDataChannel(onDataReceived);
const videoTileContent = useMemo(() => {
return (
<div className="flex flex-col w-full grow text-gray-950 bg-black rounded-sm border border-gray-800 relative">
{agentVideoTrack ? (
<VideoTrack
trackRef={agentVideoTrack}
className="absolute top-1/2 -translate-y-1/2 object-cover object-position-center w-full h-full"
/>
) : (
<div className="flex flex-col items-center justify-center gap-2 text-gray-700 text-center h-full w-full">
<LoadingSVG />
Waiting for video track
</div>
)}
</div>
);
}, [agentVideoTrack]);
const audioTileContent = useMemo(() => {
return (
<div className="flex items-center justify-center w-full">
{agentAudioTrack ? (
<AgentMultibandAudioVisualizer
state={agentState}
barWidth={30}
minBarHeight={30}
maxBarHeight={150}
accentColor={themeColor}
accentShade={500}
frequencies={subscribedVolumes}
borderRadius={12}
gap={16}
/>
) : (
<div className="flex flex-col items-center gap-2 text-gray-700 text-center w-full">
<LoadingSVG />
Waiting for audio track
</div>
)}
</div>
);
}, [agentAudioTrack, subscribedVolumes, themeColor, agentState]);
const chatTileContent = useMemo(() => {
return (
<ChatTile
messages={messages}
accentColor={themeColor}
onSend={(message) => {
if (roomContext.state === ConnectionState.Disconnected) {
return;
}
setMessages([
...messages,
{ name: "You", message: message, isSelf: true },
]);
const data = {
type: "user_chat_message",
text: message,
};
const encoder = new TextEncoder();
localParticipant.localParticipant?.publishData(
encoder.encode(JSON.stringify(data)),
DataPacket_Kind.RELIABLE
);
}}
/>
);
}, [
localParticipant.localParticipant,
messages,
roomContext.state,
themeColor,
]);
const settingsTileContent = useMemo(() => {
return (
<div className="flex flex-col gap-4 h-full w-full items-start overflow-y-auto">
{description && (
<ConfigurationPanelItem title="Description">
{description}
</ConfigurationPanelItem>
)}
<ConfigurationPanelItem title="Settings">
<div className="flex flex-col gap-2">
<NameValueRow
name="Agent URL"
value={process.env.NEXT_PUBLIC_LIVEKIT_URL}
/>
{metadata?.map((data, index) => (
<NameValueRow
key={data.name + index}
name={data.name}
value={data.value}
/>
))}
</div>
</ConfigurationPanelItem>
<ConfigurationPanelItem title="Status">
<div className="flex flex-col gap-2">
<NameValueRow
name="Room connected"
value={
roomState === ConnectionState.Connecting ? (
<LoadingSVG diameter={16} strokeWidth={2} />
) : (
roomState
)
}
valueColor={
roomState === ConnectionState.Connected
? `${themeColor}-500`
: "gray-500"
}
/>
<NameValueRow
name="Agent connected"
value={
isAgentConnected ? (
"true"
) : roomState === ConnectionState.Connected ? (
<LoadingSVG diameter={12} strokeWidth={2} />
) : (
"false"
)
}
valueColor={isAgentConnected ? `${themeColor}-500` : "gray-500"}
/>
<NameValueRow
name="Agent status"
value={
agentState !== "offline" && agentState !== "speaking" ? (
<div className="flex gap-2 items-center">
<LoadingSVG diameter={12} strokeWidth={2} />
{agentState}
</div>
) : (
agentState
)
}
valueColor={
agentState === "speaking" ? `${themeColor}-500` : "gray-500"
}
/>
<NameValueRow
name="User status"
value={userState}
valueColor={
userState === "silent" ? "gray-500" : `${themeColor}-500`
}
/>
</div>
</ConfigurationPanelItem>
{localVideoTrack && (
<ConfigurationPanelItem
title="Camera"
deviceSelectorKind="videoinput"
>
<div className="relative">
<VideoTrack
className="rounded-sm border border-gray-800 opacity-70 w-full"
trackRef={localVideoTrack}
/>
</div>
</ConfigurationPanelItem>
)}
{localMicTrack && (
<ConfigurationPanelItem
title="Microphone"
deviceSelectorKind="audioinput"
>
<AudioInputTile frequencies={localMultibandVolume} />
</ConfigurationPanelItem>
)}
<div className="w-full">
<ConfigurationPanelItem title="Color">
<ColorPicker
colors={themeColors}
selectedColor={themeColor}
onSelect={(color) => {
setThemeColor(color);
}}
/>
</ConfigurationPanelItem>
</div>
{showQR && (
<div className="w-full">
<ConfigurationPanelItem title="QR Code">
<QRCodeSVG value={window.location.href} width="128" />
</ConfigurationPanelItem>
</div>
)}
</div>
);
}, [
agentState,
description,
isAgentConnected,
localMicTrack,
localMultibandVolume,
localVideoTrack,
metadata,
roomState,
themeColor,
themeColors,
userState,
showQR,
]);
let mobileTabs: PlaygroundTab[] = [];
if (outputs?.includes(PlaygroundOutputs.Video)) {
mobileTabs.push({
title: "Video",
content: (
<PlaygroundTile
className="w-full h-full grow"
childrenClassName="justify-center"
>
{videoTileContent}
</PlaygroundTile>
),
});
}
if (outputs?.includes(PlaygroundOutputs.Audio)) {
mobileTabs.push({
title: "Audio",
content: (
<PlaygroundTile
className="w-full h-full grow"
childrenClassName="justify-center"
>
{audioTileContent}
</PlaygroundTile>
),
});
}
if (outputs?.includes(PlaygroundOutputs.Chat)) {
mobileTabs.push({
title: "Chat",
content: chatTileContent,
});
}
mobileTabs.push({
title: "Settings",
content: (
<PlaygroundTile
padding={false}
backgroundColor="gray-950"
className="h-full w-full basis-1/4 items-start overflow-y-auto flex"
childrenClassName="h-full grow items-start"
>
{settingsTileContent}
</PlaygroundTile>
),
});
return (
<>
<PlaygroundHeader
title={title}
logo={logo}
githubLink={githubLink}
height={headerHeight}
accentColor={themeColor}
connectionState={roomState}
onConnectClicked={() =>
onConnect(roomState === ConnectionState.Disconnected)
}
/>
<div
className={`flex gap-4 py-4 grow w-full selection:bg-${themeColor}-900`}
style={{ height: `calc(100% - ${headerHeight}px)` }}
>
<div className="flex flex-col grow basis-1/2 gap-4 h-full lg:hidden">
<PlaygroundTabbedTile
className="h-full"
tabs={mobileTabs}
initialTab={mobileTabs.length - 1}
/>
</div>
<div
className={`flex-col grow basis-1/2 gap-4 h-full hidden lg:${
!outputs?.includes(PlaygroundOutputs.Audio) &&
!outputs?.includes(PlaygroundOutputs.Video)
? "hidden"
: "flex"
}`}
>
{outputs?.includes(PlaygroundOutputs.Video) && (
<PlaygroundTile
title="Video"
className="w-full h-full grow"
childrenClassName="justify-center"
>
{videoTileContent}
</PlaygroundTile>
)}
{outputs?.includes(PlaygroundOutputs.Audio) && (
<PlaygroundTile
title="Audio"
className="w-full h-full grow"
childrenClassName="justify-center"
>
{audioTileContent}
</PlaygroundTile>
)}
</div>
{outputs?.includes(PlaygroundOutputs.Chat) && (
<PlaygroundTile
title="Chat"
className="h-full grow basis-1/4 hidden lg:flex"
>
{chatTileContent}
</PlaygroundTile>
)}
<PlaygroundTile
padding={false}
backgroundColor="gray-950"
className="h-full w-full basis-1/4 items-start overflow-y-auto hidden max-w-[480px] lg:flex"
childrenClassName="h-full grow items-start"
>
{settingsTileContent}
</PlaygroundTile>
</div>
</>
);
}

View File

@@ -0,0 +1,94 @@
import { useMediaDeviceSelect } from "@livekit/components-react";
import { useEffect, useState } from "react";
type PlaygroundDeviceSelectorProps = {
kind: MediaDeviceKind;
};
export const PlaygroundDeviceSelector = ({
kind,
}: PlaygroundDeviceSelectorProps) => {
const [showMenu, setShowMenu] = useState(false);
const deviceSelect = useMediaDeviceSelect({ kind: kind });
const [selectedDeviceName, setSelectedDeviceName] = useState("");
useEffect(() => {
deviceSelect.devices.forEach((device) => {
if (device.deviceId === deviceSelect.activeDeviceId) {
setSelectedDeviceName(device.label);
}
});
}, [deviceSelect.activeDeviceId, deviceSelect.devices, selectedDeviceName]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (showMenu) {
setShowMenu(false);
}
};
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
}, [showMenu]);
return (
<div>
<button
className="flex gap-2 items-center px-2 py-1 bg-gray-900 text-gray-300 border border-gray-800 rounded-sm hover:bg-gray-800"
onClick={(e) => {
setShowMenu(!showMenu);
e.stopPropagation();
}}
>
<span className="max-w-[80px] overflow-ellipsis overflow-hidden whitespace-nowrap">
{selectedDeviceName}
</span>
<ChevronSVG />
</button>
<div
className="absolute right-4 top-12 bg-gray-800 text-gray-300 border border-gray-800 rounded-sm z-10"
style={{
display: showMenu ? "block" : "none",
}}
>
{deviceSelect.devices.map((device, index) => {
return (
<div
onClick={() => {
deviceSelect.setActiveMediaDevice(device.deviceId);
setShowMenu(false);
}}
className={`${
device.deviceId === deviceSelect.activeDeviceId
? "text-white"
: "text-gray-500"
} bg-gray-900 text-xs py-2 px-2 cursor-pointer hover:bg-gray-800 hover:text-white`}
key={index}
>
{device.label}
</div>
);
})}
</div>
</div>
);
};
const ChevronSVG = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 5H5V7H3V5ZM7 9V7H5V9H7ZM9 9V11H7V9H9ZM11 7V9H9V7H11ZM11 7V5H13V7H11Z"
fill="currentColor"
fillOpacity="0.8"
/>
</svg>
);

View File

@@ -0,0 +1,122 @@
import { Button } from "@/components/button/Button";
import { ConnectionState } from "livekit-client";
import { LoadingSVG } from "@/components/button/LoadingSVG";
import { ReactNode } from "react";
type PlaygroundHeader = {
logo?: ReactNode;
title?: ReactNode;
githubLink?: string;
height: number;
accentColor: string;
connectionState: ConnectionState;
onConnectClicked: () => void;
};
export const PlaygroundHeader = ({
logo,
title,
githubLink,
accentColor,
height,
onConnectClicked,
connectionState,
}: PlaygroundHeader) => {
return (
<div
className={`flex gap-4 pt-4 text-${accentColor}-500 justify-between items-center shrink-0`}
style={{
height: height + "px",
}}
>
<div className="flex items-center gap-3 basis-2/3">
<div className="flex lg:basis-1/2">
<a href="https://livekit.io">{logo ?? <LKLogo />}</a>
</div>
<div className="lg:basis-1/2 lg:text-center text-xs lg:text-base lg:font-semibold text-white">
{title}
</div>
</div>
<div className="flex basis-1/3 justify-end items-center gap-4">
{githubLink && (
<a
href={githubLink}
target="_blank"
className={`text-white hover:text-white/80`}
>
<GithubSVG />
</a>
)}
<Button
accentColor={
connectionState === ConnectionState.Connected ? "red" : accentColor
}
disabled={connectionState === ConnectionState.Connecting}
onClick={() => {
onConnectClicked();
}}
>
{connectionState === ConnectionState.Connecting ? (
<LoadingSVG />
) : connectionState === ConnectionState.Connected ? (
"Disconnect"
) : (
"Connect"
)}
</Button>
</div>
</div>
);
};
const LKLogo = () => (
<svg
width="28"
height="28"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_101_119699)">
<path
d="M19.2006 12.7998H12.7996V19.2008H19.2006V12.7998Z"
fill="currentColor"
/>
<path
d="M25.6014 6.40137H19.2004V12.8024H25.6014V6.40137Z"
fill="currentColor"
/>
<path
d="M25.6014 19.2002H19.2004V25.6012H25.6014V19.2002Z"
fill="currentColor"
/>
<path d="M32 0H25.599V6.401H32V0Z" fill="currentColor" />
<path d="M32 25.5986H25.599V31.9996H32V25.5986Z" fill="currentColor" />
<path
d="M6.401 25.599V19.2005V12.7995V6.401V0H0V6.401V12.7995V19.2005V25.599V32H6.401H12.7995H19.2005V25.599H12.7995H6.401Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_101_119699">
<rect width="32" height="32" fill="white" />
</clipPath>
</defs>
</svg>
);
const GithubSVG = () => (
<svg
width="24"
height="24"
viewBox="0 0 98 96"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="currentColor"
/>
</svg>
);

View File

@@ -0,0 +1,104 @@
import { ReactNode, useState } from "react";
const titleHeight = 32;
type PlaygroundTileProps = {
title?: string;
children?: ReactNode;
className?: string;
childrenClassName?: string;
padding?: boolean;
backgroundColor?: string;
};
export type PlaygroundTab = {
title: string;
content: ReactNode;
};
export type PlaygroundTabbedTileProps = {
tabs: PlaygroundTab[];
initialTab?: number;
} & PlaygroundTileProps;
export const PlaygroundTile: React.FC<PlaygroundTileProps> = ({
children,
title,
className,
childrenClassName,
padding = true,
backgroundColor = "transparent",
}) => {
const contentPadding = padding ? 4 : 0;
return (
<div
className={`flex flex-col border rounded-sm border-gray-800 text-gray-500 bg-${backgroundColor} ${className}`}
>
{title && (
<div
className="flex items-center justify-center text-xs uppercase py-2 border-b border-b-gray-800 tracking-wider"
style={{
height: `${titleHeight}px`,
}}
>
<h2>{title}</h2>
</div>
)}
<div
className={`flex flex-col items-center grow w-full ${childrenClassName}`}
style={{
height: `calc(100% - ${title ? titleHeight + "px" : "0px"})`,
padding: `${contentPadding * 4}px`,
}}
>
{children}
</div>
</div>
);
};
export const PlaygroundTabbedTile: React.FC<PlaygroundTabbedTileProps> = ({
tabs,
initialTab = 0,
className,
childrenClassName,
backgroundColor = "transparent",
}) => {
const contentPadding = 4;
const [activeTab, setActiveTab] = useState(initialTab);
return (
<div
className={`flex flex-col h-full border rounded-sm border-gray-800 text-gray-500 bg-${backgroundColor} ${className}`}
>
<div
className="flex items-center justify-start text-xs uppercase border-b border-b-gray-800 tracking-wider"
style={{
height: `${titleHeight}px`,
}}
>
{tabs.map((tab, index) => (
<button
key={index}
className={`px-4 py-2 rounded-sm hover:bg-gray-800 hover:text-gray-300 border-r border-r-gray-800 ${
index === activeTab
? `bg-gray-900 text-gray-300`
: `bg-transparent text-gray-500`
}`}
onClick={() => setActiveTab(index)}
>
{tab.title}
</button>
))}
</div>
<div
className={`w-full ${childrenClassName}`}
style={{
height: `calc(100% - ${titleHeight}px)`,
padding: `${contentPadding * 4}px`,
}}
>
{tabs[activeTab].content}
</div>
</div>
);
};

View File

@@ -0,0 +1,37 @@
export type ToastType = "error" | "success" | "info";
export type ToastProps = {
message: string;
type: ToastType;
onDismiss: () => void;
};
export const PlaygroundToast = ({ message, type, onDismiss }: ToastProps) => {
const color =
type === "error" ? "red" : type === "success" ? "green" : "amber";
return (
<div
className={`absolute text-sm break-words px-4 pr-12 py-2 bg-${color}-950 rounded-sm border border-${color}-800 text-${color}-400 top-4 left-4 right-4`}
>
<button
className={`absolute right-2 border border-transparent rounded-md px-2 hover:bg-${color}-900 hover:text-${color}-300`}
onClick={onDismiss}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
fill="currentColor"
/>
</svg>
</button>
{message}
</div>
);
};

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
type AgentMultibandAudioVisualizerProps = {
state: "listening" | "speaking" | "thinking" | "offline";
barWidth: number;
minBarHeight: number;
maxBarHeight: number;
accentColor: string;
accentShade?: number;
frequencies: Float32Array[];
borderRadius: number;
gap: number;
};
export const AgentMultibandAudioVisualizer = ({
state,
barWidth,
minBarHeight,
maxBarHeight,
accentColor,
accentShade,
frequencies,
borderRadius,
gap,
}: AgentMultibandAudioVisualizerProps) => {
const summedFrequencies = frequencies.map((bandFrequencies) => {
const sum = bandFrequencies.reduce((a, b) => a + b, 0);
return Math.sqrt(sum / bandFrequencies.length);
});
const [thinkingIndex, setThinkingIndex] = useState(
Math.floor(summedFrequencies.length / 2)
);
const [thinkingDirection, setThinkingDirection] = useState<"left" | "right">(
"right"
);
useEffect(() => {
if (state !== "thinking") {
setThinkingIndex(Math.floor(summedFrequencies.length / 2));
return;
}
const timeout = setTimeout(() => {
if (thinkingDirection === "right") {
if (thinkingIndex === summedFrequencies.length - 1) {
setThinkingDirection("left");
setThinkingIndex((prev) => prev - 1);
} else {
setThinkingIndex((prev) => prev + 1);
}
} else {
if (thinkingIndex === 0) {
setThinkingDirection("right");
setThinkingIndex((prev) => prev + 1);
} else {
setThinkingIndex((prev) => prev - 1);
}
}
}, 200);
return () => clearTimeout(timeout);
}, [state, summedFrequencies.length, thinkingDirection, thinkingIndex]);
return (
<div
className={`flex flex-row items-center`}
style={{
gap: gap + "px",
}}
>
{summedFrequencies.map((frequency, index) => {
const isCenter = index === Math.floor(summedFrequencies.length / 2);
let color = accentColor;
let shadow = `shadow-lg-${accentColor}`;
let transform;
if (state === "listening") {
color = isCenter ? `${accentColor}-${accentShade}` : "gray-950";
shadow = !isCenter ? "" : shadow;
transform = !isCenter ? "scale(1.0)" : "scale(1.2)";
} else if (state === "speaking") {
color = `${accentColor}${accentShade ? "-" + accentShade : ""}`;
} else if (state === "thinking") {
color =
index === thinkingIndex
? `${accentColor}-${accentShade}`
: "gray-950";
shadow = "";
transform = thinkingIndex !== index ? "scale(1)" : "scale(1.1)";
}
return (
<div
className={`bg-${color} ${shadow} ${
isCenter && state === "listening" ? "animate-pulse" : ""
}`}
key={"frequency-" + index}
style={{
height:
minBarHeight + frequency * (maxBarHeight - minBarHeight) + "px",
borderRadius: borderRadius + "px",
width: barWidth + "px",
transition:
"background-color 0.35s ease-out, transform 0.25s ease-out",
transform: transform,
}}
></div>
);
})}
</div>
);
};