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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
128
src/components/playground/SettingsDropdown.tsx
Normal file
128
src/components/playground/SettingsDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/playground/icons.tsx
Normal file
39
src/components/playground/icons.tsx
Normal 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>
|
||||
);
|
||||
@@ -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
198
src/hooks/useConfig.tsx
Normal 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");
|
||||
89
src/hooks/useTokenGenerator.tsx
Normal file
89
src/hooks/useTokenGenerator.tsx
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"-"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user