Use agent status from metadata channels (#10)

This commit is contained in:
David Zhao
2024-01-15 13:51:56 -08:00
committed by GitHub
parent 0d00b73f59
commit e1cc9a08a2
6 changed files with 172 additions and 109 deletions

View File

@@ -1,11 +1,11 @@
import { useWindowResize } from "@/hooks/useWindowResize"; import { useWindowResize } from "@/hooks/useWindowResize";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
type ChatMessageInput = { type ChatMessageInput = {
placeholder: string; placeholder: string;
accentColor: string; accentColor: string;
height: number; height: number;
onSend: (message: string) => void; onSend?: (message: string) => void;
}; };
export const ChatMessageInput = ({ export const ChatMessageInput = ({
@@ -23,14 +23,17 @@ export const ChatMessageInput = ({
const [isTyping, setIsTyping] = useState(false); const [isTyping, setIsTyping] = useState(false);
const [inputHasFocus, setInputHasFocus] = useState(false); const [inputHasFocus, setInputHasFocus] = useState(false);
const handleSend = () => { const handleSend = useCallback(() => {
if (!onSend) {
return;
}
if (message === "") { if (message === "") {
return; return;
} }
onSend(message); onSend(message);
setMessage(""); setMessage("");
}; }, [onSend, message]);
useEffect(() => { useEffect(() => {
setIsTyping(true); setIsTyping(true);
@@ -105,6 +108,7 @@ export const ChatMessageInput = ({
{message.replaceAll(" ", "\u00a0")} {message.replaceAll(" ", "\u00a0")}
</span> </span>
<button <button
disabled={message.length === 0 || !onSend}
onClick={handleSend} onClick={handleSend}
className={`text-xs uppercase text-${accentColor}-500 hover:bg-${accentColor}-950 p-2 rounded-md opacity-${ className={`text-xs uppercase text-${accentColor}-500 hover:bg-${accentColor}-950 p-2 rounded-md opacity-${
message.length > 0 ? 100 : 25 message.length > 0 ? 100 : 25

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ChatMessage } from "@/components/chat/ChatMessage"; import { ChatMessage } from "@/components/chat/ChatMessage";
import { ChatMessageInput } from "@/components/chat/ChatMessageInput"; import { ChatMessageInput } from "@/components/chat/ChatMessageInput";
import { useEffect, useRef } from "react";
const inputHeight = 48; const inputHeight = 48;
@@ -8,12 +8,13 @@ export type ChatMessageType = {
name: string; name: string;
message: string; message: string;
isSelf: boolean; isSelf: boolean;
timestamp: number;
}; };
type ChatTileProps = { type ChatTileProps = {
messages: ChatMessageType[]; messages: ChatMessageType[];
accentColor: string; accentColor: string;
onSend: (message: string) => void; onSend?: (message: string) => Promise<void>;
}; };
export const ChatTile = ({ messages, accentColor, onSend }: ChatTileProps) => { export const ChatTile = ({ messages, accentColor, onSend }: ChatTileProps) => {

View File

@@ -1,23 +1,10 @@
"use client"; "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 { LoadingSVG } from "@/components/button/LoadingSVG";
import { ChatMessageType, ChatTile } from "@/components/chat/ChatTile";
import { ColorPicker } from "@/components/colorPicker/ColorPicker";
import { AudioInputTile } from "@/components/config/AudioInputTile";
import { ConfigurationPanelItem } from "@/components/config/ConfigurationPanelItem";
import { NameValueRow } from "@/components/config/NameValueRow"; import { NameValueRow } from "@/components/config/NameValueRow";
import { PlaygroundHeader } from "@/components/playground/PlaygroundHeader"; import { PlaygroundHeader } from "@/components/playground/PlaygroundHeader";
import { import {
@@ -25,12 +12,29 @@ import {
PlaygroundTabbedTile, PlaygroundTabbedTile,
PlaygroundTile, PlaygroundTile,
} from "@/components/playground/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"; import { AgentMultibandAudioVisualizer } from "@/components/visualization/AgentMultibandAudioVisualizer";
import { useMultibandTrackVolume } from "@/hooks/useTrackVolume";
import { AgentState } from "@/lib/types";
import {
VideoTrack,
useChat,
useConnectionState,
useDataChannel,
useEnsureRoom,
useLocalParticipant,
useRemoteParticipants,
useTracks,
} from "@livekit/components-react";
import {
ConnectionState,
LocalParticipant,
ParticipantEvent,
RemoteParticipant,
RoomEvent,
Track,
} from "livekit-client";
import { QRCodeSVG } from "qrcode.react";
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
export enum PlaygroundOutputs { export enum PlaygroundOutputs {
Video, Video,
@@ -38,6 +42,11 @@ export enum PlaygroundOutputs {
Chat, Chat,
} }
export interface PlaygroundMeta {
name: string;
value: string;
}
export interface PlaygroundProps { export interface PlaygroundProps {
logo?: ReactNode; logo?: ReactNode;
title?: string; title?: string;
@@ -48,7 +57,7 @@ export interface PlaygroundProps {
outputs?: PlaygroundOutputs[]; outputs?: PlaygroundOutputs[];
showQR?: boolean; showQR?: boolean;
onConnect: (connect: boolean, opts?: { token: string; url: string }) => void; onConnect: (connect: boolean, opts?: { token: string; url: string }) => void;
metadata?: { name: string; value: string }[]; metadata?: PlaygroundMeta[];
} }
const headerHeight = 56; const headerHeight = 56;
@@ -65,15 +74,17 @@ export default function Playground({
onConnect, onConnect,
metadata, metadata,
}: PlaygroundProps) { }: PlaygroundProps) {
const [agentState, setAgentState] = useState< const [agentState, setAgentState] = useState<AgentState>("offline");
"listening" | "speaking" | "thinking" | "offline"
>("offline");
const [userState, setUserState] = useState<"silent" | "speaking">("silent");
const [themeColor, setThemeColor] = useState(defaultColor); const [themeColor, setThemeColor] = useState(defaultColor);
const [messages, setMessages] = useState<ChatMessageType[]>([]); const [messages, setMessages] = useState<ChatMessageType[]>([]);
const localParticipant = useLocalParticipant(); const [transcripts, setTranscripts] = useState<ChatMessageType[]>([]);
const roomContext = useRoomContext(); const { localParticipant } = useLocalParticipant();
const room = useEnsureRoom();
const participants = useRemoteParticipants({
updateOnlyOn: [RoomEvent.ParticipantMetadataChanged],
});
const agentParticipant = participants.find((p) => p.isAgent);
const { send: sendChat, chatMessages } = useChat();
const visualizerState = useMemo(() => { const visualizerState = useMemo(() => {
if (agentState === "thinking") { if (agentState === "thinking") {
return "thinking"; return "thinking";
@@ -118,39 +129,93 @@ export default function Playground({
20 20
); );
const isAgentConnected = !!useRemoteParticipants().find((p) => p.isAgent);
useEffect(() => { useEffect(() => {
if (isAgentConnected && agentState === "offline") { if (!agentParticipant || !room) {
setAgentState("listening"); return;
} else if (!isAgentConnected) {
setAgentState("offline");
} }
}, [isAgentConnected, agentState]); const metadataChanged = () => {
let agentMd: any = {};
if (agentParticipant.metadata) {
agentMd = JSON.parse(agentParticipant.metadata);
}
if (agentMd.agent_state) {
setAgentState(agentMd.agent_state);
} else {
setAgentState("starting");
}
};
const disconnected = (p: RemoteParticipant) => {
if (agentParticipant.identity === p.identity) {
setAgentState("offline");
}
};
agentParticipant.on(
ParticipantEvent.ParticipantMetadataChanged,
metadataChanged
);
room.on(RoomEvent.ParticipantDisconnected, disconnected);
return () => {
agentParticipant.off(
ParticipantEvent.ParticipantMetadataChanged,
metadataChanged
);
room.off(RoomEvent.ParticipantDisconnected, disconnected);
};
}, [agentParticipant, room]);
const isAgentConnected = agentState !== "offline";
const onDataReceived = useCallback( const onDataReceived = useCallback(
(msg: any) => { (msg: any) => {
const decoded = JSON.parse(new TextDecoder("utf-8").decode(msg.payload)); if (msg.topic === "transcription") {
if (decoded.type === "state") { const decoded = JSON.parse(
const { agent_state, user_state } = decoded; new TextDecoder("utf-8").decode(msg.payload)
setAgentState(agent_state); );
setUserState(user_state); let timestamp = new Date().getTime();
} else if (decoded.type === "transcription") { if ("timestamp" in decoded && decoded.timestamp > 0) {
setMessages([ timestamp = decoded.timestamp;
...messages, }
{ name: "You", message: decoded.text, isSelf: true }, setTranscripts([
]); ...transcripts,
} else if (decoded.type === "agent_chat_message") { {
setMessages([ name: "You",
...messages, message: decoded.text,
{ name: "Agent", message: decoded.text, isSelf: false }, timestamp: timestamp,
isSelf: true,
},
]); ]);
} }
console.log("data received", decoded, msg.from);
}, },
[messages] [transcripts]
); );
// combine transcripts and chat together
useEffect(() => {
const allMessages = [...transcripts];
for (const msg of chatMessages) {
const isAgent = msg.from?.identity === agentParticipant?.identity;
const isSelf = msg.from?.identity === localParticipant?.identity;
let name = msg.from?.name;
if (!name) {
if (isAgent) {
name = "Agent";
} else if (isSelf) {
name = "You";
} else {
name = "Unknown";
}
}
allMessages.push({
name,
message: msg.message,
timestamp: msg?.timestamp,
isSelf: isSelf,
});
}
allMessages.sort((a, b) => a.timestamp - b.timestamp);
setMessages(allMessages);
}, [transcripts, chatMessages, localParticipant, agentParticipant]);
useDataChannel(onDataReceived); useDataChannel(onDataReceived);
const videoTileContent = useMemo(() => { const videoTileContent = useMemo(() => {
@@ -201,32 +266,10 @@ export default function Playground({
<ChatTile <ChatTile
messages={messages} messages={messages}
accentColor={themeColor} accentColor={themeColor}
onSend={(message) => { onSend={sendChat}
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
);
}}
/> />
); );
}, [ }, [messages, themeColor, sendChat]);
localParticipant.localParticipant,
messages,
roomContext.state,
themeColor,
]);
const settingsTileContent = useMemo(() => { const settingsTileContent = useMemo(() => {
return ( return (
@@ -239,10 +282,6 @@ export default function Playground({
<ConfigurationPanelItem title="Settings"> <ConfigurationPanelItem title="Settings">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<NameValueRow
name="Agent URL"
value={process.env.NEXT_PUBLIC_LIVEKIT_URL}
/>
{metadata?.map((data, index) => ( {metadata?.map((data, index) => (
<NameValueRow <NameValueRow
key={data.name + index} key={data.name + index}
@@ -298,13 +337,6 @@ export default function Playground({
agentState === "speaking" ? `${themeColor}-500` : "gray-500" agentState === "speaking" ? `${themeColor}-500` : "gray-500"
} }
/> />
<NameValueRow
name="User status"
value={userState}
valueColor={
userState === "silent" ? "gray-500" : `${themeColor}-500`
}
/>
</div> </div>
</ConfigurationPanelItem> </ConfigurationPanelItem>
{localVideoTrack && ( {localVideoTrack && (
@@ -359,7 +391,6 @@ export default function Playground({
roomState, roomState,
themeColor, themeColor,
themeColors, themeColors,
userState,
showQR, showQR,
]); ]);

View File

@@ -1,7 +1,8 @@
import { AgentState } from "@/lib/types";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
type AgentMultibandAudioVisualizerProps = { type AgentMultibandAudioVisualizerProps = {
state: "listening" | "speaking" | "thinking" | "offline"; state: AgentState;
barWidth: number; barWidth: number;
minBarHeight: number; minBarHeight: number;
maxBarHeight: number; maxBarHeight: number;

View File

@@ -14,3 +14,10 @@ export interface TokenResult {
identity: string; identity: string;
accessToken: string; accessToken: string;
} }
export type AgentState =
| "listening"
| "speaking"
| "thinking"
| "offline"
| "starting";

View File

@@ -1,21 +1,22 @@
import Head from "next/head";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Inter } from "next/font/google";
import { generateRandomAlphanumeric } from "@/lib/util"; import { generateRandomAlphanumeric } from "@/lib/util";
import { motion, AnimatePresence } from "framer-motion";
import { import {
LiveKitRoom, LiveKitRoom,
RoomAudioRenderer, RoomAudioRenderer,
StartAudio, StartAudio,
useToken, useToken,
} from "@livekit/components-react"; } 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 { PlaygroundConnect } from "@/components/PlaygroundConnect";
import Playground, { import Playground, {
PlaygroundMeta,
PlaygroundOutputs, PlaygroundOutputs,
} from "@/components/playground/Playground"; } from "@/components/playground/Playground";
import { useAppConfig } from "@/hooks/useAppConfig";
import { PlaygroundConnect } from "@/components/PlaygroundConnect";
import { PlaygroundToast, ToastType } from "@/components/toast/PlaygroundToast"; import { PlaygroundToast, ToastType } from "@/components/toast/PlaygroundToast";
import { useAppConfig } from "@/hooks/useAppConfig";
const themeColors = [ const themeColors = [
"cyan", "cyan",
@@ -40,10 +41,9 @@ export default function Home() {
process.env.NEXT_PUBLIC_LIVEKIT_URL process.env.NEXT_PUBLIC_LIVEKIT_URL
); );
const [customToken, setCustomToken] = useState<string>(); const [customToken, setCustomToken] = useState<string>();
const [metadata, setMetadata] = useState<PlaygroundMeta[]>([]);
const [roomName, setRoomName] = useState( const [roomName, setRoomName] = useState(createRoomName());
[generateRandomAlphanumeric(4), generateRandomAlphanumeric(4)].join("-")
);
const tokenOptions = useMemo(() => { const tokenOptions = useMemo(() => {
return { return {
@@ -54,12 +54,25 @@ export default function Home() {
// set a new room name each time the user disconnects so that a new token gets fetched behind the scenes for a different room // 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(() => { useEffect(() => {
if (shouldConnect === false) { if (shouldConnect === false) {
setRoomName( setRoomName(createRoomName());
[generateRandomAlphanumeric(4), generateRandomAlphanumeric(4)].join("-")
);
} }
}, [shouldConnect]); }, [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 token = useToken("/api/token", roomName, tokenOptions);
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const outputs = [ const outputs = [
@@ -133,7 +146,7 @@ export default function Home() {
themeColors={themeColors} themeColors={themeColors}
defaultColor={appConfig?.theme_color ?? "cyan"} defaultColor={appConfig?.theme_color ?? "cyan"}
onConnect={handleConnect} onConnect={handleConnect}
metadata={[{ name: "Room Name", value: roomName }]} metadata={metadata}
/> />
<RoomAudioRenderer /> <RoomAudioRenderer />
<StartAudio label="Click to enable audio playback" /> <StartAudio label="Click to enable audio playback" />
@@ -150,3 +163,9 @@ export default function Home() {
</> </>
); );
} }
function createRoomName() {
return [generateRandomAlphanumeric(4), generateRandomAlphanumeric(4)].join(
"-"
);
}