Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/storybook/.storybook/lk-decorators/MockConversation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as React from 'react';
import { Decorator } from '@storybook/react-vite';
import { useSessionContext } from '@livekit/components-react';
import { DataPacket_Kind, Participant, type RemoteParticipant, RoomEvent } from 'livekit-client';

/**
* Mirrors `LegacyDataTopic.CHAT` from `@livekit/components-core` (not re-exported from
* `@livekit/components-react`, so it's inlined here rather than adding a new dependency).
*/
const LEGACY_CHAT_TOPIC = 'lk-chat-topic';

const MOCK_AGENT_PARTICIPANT = new Participant('mock-agent-sid', 'agent', 'Agent');

export type MockConversationMessage = {
id: string;
from: 'user' | 'agent';
message: string;
};

/**
* Populates the current session's transcript with a scripted conversation, without a real
* connection or backend agent. Works by emitting fake `RoomEvent.DataReceived` events on the
* legacy chat topic -- the same public SDK event the chat pipeline listens on -- so any
* component reading messages via `useSessionMessages()` renders them as if they were received
* for real.
*
* Must be nested inside a decorator that provides `SessionContext` (e.g. `AgentSessionProvider`).
*/
export function withMockConversation(messages: MockConversationMessage[]): Decorator {
return (Story) => {
const { room } = useSessionContext();

React.useEffect(() => {
messages.forEach(({ id, from, message }) => {
const payload = new TextEncoder().encode(
JSON.stringify({ id, timestamp: Date.now(), message }),
) as Uint8Array<ArrayBuffer>;
// `RoomEvent.DataReceived` is typed as remote-only, but `setupChat` (the only consumer of
// this event) reads `from` as a plain `Participant`, so this cast is safe -- the real
// local participant is a `LocalParticipant`, not a `RemoteParticipant` either.
const participant = (from === 'user'
? room.localParticipant
: MOCK_AGENT_PARTICIPANT) as unknown as RemoteParticipant;
room.emit(
RoomEvent.DataReceived,
payload,
participant,
DataPacket_Kind.RELIABLE,
LEGACY_CHAT_TOPIC,
);
});
}, [room]);

return <>{Story()}</>;
};
}
1 change: 1 addition & 0 deletions docs/storybook/.storybook/tailwind.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import 'tailwindcss';
@import 'shadcn/tailwind.css';

/* where to source the shadcn styles from */
@source './**/*.{js,ts,jsx,tsx}';
Expand Down
8 changes: 5 additions & 3 deletions docs/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,18 @@
"@storybook/react": "^10.1.4",
"@storybook/react-vite": "^10.1.11",
"@storybook/testing-library": "^0.2.2",
"@tailwindcss/postcss": "^4",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/vite": "^4.3.2",
"autoprefixer": "^10.4.22",
"babel-loader": "^9.0.0",
"eslint-config-lk-custom": "workspace:*",
"eslint-plugin-storybook": "10.1.4",
"next-themes": "^0.4.6",
"postcss": "^8.5.15",
"shadcn": "^4.13.0",
"storybook": "^10.1.4",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.3.2",
"tslib": "^2.6.2",
"tsx": "^4.0.0",
"typescript": "5.8.2",
"vite": "^7.3.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import React from 'react';
import { StoryObj } from '@storybook/react-vite';
import { within, userEvent } from 'storybook/test';
import { useTheme } from 'next-themes';
import { AgentSessionProvider } from '../../.storybook/lk-decorators/AgentSessionProvider';
import {
MockConversationMessage,
withMockConversation,
} from '../../.storybook/lk-decorators/MockConversation';
import { AgentSessionView_01, AgentSessionView_01Props } from '@agents-ui';

const SAMPLE_CONVERSATION: MockConversationMessage[] = [
{ id: '1', from: 'agent', message: 'Hi, how can I help you today?' },
{ id: '2', from: 'user', message: 'Hi, how are you?' },
{ id: '3', from: 'agent', message: "I'm good, thank you!" },
{ id: '4', from: 'user', message: 'This is a longer message that should wrap to the next line.' },
{
id: '5',
from: 'agent',
message: "Great, I'm responding with an even longer message to see how it wraps.",
},
...Array.from({ length: 15 }, (_, index) => ({
id: `${6 + index}`,
from: (index % 2 === 0 ? 'user' : 'agent') as MockConversationMessage['from'],
message:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
})),
];

export default {
component: AgentSessionView_01,
decorators: [AgentSessionProvider],
render: (args: AgentSessionView_01Props) => {
const { resolvedTheme = 'dark' } = useTheme();
return (
<AgentSessionView_01 themeMode={resolvedTheme as 'dark' | 'light'} {...args} />
);
return <AgentSessionView_01 themeMode={resolvedTheme as 'dark' | 'light'} {...args} />;
},
args: {
className: 'h-screen w-screen',
Expand Down Expand Up @@ -57,3 +78,13 @@ export default {
export const Default: StoryObj<AgentSessionView_01Props> = {
args: {},
};

export const WithConversation: StoryObj<AgentSessionView_01Props> = {
decorators: [withMockConversation(SAMPLE_CONVERSATION)],
args: {},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const transcriptToggle = await canvas.findByRole('button', { name: 'Toggle transcript' });
await userEvent.click(transcriptToggle);
},
};
3 changes: 1 addition & 2 deletions packages/shadcn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Agents UI is the easiest way to build agentic voice applications faster on top of LiveKit primitives.

Agents UI is a component library built on top of [shadcn/ui](https://ui.shadcn.com/) and [AI Elements](https://ai-sdk.dev/elements) to accelerate building agentic applications on top of LiveKit's real-time platform. It provides pre-built components like controling IO, managing sessions, rendering transcripts, visualing audio streams, and more.
Agents UI is a component library built on top of [shadcn/ui](https://ui.shadcn.com/) to accelerate building agentic applications on top of LiveKit's real-time platform. It provides pre-built components like controling IO, managing sessions, rendering transcripts, visualing audio streams, and more.

## Components

Expand Down Expand Up @@ -103,7 +103,6 @@ After installation, no additional setup is needed. The component's styles (Tailw
packages/shadcn/
├── components/
│ ├── agents-ui/ # LiveKit agent-specific components
│ ├── ai-elements/ # Reusable AI conversation components
│ ├── ui/ # Base UI primitives (shadcn/ui style)
│ └── session-provider.tsx
├── hooks/
Expand Down
5 changes: 1 addition & 4 deletions packages/shadcn/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"registries": {
"@ai-elements": "https://registry.ai-sdk.dev/{name}.json"
}
"iconLibrary": "lucide"
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const motionAnimationProps = {
},
visible: {
opacity: [0.5, 1],
scale: [1, 1.2],
scale: [0.9, 1],
transition: {
type: 'spring' as const,
bounce: 0,
Expand All @@ -34,7 +34,7 @@ const motionAnimationProps = {
const agentChatIndicatorVariants = cva('bg-muted-foreground inline-block size-2.5 rounded-full', {
variants: {
size: {
sm: 'size-2.5',
sm: 'size-3',
md: 'size-4',
lg: 'size-6',
},
Expand Down
69 changes: 42 additions & 27 deletions packages/shadcn/components/agents-ui/agent-chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import { type ComponentProps } from 'react';
import { type AgentState, type ReceivedMessage } from '@livekit/components-react';
import { Streamdown } from 'streamdown';
import { Bubble, BubbleContent } from '@/components/ui/bubble';
import { Message, MessageContent } from '@/components/ui/message';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '@/components/ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message';
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '@/components/ui/message-scroller';
import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator';
import { AnimatePresence } from 'motion/react';

Expand Down Expand Up @@ -52,28 +57,38 @@ export function AgentChatTranscript({
...props
}: AgentChatTranscriptProps) {
return (
<Conversation className={className} {...props}>
<ConversationContent>
{messages.map((receivedMessage) => {
const { id, timestamp, from, message } = receivedMessage;
const time = new Date(timestamp);
const messageOrigin = from?.isLocal ? 'user' : 'assistant';
const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US';
const title = time.toLocaleTimeString(locale, { timeStyle: 'full' });
<MessageScrollerProvider autoScroll defaultScrollPosition="last-anchor">
<MessageScroller className={className} {...props}>
<MessageScrollerViewport>
<MessageScrollerContent aria-busy={agentState === 'thinking'}>
{messages.map((receivedMessage) => {
const { id, timestamp, from, message } = receivedMessage;
const time = new Date(timestamp);
const isUser = from?.isLocal;
const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US';
const title = time.toLocaleTimeString(locale, { timeStyle: 'full' });

return (
<Message key={id} title={title} from={messageOrigin}>
<MessageContent>
<MessageResponse>{message}</MessageResponse>
</MessageContent>
</Message>
);
})}
<AnimatePresence>
{agentState === 'thinking' && <AgentChatIndicator size="sm" />}
</AnimatePresence>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
return (
<MessageScrollerItem key={id} messageId={id} scrollAnchor={isUser}>
<Message align={isUser ? 'end' : 'start'} title={title}>
<MessageContent>
<Bubble align={isUser ? 'end' : 'start'} variant={isUser ? 'secondary' : 'ghost'}>
<BubbleContent>
<Streamdown>{message}</Streamdown>
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
);
})}
<AnimatePresence>
{agentState === 'thinking' && <AgentChatIndicator size="sm" />}
</AnimatePresence>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ import {
AgentControlBar,
type AgentControlBarControls,
} from '@/components/agents-ui/agent-control-bar';
import { Shimmer } from '@/components/ai-elements/shimmer';
import { cn } from '@/lib/utils';
import { TileLayout } from './tile-view';

const MotionMessage = motion.create(Shimmer);

const BOTTOM_VIEW_MOTION_PROPS: MotionProps = {
variants: {
visible: {
Expand Down Expand Up @@ -218,12 +215,12 @@ export function AgentSessionView_01({
{isChatOpen && (
<motion.div
{...CHAT_MOTION_PROPS}
className="flex h-full w-full flex-col gap-4 space-y-3 transition-opacity duration-300 ease-out"
className="h-full w-full transition-opacity duration-300 ease-out"
>
<AgentChatTranscript
agentState={agentState}
messages={messages}
className="mx-auto w-full max-w-2xl [&_.is-user>div]:rounded-[22px] [&>div>div]:px-4 [&>div>div]:pt-40 md:[&>div>div]:px-6"
className="mx-auto w-full max-w-2xl px-4 md:px-6 **:data-[slot=message-scroller-content]:pb-4 **:data-[slot=message-scroller-content]:pt-40"
/>
</motion.div>
)}
Expand Down Expand Up @@ -252,20 +249,18 @@ export function AgentSessionView_01({
{isPreConnectBufferEnabled && (
<AnimatePresence>
{messages.length === 0 && (
<MotionMessage
<motion.p
key="pre-connect-message"
duration={2}
aria-hidden={messages.length > 0}
{...SHIMMER_MOTION_PROPS}
className="pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold"
className="shimmer shimmer-duration-2000 pointer-events-none mx-auto block w-full max-w-2xl pb-4 text-center text-sm font-semibold"
>
{preConnectMessage}
</MotionMessage>
</motion.p>
)}
</AnimatePresence>
)}
<div className="bg-background relative mx-auto max-w-2xl pb-3 md:pb-12">
<Fade bottom className="absolute inset-x-0 top-0 h-4 -translate-y-full" />
<AgentControlBar
variant="livekit"
controls={controls}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export function TileLayout({
const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0;

return (
<div className="absolute inset-x-0 top-8 bottom-32 z-50 md:top-12 md:bottom-40">
<div className="pointer-events-none absolute inset-x-0 top-8 bottom-32 z-50 md:top-12 md:bottom-40">
<div className="relative mx-auto h-full max-w-2xl px-4 md:px-0">
<div className={cn(tileViewClassNames.grid)}>
{/* Agent */}
Expand Down
Loading
Loading