better-chatbot / src /components /chat-bot-voice.tsx
Bot
Initial commit for HF Spaces
05c5ed5
Raw
History Blame Contribute Delete
24.4 kB
"use client";
import { getToolName, isToolUIPart, TextPart } from "ai";
import { DEFAULT_VOICE_TOOLS, UIMessageWithCompleted } from "lib/ai/speech";
import {
OPENAI_VOICE,
useOpenAIVoiceChat as OpenAIVoiceChat,
} from "lib/ai/speech/open-ai/use-voice-chat.openai";
import { cn, groupBy, isNull } from "lib/utils";
import {
CheckIcon,
Loader,
MicIcon,
MicOffIcon,
PhoneIcon,
Settings2Icon,
TriangleAlertIcon,
XIcon,
MessagesSquareIcon,
MessageSquareMoreIcon,
WrenchIcon,
ChevronRight,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { safe } from "ts-safe";
import { Alert, AlertDescription, AlertTitle } from "ui/alert";
import { Button } from "ui/button";
import { Drawer, DrawerContent, DrawerPortal, DrawerTitle } from "ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "ui/dropdown-menu";
import { GeminiIcon } from "ui/gemini-icon";
import { MessageLoading } from "ui/message-loading";
import { OpenAIIcon } from "ui/openai-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip";
import { ToolMessagePart } from "./message-parts";
import { EnabledTools, EnabledToolsDropdown } from "./enabled-tools-dropdown";
import { appStore } from "@/app/store";
import { useShallow } from "zustand/shallow";
import { useTranslations } from "next-intl";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "ui/dialog";
import JsonView from "ui/json-view";
import { isShortcutEvent, Shortcuts } from "lib/keyboard-shortcuts";
import { useAgent } from "@/hooks/queries/use-agent";
import { ChatMention } from "app-types/chat";
import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar";
const prependTools: EnabledTools[] = [
{
groupName: "Browser",
tools: DEFAULT_VOICE_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
})),
},
];
export function ChatBotVoice() {
const t = useTranslations("Chat");
const [
agentId,
appStoreMutate,
voiceChat,
model,
allowedMcpServers,
mcpList,
] = appStore(
useShallow((state) => [
state.voiceChat.agentId,
state.mutate,
state.voiceChat,
state.chatModel,
state.allowedMcpServers,
state.mcpList,
]),
);
const { agent } = useAgent(agentId);
const [isClosing, setIsClosing] = useState(false);
const startAudio = useRef<HTMLAudioElement>(null);
const [useCompactView, setUseCompactView] = useState(true);
const toolMentions = useMemo<ChatMention[]>(() => {
if (!agentId) {
if (!allowedMcpServers) return [];
return mcpList
.filter((v) => {
return (
v.id in allowedMcpServers && allowedMcpServers[v.id]?.tools?.length
);
})
.flatMap((v) => {
const tools = allowedMcpServers[v.id].tools;
return tools.map((tool) => {
const toolInfo = v.toolInfo.find((t) => t.name === tool);
const mention: ChatMention = {
type: "mcpTool",
serverName: v.name,
serverId: v.id,
name: tool,
description: toolInfo?.description ?? "",
};
return mention;
});
});
}
return (
agent?.instructions.mentions?.filter((v) => v.type === "mcpTool") ?? []
);
}, [agentId, agent, mcpList, allowedMcpServers]);
const {
isListening,
isAssistantSpeaking,
isLoading,
isActive,
isUserSpeaking,
messages,
error,
start,
startListening,
stop,
stopListening,
} = OpenAIVoiceChat({
toolMentions,
agentId,
...voiceChat.options.providerOptions,
});
const startWithSound = useCallback(() => {
if (!startAudio.current) {
startAudio.current = new Audio("/sounds/start_voice.ogg");
}
start().then(() => {
startAudio.current?.play().catch(() => {});
});
}, [start]);
const endVoiceChat = useCallback(async () => {
setIsClosing(true);
await safe(() => stop());
setIsClosing(false);
appStoreMutate({
voiceChat: {
...voiceChat,
isOpen: false,
},
});
}, [messages, model]);
const statusMessage = useMemo(() => {
if (isLoading) {
return (
<p className="fade-in animate-in duration-3000" key="start">
{t("VoiceChat.preparing")}
</p>
);
}
if (!isActive)
return (
<p className="fade-in animate-in duration-3000" key="start">
{t("VoiceChat.startVoiceChat")}
</p>
);
if (!isListening)
return (
<p className="fade-in animate-in duration-3000" key="stop">
{t("VoiceChat.yourMicIsOff")}
</p>
);
if (!isAssistantSpeaking && messages.length === 0) {
return (
<p className="fade-in animate-in duration-3000" key="ready">
{t("VoiceChat.readyWhenYouAreJustStartTalking")}
</p>
);
}
if (isUserSpeaking && useCompactView) {
return <MessageLoading className="text-muted-foreground" />;
}
if (!isAssistantSpeaking && !isUserSpeaking) {
return (
<p className="delayed-fade-in" key="ready">
{t("VoiceChat.readyWhenYouAreJustStartTalking")}
</p>
);
}
}, [
isAssistantSpeaking,
isUserSpeaking,
isActive,
isLoading,
isListening,
messages.length,
useCompactView,
]);
const mcpTools = useMemo<EnabledTools[]>(() => {
const mcpMentions = toolMentions.filter(
(v) => v.type === "mcpTool",
) as Extract<ChatMention, { type: "mcpTool" }>[];
const groupByServer = groupBy(mcpMentions, "serverName");
return Object.entries(groupByServer).map(([serverName, tools]) => {
return {
groupName: serverName,
tools: tools.map((v) => ({
name: v.name,
description: v.description,
})),
};
});
}, [toolMentions]);
const tools = useMemo<EnabledTools[]>(() => {
return [...prependTools, ...mcpTools];
}, [prependTools, mcpTools]);
useEffect(() => {
return () => {
if (isActive) {
stop();
}
};
}, [voiceChat.options, isActive]);
useEffect(() => {
if (voiceChat.isOpen) {
// startWithSound();
} else if (isActive) {
stop();
}
}, [voiceChat.isOpen]);
useEffect(() => {
if (!voiceChat.isOpen && !isNull(voiceChat.agentId)) {
appStoreMutate((prev) => ({
voiceChat: {
...prev.voiceChat,
agentId: undefined,
},
}));
}
}, [voiceChat.isOpen]);
useEffect(() => {
if (error && isActive) {
toast.error(error.message);
stop();
}
}, [error]);
useEffect(() => {
if (voiceChat.isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
const isVoiceChatEvent = isShortcutEvent(e, Shortcuts.toggleVoiceChat);
if (isVoiceChatEvent) {
e.preventDefault();
e.stopPropagation();
appStoreMutate((prev) => ({
voiceChat: {
...prev.voiceChat,
isOpen: true,
agentId: undefined,
},
}));
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [voiceChat.isOpen]);
return (
<Drawer dismissible={false} open={voiceChat.isOpen} direction="top">
<DrawerPortal>
<DrawerContent className="max-h-[100vh]! h-full border-none! rounded-none! flex flex-col bg-card">
<div className="w-full h-full flex flex-col ">
<div
className="w-full flex p-6 gap-2"
style={{
userSelect: "text",
}}
>
{agent && (
<Tooltip>
<TooltipTrigger asChild>
<div
style={agent.icon?.style}
className="size-9 items-center justify-center flex rounded-lg ring ring-secondary"
>
<Avatar className="size-6">
<AvatarImage src={agent.icon?.value} />
<AvatarFallback>
{agent.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="p-3 max-w-xs">
<div className="space-y-2">
<div className="font-semibold text-sm">{agent.name}</div>
{agent.description && (
<div className="text-xs text-muted-foreground leading-relaxed">
{agent.description}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"secondary"}
size={"icon"}
onClick={() => setUseCompactView(!useCompactView)}
>
{useCompactView ? (
<MessageSquareMoreIcon />
) : (
<MessagesSquareIcon />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{useCompactView
? t("VoiceChat.compactDisplayMode")
: t("VoiceChat.conversationDisplayMode")}
</TooltipContent>
</Tooltip>
<EnabledToolsDropdown align="start" side="bottom" tools={tools} />
<DrawerTitle className="ml-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={"ghost"} size={"icon"}>
<Settings2Icon className="text-foreground size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="left"
className="min-w-40"
align="start"
>
<DropdownMenuGroup className="cursor-pointer">
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="flex items-center gap-2 cursor-pointer"
icon=""
>
<OpenAIIcon className="size-3.5 stroke-none fill-foreground" />
Open AI
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{Object.entries(OPENAI_VOICE).map(
([key, value]) => (
<DropdownMenuItem
className="cursor-pointer flex items-center justify-between"
onClick={() =>
appStoreMutate({
voiceChat: {
...voiceChat,
options: {
provider: "openai",
providerOptions: {
voice: value,
},
},
},
})
}
key={key}
>
{key}
{value ===
voiceChat.options.providerOptions
?.voice && (
<CheckIcon className="size-3.5" />
)}
</DropdownMenuItem>
),
)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger
className="flex items-center gap-2 text-muted-foreground"
icon=""
>
<GeminiIcon className="size-3.5" />
Gemini
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<div className="text-xs text-muted-foreground p-6">
Not Implemented Yet
</div>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</DrawerTitle>
</div>
<div className="flex-1 min-h-0 mx-auto w-full">
{error ? (
<div className="max-w-3xl mx-auto">
<Alert variant={"destructive"}>
<TriangleAlertIcon className="size-4 " />
<AlertTitle className="">Error</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<AlertDescription className="my-4 ">
<p className="text-muted-foreground ">
{t("VoiceChat.pleaseCloseTheVoiceChatAndTryAgain")}
</p>
</AlertDescription>
</Alert>
</div>
) : null}
{isLoading ? (
<div className="flex-1"></div>
) : (
<div className="h-full w-full">
{useCompactView ? (
<CompactMessageView messages={messages} />
) : (
<ConversationView messages={messages} />
)}
</div>
)}
</div>
<div className="relative w-full p-6 flex items-center justify-center gap-4">
<div className="text-sm text-muted-foreground absolute -top-5 left-0 w-full justify-center flex items-center">
{statusMessage}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"secondary"}
size={"icon"}
disabled={isClosing || isLoading}
onClick={() => {
if (!isActive) {
startWithSound();
} else if (isListening) {
stopListening();
} else {
startListening();
}
}}
className={cn(
"rounded-full p-6 transition-colors duration-300",
isLoading
? "bg-accent-foreground text-accent animate-pulse"
: !isActive
? "bg-green-500/10 text-green-500 hover:bg-green-500/30"
: !isListening
? "bg-destructive/30 text-destructive hover:bg-destructive/10"
: isUserSpeaking
? "bg-input text-foreground"
: "",
)}
>
{isLoading || isClosing ? (
<Loader className="size-6 animate-spin" />
) : !isActive ? (
<PhoneIcon className="size-6 fill-green-500 stroke-none" />
) : isListening ? (
<MicIcon
className={`size-6 ${isUserSpeaking ? "text-primary" : "text-muted-foreground transition-colors duration-300"}`}
/>
) : (
<MicOffIcon className="size-6" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{!isActive
? t("VoiceChat.startConversation")
: isListening
? t("VoiceChat.closeMic")
: t("VoiceChat.openMic")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={"secondary"}
size={"icon"}
className="rounded-full p-6"
disabled={isLoading || isClosing}
onClick={endVoiceChat}
>
<XIcon className="text-foreground size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("VoiceChat.endConversation")}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</Drawer>
);
}
function ConversationView({
messages,
}: { messages: UIMessageWithCompleted[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
ref.current.scrollTo({
top: ref.current.scrollHeight,
behavior: "smooth",
});
}
}, [messages.length]);
return (
<div className="select-text w-full overflow-y-auto h-full" ref={ref}>
<div className="max-w-4xl mx-auto flex flex-col px-6 gap-6 pb-44 min-h-0 min-w-0">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex px-4 py-3",
message.role == "user" &&
"ml-auto max-w-2xl text-foreground rounded-2xl w-fit bg-input/40",
)}
>
{!message.completed ? (
<MessageLoading
className={cn(
message.role == "user"
? "text-muted-foreground"
: "text-foreground",
)}
/>
) : (
message.parts.map((part, index) => {
if (part.type === "text") {
if (!part.text) {
return (
<MessageLoading
key={index}
className={cn(
message.role == "user"
? "text-muted-foreground"
: "text-foreground",
)}
/>
);
}
return (
<p key={index}>
{(part.text || "...")
?.trim()
.split(" ")
.map((word, wordIndex) => (
<span
key={wordIndex}
className="animate-in fade-in duration-3000"
>
{word}{" "}
</span>
))}
</p>
);
} else if (isToolUIPart(part)) {
return (
<ToolMessagePart
key={index}
part={part}
showActions={false}
messageId={message.id}
isLast={part.state.startsWith("input")}
/>
);
}
return <p key={index}>{part.type} unknown part</p>;
})
)}
</div>
))}
</div>
</div>
);
}
function CompactMessageView({
messages,
}: {
messages: UIMessageWithCompleted[];
}) {
const { toolParts, textPart } = useMemo(() => {
const toolParts = messages
.filter((msg) => msg.parts.some(isToolUIPart))
.map((msg) => msg.parts.find(isToolUIPart));
const textPart = messages.findLast((msg) => msg.role === "assistant")
?.parts[0] as TextPart;
return { toolParts, textPart };
}, [messages]);
return (
<div className="relative w-full h-full overflow-hidden">
<div className="absolute bottom-6 max-h-[80vh] overflow-y-auto left-6 z-10 flex-col gap-2 hidden md:flex">
{toolParts.map((toolPart, index) => {
const isExecuting = toolPart?.state.startsWith("input");
if (!toolPart) return null;
return (
<Dialog key={index}>
<DialogTrigger asChild>
<div className="animate-in slide-in-from-bottom-2 fade-in duration-3000 max-w-xs w-full">
<Button
variant={"outline"}
size={"icon"}
className="w-full bg-card flex items-center gap-2 px-2 text-xs text-muted-foreground"
>
<WrenchIcon className="size-3.5" />
<span className="text-sm font-bold min-w-0 truncate mr-auto">
{getToolName(toolPart)}
</span>
{isExecuting ? (
<Loader className="size-3.5 animate-spin" />
) : (
<ChevronRight className="size-3.5" />
)}
</Button>
</div>
</DialogTrigger>
<DialogContent className="z-50 md:max-w-2xl! max-h-[80vh] overflow-y-auto p-8">
<DialogTitle>{getToolName(toolPart)}</DialogTitle>
<div className="flex flex-row gap-4 text-sm ">
<div className="w-1/2 min-w-0 flex flex-col">
<div className="flex items-center gap-2 mb-2 pt-2 pb-1 z-10">
<h5 className="text-muted-foreground text-sm font-medium">
Inputs
</h5>
</div>
<JsonView data={toolPart.input} />
</div>
<div className="w-1/2 min-w-0 pl-4 flex flex-col">
<div className="flex items-center gap-2 mb-4 pt-2 pb-1 z-10">
<h5 className="text-muted-foreground text-sm font-medium">
Outputs
</h5>
</div>
<JsonView
data={
toolPart.state === "output-available"
? toolPart.output
: toolPart.state == "output-error"
? toolPart.errorText
: {}
}
/>
</div>
</div>
</DialogContent>
</Dialog>
);
})}
</div>
{/* Current Message - Prominent */}
{textPart && (
<div className="w-full mx-auto h-full max-h-[80vh] overflow-y-auto px-4 lg:max-w-4xl flex-1 flex items-center">
<div className="animate-in fade-in-50 duration-1000">
<p className="text-2xl md:text-3xl lg:text-4xl font-semibold leading-tight tracking-wide">
{textPart.text?.split(" ").map((word, wordIndex) => (
<span
key={wordIndex}
className="animate-in fade-in duration-5000"
>
{word}{" "}
</span>
))}
</p>
</div>
</div>
)}
</div>
);
}