Allowing settings to be changed in URL hash (#45)

Co-authored-by: mattherzog <Herzog.Matt@gmail.com>
Co-authored-by: Neil Dwyer <neildwyer1991@gmail.com>
This commit is contained in:
David Zhao
2024-05-06 10:07:37 -07:00
committed by GitHub
parent 0d82ab4b26
commit 5b99dae15f
14 changed files with 1353 additions and 292 deletions

View File

@@ -1,17 +1,19 @@
import { useConfig } from "@/hooks/useConfig";
import { Button } from "./button/Button";
import { useRef } from "react";
import { useState } from "react";
type PlaygroundConnectProps = {
accentColor: string;
onConnectClicked: (url: string, roomToken: string) => void;
onConnectClicked: () => void;
};
export const PlaygroundConnect = ({
accentColor,
onConnectClicked,
}: PlaygroundConnectProps) => {
const urlInput = useRef<HTMLInputElement>(null);
const tokenInput = useRef<HTMLTextAreaElement>(null);
const { setUserSettings, config } = useConfig();
const [url, setUrl] = useState(config.settings.ws_url)
const [token, setToken] = useState(config.settings.token)
return (
<div className="flex left-0 top-0 w-full h-full bg-black/80 items-center justify-center text-center">
@@ -25,12 +27,14 @@ export const PlaygroundConnect = ({
</div>
<div className="flex flex-col gap-2 my-4">
<input
ref={urlInput}
value={url}
onChange={(e) => setUrl(e.target.value)}
className="text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-2"
placeholder="wss://url"
></input>
<textarea
ref={tokenInput}
value={token}
onChange={(e) => setToken(e.target.value)}
className="text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-2"
placeholder="room token..."
></textarea>
@@ -39,12 +43,11 @@ export const PlaygroundConnect = ({
accentColor={accentColor}
className="w-full"
onClick={() => {
if (urlInput.current && tokenInput.current) {
onConnectClicked(
urlInput.current.value,
tokenInput.current.value
);
}
const newSettings = {...config.settings};
newSettings.ws_url = url;
newSettings.token = token;
setUserSettings(newSettings);
onConnectClicked();
}}
>
Connect

View File

@@ -13,6 +13,7 @@ import {
PlaygroundTile,
} from "@/components/playground/PlaygroundTile";
import { AgentMultibandAudioVisualizer } from "@/components/visualization/AgentMultibandAudioVisualizer";
import { useConfig } from "@/hooks/useConfig";
import { useMultibandTrackVolume } from "@/hooks/useTrackVolume";
import { AgentState } from "@/lib/types";
import {
@@ -21,9 +22,9 @@ import {
useConnectionState,
useDataChannel,
useLocalParticipant,
useParticipantInfo,
useRemoteParticipant,
useRemoteParticipants,
useRoomContext,
useRoomInfo,
useTracks,
} from "@livekit/components-react";
import {
@@ -48,38 +49,24 @@ export interface PlaygroundMeta {
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?: PlaygroundMeta[];
videoFit?: "contain" | "cover";
}
const headerHeight = 56;
export default function Playground({
logo,
title,
githubLink,
description,
outputs,
showQR,
themeColors,
defaultColor,
onConnect,
metadata,
videoFit,
}: PlaygroundProps) {
const {config, setUserSettings} = useConfig();
const { name } = useRoomInfo();
const [agentState, setAgentState] = useState<AgentState>("offline");
const [themeColor, setThemeColor] = useState(defaultColor);
const [messages, setMessages] = useState<ChatMessageType[]>([]);
const [transcripts, setTranscripts] = useState<ChatMessageType[]>([]);
const { localParticipant } = useLocalParticipant();
const [outputs, setOutputs] = useState<PlaygroundOutputs[]>([]);
const participants = useRemoteParticipants({
updateOnlyOn: [RoomEvent.ParticipantMetadataChanged],
@@ -87,18 +74,16 @@ export default function Playground({
const agentParticipant = participants.find((p) => p.isAgent);
const { send: sendChat, chatMessages } = useChat();
const visualizerState = useMemo(() => {
if (agentState === "thinking") {
return "thinking";
} else if (agentState === "speaking") {
return "talking";
}
return "idle";
}, [agentState]);
const roomState = useConnectionState();
const tracks = useTracks();
useEffect(() => {
if (roomState === ConnectionState.Connected) {
localParticipant.setCameraEnabled(config.settings.inputs.camera);
localParticipant.setMicrophoneEnabled(config.settings.inputs.mic);
}
}, [config, localParticipant, roomState]);
const agentAudioTrack = tracks.find(
(trackRef) =>
trackRef.publication.kind === Track.Kind.Audio &&
@@ -203,7 +188,7 @@ export default function Playground({
useDataChannel(onDataReceived);
const videoTileContent = useMemo(() => {
const videoFitClassName = `object-${videoFit}`;
const videoFitClassName = `object-${config.video_fit || "cover"}`;
return (
<div className="flex flex-col w-full grow text-gray-950 bg-black rounded-sm border border-gray-800 relative">
{agentVideoTrack ? (
@@ -219,7 +204,7 @@ export default function Playground({
)}
</div>
);
}, [agentVideoTrack, videoFit]);
}, [agentVideoTrack, config]);
const audioTileContent = useMemo(() => {
return (
@@ -230,7 +215,7 @@ export default function Playground({
barWidth={30}
minBarHeight={30}
maxBarHeight={150}
accentColor={themeColor}
accentColor={config.settings.theme_color}
accentShade={500}
frequencies={subscribedVolumes}
borderRadius={12}
@@ -244,37 +229,46 @@ export default function Playground({
)}
</div>
);
}, [agentAudioTrack, subscribedVolumes, themeColor, agentState]);
}, [
agentAudioTrack,
agentState,
config.settings.theme_color,
subscribedVolumes,
]);
const chatTileContent = useMemo(() => {
return (
<ChatTile
messages={messages}
accentColor={themeColor}
accentColor={config.settings.theme_color}
onSend={sendChat}
/>
);
}, [messages, themeColor, sendChat]);
}, [config.settings.theme_color, messages, sendChat]);
const settingsTileContent = useMemo(() => {
return (
<div className="flex flex-col gap-4 h-full w-full items-start overflow-y-auto">
{description && (
{config.description && (
<ConfigurationPanelItem title="Description">
{description}
{config.description}
</ConfigurationPanelItem>
)}
<ConfigurationPanelItem title="Settings">
<div className="flex flex-col gap-2">
{metadata?.map((data, index) => (
{localParticipant && (
<div className="flex flex-col gap-2">
<NameValueRow
key={data.name + index}
name={data.name}
value={data.value}
name="Room"
value={name}
valueColor={`${config.settings.theme_color}-500`}
/>
))}
</div>
<NameValueRow
name="Participant"
value={localParticipant.identity}
/>
</div>
)}
</ConfigurationPanelItem>
<ConfigurationPanelItem title="Status">
<div className="flex flex-col gap-2">
@@ -289,7 +283,7 @@ export default function Playground({
}
valueColor={
roomState === ConnectionState.Connected
? `${themeColor}-500`
? `${config.settings.theme_color}-500`
: "gray-500"
}
/>
@@ -304,7 +298,11 @@ export default function Playground({
"false"
)
}
valueColor={isAgentConnected ? `${themeColor}-500` : "gray-500"}
valueColor={
isAgentConnected
? `${config.settings.theme_color}-500`
: "gray-500"
}
/>
<NameValueRow
name="Agent status"
@@ -319,7 +317,9 @@ export default function Playground({
)
}
valueColor={
agentState === "speaking" ? `${themeColor}-500` : "gray-500"
agentState === "speaking"
? `${config.settings.theme_color}-500`
: "gray-500"
}
/>
</div>
@@ -349,14 +349,16 @@ export default function Playground({
<ConfigurationPanelItem title="Color">
<ColorPicker
colors={themeColors}
selectedColor={themeColor}
selectedColor={config.settings.theme_color}
onSelect={(color) => {
setThemeColor(color);
const userSettings = { ...config.settings };
userSettings.theme_color = color;
setUserSettings(userSettings);
}}
/>
</ConfigurationPanelItem>
</div>
{showQR && (
{config.show_qr && (
<div className="w-full">
<ConfigurationPanelItem title="QR Code">
<QRCodeSVG value={window.location.href} width="128" />
@@ -366,17 +368,18 @@ export default function Playground({
</div>
);
}, [
agentState,
description,
config.description,
config.settings,
config.show_qr,
// metadata,
roomState,
isAgentConnected,
agentState,
localVideoTrack,
localMicTrack,
localMultibandVolume,
localVideoTrack,
metadata,
roomState,
themeColor,
themeColors,
showQR,
setUserSettings,
]);
let mobileTabs: PlaygroundTab[] = [];
@@ -432,18 +435,18 @@ export default function Playground({
return (
<>
<PlaygroundHeader
title={title}
title={config.title}
logo={logo}
githubLink={githubLink}
githubLink={config.github_link}
height={headerHeight}
accentColor={themeColor}
accentColor={config.settings.theme_color}
connectionState={roomState}
onConnectClicked={() =>
onConnect(roomState === ConnectionState.Disconnected)
}
/>
<div
className={`flex gap-4 py-4 grow w-full selection:bg-${themeColor}-900`}
className={`flex gap-4 py-4 grow w-full selection:bg-${config.settings.theme_color}-900`}
style={{ height: `calc(100% - ${headerHeight}px)` }}
>
<div className="flex flex-col grow basis-1/2 gap-4 h-full lg:hidden">

View File

@@ -1,6 +1,7 @@
import { Button } from "@/components/button/Button";
import { ConnectionState } from "livekit-client";
import { LoadingSVG } from "@/components/button/LoadingSVG";
import { SettingsDropdown } from "@/components/playground/SettingsDropdown";
import { ConnectionState } from "livekit-client";
import { ReactNode } from "react";
type PlaygroundHeader = {
@@ -37,7 +38,7 @@ export const PlaygroundHeader = ({
{title}
</div>
</div>
<div className="flex basis-1/3 justify-end items-center gap-4">
<div className="flex basis-1/3 justify-end items-center gap-2">
{githubLink && (
<a
href={githubLink}
@@ -47,6 +48,7 @@ export const PlaygroundHeader = ({
<GithubSVG />
</a>
)}
<SettingsDropdown />
<Button
accentColor={
connectionState === ConnectionState.Connected ? "red" : accentColor

View File

@@ -0,0 +1,128 @@
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronIcon } from "./icons";
import { useConfig } from "@/hooks/useConfig";
type SettingType = "inputs" | "outputs" | "chat" | "theme_color"
type SettingValue = {
title: string;
type: SettingType | "separator";
key: string;
};
const settingsDropdown: SettingValue[] = [
{
title: "Show chat",
type: "chat",
key: "N/A",
},
{
title: "---",
type: "separator",
key: "separator_1",
},
{
title: "Show video",
type: "outputs",
key: "video",
},
{
title: "Show audio",
type: "outputs",
key: "audio",
},
{
title: "---",
type: "separator",
key: "separator_2",
},
{
title: "Enable camera",
type: "inputs",
key: "camera",
},
{
title: "Enable mic",
type: "inputs",
key: "mic",
},
];
export const SettingsDropdown = () => {
const {config, setUserSettings} = useConfig();
const isEnabled = (setting: SettingValue) => {
if (setting.type === "separator" || setting.type === "theme_color") return false;
if (setting.type === "chat") {
return config.settings[setting.type];
}
if(setting.type === "inputs") {
const key = setting.key as "camera" | "mic";
return config.settings.inputs[key];
} else if(setting.type === "outputs") {
const key = setting.key as "video" | "audio";
return config.settings.outputs[key];
}
return false;
};
const toggleSetting = (setting: SettingValue) => {
if (setting.type === "separator" || setting.type === "theme_color") return;
const newValue = !isEnabled(setting);
const newSettings = {...config.settings}
if(setting.type === "chat") {
newSettings.chat = newValue;
} else if(setting.type === "inputs") {
newSettings.inputs[setting.key as "camera" | "mic"] = newValue;
} else if(setting.type === "outputs") {
newSettings.outputs[setting.key as "video" | "audio"] = newValue;
}
setUserSettings(newSettings);
};
return (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger className="group inline-flex max-h-12 items-center gap-1 rounded-md hover:bg-gray-800 bg-gray-900 border-gray-800 p-1 pr-2 text-gray-100">
<button className="my-auto text-sm flex gap-1 pl-2 py-1 h-full items-center">
Settings
<ChevronIcon />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="z-50 flex w-60 flex-col gap-0 overflow-hidden rounded text-gray-100 border border-gray-800 bg-gray-900 py-2 text-sm"
sideOffset={5}
collisionPadding={16}
>
{settingsDropdown.map((setting) => {
if (setting.type === "separator") {
return (
<div
key={setting.key}
className="border-t border-gray-800 my-2"
/>
);
}
return (
<DropdownMenu.Label
key={setting.key}
onClick={() => toggleSetting(setting)}
className="flex max-w-full flex-row items-end gap-2 px-3 py-2 text-xs hover:bg-gray-800 cursor-pointer"
>
<div className="w-4 h-4 flex items-center">
{isEnabled(setting) && <CheckIcon />}
</div>
<span>{setting.title}</span>
</DropdownMenu.Label>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};

View File

@@ -0,0 +1,39 @@
export const CheckIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<g clip-path="url(#clip0_718_9977)">
<path
d="M1.5 7.5L4.64706 10L10.5 2"
stroke="white"
stroke-width="1.5"
stroke-linecap="square"
/>
</g>
<defs>
<clipPath id="clip0_718_9977">
<rect width="12" height="12" fill="white" />
</clipPath>
</defs>
</svg>
);
export const ChevronIcon = () => (
<svg
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="fill-gray-200 transition-all group-hover:fill-white group-data-[state=open]:rotate-180"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="m8 10.7.4-.3 4-4 .3-.4-.7-.7-.4.3L8 9.3 4.4 5.6 4 5.3l-.7.7.3.4 4 4 .4.3Z"
/>
</svg>
);

View File

@@ -1,54 +0,0 @@
import jsYaml from "js-yaml";
const APP_CONFIG = process.env.NEXT_PUBLIC_APP_CONFIG;
export type AppConfig = {
title: string;
description: string;
github_link?: string;
theme_color?: string;
video_fit?: "cover" | "contain";
outputs: {
audio: boolean;
video: boolean;
chat: boolean;
};
inputs: {
mic: boolean;
camera: boolean;
};
show_qr?: boolean;
};
// Fallback if NEXT_PUBLIC_APP_CONFIG is not set
const defaultConfig: AppConfig = {
title: "Agents Playground",
description: "A playground for testing LiveKit Agents",
theme_color: "cyan",
video_fit: "cover",
outputs: {
audio: true,
video: true,
chat: true,
},
inputs: {
mic: true,
camera: true,
},
show_qr: false,
};
export const useAppConfig = (): AppConfig => {
if (APP_CONFIG) {
try {
const parsedConfig = jsYaml.load(APP_CONFIG);
console.log("parsedConfig:", parsedConfig);
return parsedConfig as AppConfig;
} catch (e) {
console.error("Error parsing app config:", e);
return defaultConfig;
}
} else {
return defaultConfig;
}
};

198
src/hooks/useConfig.tsx Normal file
View File

@@ -0,0 +1,198 @@
"use client"
import { getCookie, setCookie } from "cookies-next";
import jsYaml from "js-yaml";
import { useRouter } from "next/navigation";
import React, { createContext, useCallback, useMemo, useState } from "react";
import { useEffect } from "react";
export type AppConfig = {
title: string;
description: string;
github_link?: string;
video_fit?: "cover" | "contain";
settings: UserSettings;
show_qr?: boolean;
};
export type UserSettings = {
theme_color: string;
chat: boolean;
inputs: {
camera: boolean;
mic: boolean;
};
outputs: {
audio: boolean;
video: boolean;
};
ws_url: string;
token: string;
};
// Fallback if NEXT_PUBLIC_APP_CONFIG is not set
const defaultConfig: AppConfig = {
title: "LiveKit Agents Playground",
description: "A playground for testing LiveKit Agents",
video_fit: "cover",
settings: {
theme_color: "cyan",
chat: true,
inputs: {
camera: true,
mic: true,
},
outputs: {
audio: true,
video: true,
},
ws_url: "",
token: ""
},
show_qr: false,
};
const useAppConfig = (): AppConfig => {
return useMemo(() => {
if (process.env.NEXT_PUBLIC_APP_CONFIG) {
try {
const parsedConfig = jsYaml.load(
process.env.NEXT_PUBLIC_APP_CONFIG
) as AppConfig;
return parsedConfig;
} catch (e) {
console.error("Error parsing app config:", e);
}
}
return defaultConfig;
}, []);
};
type ConfigData = {
config: AppConfig;
setUserSettings: (settings: UserSettings) => void;
};
const ConfigContext = createContext<ConfigData | undefined>(undefined);
export const ConfigProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const appConfig = useAppConfig();
const router = useRouter();
const getSettingsFromUrl = useCallback(() => {
if(typeof window === 'undefined') {
return null;
}
if (!window.location.hash) {
return null;
}
const params = new URLSearchParams(window.location.hash.replace("#", ""));
return {
chat: params.get("chat") === "1",
theme_color: params.get("theme_color"),
inputs: {
camera: params.get("cam") === "1",
mic: params.get("mic") === "1",
},
outputs: {
audio: params.get("audio") === "1",
video: params.get("video") === "1",
chat: params.get("chat") === "1",
},
ws_url: "",
token: ""
} as UserSettings;
}, [])
const getSettingsFromCookies = useCallback(() => {
const jsonSettings = getCookie("lk_settings");
if (!jsonSettings) {
return null;
}
return JSON.parse(jsonSettings) as UserSettings;
}, [])
const setUrlSettings = useCallback((us: UserSettings) => {
const obj = new URLSearchParams({
cam: boolToString(us.inputs.camera),
mic: boolToString(us.inputs.mic),
video: boolToString(us.outputs.video),
audio: boolToString(us.outputs.audio),
chat: boolToString(us.chat),
theme_color: us.theme_color || "cyan",
});
// Note: We don't set ws_url and token to the URL on purpose
router.replace("/#" + obj.toString());
}, [router])
const setCookieSettings = useCallback((us: UserSettings) => {
const json = JSON.stringify(us);
setCookie("lk_settings", json);
}, [])
const getConfig = useCallback(() => {
const appConfigFromSettings = appConfig;
const cookieSettigs = getSettingsFromCookies();
const urlSettings = getSettingsFromUrl();
if(!cookieSettigs) {
if(urlSettings) {
setCookieSettings(urlSettings);
}
}
if(!urlSettings) {
if(cookieSettigs) {
setUrlSettings(cookieSettigs);
}
}
const newCookieSettings = getSettingsFromCookies();
if(!newCookieSettings) {
return appConfigFromSettings;
}
appConfigFromSettings.settings = newCookieSettings;
return {...appConfigFromSettings};
}, [
appConfig,
getSettingsFromCookies,
getSettingsFromUrl,
setCookieSettings,
setUrlSettings,
]);
const setUserSettings = useCallback((settings: UserSettings) => {
setUrlSettings(settings);
setCookieSettings(settings);
_setConfig((prev) => {
return {
...prev,
settings: settings,
};
})
}, [setCookieSettings, setUrlSettings]);
const [config, _setConfig] = useState<AppConfig>(getConfig());
// Run things client side because we use cookies
useEffect(() => {
_setConfig(getConfig());
}, [getConfig]);
return (
<ConfigContext.Provider value={{ config, setUserSettings }}>
{children}
</ConfigContext.Provider>
);
};
export const useConfig = () => {
const context = React.useContext(ConfigContext);
if (context === undefined) {
throw new Error("useConfig must be used within a ConfigProvider");
}
return context;
}
const boolToString = (b: boolean) => (b ? "1" : "0");

View File

@@ -0,0 +1,89 @@
"use client"
import React, { createContext, useState } from "react";
import { useConfig } from "./useConfig";
import { useCallback } from "react";
// Note: cloud mode is only used in our private, hosted version
export type Mode = "cloud" | "env" | "manual";
type TokenGeneratorData = {
shouldConnect: boolean;
wsUrl: string;
token: string;
disconnect: () => Promise<void>;
connect: (mode: Mode) => Promise<void>;
};
const TokenGeneratorContext = createContext<TokenGeneratorData | undefined>(undefined);
export const TokenGeneratorProvider = ({
children,
generateConnectionDetails,
}: {
children: React.ReactNode;
// generateConnectionDetails is only required in cloud mode
generateConnectionDetails?: () => Promise<{ wsUrl: string; token: string }>;
}) => {
const { config } = useConfig();
const [token, setToken] = useState("");
const [wsUrl, setWsUrl] = useState("");
const [shouldConnect, setShouldConnect] = useState(false);
const connect = useCallback(
async (mode: Mode) => {
if (mode === "cloud") {
if (!generateConnectionDetails) {
throw new Error(
"generateConnectionDetails must be provided in cloud mode"
);
}
const { wsUrl, token } = await generateConnectionDetails();
setWsUrl(wsUrl);
setToken(token);
} else if (mode === "env") {
const url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
if (!url) {
throw new Error("NEXT_PUBLIC_LIVEKIT_URL must be set in env mode");
}
const res = await fetch("/api/token");
const { accessToken } = await res.json();
setWsUrl(url);
setToken(accessToken);
} else if (mode === "manual") {
setWsUrl(config.settings.ws_url);
setToken(config.settings.token);
}
setShouldConnect(true);
},
[
config.settings.token,
config.settings.ws_url,
generateConnectionDetails,
]
);
const disconnect = useCallback(async () => {
setShouldConnect(false);
}, []);
return (
<TokenGeneratorContext.Provider
value={{
wsUrl,
token,
shouldConnect,
connect,
disconnect,
}}
>
{children}
</TokenGeneratorContext.Provider>
);
};
export const useTokenGenerator = () => {
const context = React.useContext(TokenGeneratorContext);
if (context === undefined) {
throw new Error("useTokenGenerator must be used within a TokenGeneratorProvider");
}
return context;
}

View File

@@ -1,4 +1,5 @@
import { NextApiRequest, NextApiResponse } from "next";
import { generateRandomAlphanumeric } from "@/lib/util";
import { AccessToken } from "livekit-server-sdk";
import type { AccessTokenOptions, VideoGrant } from "livekit-server-sdk";
@@ -13,48 +14,19 @@ const createToken = (userInfo: AccessTokenOptions, grant: VideoGrant) => {
return at.toJwt();
};
const roomPattern = /\w{4}\-\w{4}/;
export default async function handleToken(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { roomName, identity, name, metadata } = req.query;
if (typeof identity !== "string" || typeof roomName !== "string") {
res.statusMessage =
"identity and roomName have to be specified in the request";
res.status(403).end();
return;
}
if (!apiKey || !apiSecret) {
res.statusMessage = "Environment variables aren't set up correctly";
res.status(500).end();
return;
}
if (Array.isArray(name)) {
throw Error("provide max one name");
}
if (Array.isArray(metadata)) {
throw Error("provide max one metadata string");
}
// enforce room name to be xxxx-xxxx
// this is simple & naive way to prevent user from guessing room names
// please use your own authentication mechanisms in your own app
if (!roomName.match(roomPattern)) {
res.statusMessage = "Invalid roomName";
res.status(400).end();
return;
}
// if (!userSession.isAuthenticated) {
// res.status(403).end();
// return;
// }
const roomName = `room-${generateRandomAlphanumeric(4)}-${generateRandomAlphanumeric(4)}`;
const identity = `identity-${generateRandomAlphanumeric(4)}`
const grant: VideoGrant = {
room: roomName,
@@ -64,7 +36,7 @@ export default async function handleToken(
canSubscribe: true,
};
const token = await createToken({ identity, name, metadata }, grant);
const token = await createToken({ identity }, grant);
const result: TokenResult = {
identity,
accessToken: token,
@@ -75,4 +47,4 @@ export default async function handleToken(
res.statusMessage = (e as Error).message;
res.status(500).end();
}
}
}

View File

@@ -1,22 +1,20 @@
import { generateRandomAlphanumeric } from "@/lib/util";
import {
LiveKitRoom,
RoomAudioRenderer,
StartAudio,
useToken,
} from "@livekit/components-react";
import { AnimatePresence, motion } from "framer-motion";
import { Inter } from "next/font/google";
import Head from "next/head";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { PlaygroundConnect } from "@/components/PlaygroundConnect";
import Playground, {
PlaygroundMeta,
PlaygroundOutputs,
} from "@/components/playground/Playground";
import Playground, { PlaygroundMeta } from "@/components/playground/Playground";
import { PlaygroundToast, ToastType } from "@/components/toast/PlaygroundToast";
import { useAppConfig } from "@/hooks/useAppConfig";
import { ConfigProvider, useConfig } from "@/hooks/useConfig";
import { Mode, TokenGeneratorProvider, useTokenGenerator } from "@/hooks/useTokenGenerator";
import { useRef } from "react";
import { useMemo } from "react";
const themeColors = [
"cyan",
@@ -32,77 +30,47 @@ const themeColors = [
const inter = Inter({ subsets: ["latin"] });
export default function Home() {
return (
<ConfigProvider>
<TokenGeneratorProvider>
<HomeInner />
</TokenGeneratorProvider>
</ConfigProvider>
);
}
export function HomeInner() {
const [toastMessage, setToastMessage] = useState<{
message: string;
type: ToastType;
} | null>(null);
const [shouldConnect, setShouldConnect] = useState(false);
const [liveKitUrl, setLiveKitUrl] = useState(
process.env.NEXT_PUBLIC_LIVEKIT_URL
);
const [customToken, setCustomToken] = useState<string>();
const [metadata, setMetadata] = useState<PlaygroundMeta[]>([]);
const { shouldConnect, wsUrl, token, connect, disconnect } =
useTokenGenerator();
const [roomName, setRoomName] = useState(createRoomName());
const tokenOptions = useMemo(() => {
return {
userInfo: { identity: generateRandomAlphanumeric(16) },
};
}, []);
// set a new room name each time the user disconnects so that a new token gets fetched behind the scenes for a different room
useEffect(() => {
if (shouldConnect === false) {
setRoomName(createRoomName());
}
}, [shouldConnect]);
useEffect(() => {
const md: PlaygroundMeta[] = [];
if (liveKitUrl && liveKitUrl !== process.env.NEXT_PUBLIC_LIVEKIT_URL) {
md.push({ name: "LiveKit URL", value: liveKitUrl });
}
if (!customToken && tokenOptions.userInfo?.identity) {
md.push({ name: "Room Name", value: roomName });
md.push({
name: "Participant Identity",
value: tokenOptions.userInfo.identity,
});
}
setMetadata(md);
}, [liveKitUrl, roomName, tokenOptions, customToken]);
const token = useToken("/api/token", roomName, tokenOptions);
const appConfig = useAppConfig();
const outputs = [
appConfig?.outputs.audio && PlaygroundOutputs.Audio,
appConfig?.outputs.video && PlaygroundOutputs.Video,
appConfig?.outputs.chat && PlaygroundOutputs.Chat,
].filter((item) => typeof item !== "boolean") as PlaygroundOutputs[];
const {config} = useConfig();
const handleConnect = useCallback(
(connect: boolean, opts?: { url: string; token: string }) => {
if (connect && opts) {
setLiveKitUrl(opts.url);
setCustomToken(opts.token);
}
setShouldConnect(connect);
(c: boolean, mode: Mode) => {
c ? connect(mode) : disconnect();
},
[]
[connect, disconnect]
);
const showPG = useMemo(() => {
if (process.env.NEXT_PUBLIC_LIVEKIT_URL) {
return true;
}
if(wsUrl) {
return true;
}
return false;
}, [wsUrl])
return (
<>
<Head>
<title>{appConfig?.title ?? "LiveKit Agents Playground"}</title>
<meta
name="description"
content={
appConfig?.description ??
"Quickly prototype and test your multimodal agents"
}
/>
<title>{config.title}</title>
<meta name="description" content={config.description} />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
@@ -136,13 +104,11 @@ export default function Home() {
</motion.div>
)}
</AnimatePresence>
{liveKitUrl ? (
{showPG ? (
<LiveKitRoom
className="flex flex-col h-full w-full"
serverUrl={liveKitUrl}
token={customToken ?? token}
audio={appConfig?.inputs.mic}
video={appConfig?.inputs.camera}
serverUrl={wsUrl}
token={token}
connect={shouldConnect}
onError={(e) => {
setToastMessage({ message: e.message, type: "error" });
@@ -150,16 +116,13 @@ export default function Home() {
}}
>
<Playground
title={appConfig?.title}
githubLink={appConfig?.github_link}
outputs={outputs}
showQR={appConfig?.show_qr}
description={appConfig?.description}
themeColors={themeColors}
defaultColor={appConfig?.theme_color ?? "cyan"}
onConnect={handleConnect}
metadata={metadata}
videoFit={appConfig?.video_fit ?? "cover"}
onConnect={(c) => {
const mode = process.env.NEXT_PUBLIC_LIVEKIT_URL
? "env"
: "manual";
handleConnect(c, mode);
}}
/>
<RoomAudioRenderer />
<StartAudio label="Click to enable audio playback" />
@@ -167,18 +130,13 @@ export default function Home() {
) : (
<PlaygroundConnect
accentColor={themeColors[0]}
onConnectClicked={(url, token) => {
handleConnect(true, { url, token });
onConnectClicked={() => {
const mode = process.env.NEXT_PUBLIC_LIVEKIT_URL ? "env" : "manual";
handleConnect(true, mode);
}}
/>
)}
</main>
</>
);
}
function createRoomName() {
return [generateRandomAlphanumeric(4), generateRandomAlphanumeric(4)].join(
"-"
);
}
}