Init
This commit is contained in:
62
src/components/PlaygroundConnect.tsx
Normal file
62
src/components/PlaygroundConnect.tsx
Normal 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`}
|
||||
>
|
||||
Don’t have a URL or token? Try out our KITT example to see agents in
|
||||
action!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
src/components/button/Button.tsx
Normal file
27
src/components/button/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/components/button/LoadingSVG.tsx
Normal file
31
src/components/button/LoadingSVG.tsx
Normal 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>
|
||||
);
|
||||
32
src/components/chat/ChatMessage.tsx
Normal file
32
src/components/chat/ChatMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
src/components/chat/ChatMessageInput.tsx
Normal file
118
src/components/chat/ChatMessageInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
src/components/chat/ChatTile.tsx
Normal file
56
src/components/chat/ChatTile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/components/colorPicker/ColorPicker.tsx
Normal file
49
src/components/colorPicker/ColorPicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
26
src/components/config/AudioInputTile.tsx
Normal file
26
src/components/config/AudioInputTile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
src/components/config/ConfigurationPanelItem.tsx
Normal file
40
src/components/config/ConfigurationPanelItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
src/components/config/NameValueRow.tsx
Normal file
22
src/components/config/NameValueRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
487
src/components/playground/Playground.tsx
Normal file
487
src/components/playground/Playground.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
src/components/playground/PlaygroundDeviceSelector.tsx
Normal file
94
src/components/playground/PlaygroundDeviceSelector.tsx
Normal 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>
|
||||
);
|
||||
122
src/components/playground/PlaygroundHeader.tsx
Normal file
122
src/components/playground/PlaygroundHeader.tsx
Normal 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>
|
||||
);
|
||||
104
src/components/playground/PlaygroundTile.tsx
Normal file
104
src/components/playground/PlaygroundTile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
src/components/toast/PlaygroundToast.tsx
Normal file
37
src/components/toast/PlaygroundToast.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
src/components/visualization/AgentMultibandAudioVisualizer.tsx
Normal file
113
src/components/visualization/AgentMultibandAudioVisualizer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user