diff --git a/docs/storybook/stories/agents-ui/AgentChatIndicator.stories.tsx b/docs/storybook/stories/agents-ui/AgentChatIndicator.stories.tsx new file mode 100644 index 000000000..95737803b --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentChatIndicator.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentChatIndicator, AgentChatIndicatorProps } from '@agents-ui'; + +export default { + component: AgentChatIndicator, + decorators: [AgentSessionProvider], + render: (args: AgentChatIndicatorProps) => , + args: { + agentState: 'thinking', + }, + argTypes: { + size: { + options: ['sm', 'md', 'lg'], + control: { type: 'radio' }, + }, + }, + parameters: { + layout: 'centered', + actions: { handles: [] }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/docs/storybook/stories/agents-ui/AgentChatTranscript.stories.tsx b/docs/storybook/stories/agents-ui/AgentChatTranscript.stories.tsx new file mode 100644 index 000000000..e9c232ae2 --- /dev/null +++ b/docs/storybook/stories/agents-ui/AgentChatTranscript.stories.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { AgentChatTranscript, AgentChatTranscriptProps } from '@agents-ui'; + +export default { + component: AgentChatTranscript, + decorators: [AgentSessionProvider], + render: (args: AgentChatTranscriptProps) => { + return ( +
+ +
+ ); + }, + args: { + agentState: 'thinking', + messages: [ + { + id: '1', + timestamp: new Date().toISOString(), + from: { isLocal: false }, + message: 'Hi, how are you?', + }, + { + id: '2', + timestamp: new Date().toISOString(), + from: { isLocal: true }, + message: "I'm good, thank you!", + }, + { + id: '3', + timestamp: new Date().toISOString(), + from: { isLocal: false }, + message: 'This is a longer message that should wrap to the next line.', + }, + { + id: '4', + timestamp: new Date().toISOString(), + from: { isLocal: true }, + message: "Great I'm responding with an even longer message to see how it wraps.", + }, + ...Array.from({ length: 20 }).map((_, index) => ({ + id: `${5 + index}`, + timestamp: new Date().toISOString(), + from: { isLocal: index % 2 === 1 }, + message: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + })), + ], + }, + argTypes: { + agentState: { + control: 'radio', + options: [ + 'thinking', + 'speaking', + 'listening', + 'idle', + 'connecting', + 'disconnected', + 'failed', + ], + }, + }, + parameters: { + layout: 'fullscreen', + actions: { handles: [] }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/docs/storybook/stories/agents-ui/Introduction.mdx b/docs/storybook/stories/agents-ui/Introduction.mdx new file mode 100644 index 000000000..e87a2f942 --- /dev/null +++ b/docs/storybook/stories/agents-ui/Introduction.mdx @@ -0,0 +1,45 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Agents UI + +Agents UI is a set of components for building voice-first agents using LiveKit. + +## Setup + +Configure our registry in your project by adding the following to your projects `components.json` file: + +```json | components.json +{ + "registries": { + "@agents-ui": "https://ui.livekit.io/r/{name}.json" + } +} +``` + +## Installation + +```bash +pnpm dlx shadcn@latest add @agents-ui/{componentName} +``` + +## Implementation Example + +```tsx +import { useSession } from '@livekit/components-react'; +import { TokenSource } from 'livekit-client'; +import { AgentSessionProvider } from '@agents-ui/agent-session-provider'; +import { AgentControlBar } from '@agents-ui/agent-control-bar'; + +export default function Example() { + // using a sandbox token server for prototyping + const session = useSession(TokenSource.sandboxTokenServer('token-XXXXXX')); + + return ( + + + + ); +} +``` diff --git a/docs/storybook/stories/agents-ui/StartAudioButton.stories.tsx b/docs/storybook/stories/agents-ui/StartAudioButton.stories.tsx new file mode 100644 index 000000000..252d7b179 --- /dev/null +++ b/docs/storybook/stories/agents-ui/StartAudioButton.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { StoryObj } from '@storybook/react-vite'; +import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider'; +import { StartAudioButton, type StartAudioButtonProps } from '@agents-ui'; + +export default { + component: StartAudioButton, + decorators: [AgentSessionProvider], + render: (args: StartAudioButtonProps) => { + return ( + <> +

A button will be rendered below if audio playback is blocked.

+ + + ); + }, + args: { + label: 'Click to allow audio playback', + }, + argTypes: { + label: { control: { type: 'text' } }, + onClick: { action: 'onClick' }, + className: { control: { type: 'text' } }, + }, + parameters: { + layout: 'centered', + actions: { + handles: [], + }, + }, +}; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/shadcn/.env.example b/packages/shadcn/.env.example new file mode 100644 index 000000000..8bbb8ae4e --- /dev/null +++ b/packages/shadcn/.env.example @@ -0,0 +1 @@ +DEST_PATH=/path/to/dest/folder \ No newline at end of file diff --git a/packages/shadcn/.gitignore b/packages/shadcn/.gitignore index 1734597ac..efb48f5e5 100644 --- a/packages/shadcn/.gitignore +++ b/packages/shadcn/.gitignore @@ -4,3 +4,5 @@ node_modules .cache dist temp + +!.env.example \ No newline at end of file diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx index a4b0f92e1..e9687b4f6 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx @@ -73,8 +73,8 @@ export interface AgentAudioVisualizerBarProps { } export function AgentAudioVisualizerBar({ - size, - state, + size = 'md', + state = 'connecting', barCount, audioTrack, className, diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx index c76e1da4f..1a9c61531 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-grid.tsx @@ -159,7 +159,7 @@ const GridCell = memo(function GridCell({ }); export type AgentAudioVisualizerGridProps = GridOptions & { - state: AgentState; + state?: AgentState; audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder; className?: string; children?: ReactNode; @@ -167,7 +167,7 @@ export type AgentAudioVisualizerGridProps = GridOptions & { export function AgentAudioVisualizerGrid({ size = 'md', - state, + state = 'connecting', radius, rowCount: _rowCount = 5, columnCount: _columnCount = 5, diff --git a/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx b/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx index 637c1e762..f0387e485 100644 --- a/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx +++ b/packages/shadcn/components/agents-ui/agent-audio-visualizer-radial.tsx @@ -44,8 +44,8 @@ export interface AgentAudioVisualizerRadialProps { } export function AgentAudioVisualizerRadial({ - size, - state, + size = 'md', + state = 'connecting', radius, barCount, audioTrack, diff --git a/packages/shadcn/components/agents-ui/agent-chat-indicator.tsx b/packages/shadcn/components/agents-ui/agent-chat-indicator.tsx new file mode 100644 index 000000000..114e3d706 --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-chat-indicator.tsx @@ -0,0 +1,57 @@ +import { motion } from 'motion/react'; +import { cva, VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +const motionAnimationProps = { + variants: { + hidden: { + opacity: 0, + scale: 0.1, + transition: { + duration: 0.1, + ease: 'linear', + }, + }, + visible: { + opacity: [0.5, 1], + scale: [1, 1.2], + transition: { + type: 'spring', + bounce: 0, + duration: 0.5, + repeat: Infinity, + repeatType: 'mirror' as const, + }, + }, + }, + initial: 'hidden', + animate: 'visible', + exit: 'hidden', +}; + +const agentChatIndicatorVariants = cva('bg-muted-foreground inline-block size-2.5 rounded-full', { + variants: { + size: { + sm: 'size-2.5', + md: 'size-4', + lg: 'size-6', + }, + }, + defaultVariants: { + size: 'md', + }, +}); + +export interface AgentChatIndicatorProps extends VariantProps { + className?: string; +} + +export function AgentChatIndicator({ size, className }: AgentChatIndicatorProps) { + return ( + + ); +} diff --git a/packages/shadcn/components/agents-ui/agent-chat-transcript.tsx b/packages/shadcn/components/agents-ui/agent-chat-transcript.tsx new file mode 100644 index 000000000..2b45cf62c --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-chat-transcript.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { type AgentState, type ReceivedMessage } from '@livekit/components-react'; +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from '@/components/ai-elements/conversation'; +import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message'; +import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator'; +import { AnimatePresence } from 'motion/react'; + +export interface AgentChatTranscriptProps { + agentState?: AgentState; + messages?: ReceivedMessage[]; + className?: string; +} + +export function AgentChatTranscript({ + agentState, + messages = [], + className, +}: AgentChatTranscriptProps) { + return ( + + + {messages.map((receivedMessage) => { + const { id, timestamp, from, message } = receivedMessage; + const locale = navigator?.language ?? 'en-US'; + const messageOrigin = from?.isLocal ? 'user' : 'assistant'; + const time = new Date(timestamp); + const title = time.toLocaleTimeString(locale, { timeStyle: 'full' }); + + return ( + + + {message} + + + ); + })} + + {agentState === 'thinking' && } + + + + + ); +} diff --git a/packages/shadcn/components/agents-ui/agent-session-provider.tsx b/packages/shadcn/components/agents-ui/agent-session-provider.tsx new file mode 100644 index 000000000..f064dfe08 --- /dev/null +++ b/packages/shadcn/components/agents-ui/agent-session-provider.tsx @@ -0,0 +1,21 @@ +import { + SessionProvider, + RoomAudioRenderer, + type SessionProviderProps, + type RoomAudioRendererProps, +} from '@livekit/components-react'; + +export type AgentSessionProviderProps = SessionProviderProps & RoomAudioRendererProps; + +export function AgentSessionProvider({ + session, + children, + ...roomAudioRendererProps +}: AgentSessionProviderProps) { + return ( + + {children} + + + ); +} diff --git a/packages/shadcn/components/agents-ui/start-audio-button.tsx b/packages/shadcn/components/agents-ui/start-audio-button.tsx new file mode 100644 index 000000000..4737a57d7 --- /dev/null +++ b/packages/shadcn/components/agents-ui/start-audio-button.tsx @@ -0,0 +1,15 @@ +import { useEnsureRoom, useStartAudio } from '@livekit/components-react'; +import { Button } from '@/components/ui/button'; +import { Room } from 'livekit-client'; + +export interface StartAudioButtonProps extends React.ButtonHTMLAttributes { + room?: Room; + label: string; +} + +export function StartAudioButton({ label, room, ...props }: StartAudioButtonProps) { + const roomEnsured = useEnsureRoom(room); + const { mergedProps } = useStartAudio({ room: roomEnsured, props }); + + return ; +} diff --git a/packages/shadcn/components/session-provider.tsx b/packages/shadcn/components/session-provider.tsx new file mode 100644 index 000000000..41d52eb25 --- /dev/null +++ b/packages/shadcn/components/session-provider.tsx @@ -0,0 +1,15 @@ +'use client'; +import type { ReactNode } from 'react'; +import { TokenSource } from 'livekit-client'; +import { useSession, SessionProvider as LiveKitSessionProvider } from '@livekit/components-react'; + +type SessionProviderProps = { + children: ReactNode; +}; + +export function SessionProvider({ children }: SessionProviderProps) { + const tokenSource = TokenSource.endpoint(process.env.NEXT_PUBLIC_LK_TOKEN_ENDPOINT); + const session = useSession(tokenSource); + + return {children}; +} diff --git a/packages/shadcn/index.ts b/packages/shadcn/index.ts index 9578af194..ae91f4c81 100644 --- a/packages/shadcn/index.ts +++ b/packages/shadcn/index.ts @@ -1,7 +1,11 @@ -export * from './components/agents-ui/agent-audio-visualizer-bar'; +export * from './components/agents-ui/agent-session-provider'; +export * from './components/agents-ui/start-audio-button'; +export * from './components/agents-ui/agent-disconnect-button'; export * from './components/agents-ui/agent-track-toggle'; export * from './components/agents-ui/agent-track-control'; -export * from './components/agents-ui/agent-disconnect-button'; export * from './components/agents-ui/agent-control-bar'; -export * from './components/agents-ui/agent-audio-visualizer-radial'; +export * from './components/agents-ui/agent-chat-indicator'; +export * from './components/agents-ui/agent-chat-transcript'; +export * from './components/agents-ui/agent-audio-visualizer-bar'; export * from './components/agents-ui/agent-audio-visualizer-grid'; +export * from './components/agents-ui/agent-audio-visualizer-radial'; diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 91da571a4..215fc9e1c 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -6,8 +6,9 @@ "type": "module", "main": "index.ts", "scripts": { - "registry:build": "shadcn build --output ./dist", - "registry:serve": "python3 -m http.server 3210 -d dist" + "registry:build": "rm -rf dist && shadcn build --output ./dist", + "registry:serve": "python3 -m http.server 3210 -d dist", + "registry:update": "pnpm registry:build && node --experimental-strip-types --env-file=.env.local ./scripts/update.ts" }, "keywords": [], "author": "", diff --git a/packages/shadcn/registry.json b/packages/shadcn/registry.json index 9253c311c..85e3be232 100644 --- a/packages/shadcn/registry.json +++ b/packages/shadcn/registry.json @@ -157,6 +157,65 @@ "class-variance-authority" ], "registryDependencies": ["utils"] + }, + { + "name": "agent-session-provider", + "type": "registry:component", + "title": "Agent Session Provider", + "description": "A component for providing an agent's session context and ensuring remote participants’ audio tracks (microphones and screen share) are audible.", + "files": [ + { + "path": "components/agents-ui/agent-session-provider.tsx", + "type": "registry:component" + } + ], + "dependencies": ["@livekit/components-react@^2.0.0"] + }, + { + "name": "start-audio-button", + "type": "registry:component", + "title": "Start Audio Button", + "description": "A button for starting the agent session's audio track when the browser blocks audio playback.", + "files": [ + { + "path": "components/agents-ui/start-audio-button.tsx", + "type": "registry:component" + } + ], + "dependencies": ["livekit-client@^2.0.0", "@livekit/components-react@^2.0.0"], + "registryDependencies": ["button"] + }, + { + "name": "agent-chat-indicator", + "type": "registry:component", + "title": "Agent Chat Indicator", + "description": "A component for indicating the agent's chat status with a blinking dot. Useful for indicating the agent is thinking before generating a response.", + "files": [ + { + "path": "components/agents-ui/agent-chat-indicator.tsx", + "type": "registry:component" + } + ], + "dependencies": ["motion", "class-variance-authority"], + "registryDependencies": ["utils"] + }, + { + "name": "agent-chat-transcript", + "type": "registry:component", + "title": "Agent Chat Transcript", + "description": "A component for displaying the agent session's chat transcript.", + "files": [ + { + "path": "components/agents-ui/agent-chat-transcript.tsx", + "type": "registry:component" + } + ], + "dependencies": ["@livekit/components-react@^2.0.0", "motion"], + "registryDependencies": [ + "@ai-elements/message", + "@ai-elements/conversation", + "@agents-ui/agent-chat-indicator" + ] } ] } diff --git a/packages/shadcn/scripts/update.ts b/packages/shadcn/scripts/update.ts new file mode 100644 index 000000000..cf5ab5661 --- /dev/null +++ b/packages/shadcn/scripts/update.ts @@ -0,0 +1,16 @@ +import { execSync } from 'node:child_process'; + +// copy .env.example to .env.local and edit DEST_PATH +if (!process.env.DEST_PATH) { + throw new Error('DEST_PATH is not set'); +} + +console.log('--------------------------------'); +console.log(`Cleaning ${process.env.DEST_PATH}`); +execSync(`rm -rf ${process.env.DEST_PATH}/*`); +console.log('--------------------------------'); +console.log(`Copying dist to ${process.env.DEST_PATH}`); +execSync(`cp -r dist/* ${process.env.DEST_PATH}`); +console.log('--------------------------------'); + +console.log('Done');