setup basic components

This commit is contained in:
bluemoehre
2026-06-30 23:02:06 +02:00
parent 98649496bd
commit 7121715af9
43 changed files with 6523 additions and 63 deletions
+47 -14
View File
@@ -1,20 +1,53 @@
import { Button } from "@/components/ui/button"
import { useState } from 'react'
import { HomeView } from '@/views/HomeView'
import { SettingsView } from '@/views/SettingsView'
import { JobsView } from '@/views/JobsView'
import { NotificationsView } from '@/views/NotificationsView'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
export function App() {
const [showSettings, setShowSettings] = useState(false)
const [showJobs, setShowJobs] = useState(false)
const [showNotifications, setShowNotifications] = useState(false)
return (
<div className="flex min-h-svh p-6">
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
<div>
<h1 className="font-medium">Project ready!</h1>
<p>You may now add components and start building.</p>
<p>We&apos;ve already added the button component for you.</p>
<Button className="mt-2">Button</Button>
</div>
<div className="font-mono text-xs text-muted-foreground">
(Press <kbd>d</kbd> to toggle dark mode)
</div>
</div>
</div>
<>
<HomeView
onSettingsClick={() => setShowSettings(true)}
onJobsClick={() => setShowJobs(true)}
onNotificationsClick={() => setShowNotifications(true)}
/>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<DialogContent className="grid-rows-[auto_1fr]">
<DialogHeader className="border-b -mx-4 px-4 pb-4">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<SettingsView />
</DialogContent>
</Dialog>
<Dialog open={showJobs} onOpenChange={setShowJobs}>
<DialogContent className="grid-rows-[auto_1fr]">
<DialogHeader className="border-b -mx-4 px-4 pb-4">
<DialogTitle>Running Jobs</DialogTitle>
</DialogHeader>
<JobsView />
</DialogContent>
</Dialog>
<Dialog open={showNotifications} onOpenChange={setShowNotifications}>
<DialogContent className="grid-rows-[auto_1fr]">
<DialogHeader className="border-b -mx-4 px-4 pb-4">
<DialogTitle>Notifications</DialogTitle>
</DialogHeader>
<NotificationsView />
</DialogContent>
</Dialog>
</>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { BellRingIcon, ListChecksIcon, SettingsIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface AppBarProps {
onSettingsClick: () => void
onJobsClick: () => void
onNotificationsClick: () => void
}
export function AppBar({ onSettingsClick, onJobsClick, onNotificationsClick }: AppBarProps) {
return (
<header className="sticky top-0 z-50 flex w-full items-center border-b bg-background">
<div className="flex h-14 w-full items-center justify-between px-4">
<span className="text-lg font-bold tracking-tight">MAIA2</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={onNotificationsClick}>
<BellRingIcon />
<span className="sr-only">Notifications</span>
</Button>
<Button variant="ghost" size="icon" onClick={onJobsClick}>
<ListChecksIcon />
<span className="sr-only">Running Jobs</span>
</Button>
<Button variant="ghost" size="icon" onClick={onSettingsClick}>
<SettingsIcon />
<span className="sr-only">Settings</span>
</Button>
</div>
</div>
</header>
)
}
@@ -0,0 +1,427 @@
'use client'
import React, { useMemo, type ComponentProps } from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/components-react'
import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy'
import { useAgentAudioVisualizerAura } from '@/hooks/agents-ui/use-agent-audio-visualizer-aura'
import { cn } from '@/lib/utils'
const DEFAULT_COLOR = '#1FD5F9'
function hexToRgb(hexColor: string) {
try {
const rgbColor = hexColor.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/)
if (rgbColor) {
const [, r, g, b] = rgbColor
const color = [r, g, b].map((c = '00') => parseInt(c, 16) / 255)
return color
}
} catch (error) {
console.error(`Invalid hex color '${hexColor}'.\nFalling back to default color '${DEFAULT_COLOR}'.`)
}
return hexToRgb(DEFAULT_COLOR)
}
const shaderSource = `
const float TAU = 6.283185;
// Noise for dithering
vec2 randFibo(vec2 p) {
p = fract(p * vec2(443.897, 441.423));
p += dot(p, p.yx + 19.19);
return fract((p.xx + p.yx) * p.xy);
}
// Tonemap
vec3 Tonemap(vec3 x) {
x *= 4.0;
return x / (1.0 + x);
}
// Luma for alpha
float luma(vec3 color) {
return dot(color, vec3(0.299, 0.587, 0.114));
}
// RGB to HSV
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
// HSV to RGB
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
// SDF shapes
float sdCircle(vec2 st, float r) {
return length(st) - r;
}
float sdLine(vec2 p, float r) {
float halfLen = r * 2.0;
vec2 a = vec2(-halfLen, 0.0);
vec2 b = vec2(halfLen, 0.0);
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
float getSdf(vec2 st) {
if(uShape == 1.0) return sdCircle(st, uScale);
else if(uShape == 2.0) return sdLine(st, uScale);
return sdCircle(st, uScale); // Default
}
vec2 turb(vec2 pos, float t, float it) {
// Initial rotation matrix for swirl direction
mat2 rotation = mat2(0.6, -0.25, 0.25, 0.9);
// Secondary rotation applied each iteration (approx 53 degree rotation)
mat2 layerRotation = mat2(0.6, -0.8, 0.8, 0.6);
float frequency = mix(2.0, 15.0, uFrequency);
float amplitude = uAmplitude;
float frequencyGrowth = 1.4;
float animTime = t * 0.1 * uSpeed;
const int LAYERS = 4;
for(int i = 0; i < LAYERS; i++) {
// Calculate wave displacement for this layer
vec2 rotatedPos = pos * rotation;
vec2 wave = sin(frequency * rotatedPos + float(i) * animTime + it);
// Apply displacement along rotation direction
pos += (amplitude / frequency) * rotation[0] * wave;
// Evolve parameters for next layer
rotation *= layerRotation;
amplitude *= mix(1.0, max(wave.x, wave.y), uVariance);
frequency *= frequencyGrowth;
}
return pos;
}
const float ITERATIONS = 36.0;
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec3 pp = vec3(0.0);
vec3 bloom = vec3(0.0);
float t = iTime * 0.5;
vec2 pos = uv - 0.5;
vec2 prevPos = turb(pos, t, 0.0 - 1.0 / ITERATIONS);
float spacing = mix(1.0, TAU, uSpacing);
for(float i = 1.0; i < ITERATIONS + 1.0; i++) {
float iter = i / ITERATIONS;
vec2 st = turb(pos, t, iter * spacing);
float d = abs(getSdf(st));
float pd = distance(st, prevPos);
prevPos = st;
float dynamicBlur = exp2(pd * 2.0 * 1.4426950408889634) - 1.0;
float ds = smoothstep(0.0, uBlur * 0.05 + max(dynamicBlur * uSmoothing, 0.001), d);
// Shift color based on iteration using uColorScale
vec3 color = uColor;
if(uColorShift > 0.01) {
vec3 hsv = rgb2hsv(color);
// Shift hue by iteration
hsv.x = fract(hsv.x + (1.0 - iter) * uColorShift * 0.3);
color = hsv2rgb(hsv);
}
float invd = 1.0 / max(d + dynamicBlur, 0.001);
pp += (ds - 1.0) * color;
bloom += clamp(invd, 0.0, 250.0) * color;
}
pp *= 1.0 / ITERATIONS;
vec3 color;
// Dark mode (default)
if(uMode < 0.5) {
// use bloom effect
bloom = bloom / (bloom + 2e4);
color = (-pp + bloom * 3.0 * uBloom) * 1.2;
color += (randFibo(fragCoord).x - 0.5) / 255.0;
color = Tonemap(color);
float alpha = luma(color) * uMix;
fragColor = vec4(color * uMix, alpha);
}
// Light mode
else {
// no bloom effect
color = -pp;
color += (randFibo(fragCoord).x - 0.5) / 255.0;
// Preserve hue by tone mapping brightness only
float brightness = length(color);
vec3 direction = brightness > 0.0 ? color / brightness : color;
// Reinhard on brightness
float factor = 2.0;
float mappedBrightness = (brightness * factor) / (1.0 + brightness * factor);
color = direction * mappedBrightness;
// Boost saturation to compensate for white background bleed-through
// When alpha < 1.0, white bleeds through making colors look desaturated
// So we increase saturation to maintain vibrant appearance
float gray = dot(color, vec3(0.2, 0.5, 0.1));
float saturationBoost = 3.0;
color = mix(vec3(gray), color, saturationBoost);
// Clamp between 0-1
color = clamp(color, 0.0, 1.0);
float alpha = mappedBrightness * clamp(uMix, 1.0, 2.0);
fragColor = vec4(color, alpha);
}
}`
interface AuraShaderProps {
/**
* Aurora wave speed
* @default 1.0
*/
speed?: number
/**
* Turbulence amplitude
* @default 0.5
*/
amplitude?: number
/**
* Wave frequency and complexity
* @default 0.5
*/
frequency?: number
/**
* Shape scale
* @default 0.3
*/
scale?: number
/**
* Shape type: 1=circle, 2=line
* @default 1
*/
shape?: number
/**
* Edge blur/softness
* @default 1.0
*/
blur?: number
/**
* Color of the aura in hexidecimal format.
* @default '#1FD5F9'
*/
color?: `#${string}`
/**
* Color variation across layers (0-1)
* Controls how much colors change between iterations
* @default 0.5
* @example 0.0 - minimal color variation (more uniform)
* @example 0.5 - moderate variation (default)
* @example 1.0 - maximum variation (rainbow effect)
*/
colorShift?: number
/**
* Brightness of the aurora (0-1)
* @default 1.0
*/
brightness?: number
/**
* Display mode for different backgrounds
* - 'dark': Optimized for dark backgrounds (default)
* - 'light': Optimized for light/white backgrounds (inverts colors)
* @default 'dark'
*/
themeMode?: 'dark' | 'light'
}
function AuraShader({
shape = 1.0,
speed = 1.0,
amplitude = 0.5,
frequency = 0.5,
scale = 0.2,
blur = 1.0,
color = DEFAULT_COLOR,
colorShift = 1.0,
brightness = 1.0,
themeMode = typeof window !== 'undefined' && document.documentElement.classList.contains('dark') ? 'dark' : 'light',
ref,
className,
...props
}: AuraShaderProps & ComponentProps<'div'>) {
const rgbColor = useMemo(() => hexToRgb(color), [color])
return (
<div ref={ref} className={className} {...props}>
<ReactShaderToy
fs={shaderSource}
devicePixelRatio={globalThis.devicePixelRatio ?? 1}
uniforms={{
// Aurora wave speed
uSpeed: { type: '1f', value: speed },
// Edge blur/softness
uBlur: { type: '1f', value: blur },
// Shape scale
uScale: { type: '1f', value: scale },
// Shape type: 1=circle, 2=line
uShape: { type: '1f', value: shape },
// Wave frequency and complexity
uFrequency: { type: '1f', value: frequency },
// Turbulence amplitude
uAmplitude: { type: '1f', value: amplitude },
// Light intensity (bloom)
uBloom: { type: '1f', value: 0.0 },
// Brightness of the aurora (0-1)
uMix: { type: '1f', value: brightness },
// Color variation across layers (0-1)
uSpacing: { type: '1f', value: 0.5 },
// Color palette offset - shifts colors along the gradient (0-1)
uColorShift: { type: '1f', value: colorShift },
// Color variation across layers (0-1)
uVariance: { type: '1f', value: 0.1 },
// Smoothing of the aurora (0-1)
uSmoothing: { type: '1f', value: 1.0 },
// Display mode: 0=dark background, 1=light background
uMode: { type: '1f', value: themeMode === 'light' ? 1.0 : 0.0 },
// Color
uColor: { type: '3fv', value: rgbColor ?? [0, 0.7, 1] },
}}
onError={(error) => {
console.error('Shader error:', error)
}}
onWarning={(warning) => {
console.warn('Shader warning:', warning)
}}
style={{ width: '100%', height: '100%' }}
/>
</div>
)
}
AuraShader.displayName = 'AuraShader'
export const AgentAudioVisualizerAuraVariants = cva(['aspect-square'], {
variants: {
size: {
icon: 'h-[24px] gap-[2px]',
sm: 'h-[56px] gap-[4px]',
md: 'h-[112px] gap-[8px]',
lg: 'h-[224px] gap-[16px]',
xl: 'h-[448px] gap-[32px]',
},
},
defaultVariants: {
size: 'md',
},
})
export interface AgentAudioVisualizerAuraProps {
/**
* The size of the visualizer.
* @defaultValue 'lg'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
/**
* Agent state
* @default 'connecting'
*/
state?: AgentState
/**
* The color of the aura in hexidecimal format.
* @defaultValue '#1FD5F9'
*/
color?: `#${string}`
/**
* The color shift of the aura.
* @defaultValue 0.05
*/
colorShift?: number
/**
* The theme mode of the aura.
* @defaultValue 'dark'
*/
themeMode?: 'dark' | 'light'
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
}
/**
* An shader-based audio visualizer that responds to agent state and audio levels.
* Displays an animated elliptical aura that reacts to the current agent state (connecting, thinking, speaking, etc.)
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerAura
* size="md"
* state="speaking"
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerAura({
size = 'lg',
state = 'connecting',
color = DEFAULT_COLOR,
colorShift = 0.05,
audioTrack,
themeMode,
className,
ref,
...props
}: AgentAudioVisualizerAuraProps & ComponentProps<'div'> & VariantProps<typeof AgentAudioVisualizerAuraVariants>) {
const { speed, scale, amplitude, frequency, brightness } = useAgentAudioVisualizerAura(state, audioTrack)
return (
<AuraShader
ref={ref}
data-lk-state={state}
blur={0.2}
color={color}
colorShift={colorShift}
speed={speed}
scale={scale}
themeMode={themeMode}
amplitude={amplitude}
frequency={frequency}
brightness={brightness}
className={cn(AgentAudioVisualizerAuraVariants({ size }), className)}
{...props}
/>
)
}
@@ -0,0 +1,209 @@
'use client'
import React, {
type CSSProperties,
Children,
type ComponentProps,
type ReactNode,
cloneElement,
isValidElement,
useMemo,
} from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
import { type AgentState, type TrackReferenceOrPlaceholder, useMultibandTrackVolume } from '@livekit/components-react'
import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar'
import { cn } from '@/lib/utils'
function cloneSingleChild(children: ReactNode | ReactNode[], props?: Record<string, unknown>, key?: unknown) {
return Children.map(children, (child) => {
// Checking isValidElement is the safe way and avoids a typescript error too.
if (isValidElement(child) && Children.only(children)) {
const childProps = child.props as Record<string, unknown>
if (childProps.className) {
// make sure we retain classnames of both passed props and child
props ??= {}
props.className = cn(childProps.className as string, props.className as string)
props.style = {
...(childProps.style as CSSProperties),
...(props.style as CSSProperties),
}
}
return cloneElement(child, { ...props, key: key ? String(key) : undefined })
}
return child
})
}
export const AgentAudioVisualizerBarElementVariants = cva(
['rounded-full transition-colors duration-250 ease-linear', 'bg-current/10 data-[lk-highlighted=true]:bg-current'],
{
variants: {
size: {
icon: 'min-h-[4px] w-[4px]',
sm: 'min-h-[8px] w-[8px]',
md: 'min-h-[16px] w-[16px]',
lg: 'min-h-[32px] w-[32px]',
xl: 'min-h-[64px] w-[64px]',
},
},
defaultVariants: {
size: 'md',
},
}
)
export const AgentAudioVisualizerBarVariants = cva('relative flex items-center justify-center', {
variants: {
size: {
icon: 'h-[24px] gap-[2px]',
sm: 'h-[56px] gap-[4px]',
md: 'h-[112px] gap-[8px]',
lg: 'h-[224px] gap-[16px]',
xl: 'h-[448px] gap-[32px]',
},
},
defaultVariants: {
size: 'md',
},
})
/**
* Props for the AgentAudioVisualizerBar component.
*/
export interface AgentAudioVisualizerBarProps {
/**
* The size of the visualizer.
* @defaultValue 'md'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
/**
* The current state of the agent. Determines the animation pattern.
* @defaultValue 'connecting'
*/
state?: AgentState
/**
* The color of the bars in hexidecimal format.
*/
color?: `#${string}`
/**
* The number of bars to display in the visualizer.
* If not provided, defaults based on size: 3 for 'icon'/'sm', 5 for others.
*/
barCount?: number
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
/**
* Additional CSS class names to apply to the container.
*/
className?: string
/**
* Custom div element to render as grid cells. Each child receives data-lk-index,
* data-lk-highlighted props and style props for height. Must be a single div element.
*/
children?: ReactNode
}
/**
* A bar-style audio visualizer that responds to agent state and audio levels.
* Displays animated bars that react to the current agent state (connecting, thinking, speaking, etc.)
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerBar
* size="md"
* state="speaking"
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerBar({
size = 'md',
state = 'connecting',
color,
barCount,
audioTrack,
className,
children,
style,
...props
}: AgentAudioVisualizerBarProps & VariantProps<typeof AgentAudioVisualizerBarVariants> & ComponentProps<'div'>) {
const _barCount = useMemo(() => {
if (barCount) {
return barCount
}
switch (size) {
case 'icon':
case 'sm':
return 3
default:
return 5
}
}, [barCount, size])
const volumeBands = useMultibandTrackVolume(audioTrack, {
bands: _barCount,
loPass: 100,
hiPass: 200,
})
const sequencerInterval = useMemo(() => {
switch (state) {
case 'connecting':
return 2000 / _barCount
case 'initializing':
return 2000
case 'listening':
return 500
case 'thinking':
return 150
default:
return 1000
}
}, [state, _barCount])
const highlightedIndices = useAgentAudioVisualizerBarAnimator(state, _barCount, sequencerInterval)
const bands = useMemo(
() => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)),
[state, volumeBands, _barCount]
)
if (children && Array.isArray(children)) {
throw new Error('AgentAudioVisualizerBar children must be a single element.')
}
return (
<div
data-lk-state={state}
style={{ ...style, color } as CSSProperties}
className={cn(AgentAudioVisualizerBarVariants({ size }), className)}
{...props}
>
{bands.map((band: number, idx: number) =>
children ? (
<React.Fragment key={idx}>
{cloneSingleChild(children, {
'data-lk-index': idx,
'data-lk-highlighted': highlightedIndices.includes(idx),
'style': { height: `${band * 100}%` },
})}
</React.Fragment>
) : (
<div
key={idx}
data-lk-index={idx}
data-lk-highlighted={highlightedIndices.includes(idx)}
style={{ height: `${band * 100}%` }}
className={cn(AgentAudioVisualizerBarElementVariants({ size }))}
/>
)
)}
</div>
)
}
@@ -0,0 +1,275 @@
'use client'
import React, {
type CSSProperties,
Children,
type ComponentProps,
type ReactNode,
cloneElement,
isValidElement,
memo,
useMemo,
} from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'
import { type AgentState, type TrackReferenceOrPlaceholder, useMultibandTrackVolume } from '@livekit/components-react'
import { type Coordinate, useAgentAudioVisualizerGridAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-grid'
import { cn } from '@/lib/utils'
function cloneSingleChild(children: ReactNode | ReactNode[], props?: Record<string, unknown>, key?: unknown) {
return Children.map(children, (child) => {
// Checking isValidElement is the safe way and avoids a typescript error too.
if (isValidElement(child) && Children.only(children)) {
const childProps = child.props as Record<string, unknown>
if (childProps.className) {
// make sure we retain classnames of both passed props and child
props ??= {}
props.className = cn(childProps.className as string, props.className as string)
props.style = {
...(childProps.style as CSSProperties),
...(props.style as CSSProperties),
}
}
return cloneElement(child, { ...props, key: key ? String(key) : undefined })
}
return child
})
}
export const AgentAudioVisualizerGridCellVariants = cva(
[
'h-1 w-1 place-self-center rounded-full bg-current/10 transition-all ease-out',
'data-[lk-highlighted=true]:bg-current',
],
{
variants: {
size: {
icon: ['h-[2px] w-[2px]'],
sm: ['h-[4px] w-[4px]'],
md: ['h-[8px] w-[8px]'],
lg: ['h-[12px] w-[12px]'],
xl: ['h-[16px] w-[16px]'],
},
},
defaultVariants: {
size: 'md',
},
}
)
export const AgentAudioVisualizerGridVariants = cva('grid', {
variants: {
size: {
icon: ['gap-[2px]'],
sm: ['gap-[4px]'],
md: ['gap-[8px]'],
lg: ['gap-[12px]'],
xl: ['gap-[16px]'],
},
},
defaultVariants: {
size: 'md',
},
})
/**
* Configuration options for the grid visualizer.
*/
export interface GridOptions {
/**
* The radius for the animation spread effect.
*/
radius?: number
/**
* The interval in milliseconds between animation frames.
* @defaultValue 100
*/
interval?: number
/**
* The number of rows in the grid.
* @defaultValue 5
*/
rowCount?: number
/**
* The number of columns in the grid.
* @defaultValue 5
*/
columnCount?: number
/**
* Additional CSS class names to apply to the container.
*/
className?: string
}
const sizeDefaults = {
icon: 3,
sm: 5,
md: 5,
lg: 5,
xl: 5,
}
function useGrid(
size: VariantProps<typeof AgentAudioVisualizerGridVariants>['size'] = 'md',
columnCount = sizeDefaults[size as keyof typeof sizeDefaults],
rowCount = sizeDefaults[size as keyof typeof sizeDefaults]
) {
return useMemo(() => {
const _columnCount = columnCount
const _rowCount = rowCount ?? columnCount
const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx)
return { columnCount: _columnCount, rowCount: _rowCount, items }
}, [columnCount, rowCount])
}
interface GridCellProps {
index: number
state: AgentState
interval: number
rowCount: number
columnCount: number
volumeBands: number[]
highlightedCoordinate: Coordinate
children?: ReactNode
}
const GridCell = memo(function GridCell({
index,
state,
interval,
rowCount,
columnCount,
volumeBands,
highlightedCoordinate,
children,
}: GridCellProps) {
if (state === 'speaking') {
const y = Math.floor(index / columnCount)
const rowMidPoint = Math.floor(rowCount / 2)
const volumeChunks = 1 / (rowMidPoint + 1)
const distanceToMid = Math.abs(rowMidPoint - y)
const threshold = distanceToMid * volumeChunks
const isHighlighted = (volumeBands[index % columnCount] ?? 0) >= threshold
return cloneSingleChild(children, {
'data-lk-index': index,
'data-lk-highlighted': isHighlighted,
})
}
const isHighlighted =
highlightedCoordinate.x === index % columnCount && highlightedCoordinate.y === Math.floor(index / columnCount)
const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100)
return cloneSingleChild(children, {
'data-lk-index': index,
'data-lk-highlighted': isHighlighted,
'style': {
transitionDuration: `${transitionDurationInSeconds}s`,
},
})
})
/**
* Props for the AgentAudioVisualizerGrid component.
*/
export type AgentAudioVisualizerGridProps = GridOptions & {
/**
* The size of the visualizer.
* @defaultValue 'md'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
/**
* The current state of the agent. Determines the animation pattern.
* @defaultValue 'connecting'
*/
state?: AgentState
/**
* The color of the grid cells in hexidecimal format.
*/
color?: `#${string}`
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
/**
* Additional CSS class names to apply to the container.
*/
className?: string
/**
* Custom element to render as grid cells. Each child receives data-lk-index
* and data-lk-highlighted props.
*/
children?: ReactNode
} & VariantProps<typeof AgentAudioVisualizerGridVariants>
/**
* A grid-style audio visualizer that responds to agent state and audio levels.
* Displays an animated grid of cells that react to the current agent state
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerGrid
* size="md"
* state="speaking"
* rowCount={5}
* columnCount={5}
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerGrid({
size = 'md',
state = 'connecting',
radius,
color,
rowCount: _rowCount = 5,
columnCount: _columnCount = 5,
interval = 100,
className,
children,
audioTrack,
style,
...props
}: AgentAudioVisualizerGridProps & ComponentProps<'div'>) {
const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount)
const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(state, rowCount, columnCount, interval, radius)
const volumeBands = useMultibandTrackVolume(audioTrack, {
bands: columnCount,
loPass: 100,
hiPass: 200,
})
if (children && Array.isArray(children)) {
throw new Error('AgentAudioVisualizerGrid children must be a single element.')
}
return (
<div
data-lk-state={state}
className={cn(AgentAudioVisualizerGridVariants({ size }), className)}
style={{ ...style, gridTemplateColumns: `repeat(${columnCount}, 1fr)`, color } as CSSProperties}
{...props}
>
{items.map((idx) => (
<GridCell
key={idx}
index={idx}
state={state}
interval={interval}
rowCount={rowCount}
columnCount={columnCount}
volumeBands={volumeBands}
highlightedCoordinate={highlightedCoordinate}
>
{children ?? <div className={AgentAudioVisualizerGridCellVariants({ size })} />}
</GridCell>
))}
</div>
)
}
@@ -0,0 +1,204 @@
'use client'
import { type ComponentProps, type CSSProperties, useMemo } from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
import { type AgentState, type TrackReferenceOrPlaceholder, useMultibandTrackVolume } from '@livekit/components-react'
import { cn } from '@/lib/utils'
import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial'
export const AgentAudioVisualizerRadialVariants = cva(
[
'relative flex items-center justify-center',
'**:data-lk-index:bg-current/10',
'**:data-lk-index:absolute **:data-lk-index:top-1/2 **:data-lk-index:left-1/2 **:data-lk-index:origin-bottom **:data-lk-index:-translate-x-1/2',
'**:data-lk-index:rounded-full **:data-lk-index:transition-colors **:data-lk-index:duration-150 **:data-lk-index:ease-linear **:data-lk-index:data-[lk-highlighted=true]:bg-current',
'has-data-[lk-state=connecting]:**:data-lk-index:duration-300',
'has-data-[lk-state=initializing]:**:data-lk-index:duration-300',
'has-data-[lk-state=listening]:**:data-lk-index:duration-300',
'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:**:data-lk-index:bg-current',
],
{
variants: {
size: {
icon: ['h-[24px] gap-[2px]'],
sm: ['h-[56px] gap-[4px]'],
md: ['h-[112px] gap-[8px]'],
lg: ['h-[224px] gap-[16px]'],
xl: ['h-[448px] gap-[32px]'],
},
},
defaultVariants: {
size: 'md',
},
}
)
/**
* Props for the AgentAudioVisualizerRadial component.
*/
export interface AgentAudioVisualizerRadialProps {
/**
* The size of the visualizer.
* @defaultValue 'md'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
/**
* The current state of the agent. Determines the animation pattern.
* @defaultValue 'connecting'
*/
state?: AgentState
/**
* The color of the radial bars in hexidecimal format.
*/
color?: `#${string}`
/**
* The radius (distance from center) for the radial bars.
* If not provided, defaults based on size.
*/
radius?: number
/**
* The number of bars to display around the circle.
* Should be divisible by 4 for optimal visual results.
* If not provided, defaults to 12 for 'icon'/'sm', 24 for others.
*/
barCount?: number
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
/**
* Additional CSS class names to apply to the container.
*/
className?: string
}
/**
* A radial (circular) audio visualizer that responds to agent state and audio levels.
* Displays animated bars arranged in a circle that react to the current agent state
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentAudioVisualizerRadial
* size="lg"
* state="speaking"
* barCount={24}
* audioTrack={agentAudioTrack}
* />
* ```
*/
export function AgentAudioVisualizerRadial({
size = 'md',
state = 'connecting',
color,
radius,
barCount,
audioTrack,
className,
style,
...props
}: AgentAudioVisualizerRadialProps & ComponentProps<'div'> & VariantProps<typeof AgentAudioVisualizerRadialVariants>) {
const _barCount = useMemo(() => {
if (barCount) {
return barCount
}
switch (size) {
case 'icon':
case 'sm':
return 12
default:
return 24
}
}, [barCount, size])
const volumeBands = useMultibandTrackVolume(audioTrack, {
bands: _barCount,
loPass: 100,
hiPass: 200,
})
const sequencerInterval = useMemo(() => {
switch (state) {
case 'connecting':
case 'listening':
return 500
case 'initializing':
return 250
case 'thinking':
return Infinity
default:
return 1000
}
}, [state, _barCount])
const distanceFromCenter = useMemo(() => {
if (radius) {
return radius
}
switch (size) {
case 'icon':
return 6
case 'xl':
return 128
case 'lg':
return 64
case 'sm':
return 16
case 'md':
default:
return 32
}
}, [size, radius])
if (_barCount % 4 !== 0) {
console.warn('barCount should be divisible by 4 for optimal visual results')
}
const highlightedIndices = useAgentAudioVisualizerRadialAnimator(state, _barCount, sequencerInterval)
const bands = useMemo(
() => (audioTrack ? volumeBands : new Array(_barCount).fill(0)),
[audioTrack, volumeBands, _barCount]
)
const dotSize = useMemo(() => {
return (distanceFromCenter * Math.PI) / _barCount
}, [distanceFromCenter, _barCount])
return (
<div
data-lk-state={state}
className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}
style={{ ...style, color } as CSSProperties}
{...props}
>
{bands.map((band, idx) => {
const angle = (idx / _barCount) * Math.PI * 2
return (
<div
key={`${_barCount}-${idx}`}
data-lk-state={state}
className="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
style={{
transformOrigin: 'center',
transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,
}}
>
<div
data-lk-index={idx}
data-lk-highlighted={highlightedIndices.includes(idx)}
style={{
width: dotSize,
minHeight: dotSize,
height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,
}}
/>
</div>
)
})}
</div>
)
}
@@ -0,0 +1,365 @@
'use client'
import { useMemo, type ComponentProps } from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { type AgentState, type TrackReferenceOrPlaceholder } from '@livekit/components-react'
import { ReactShaderToy } from '@/components/agents-ui/react-shader-toy'
import { useAgentAudioVisualizerWave } from '@/hooks/agents-ui/use-agent-audio-visualizer-wave'
import { cn } from '@/lib/utils'
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'
const DEFAULT_COLOR = '#1FD5F9'
function hexToRgb(hexColor: string) {
try {
const rgbColor = hexColor.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/)
if (rgbColor) {
const [, r, g, b] = rgbColor
const color = [r, g, b].map((c = '00') => parseInt(c, 16) / 255)
return color
}
} catch {
console.error(`Invalid hex color '${hexColor}'.\nFalling back to default color '${DEFAULT_COLOR}'.`)
}
return hexToRgb(DEFAULT_COLOR)
}
const shaderSource = `
const float TAU = 6.28318530718;
// Noise for dithering
vec2 randFibo(vec2 p) {
p = fract(p * vec2(443.897, 441.423));
p += dot(p, p.yx + 19.19);
return fract((p.xx + p.yx) * p.xy);
}
// Luma for alpha
float luma(vec3 color) {
return dot(color, vec3(0.299, 0.587, 0.114));
}
// RGB to HSV
vec3 rgb2hsv(vec3 c) {
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
// HSV to RGB
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
// Bell curve function for attenuation from center with rounded top
float bellCurve(float distanceFromCenter, float maxDistance) {
float normalizedDistance = distanceFromCenter / maxDistance;
// Use cosine with high power for smooth rounded top
return pow(cos(normalizedDistance * (3.14159265359 / 4.0)), 16.0);
}
// Calculate the sine wave
float oscilloscopeWave(float x, float centerX, float time) {
float relativeX = x - centerX;
float maxDistance = centerX;
float distanceFromCenter = abs(relativeX);
// Apply bell curve for amplitude attenuation
float bell = bellCurve(distanceFromCenter, maxDistance);
// Calculate wave with uniforms and bell curve attenuation
float wave = sin(relativeX * uFrequency + time * uSpeed) * uAmplitude * bell;
return wave;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
vec2 pos = uv - 0.5;
// Calculate center and positions
float centerX = 0.5;
float centerY = 0.5;
float x = uv.x;
float y = uv.y;
// Convert line width from pixels to UV space
// Use the average of width and height to handle aspect ratio
float pixelSize = 2.0 / (iResolution.x + iResolution.y);
float lineWidthUV = uLineWidth * pixelSize;
float smoothingUV = uSmoothing * pixelSize;
// Find minimum distance to the wave by sampling nearby points
// This gives us consistent line width without high-frequency artifacts
const int NUM_SAMPLES = 50; // Must be const for GLSL loop
float minDist = 1000.0;
float sampleRange = 0.02; // Range to search for closest point
for(int i = 0; i < NUM_SAMPLES; i++) {
float offset = (float(i) / float(NUM_SAMPLES - 1) - 0.5) * sampleRange;
float sampleX = x + offset;
float waveY = centerY + oscilloscopeWave(sampleX, centerX, iTime);
// Calculate distance from current pixel to this point on the wave
vec2 wavePoint = vec2(sampleX, waveY);
vec2 currentPoint = vec2(x, y);
float dist = distance(currentPoint, wavePoint);
minDist = min(minDist, dist);
}
// Solid line with smooth edges using minimum distance
float line = smoothstep(lineWidthUV + smoothingUV, lineWidthUV - smoothingUV, minDist);
vec3 color = uColor;
if(abs(uColorShift) > 0.01) {
// Keep the center 50% at base color, then ramp shift across outer 25% on each side.
float centerBandHalfWidth = 0.2;
float edgeBandWidth = 0.5;
float distanceFromCenter = abs(x - centerX);
float edgeFactor = clamp((distanceFromCenter - centerBandHalfWidth) / edgeBandWidth, 0.0, 1.0);
vec3 hsv = rgb2hsv(color);
// Hue shift is zero in the center band and strongest at far edges.
hsv.x = fract(hsv.x + edgeFactor * uColorShift * 0.3);
color = hsv2rgb(hsv);
}
// Apply line intensity
color *= line;
// Add dithering for smoother gradients
// color += (randFibo(fragCoord).x - 0.5) / 255.0;
// Calculate alpha based on line intensity
float alpha = line * uMix;
fragColor = vec4(color * uMix, alpha);
}`
interface WaveShaderProps {
/**
* Class name
* @default ''
*/
className?: string
/**
* Speed of the oscilloscope
* @default 10
*/
speed?: number
/**
* Amplitude of the oscilloscope
* @default 0.02
*/
amplitude?: number
/**
* Frequency of the oscilloscope
* @default 20.0
*/
frequency?: number
/**
* Color of the oscilloscope in hexidecimal format.
* @default '#1FD5F9'
*/
color?: `#${string}`
/**
* Hue shift amount applied toward the outside of the wave. Center remains at the base color.
* @default 0.05
*/
colorShift?: number
/**
* Mix of the oscilloscope
* @default 1.0
*/
mix?: number
/**
* Line width of the oscilloscope in pixels
* @default 2.0
*/
lineWidth?: number
/**
* Blur of the oscilloscope in pixels
* @default 0.5
*/
blur?: number
}
function WaveShader({
speed = 10,
color = '#1FD5F9',
colorShift = 0.05,
mix = 1.0,
amplitude = 0.02,
frequency = 20.0,
lineWidth = 2.0,
blur = 0.5,
ref,
className,
...props
}: WaveShaderProps & ComponentProps<'div'>) {
const rgbColor = useMemo(() => hexToRgb(color), [color])
return (
<div ref={ref} className={className} {...props}>
<ReactShaderToy
fs={shaderSource}
devicePixelRatio={globalThis.devicePixelRatio ?? 1}
uniforms={{
uSpeed: { type: '1f', value: speed },
uAmplitude: { type: '1f', value: amplitude },
uFrequency: { type: '1f', value: frequency },
uMix: { type: '1f', value: mix },
uLineWidth: { type: '1f', value: lineWidth },
uSmoothing: { type: '1f', value: blur },
uColor: { type: '3fv', value: rgbColor },
uColorShift: { type: '1f', value: colorShift },
}}
onError={(error) => {
console.error('Shader error:', error)
}}
onWarning={(warning) => {
console.warn('Shader warning:', warning)
}}
style={{ width: '100%', height: '100%' }}
/>
</div>
)
}
WaveShader.displayName = 'WaveShader'
export const AgentAudioVisualizerWaveVariants = cva(['aspect-square'], {
variants: {
size: {
icon: 'h-[24px]',
sm: 'h-[56px]',
md: 'h-[112px]',
lg: 'h-[224px]',
xl: 'h-[448px]',
},
},
defaultVariants: {
size: 'lg',
},
})
export interface AgentAudioVisualizerWaveProps {
/**
* The size of the visualizer.
* @defaultValue 'lg'
*/
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl'
/**
* The agent state.
* @defaultValue 'speaking'
*/
state?: AgentState
/**
* The color of the wave in hexidecimal format.
* @defaultValue '#1FD5F9'
*/
color?: `#${string}`
/**
* The color shift of the wave. Higher values increase hue variation toward the edges.
* @defaultValue 0.05
*/
colorShift?: number
/**
* The line width of the wave in pixels.
* @defaultValue 2.0
*/
lineWidth?: number
/**
* The blur of the wave in pixels.
* @defaultValue 0.5
*/
blur?: number
/**
* The audio track to visualize. Can be a local/remote audio track or a track reference.
*/
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
/**
* Additional CSS class names to apply to the container.
*/
className?: string
}
/**
* A wave-style audio visualizer that responds to agent state and audio levels.
* Displays an animated wave that reacts to the current agent state (connecting, thinking, speaking, etc.)
* and audio volume when speaking.
*
* @extends ComponentProps<'div'>
*
* @example ```tsx
* <AgentAudioVisualizerWave
* size="lg"
* state="speaking"
* color="#1FD5F9"
* colorShift={0.3}
* lineWidth={2}
* blur={0.5}
* audioTrack={audioTrack}
* />
* ```
*/
export function AgentAudioVisualizerWave({
size = 'lg',
state = 'speaking',
color,
colorShift = 0.05,
lineWidth,
blur,
audioTrack,
className,
ref,
...props
}: AgentAudioVisualizerWaveProps & ComponentProps<'div'> & VariantProps<typeof AgentAudioVisualizerWaveVariants>) {
const _lineWidth = useMemo(() => {
if (lineWidth !== undefined) {
return lineWidth
}
switch (size) {
case 'icon':
case 'sm':
return 2
default:
return 1
}
}, [lineWidth, size])
const { speed, amplitude, frequency, opacity } = useAgentAudioVisualizerWave({
state,
audioTrack,
})
return (
<WaveShader
ref={ref}
data-lk-state={state}
speed={speed}
color={color}
colorShift={colorShift}
mix={opacity}
amplitude={amplitude}
frequency={frequency}
lineWidth={_lineWidth}
blur={blur}
className={cn(
AgentAudioVisualizerWaveVariants({ size }),
'mask-[linear-gradient(90deg,transparent_0%,black_20%,black_80%,transparent_100%)]',
className
)}
{...props}
/>
)
}
@@ -0,0 +1,90 @@
import { type Ref, type ComponentProps } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { motion, type MotionProps } from 'motion/react'
import { cn } from '@/lib/utils'
const motionAnimationProps = {
variants: {
hidden: {
opacity: 0,
scale: 0.1,
transition: {
duration: 0.1,
ease: 'linear' as const,
},
},
visible: {
opacity: [0.5, 1],
scale: [1, 1.2],
transition: {
type: 'spring' as const,
bounce: 0,
duration: 0.5,
repeat: Infinity,
repeatType: 'mirror' as const,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
}
const agentChatIndicatorVariants = cva('inline-block size-2.5 rounded-full bg-muted-foreground', {
variants: {
size: {
sm: 'size-2.5',
md: 'size-4',
lg: 'size-6',
},
},
defaultVariants: {
size: 'md',
},
})
/**
* Props for the AgentChatIndicator component.
*/
export interface AgentChatIndicatorProps extends MotionProps {
/**
* The size of the indicator dot.
* @defaultValue 'md'
*/
size?: 'sm' | 'md' | 'lg'
/**
* Additional CSS class names to apply to the indicator.
*/
className?: string
/**
* Allows getting a ref to the component instance.\nOnce the component unmounts, React will set `ref.current` to `null`\n(or call the ref with `null` if you passed a callback ref).\n@see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
*/
ref?: Ref<HTMLSpanElement>
}
/**
* An animated indicator that shows the agent is processing or thinking.
* Displays as a pulsing dot, typically used in chat interfaces.
*
* @extends ComponentProps<'span'>
*
* @example
* ```tsx
* {agentState === 'thinking' && <AgentChatIndicator size="md" />}
* ```
*/
export function AgentChatIndicator({
size = 'md',
className,
...props
}: AgentChatIndicatorProps & ComponentProps<'span'> & VariantProps<typeof agentChatIndicatorVariants>) {
return (
<motion.span
{...motionAnimationProps}
transition={{ duration: 0.1, ease: 'linear' as const }}
className={cn(agentChatIndicatorVariants({ size }), className)}
{...props}
/>
)
}
@@ -0,0 +1,68 @@
'use client'
import { type ComponentProps } from 'react'
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'
/**
* Props for the AgentChatTranscript component.
*/
export interface AgentChatTranscriptProps extends ComponentProps<'div'> {
/**
* The current state of the agent. When 'thinking', displays a loading indicator.
*/
agentState?: AgentState
/**
* Array of messages to display in the transcript.
* @defaultValue []
*/
messages?: ReceivedMessage[]
/**
* Additional CSS class names to apply to the conversation container.
*/
className?: string
}
/**
* A chat transcript component that displays a conversation between the user and agent.
* Shows messages with timestamps and origin indicators, plus a thinking indicator
* when the agent is processing.
*
* @extends ComponentProps<'div'>
*
* @example
* ```tsx
* <AgentChatTranscript
* agentState={agentState}
* messages={chatMessages}
* />
* ```
*/
export function AgentChatTranscript({ agentState, messages = [], className, ...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' })
return (
<Message key={id} title={title} from={messageOrigin}>
<MessageContent>
<MessageResponse>{message}</MessageResponse>
</MessageContent>
</Message>
)
})}
<AnimatePresence>{agentState === 'thinking' && <AgentChatIndicator size="sm" />}</AnimatePresence>
</ConversationContent>
<ConversationScrollButton />
</Conversation>
)
}
@@ -0,0 +1,404 @@
'use client'
import { useEffect, useRef, useState, type ComponentProps } from 'react'
import { useChat } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react'
import { motion, type MotionProps } from 'motion/react'
import { cn } from '@/lib/utils'
import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button'
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control'
import { AgentTrackToggle, agentTrackToggleVariants } from '@/components/agents-ui/agent-track-toggle'
import { Button } from '@/components/ui/button'
import { Toggle } from '@/components/ui/toggle'
import {
useInputControls,
usePublishPermissions,
type UseInputControlsProps,
} from '@/hooks/agents-ui/use-agent-control-bar'
const LK_TOGGLE_VARIANT_1 = [
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
'data-[state=off]:[&_~_button]:bg-accent data-[state=off]:[&_~_button]:hover:bg-foreground/10',
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
'data-[state=off]:[&_~_button]:border-border data-[state=off]:[&_~_button]:hover:border-foreground/12',
'data-[state=off]:text-destructive data-[state=off]:hover:text-destructive data-[state=off]:focus:text-destructive',
'data-[state=off]:focus-visible:ring-foreground/12 data-[state=off]:focus-visible:border-ring',
'dark:data-[state=off]:[&_~_button]:bg-accent dark:data-[state=off]:[&_~_button]:hover:bg-foreground/10',
]
const LK_TOGGLE_VARIANT_2 = [
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',
'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',
'data-[state=on]:focus-visible:border-blue-700/50',
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
]
const MOTION_PROPS: MotionProps = {
variants: {
hidden: {
height: 0,
opacity: 0,
marginBottom: 0,
},
visible: {
height: 'auto',
opacity: 1,
marginBottom: 12,
},
},
initial: 'hidden',
transition: {
duration: 0.3,
ease: 'easeOut',
},
}
interface AgentChatInputProps {
chatOpen: boolean
onSend?: (message: string) => void
className?: string
}
function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentChatInputProps) {
const inputRef = useRef<HTMLTextAreaElement>(null)
const [isSending, setIsSending] = useState(false)
const [message, setMessage] = useState<string>('')
const isDisabled = isSending || message.trim().length === 0
const handleSend = async () => {
if (isDisabled) {
return
}
try {
setIsSending(true)
await onSend(message.trim())
setMessage('')
} catch (error) {
console.error(error)
} finally {
setIsSending(false)
}
}
const handleKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleButtonClick = async () => {
if (isDisabled) return
await handleSend()
}
useEffect(() => {
if (chatOpen) return
// when not disabled refocus on input
inputRef.current?.focus()
}, [chatOpen])
return (
<div className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}>
<textarea
autoFocus
ref={inputRef}
value={message}
disabled={!chatOpen || isSending}
placeholder="Type something..."
onKeyDown={handleKeyDown}
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-16 min-h-8 flex-1 resize-none [scrollbar-width:thin] py-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="button"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isSending ? 'Sending...' : 'Send'}
onClick={handleButtonClick}
className="self-end disabled:cursor-not-allowed"
>
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</div>
)
}
/** Configuration for which controls to display in the AgentControlBar. */
export interface AgentControlBarControls {
/**
* Whether to show the leave/disconnect button.
*
* @defaultValue true
*/
leave?: boolean
/**
* Whether to show the camera toggle control.
*
* @defaultValue true (if camera publish permission is granted)
*/
camera?: boolean
/**
* Whether to show the microphone toggle control.
*
* @defaultValue true (if microphone publish permission is granted)
*/
microphone?: boolean
/**
* Whether to show the screen share toggle control.
*
* @defaultValue true (if screen share publish permission is granted)
*/
screenShare?: boolean
/**
* Whether to show the chat toggle control.
*
* @defaultValue true (if data publish permission is granted)
*/
chat?: boolean
}
export interface AgentControlBarProps extends UseInputControlsProps {
/**
* The visual style of the control bar.
*
* @default 'default'
*/
variant?: 'default' | 'outline' | 'livekit'
/**
* This takes an object with the following keys: `leave`, `microphone`, `screenShare`, `camera`,
* `chat`. Each key maps to a boolean value that determines whether the control is displayed.
*
* @default
* {
* leave: true,
* microphone: true,
* screenShare: true,
* camera: true,
* chat: true,
* }
*/
controls?: AgentControlBarControls
/**
* Whether to save user choices.
*
* @default true
*/
saveUserChoices?: boolean
/**
* Whether the agent is connected to a session.
*
* @default false
*/
isConnected?: boolean
/**
* Whether the chat input interface is open.
*
* @default false
*/
isChatOpen?: boolean
/** The callback for when the user disconnects. */
onDisconnect?: () => void
/** The callback for when the chat is opened or closed. */
onIsChatOpenChange?: (open: boolean) => void
/** The callback for when a device error occurs. */
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
}
/**
* A control bar specifically designed for voice assistant interfaces. Provides controls for
* microphone, camera, screen share, chat, and disconnect. Includes an expandable chat input for
* text-based interaction with the agent.
*
* @example
*
* ```tsx
* <AgentControlBar
* variant="livekit"
* isConnected={true}
* onDisconnect={() => handleDisconnect()}
* controls={{
* microphone: true,
* camera: true,
* screenShare: false,
* chat: true,
* leave: true,
* }}
* />;
* ```
*
* @extends ComponentProps<'div'>
*/
export function AgentControlBar({
variant = 'default',
controls,
isChatOpen = false,
isConnected = false,
saveUserChoices = true,
onDisconnect,
onDeviceError,
onIsChatOpenChange,
className,
...props
}: AgentControlBarProps & ComponentProps<'div'>) {
const { send } = useChat()
const publishPermissions = usePublishPermissions()
const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen)
const {
microphoneTrack,
cameraToggle,
microphoneToggle,
screenShareToggle,
handleAudioDeviceChange,
handleVideoDeviceChange,
handleMicrophoneDeviceSelectError,
handleCameraDeviceSelectError,
} = useInputControls({ onDeviceError, saveUserChoices })
const handleSendMessage = async (message: string) => {
await send(message)
}
const visibleControls = {
leave: controls?.leave ?? true,
microphone: controls?.microphone ?? publishPermissions.microphone,
screenShare: controls?.screenShare ?? publishPermissions.screenShare,
camera: controls?.camera ?? publishPermissions.camera,
chat: controls?.chat ?? publishPermissions.data,
}
const isEmpty = Object.values(visibleControls).every((value) => !value)
if (isEmpty) {
console.warn('AgentControlBar: `visibleControls` contains only false values.')
return null
}
return (
<div
aria-label="Voice assistant controls"
className={cn(
'flex flex-col border border-input/50 bg-background p-3 drop-shadow-md/3 dark:border-muted',
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
className
)}
{...props}
>
<motion.div
{...MOTION_PROPS}
inert={!(isChatOpen || isChatOpenUncontrolled)}
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
className="flex w-full items-start overflow-hidden border-b border-input/50"
>
<AgentChatInput
chatOpen={isChatOpen || isChatOpenUncontrolled}
onSend={handleSendMessage}
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
/>
</motion.div>
<div className="flex gap-1">
<div className="flex grow gap-1">
{/* Toggle Microphone */}
{visibleControls.microphone && (
<AgentTrackControl
variant={variant === 'outline' ? 'outline' : 'default'}
kind="audioinput"
aria-label="Toggle microphone"
source={Track.Source.Microphone}
pressed={microphoneToggle.enabled}
disabled={microphoneToggle.pending}
audioTrack={microphoneTrack}
onPressedChange={microphoneToggle.toggle}
onActiveDeviceChange={handleAudioDeviceChange}
onMediaDeviceError={handleMicrophoneDeviceSelectError}
className={cn(
variant === 'livekit' && [
LK_TOGGLE_VARIANT_1,
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
]
)}
/>
)}
{/* Toggle Camera */}
{visibleControls.camera && (
<AgentTrackControl
variant={variant === 'outline' ? 'outline' : 'default'}
kind="videoinput"
aria-label="Toggle camera"
source={Track.Source.Camera}
pressed={cameraToggle.enabled}
pending={cameraToggle.pending}
disabled={cameraToggle.pending}
onPressedChange={cameraToggle.toggle}
onMediaDeviceError={handleCameraDeviceSelectError}
onActiveDeviceChange={handleVideoDeviceChange}
className={cn(
variant === 'livekit' && [
LK_TOGGLE_VARIANT_1,
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
]
)}
/>
)}
{/* Toggle Screen Share */}
{visibleControls.screenShare && (
<AgentTrackToggle
variant={variant === 'outline' ? 'outline' : 'default'}
aria-label="Toggle screen share"
source={Track.Source.ScreenShare}
pressed={screenShareToggle.enabled}
disabled={screenShareToggle.pending}
onPressedChange={screenShareToggle.toggle}
className={cn(variant === 'livekit' && [LK_TOGGLE_VARIANT_2, 'rounded-full'])}
/>
)}
{/* Toggle Transcript */}
{visibleControls.chat && (
<Toggle
variant={variant === 'outline' ? 'outline' : 'default'}
pressed={isChatOpen || isChatOpenUncontrolled}
aria-label="Toggle transcript"
onPressedChange={(state) => {
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state)
else onIsChatOpenChange(state)
}}
className={agentTrackToggleVariants({
variant: variant === 'outline' ? 'outline' : 'default',
className: cn(variant === 'livekit' && [LK_TOGGLE_VARIANT_2, 'rounded-full']),
})}
>
<MessageSquareTextIcon />
</Toggle>
)}
</div>
{/* Disconnect */}
{visibleControls.leave && (
<AgentDisconnectButton
onClick={onDisconnect}
disabled={!isConnected}
className={cn(
variant === 'livekit' &&
'rounded-full bg-destructive/10 font-mono text-xs font-bold tracking-wider text-destructive hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:bg-destructive/10 dark:hover:bg-destructive/20 dark:focus-visible:ring-destructive/4'
)}
>
<span className="hidden md:inline">END CALL</span>
<span className="inline md:hidden">END</span>
</AgentDisconnectButton>
)}
</div>
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import { type ComponentProps } from 'react'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useSessionContext } from '@livekit/components-react'
import { type VariantProps } from 'class-variance-authority'
import { PhoneOffIcon } from 'lucide-react'
/**
* Props for the AgentDisconnectButton component.
*/
export interface AgentDisconnectButtonProps extends ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
/**
* Custom icon to display. Defaults to PhoneOffIcon.
*/
icon?: React.ReactNode
/**
* The size of the button.
* @default 'default'
*/
size?: 'default' | 'sm' | 'lg' | 'icon'
/**
* The variant of the button.
* @default 'destructive'
*/
variant?: 'default' | 'outline' | 'destructive' | 'ghost' | 'link'
/**
* The children to render.
*/
children?: React.ReactNode
/**
* The callback for when the button is clicked.
*/
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
}
/**
* A button to disconnect from the current agent session.
* Calls the session's end() method when clicked.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <AgentDisconnectButton onClick={() => console.log('Disconnecting...')} />
* ```
*/
export function AgentDisconnectButton({
icon,
size = 'default',
variant = 'destructive',
children,
onClick,
...props
}: AgentDisconnectButtonProps) {
const { end } = useSessionContext()
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (typeof end === 'function') {
end()
}
}
return (
<Button size={size} variant={variant} onClick={handleClick} {...props}>
{icon ?? <PhoneOffIcon />}
{children ?? <span className={cn(size?.includes('icon') && 'sr-only')}>END CALL</span>}
</Button>
)
}
@@ -0,0 +1,57 @@
import {
SessionProvider,
type UseSessionReturn,
RoomAudioRenderer,
type SessionProviderProps,
type RoomAudioRendererProps,
} from '@livekit/components-react'
import { Room } from 'livekit-client'
/**
* Props for the AgentSessionProvider component.
* Combines SessionProviderProps with RoomAudioRendererProps.
*/
export type AgentSessionProviderProps = SessionProviderProps &
RoomAudioRendererProps & {
/**
* The room to provide.
*/
room?: Room
/**
* The volume to set for the audio renderer.
*/
volume?: number
/**
* Whether to mute the audio renderer.
*/
muted?: boolean
/**
* The session to provide.
*/
session: UseSessionReturn
/**
* The children to render.
*/
children: React.ReactNode
}
/**
* A provider component for agent sessions that wraps SessionProvider
* and includes RoomAudioRenderer for audio playback.
*
* @example
* ```tsx
* <AgentSessionProvider session={agentSession}>
* <AgentControlBar />
* <AgentChatTranscript />
* </AgentSessionProvider>
* ```
*/
export function AgentSessionProvider({ session, children, ...roomAudioRendererProps }: AgentSessionProviderProps) {
return (
<SessionProvider session={session}>
{children}
<RoomAudioRenderer {...roomAudioRendererProps} />
</SessionProvider>
)
}
@@ -0,0 +1,306 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { type TrackReferenceOrPlaceholder, useMaybeRoomContext, useMediaDeviceSelect } from '@livekit/components-react'
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'
import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toggleVariants } from '@/components/ui/toggle'
import { cn } from '@/lib/utils'
const selectVariants = cva(
[
'rounded-l-none pl-2 shadow-none',
'text-foreground hover:text-muted-foreground',
'peer-data-[state=on]/track:bg-muted peer-data-[state=on]/track:hover:bg-foreground/10',
'peer-data-[state=off]/track:text-destructive',
'peer-data-[state=off]/track:focus-visible:border-destructive peer-data-[state=off]/track:focus-visible:ring-destructive/30',
'[&_svg]:opacity-100',
],
{
variants: {
variant: {
default: [
'border-none',
'peer-data-[state=off]/track:bg-destructive/10',
'peer-data-[state=off]/track:hover:bg-destructive/15',
'peer-data-[state=off]/track:[&_svg]:text-destructive!',
'dark:peer-data-[state=on]/track:bg-accent',
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
'dark:peer-data-[state=off]/track:bg-destructive/10',
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
],
outline: [
'border border-l-0',
'peer-data-[state=off]/track:border-destructive/20',
'peer-data-[state=off]/track:bg-destructive/10',
'peer-data-[state=off]/track:hover:bg-destructive/15',
'peer-data-[state=off]/track:[&_svg]:text-destructive!',
'peer-data-[state=on]/track:hover:border-foreground/12',
'dark:peer-data-[state=off]/track:bg-destructive/10',
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
'dark:peer-data-[state=on]/track:bg-accent',
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
],
},
size: {
default: 'w-[180px]',
sm: 'w-auto',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
/**
* Props for the TrackDeviceSelect component. */
type TrackDeviceSelectProps = React.ComponentProps<typeof SelectTrigger> &
VariantProps<typeof selectVariants> & {
/**
* The size of the select.
* @defaultValue 'default'
*/
size?: 'default' | 'sm'
/**
* The variant of the select.
* @defaultValue 'default'
*/
variant?: 'default' | 'outline' | null
/**
* The type of media device (audioinput or videoinput).
*/
kind: MediaDeviceKind
/**
* The track source to control (Microphone, Camera, or ScreenShare).
*/
track?: LocalAudioTrack | LocalVideoTrack | undefined
/**
* Whether to request permissions for the media device.
*/
requestPermissions?: boolean
/**
* Callback when a media device error occurs.
*/
onMediaDeviceError?: (error: Error) => void
/**
* Callback when the device list changes.
*/
onDeviceListChange?: (devices: MediaDeviceInfo[]) => void
/**
* Callback when the active device changes.
*/
onActiveDeviceChange?: (deviceId: string) => void
}
/**
* A select component for selecting a media device.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <TrackDeviceSelect
* size="sm"
* variant="outline"
* kind="audioinput"
* track={micTrackRef}
* />
* ```
*/
function TrackDeviceSelect({
kind,
track,
size = 'default',
variant = 'default',
className,
requestPermissions = false,
onMediaDeviceError,
onDeviceListChange,
onActiveDeviceChange,
...props
}: TrackDeviceSelectProps) {
const room = useMaybeRoomContext()
const [open, setOpen] = useState(false)
const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions)
const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({
room,
kind,
track,
requestPermissions: requestPermissionsState,
onError: onMediaDeviceError,
})
useEffect(() => {
onDeviceListChange?.(devices)
}, [devices, onDeviceListChange])
const handleOpenChange = (open: boolean) => {
setOpen(open)
if (open) {
setRequestPermissionsState(true)
}
}
const handleActiveDeviceChange = (deviceId: string) => {
setActiveMediaDevice(deviceId)
onActiveDeviceChange?.(deviceId)
}
const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices])
if (filteredDevices.length < 2) {
return null
}
return (
<Select open={open} value={activeDeviceId} onOpenChange={handleOpenChange} onValueChange={handleActiveDeviceChange}>
<SelectTrigger className={cn(selectVariants({ size, variant }), className)} {...props}>
{size !== 'sm' && <SelectValue className="font-mono text-sm" placeholder={`Select a ${kind}`} />}
</SelectTrigger>
<SelectContent position="popper">
{filteredDevices.map((device) => (
<SelectItem key={device.deviceId} value={device.deviceId} className="font-mono text-xs">
{device.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
/**
* Props for the AgentTrackControl component.
*/
export type AgentTrackControlProps = VariantProps<typeof toggleVariants> & {
/**
* The type of media device (audioinput or videoinput).
*/
kind: MediaDeviceKind
/**
* The track source to control (Microphone, Camera, or ScreenShare).
*/
source: 'camera' | 'microphone' | 'screen_share'
/**
* Whether the track is currently enabled/published.
*/
pressed?: boolean
/**
* Whether the control is in a pending/loading state.
*/
pending?: boolean
/**
* Whether the control is disabled.
*/
disabled?: boolean
/**
* Additional CSS class names to apply to the container.
*/
className?: string
/**
* The audio track reference for visualization (only for microphone).
*/
audioTrack?: TrackReferenceOrPlaceholder
/**
* Callback when the pressed state changes.
*/
onPressedChange?: (pressed: boolean) => void
/**
* Callback when a media device error occurs.
*/
onMediaDeviceError?: (error: Error) => void
/**
* Callback when the active device changes.
*/
onActiveDeviceChange?: (deviceId: string) => void
}
/**
* A combined track toggle and device selector control.
* Includes a toggle button and a dropdown to select the active device.
* For microphone tracks, displays an audio visualizer.
*
* @example
* ```tsx
* <AgentTrackControl
* kind="audioinput"
* source={Track.Source.Microphone}
* pressed={isMicEnabled}
* audioTrack={micTrackRef}
* onPressedChange={(pressed) => setMicEnabled(pressed)}
* onActiveDeviceChange={(deviceId) => setMicDevice(deviceId)}
* />
* ```
*/
export function AgentTrackControl({
kind,
variant = 'default',
source,
pressed,
pending,
disabled,
className,
audioTrack,
onPressedChange,
onMediaDeviceError,
onActiveDeviceChange,
}: AgentTrackControlProps) {
return (
<div
className={cn(
'flex items-center gap-0 rounded-md',
variant === 'outline' && 'shadow-xs [&_button]:shadow-none',
className
)}
>
<AgentTrackToggle
variant={variant ?? 'default'}
source={source}
pressed={pressed}
pending={pending}
disabled={disabled}
onPressedChange={onPressedChange}
className="peer/track group/track focus:z-10 has-[.audiovisualizer]:w-auto has-[.audiovisualizer]:px-3 has-[~_button]:rounded-r-none has-[~_button]:border-r-0 has-[~_button]:pr-2 has-[~_button]:pl-3"
>
{audioTrack && (
<AgentAudioVisualizerBar
size="icon"
barCount={3}
state={pressed ? 'speaking' : 'disconnected'}
audioTrack={pressed ? audioTrack : undefined}
className="audiovisualizer flex h-6 w-auto items-center justify-center gap-0.5"
>
<span
className={cn([
'h-full min-h-0.5 w-0.5 origin-center',
'group-data-[state=off]/track:bg-destructive group-data-[state=on]/track:bg-foreground',
'data-lk-muted:bg-muted',
])}
/>
</AgentAudioVisualizerBar>
)}
</AgentTrackToggle>
{kind && (
<TrackDeviceSelect
size="sm"
kind={kind}
variant={variant}
requestPermissions={false}
onMediaDeviceError={onMediaDeviceError}
onActiveDeviceChange={onActiveDeviceChange}
className={cn([
'relative',
'before:absolute before:inset-y-0 before:left-0 before:my-2.5 before:w-px before:bg-border has-[~_button]:before:content-[""]',
!pressed && 'before:bg-destructive/20',
])}
/>
)}
</div>
)
}
@@ -0,0 +1,156 @@
import { Fragment, type ComponentProps, useMemo, useState } from 'react'
import { type VariantProps, cva } from 'class-variance-authority'
import { Track } from 'livekit-client'
import { MicIcon, MicOffIcon, MonitorUpIcon, MonitorOffIcon, LoaderIcon, VideoIcon, VideoOffIcon } from 'lucide-react'
import { Toggle } from '@/components/ui/toggle'
import { cn } from '@/lib/utils'
export const agentTrackToggleVariants = cva(['size-9'], {
variants: {
size: {
default: 'h-9 min-w-9 px-2',
sm: 'h-8 min-w-8 px-1.5',
lg: 'h-10 min-w-10 px-2.5',
},
variant: {
default: [
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
'data-[state=off]:hover:bg-destructive/15',
'data-[state=off]:focus-visible:ring-destructive/30',
'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
'data-[state=on]:hover:bg-foreground/10',
],
outline: [
'data-[state=off]:border-destructive/20 data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
'data-[state=off]:hover:bg-destructive/15 data-[state=off]:hover:text-destructive',
'data-[state=off]:focus:text-destructive',
'data-[state=off]:focus-visible:border-destructive data-[state=off]:focus-visible:ring-destructive/30',
'data-[state=on]:hover:border-foreground/12 data-[state=on]:hover:bg-foreground/10',
'dark:data-[state=on]:hover:bg-foreground/10',
],
},
},
defaultVariants: {
variant: 'default',
},
})
function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) {
if (pending) {
return LoaderIcon
}
switch (source) {
case Track.Source.Microphone:
return enabled ? MicIcon : MicOffIcon
case Track.Source.Camera:
return enabled ? VideoIcon : VideoOffIcon
case Track.Source.ScreenShare:
return enabled ? MonitorUpIcon : MonitorOffIcon
default:
return Fragment
}
}
/**
* Props for the AgentTrackToggle component.
*/
export type AgentTrackToggleProps = VariantProps<typeof agentTrackToggleVariants> &
ComponentProps<'button'> & {
/**
* The size of the toggle.
*/
size?: 'sm' | 'default' | 'lg'
/**
* The variant of the toggle.
* @defaultValue 'default'
*/
variant?: 'default' | 'outline'
/**
* The track source to toggle (Microphone, Camera, or ScreenShare).
*/
source: 'camera' | 'microphone' | 'screen_share'
/**
* Whether the toggle is in a pending/loading state.
* When true, displays a loading spinner icon.
* @defaultValue false
*/
pending?: boolean
/**
* Whether the toggle is currently pressed/enabled.
* @defaultValue false
*/
pressed?: boolean
/**
* The default pressed state when uncontrolled.
* @defaultValue false
*/
defaultPressed?: boolean
/**
* Callback fired when the pressed state changes.
*/
onPressedChange?: (pressed: boolean) => void
}
/**
* A toggle button for controlling track publishing state.
* Displays appropriate icons based on the track source and state.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <AgentTrackToggle
* source={Track.Source.Microphone}
* pressed={isMicEnabled}
* onPressedChange={(pressed) => setMicEnabled(pressed)}
* />
* ```
*/
export function AgentTrackToggle({
size = 'default',
variant = 'default',
source,
pending = false,
pressed,
defaultPressed = false,
className,
onPressedChange,
...props
}: AgentTrackToggleProps) {
const [uncontrolledPressed, setUncontrolledPressed] = useState(defaultPressed ?? false)
const isControlled = pressed !== undefined
const resolvedPressed = useMemo(
() => (isControlled ? pressed : uncontrolledPressed) ?? false,
[isControlled, pressed, uncontrolledPressed]
)
const IconComponent = getSourceIcon(source as Track.Source, resolvedPressed, pending)
const handlePressedChange = (nextPressed: boolean) => {
if (!isControlled) {
setUncontrolledPressed(nextPressed)
}
onPressedChange?.(nextPressed)
}
return (
<Toggle
size={size}
variant={variant}
pressed={isControlled ? pressed : undefined}
defaultPressed={isControlled ? undefined : defaultPressed}
aria-label={`Toggle ${source}`}
onPressedChange={handlePressedChange}
className={cn(
agentTrackToggleVariants({
size,
variant: variant ?? 'default',
className,
})
)}
{...props}
>
<IconComponent className={cn(pending && 'animate-spin')} />
{props.children}
</Toggle>
)
}
@@ -0,0 +1,275 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { AnimatePresence, type MotionProps, motion } from 'motion/react'
import { useAgent, useSessionContext, useSessionMessages } from '@livekit/components-react'
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'
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: {
opacity: 1,
translateY: '0%',
},
hidden: {
opacity: 0,
translateY: '100%',
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
transition: {
duration: 0.3,
delay: 0.5,
ease: 'easeOut',
},
}
const CHAT_MOTION_PROPS: MotionProps = {
variants: {
hidden: {
opacity: 0,
transition: {
ease: 'easeOut',
duration: 0.3,
},
},
visible: {
opacity: 1,
transition: {
delay: 0.2,
ease: 'easeOut',
duration: 0.3,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
}
const SHIMMER_MOTION_PROPS: MotionProps = {
variants: {
visible: {
opacity: 1,
transition: {
ease: 'easeIn',
duration: 0.5,
delay: 0.8,
},
},
hidden: {
opacity: 0,
transition: {
ease: 'easeIn',
duration: 0.5,
delay: 0,
},
},
},
initial: 'hidden',
animate: 'visible',
exit: 'hidden',
}
interface FadeProps {
top?: boolean
bottom?: boolean
className?: string
}
export function Fade({ top = false, bottom = false, className }: FadeProps) {
return (
<div
className={cn(
'pointer-events-none h-4 bg-linear-to-b from-background to-transparent',
top && 'bg-linear-to-b',
bottom && 'bg-linear-to-t',
className
)}
/>
)
}
export interface AgentSessionView_01Props {
/**
* Theme mode forwarded to the aura visualizer (`audioVisualizerType="aura"`) so
* the shader's blend mode adapts to the theme mode.
* Ignored by other visualizer types.
*/
themeMode?: 'dark' | 'light'
/**
* Message shown above the controls before the first chat message is sent.
*
* @default 'Agent is listening, ask it a question'
*/
preConnectMessage?: string
/**
* Enables or disables the chat toggle and transcript input controls.
*
* @default true
*/
supportsChatInput?: boolean
/**
* Enables or disables camera controls in the bottom control bar.
*
* @default true
*/
supportsVideoInput?: boolean
/**
* Enables or disables screen sharing controls in the bottom control bar.
*
* @default true
*/
supportsScreenShare?: boolean
/**
* Shows a pre-connect buffer state with a shimmer message before messages appear.
*
* @default true
*/
isPreConnectBufferEnabled?: boolean
/** Selects the visualizer style rendered in the main tile area. */
audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'
/** Primary hex color used by supported audio visualizer variants. */
audioVisualizerColor?: `#${string}`
/** Hue shift intensity used by certain visualizers. */
audioVisualizerColorShift?: number
/** Number of bars to render when `audioVisualizerType` is `bar`. */
audioVisualizerBarCount?: number
/** Number of rows in the visualizer when `audioVisualizerType` is `grid`. */
audioVisualizerGridRowCount?: number
/** Number of columns in the visualizer when `audioVisualizerType` is `grid`. */
audioVisualizerGridColumnCount?: number
/** Number of radial bars when `audioVisualizerType` is `radial`. */
audioVisualizerRadialBarCount?: number
/** Base radius of the radial visualizer when `audioVisualizerType` is `radial`. */
audioVisualizerRadialRadius?: number
/** Stroke width of the wave path when `audioVisualizerType` is `wave`. */
audioVisualizerWaveLineWidth?: number
/** Optional class name merged onto the outer `<section>` container. */
className?: string
}
export function AgentSessionView_01({
preConnectMessage = 'Agent is listening, ask it a question',
supportsChatInput = true,
supportsVideoInput = true,
supportsScreenShare = true,
isPreConnectBufferEnabled = true,
audioVisualizerType,
audioVisualizerColor,
audioVisualizerColorShift,
audioVisualizerBarCount,
audioVisualizerGridRowCount,
audioVisualizerGridColumnCount,
audioVisualizerRadialBarCount,
audioVisualizerRadialRadius,
audioVisualizerWaveLineWidth,
themeMode,
ref,
className,
...props
}: React.ComponentProps<'section'> & AgentSessionView_01Props) {
const session = useSessionContext()
const { messages } = useSessionMessages(session)
const [isChatOpen, setIsChatOpen] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const { state: agentState } = useAgent()
const controls: AgentControlBarControls = {
leave: true,
microphone: true,
chat: supportsChatInput,
camera: supportsVideoInput,
screenShare: supportsScreenShare,
}
useEffect(() => {
const lastMessage = messages.at(-1)
const lastMessageIsLocal = lastMessage?.from?.isLocal === true
if (scrollAreaRef.current && lastMessageIsLocal) {
scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight
}
}, [messages])
return (
<section
ref={ref}
className={cn('relative z-10 h-full w-full overflow-hidden bg-background', className)}
{...props}
>
<Fade top className="absolute inset-x-4 top-0 z-10 h-40" />
{/* transcript */}
<div className="absolute top-0 bottom-[135px] flex w-full flex-col md:bottom-[170px]">
<AnimatePresence>
{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"
>
<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"
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Tile layout */}
<TileLayout
isChatOpen={isChatOpen}
themeMode={themeMode}
audioVisualizerType={audioVisualizerType}
audioVisualizerColor={audioVisualizerColor}
audioVisualizerColorShift={audioVisualizerColorShift}
audioVisualizerBarCount={audioVisualizerBarCount}
audioVisualizerRadialBarCount={audioVisualizerRadialBarCount}
audioVisualizerRadialRadius={audioVisualizerRadialRadius}
audioVisualizerGridRowCount={audioVisualizerGridRowCount}
audioVisualizerGridColumnCount={audioVisualizerGridColumnCount}
audioVisualizerWaveLineWidth={audioVisualizerWaveLineWidth}
/>
{/* Bottom */}
<motion.div {...BOTTOM_VIEW_MOTION_PROPS} className="absolute inset-x-3 bottom-0 z-50 md:inset-x-12">
{/* Pre-connect message */}
{isPreConnectBufferEnabled && (
<AnimatePresence>
{messages.length === 0 && (
<MotionMessage
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"
>
{preConnectMessage}
</MotionMessage>
)}
</AnimatePresence>
)}
<div className="relative mx-auto max-w-2xl bg-background pb-3 md:pb-12">
<Fade bottom className="absolute inset-x-0 top-0 h-4 -translate-y-full" />
<AgentControlBar
variant="livekit"
controls={controls}
isChatOpen={isChatOpen}
isConnected={session.isConnected}
onDisconnect={session.end}
onIsChatOpenChange={setIsChatOpen}
/>
</div>
</motion.div>
</section>
)
}
@@ -0,0 +1,154 @@
'use client'
import React from 'react'
import { useVoiceAssistant } from '@livekit/components-react'
import { motion, type MotionProps } from 'motion/react'
import { cn } from '@/lib/utils'
import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar'
import { AgentAudioVisualizerGrid } from '@/components/agents-ui/agent-audio-visualizer-grid'
import { AgentAudioVisualizerRadial } from '@/components/agents-ui/agent-audio-visualizer-radial'
import { AgentAudioVisualizerWave } from '@/components/agents-ui/agent-audio-visualizer-wave'
const MotionAgentAudioVisualizerAura = motion.create(AgentAudioVisualizerAura)
const MotionAgentAudioVisualizerBar = motion.create(AgentAudioVisualizerBar)
const MotionAgentAudioVisualizerGrid = motion.create(AgentAudioVisualizerGrid)
const MotionAgentAudioVisualizerRadial = motion.create(AgentAudioVisualizerRadial)
const MotionAgentAudioVisualizerWave = motion.create(AgentAudioVisualizerWave)
interface AudioVisualizerProps extends MotionProps {
themeMode?: 'dark' | 'light'
isChatOpen: boolean
audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'
audioVisualizerColor?: `#${string}`
audioVisualizerColorShift?: number
audioVisualizerWaveLineWidth?: number
audioVisualizerGridRowCount?: number
audioVisualizerGridColumnCount?: number
audioVisualizerRadialBarCount?: number
audioVisualizerRadialRadius?: number
audioVisualizerBarCount?: number
className?: string
}
export function AudioVisualizer({
themeMode,
isChatOpen,
audioVisualizerType = 'bar',
audioVisualizerColor,
audioVisualizerColorShift = 0.3,
audioVisualizerBarCount = 5,
audioVisualizerRadialRadius = 100,
audioVisualizerRadialBarCount = 25,
audioVisualizerGridRowCount = 15,
audioVisualizerGridColumnCount = 15,
audioVisualizerWaveLineWidth = 3,
className,
...props
}: AudioVisualizerProps) {
const { state, audioTrack } = useVoiceAssistant()
switch (audioVisualizerType) {
case 'aura': {
return (
<MotionAgentAudioVisualizerAura
state={state}
audioTrack={audioTrack}
color={audioVisualizerColor}
colorShift={audioVisualizerColorShift}
themeMode={themeMode}
className={cn('size-[300px] md:size-[450px]', className)}
{...props}
/>
)
}
case 'wave': {
return (
<motion.div className={className} {...props}>
<MotionAgentAudioVisualizerWave
state={state}
audioTrack={audioTrack}
color={audioVisualizerColor}
colorShift={audioVisualizerColorShift}
lineWidth={isChatOpen ? audioVisualizerWaveLineWidth * 2 : audioVisualizerWaveLineWidth}
className="size-[300px] md:size-[450px]"
/>
</motion.div>
)
}
case 'grid': {
const totalCount = audioVisualizerGridRowCount * audioVisualizerGridColumnCount
let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'sm'
if (totalCount < 100) {
size = 'xl'
} else if (totalCount < 200) {
size = 'lg'
} else if (totalCount < 300) {
size = 'md'
}
return (
<MotionAgentAudioVisualizerGrid
size={size}
state={state}
color={audioVisualizerColor}
audioTrack={audioTrack}
rowCount={audioVisualizerGridRowCount}
columnCount={audioVisualizerGridColumnCount}
radius={Math.round(Math.min(audioVisualizerGridRowCount, audioVisualizerGridColumnCount) / 4)}
className={cn('size-[350px] gap-0 p-8 *:place-self-center md:size-[450px]', className)}
{...props}
/>
)
}
case 'radial': {
return (
<motion.div className={className} {...props}>
<MotionAgentAudioVisualizerRadial
size="xl"
state={state}
color={audioVisualizerColor}
audioTrack={audioTrack}
radius={audioVisualizerRadialRadius}
barCount={audioVisualizerRadialBarCount}
className="size-[450px]"
/>
</motion.div>
)
}
default: {
let size: 'icon' | 'sm' | 'md' | 'lg' | 'xl' = 'icon'
let sizedClassName = cn('size-[300px] md:size-[450px]', className)
if (audioVisualizerBarCount <= 5) {
size = 'xl'
sizedClassName = cn('size-[450px] gap-4 *:min-h-[64px] *:w-[64px]', className)
} else if (audioVisualizerBarCount <= 10) {
size = 'lg'
sizedClassName = cn('size-[450px]', className)
} else if (audioVisualizerBarCount <= 15) {
size = 'md'
sizedClassName = cn('size-[350px] md:size-[450px]', className)
} else if (audioVisualizerBarCount <= 30) {
size = 'sm'
sizedClassName = cn('size-[300px] md:size-[450px]', className)
}
return (
<MotionAgentAudioVisualizerBar
size={size}
state={state}
color={audioVisualizerColor}
audioTrack={audioTrack}
barCount={audioVisualizerBarCount}
className={sizedClassName}
{...props}
>
<span className="min-h-2.5 w-2.5 rounded-full bg-current/10 transition-colors duration-250 ease-linear data-[lk-highlighted=true]:bg-current" />
</MotionAgentAudioVisualizerBar>
)
}
}
}
@@ -0,0 +1,251 @@
import React, { useMemo } from 'react'
import {
useLocalParticipant,
useTracks,
useVoiceAssistant,
VideoTrack,
type TrackReference,
} from '@livekit/components-react'
import { Track } from 'livekit-client'
import { AnimatePresence, motion, type MotionProps } from 'motion/react'
import { cn } from '@/lib/utils'
import { AudioVisualizer } from './audio-visualizer'
const ANIMATION_TRANSITION: MotionProps['transition'] = {
type: 'spring',
stiffness: 675,
damping: 75,
mass: 1,
}
const tileViewClassNames = {
// GRID
// 2 Columns x 3 Rows
grid: ['h-full w-full', 'grid gap-x-2 place-content-center', 'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]'],
// Agent
// chatOpen: true,
// hasSecondTile: true
// layout: Column 1 / Row 1
// align: x-end y-center
agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'],
// Agent
// chatOpen: true,
// hasSecondTile: false
// layout: Column 1 / Row 1 / Column-Span 2
// align: x-center y-center
agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'],
// Agent
// chatOpen: false
// layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3
// align: x-center y-center
agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'],
// Second tile
// chatOpen: true,
// hasSecondTile: true
// layout: Column 2 / Row 1
// align: x-start y-center
secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'],
// Second tile
// chatOpen: false,
// hasSecondTile: false
// layout: Column 2 / Row 2
// align: x-end y-end
secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'],
}
export function useLocalTrackRef(source: Track.Source) {
const { localParticipant } = useLocalParticipant()
const publication = localParticipant.getTrackPublication(source)
const trackRef = useMemo<TrackReference | undefined>(
() => (publication ? { source, participant: localParticipant, publication } : undefined),
[source, publication, localParticipant]
)
return trackRef
}
interface TileLayoutProps {
themeMode?: 'dark' | 'light'
isChatOpen: boolean
audioVisualizerType?: 'bar' | 'wave' | 'grid' | 'radial' | 'aura'
audioVisualizerColor?: `#${string}`
audioVisualizerColorShift?: number
audioVisualizerWaveLineWidth?: number
audioVisualizerGridRowCount?: number
audioVisualizerGridColumnCount?: number
audioVisualizerRadialBarCount?: number
audioVisualizerRadialRadius?: number
audioVisualizerBarCount?: number
}
export function TileLayout({
themeMode,
isChatOpen,
audioVisualizerType,
audioVisualizerColor,
audioVisualizerColorShift,
audioVisualizerBarCount,
audioVisualizerRadialBarCount,
audioVisualizerRadialRadius,
audioVisualizerGridRowCount,
audioVisualizerGridColumnCount,
audioVisualizerWaveLineWidth,
}: TileLayoutProps) {
const { videoTrack: agentVideoTrack } = useVoiceAssistant()
const [screenShareTrack] = useTracks([Track.Source.ScreenShare])
const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera)
const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted
const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted
const hasSecondTile = isCameraEnabled || isScreenShareEnabled
const animationDelay = isChatOpen ? 0 : 0.15
const isAvatar = agentVideoTrack !== undefined
const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0
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="relative mx-auto h-full max-w-2xl px-4 md:px-0">
<div className={cn(tileViewClassNames.grid)}>
{/* Agent */}
<div
className={cn([
'grid',
!isChatOpen && tileViewClassNames.agentChatClosed,
isChatOpen && hasSecondTile && tileViewClassNames.agentChatOpenWithSecondTile,
isChatOpen && !hasSecondTile && tileViewClassNames.agentChatOpenWithoutSecondTile,
])}
>
<AnimatePresence mode="popLayout">
{!isAvatar && (
// Audio Agent
<motion.div
key="agent"
layoutId="agent"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
}}
className={cn('relative aspect-square h-[90px]')}
>
<AudioVisualizer
key="audio-visualizer"
initial={{ scale: 1 }}
animate={{ scale: isChatOpen ? 0.2 : 1 }}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
}}
audioVisualizerType={audioVisualizerType}
audioVisualizerColor={audioVisualizerColor}
audioVisualizerColorShift={audioVisualizerColorShift}
audioVisualizerBarCount={audioVisualizerBarCount}
audioVisualizerRadialBarCount={audioVisualizerRadialBarCount}
audioVisualizerRadialRadius={audioVisualizerRadialRadius}
audioVisualizerGridRowCount={audioVisualizerGridRowCount}
audioVisualizerGridColumnCount={audioVisualizerGridColumnCount}
audioVisualizerWaveLineWidth={audioVisualizerWaveLineWidth}
themeMode={themeMode}
isChatOpen={isChatOpen}
className={cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'rounded-[50px] border border-transparent bg-background transition-[border,drop-shadow]',
isChatOpen && 'border-input shadow-2xl/10 delay-200'
)}
style={{ color: audioVisualizerColor }}
/>
</motion.div>
)}
{isAvatar && (
// Avatar Agent
<motion.div
key="avatar"
layoutId="avatar"
initial={{
scale: 1,
opacity: 1,
maskImage: 'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 20px, transparent 20px)',
filter: 'blur(20px)',
}}
animate={{
maskImage: 'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 500px, transparent 500px)',
filter: 'blur(0px)',
borderRadius: isChatOpen ? 6 : 12,
}}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
maskImage: {
duration: 1,
},
filter: {
duration: 1,
},
}}
className={cn(
'overflow-hidden bg-black drop-shadow-xl/80',
isChatOpen ? 'h-[90px]' : 'h-auto w-full'
)}
>
<VideoTrack
width={videoWidth}
height={videoHeight}
trackRef={agentVideoTrack}
className={cn(isChatOpen && 'size-[90px] object-cover')}
/>
</motion.div>
)}
</AnimatePresence>
</div>
<div
className={cn([
'grid',
isChatOpen && tileViewClassNames.secondTileChatOpen,
!isChatOpen && tileViewClassNames.secondTileChatClosed,
])}
>
{/* Camera & Screen Share */}
<AnimatePresence>
{((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && (
<motion.div
key="camera"
layout="position"
layoutId="camera"
initial={{
opacity: 0,
scale: 0,
}}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0,
}}
transition={{
...ANIMATION_TRANSITION,
delay: animationDelay,
}}
className="aspect-square size-[90px] drop-shadow-lg/20"
>
<VideoTrack
trackRef={cameraTrack || screenShareTrack}
width={(cameraTrack || screenShareTrack)?.publication.dimensions?.width ?? 0}
height={(cameraTrack || screenShareTrack)?.publication.dimensions?.height ?? 0}
className="aspect-square size-[90px] rounded-md bg-muted object-cover"
/>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from './components/agent-session-block'
@@ -0,0 +1,888 @@
import React, { useEffect, useRef, type ComponentPropsWithoutRef } from 'react'
const PRECISIONS = ['lowp', 'mediump', 'highp']
const FS_MAIN_SHADER = `\nvoid main(void){
vec4 color = vec4(0.0,0.0,0.0,1.0);
mainImage( color, gl_FragCoord.xy );
gl_FragColor = color;
}`
const BASIC_FS = `void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
vec2 uv = fragCoord/iResolution.xy;
vec3 col = 0.5 + 0.5*cos(iTime+uv.xyx+vec3(0,2,4));
fragColor = vec4(col,1.0);
}`
const BASIC_VS = `attribute vec3 aVertexPosition;
void main(void) {
gl_Position = vec4(aVertexPosition, 1.0);
}`
const UNIFORM_TIME = 'iTime'
const UNIFORM_TIMEDELTA = 'iTimeDelta'
const UNIFORM_DATE = 'iDate'
const UNIFORM_FRAME = 'iFrame'
const UNIFORM_MOUSE = 'iMouse'
const UNIFORM_RESOLUTION = 'iResolution'
const UNIFORM_CHANNEL = 'iChannel'
const UNIFORM_CHANNELRESOLUTION = 'iChannelResolution'
const UNIFORM_DEVICEORIENTATION = 'iDeviceOrientation'
type Vector4<T = number> = [T, T, T, T]
type UniformType = keyof Uniforms
function isMatrixType(t: string, v: number[] | number): v is number[] {
return t.includes('Matrix') && Array.isArray(v)
}
function isVectorListType(t: string, v: number[] | number): v is number[] {
return t.includes('v') && Array.isArray(v) && v.length > Number.parseInt(t.charAt(0))
}
function isVectorType(t: string, v: number[] | number): v is Vector4 {
return !t.includes('v') && Array.isArray(v) && v.length > Number.parseInt(t.charAt(0))
}
const processUniform = <T extends UniformType>(
gl: WebGLRenderingContext,
location: WebGLUniformLocation,
t: T,
value: number | number[]
) => {
if (isVectorType(t, value)) {
switch (t) {
case '2f':
return gl.uniform2f(location, value[0], value[1])
case '3f':
return gl.uniform3f(location, value[0], value[1], value[2])
case '4f':
return gl.uniform4f(location, value[0], value[1], value[2], value[3])
case '2i':
return gl.uniform2i(location, value[0], value[1])
case '3i':
return gl.uniform3i(location, value[0], value[1], value[2])
case '4i':
return gl.uniform4i(location, value[0], value[1], value[2], value[3])
}
}
if (typeof value === 'number') {
switch (t) {
case '1i':
return gl.uniform1i(location, value)
default:
return gl.uniform1f(location, value)
}
}
switch (t) {
case '1iv':
return gl.uniform1iv(location, value)
case '2iv':
return gl.uniform2iv(location, value)
case '3iv':
return gl.uniform3iv(location, value)
case '4iv':
return gl.uniform4iv(location, value)
case '1fv':
return gl.uniform1fv(location, value)
case '2fv':
return gl.uniform2fv(location, value)
case '3fv':
return gl.uniform3fv(location, value)
case '4fv':
return gl.uniform4fv(location, value)
case 'Matrix2fv':
return gl.uniformMatrix2fv(location, false, value)
case 'Matrix3fv':
return gl.uniformMatrix3fv(location, false, value)
case 'Matrix4fv':
return gl.uniformMatrix4fv(location, false, value)
}
}
const uniformTypeToGLSLType = (t: string) => {
switch (t) {
case '1f':
return 'float'
case '2f':
return 'vec2'
case '3f':
return 'vec3'
case '4f':
return 'vec4'
case '1i':
return 'int'
case '2i':
return 'ivec2'
case '3i':
return 'ivec3'
case '4i':
return 'ivec4'
case '1iv':
return 'int'
case '2iv':
return 'ivec2'
case '3iv':
return 'ivec3'
case '4iv':
return 'ivec4'
case '1fv':
return 'float'
case '2fv':
return 'vec2'
case '3fv':
return 'vec3'
case '4fv':
return 'vec4'
case 'Matrix2fv':
return 'mat2'
case 'Matrix3fv':
return 'mat3'
case 'Matrix4fv':
return 'mat4'
default:
console.error(log(`The uniform type "${t}" is not valid, please make sure your uniform type is valid`))
}
}
const LinearFilter = 9729
const NearestFilter = 9728
const LinearMipMapLinearFilter = 9987
const NearestMipMapLinearFilter = 9986
const LinearMipMapNearestFilter = 9985
const NearestMipMapNearestFilter = 9984
const MirroredRepeatWrapping = 33648
const ClampToEdgeWrapping = 33071
const RepeatWrapping = 10497
class Texture {
gl: WebGLRenderingContext
url?: string
wrapS?: number
wrapT?: number
minFilter?: number
magFilter?: number
source?: HTMLImageElement | HTMLVideoElement
pow2canvas?: HTMLCanvasElement
isLoaded = false
isVideo = false
flipY = -1
width = 0
height = 0
_webglTexture: WebGLTexture | null = null
constructor(gl: WebGLRenderingContext) {
this.gl = gl
}
updateTexture = (texture: WebGLTexture, video: HTMLVideoElement, flipY: number) => {
const { gl } = this
const level = 0
const internalFormat = gl.RGBA
const srcFormat = gl.RGBA
const srcType = gl.UNSIGNED_BYTE
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, video)
}
setupVideo = (url: string) => {
const video = document.createElement('video')
let playing = false
let timeupdate = false
video.autoplay = true
video.muted = true
video.loop = true
video.crossOrigin = 'anonymous'
const checkReady = () => {
if (playing && timeupdate) {
this.isLoaded = true
}
}
video.addEventListener(
'playing',
() => {
playing = true
this.width = video.videoWidth || 0
this.height = video.videoHeight || 0
checkReady()
},
true
)
video.addEventListener(
'timeupdate',
() => {
timeupdate = true
checkReady()
},
true
)
video.src = url
return video
}
makePowerOf2 = <T extends HTMLCanvasElement | HTMLImageElement | ImageBitmap>(image: T): T => {
if (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof ImageBitmap) {
if (this.pow2canvas === undefined) this.pow2canvas = document.createElement('canvas')
this.pow2canvas.width = 2 ** Math.floor(Math.log(image.width) / Math.LN2)
this.pow2canvas.height = 2 ** Math.floor(Math.log(image.height) / Math.LN2)
const context = this.pow2canvas.getContext('2d')
context?.drawImage(image, 0, 0, this.pow2canvas.width, this.pow2canvas.height)
console.warn(
log(
`Image is not power of two ${image.width} x ${image.height}. Resized to ${this.pow2canvas.width} x ${this.pow2canvas.height};`
)
)
return this.pow2canvas as T
}
return image
}
load = async (textureArgs: TextureParams) => {
const { gl } = this
const { url, wrapS, wrapT, minFilter, magFilter, flipY = -1 }: TextureParams = textureArgs
if (!url) {
return Promise.reject(
new Error(log('Missing url, please make sure to pass the url of your texture { url: ... }'))
)
}
const isImage = /(\.jpg|\.jpeg|\.png|\.gif|\.bmp)$/i.exec(url)
const isVideo = /(\.mp4|\.3gp|\.webm|\.ogv)$/i.exec(url)
if (isImage === null && isVideo === null) {
return Promise.reject(new Error(log(`Please upload a video or an image with a valid format (url: ${url})`)))
}
Object.assign(this, { url, wrapS, wrapT, minFilter, magFilter, flipY })
const level = 0
const internalFormat = gl.RGBA
const width = 1
const height = 1
const border = 0
const srcFormat = gl.RGBA
const srcType = gl.UNSIGNED_BYTE
const pixel = new Uint8Array([255, 255, 255, 0])
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, width, height, border, srcFormat, srcType, pixel)
if (isVideo) {
const video = this.setupVideo(url)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
this._webglTexture = texture
this.source = video
this.isVideo = true
return video.play().then(() => this)
}
async function loadImage() {
return new Promise((resolve, reject) => {
const image = new Image()
image.crossOrigin = 'anonymous'
image.onload = () => {
resolve(image)
}
image.onerror = () => {
reject(new Error(log(`failed loading url: ${url}`)))
}
image.src = url ?? ''
})
}
let image = (await loadImage()) as HTMLImageElement
let isPowerOf2 = (image.width & (image.width - 1)) === 0 && (image.height & (image.height - 1)) === 0
if (
(textureArgs.wrapS !== ClampToEdgeWrapping ||
textureArgs.wrapT !== ClampToEdgeWrapping ||
(textureArgs.minFilter !== NearestFilter && textureArgs.minFilter !== LinearFilter)) &&
!isPowerOf2
) {
image = this.makePowerOf2(image)
isPowerOf2 = true
}
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)
gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, srcFormat, srcType, image)
if (isPowerOf2 && textureArgs.minFilter !== NearestFilter && textureArgs.minFilter !== LinearFilter) {
gl.generateMipmap(gl.TEXTURE_2D)
}
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, this.wrapS || RepeatWrapping)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, this.wrapT || RepeatWrapping)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, this.minFilter || LinearMipMapLinearFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, this.magFilter || LinearFilter)
this._webglTexture = texture
this.source = image
this.isVideo = false
this.isLoaded = true
this.width = image.width || 0
this.height = image.height || 0
return this
}
}
const log = (text: string) => `react-shaders: ${text}`
const latestPointerClientCoords = (e: MouseEvent | TouchEvent) => {
if ('changedTouches' in e) {
const t = e.changedTouches[0]
return [t?.clientX ?? 0, t?.clientY ?? 0]
}
return [e.clientX, e.clientY]
}
const lerpVal = (v0: number, v1: number, t: number) => v0 * (1 - t) + v1 * t
const insertStringAtIndex = (currentString: string, string: string, index: number) =>
index > 0
? currentString.substring(0, index) + string + currentString.substring(index, currentString.length)
: string + currentString
type Uniform = { type: string; value: number[] | number }
type Uniforms = Record<string, Uniform>
type TextureParams = {
url: string
wrapS?: number
wrapT?: number
minFilter?: number
magFilter?: number
flipY?: number
}
export interface ReactShaderToyProps {
/** Fragment shader GLSL code. */
fs: string
/** Vertex shader GLSL code. */
vs?: string
/**
* Textures to be passed to the shader. Textures need to be squared or will be
* automatically resized.
*
* Options default to:
*
* ```js
* {
* minFilter: LinearMipMapLinearFilter,
* magFilter: LinearFilter,
* wrapS: RepeatWrapping,
* wrapT: RepeatWrapping,
* }
* ```
*
* See [textures in the docs](https://rysana.com/docs/react-shaders#textures)
* for details.
*/
textures?: TextureParams[]
/**
* Custom uniforms to be passed to the shader.
*
* See [custom uniforms in the
* docs](https://rysana.com/docs/react-shaders#custom-uniforms) for details.
*/
uniforms?: Uniforms
/**
* Color used when clearing the canvas.
*
* See [the WebGL
* docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/clearColor)
* for details.
*/
clearColor?: Vector4
/**
* GLSL precision qualifier. Defaults to `'highp'`. Balance between
* performance and quality.
*/
precision?: 'highp' | 'lowp' | 'mediump'
/** Custom inline style for canvas. */
style?: React.CSSProperties
/** Customize WebGL context attributes. See [the WebGL docs](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/getContextAttributes) for details. */
contextAttributes?: Record<string, unknown>
/** Lerp value for `iMouse` built-in uniform. Must be between 0 and 1. */
lerp?: number
/** Device pixel ratio. */
devicePixelRatio?: number
/**
* Callback for when the textures are done loading. Useful if you want to do
* something like e.g. hide the canvas until textures are done loading.
*/
onDoneLoadingTextures?: () => void
/** Custom callback to handle errors. Defaults to `console.error`. */
onError?: (error: string) => void
/** Custom callback to handle warnings. Defaults to `console.warn`. */
onWarning?: (warning: string) => void
/**
* When true, the animation loop runs even when the canvas is not visible in the viewport.
* When false (default), animation runs only while visible (uses Intersection Observer),
* reducing CPU/GPU usage when the shader is off-screen.
*/
animateWhenNotVisible?: boolean
}
export function ReactShaderToy({
fs,
vs = BASIC_VS,
textures = [],
uniforms: propUniforms,
clearColor = [0, 0, 0, 1],
precision = 'highp',
style,
contextAttributes = {},
lerp = 1,
devicePixelRatio = 1,
onDoneLoadingTextures,
onError = console.error,
onWarning = console.warn,
animateWhenNotVisible = false,
...canvasProps
}: ReactShaderToyProps & ComponentPropsWithoutRef<'canvas'>) {
// Refs for WebGL state
const canvasRef = useRef<HTMLCanvasElement>(null)
const glRef = useRef<WebGLRenderingContext | null>(null)
const squareVerticesBufferRef = useRef<WebGLBuffer | null>(null)
const shaderProgramRef = useRef<WebGLProgram | null>(null)
const vertexPositionAttributeRef = useRef<number | undefined>(undefined)
const animFrameIdRef = useRef<number | undefined>(undefined)
const initFrameIdRef = useRef<number | undefined>(undefined)
const isVisibleRef = useRef(true)
const animateWhenNotVisibleRef = useRef(animateWhenNotVisible)
const mousedownRef = useRef(false)
const canvasPositionRef = useRef<DOMRect | undefined>(undefined)
const timerRef = useRef(0)
const lastMouseArrRef = useRef<number[]>([0, 0])
const texturesArrRef = useRef<Texture[]>([])
const lastTimeRef = useRef(0)
const resizeObserverRef = useRef<ResizeObserver | undefined>(undefined)
const uniformsRef = useRef<
Record<string, { type: string; isNeeded: boolean; value?: number[] | number; arraySize?: string }>
>({
[UNIFORM_TIME]: { type: 'float', isNeeded: false, value: 0 },
[UNIFORM_TIMEDELTA]: { type: 'float', isNeeded: false, value: 0 },
[UNIFORM_DATE]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
[UNIFORM_MOUSE]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
[UNIFORM_RESOLUTION]: { type: 'vec2', isNeeded: false, value: [0, 0] },
[UNIFORM_FRAME]: { type: 'int', isNeeded: false, value: 0 },
[UNIFORM_DEVICEORIENTATION]: { type: 'vec4', isNeeded: false, value: [0, 0, 0, 0] },
})
const propsUniformsRef = useRef<Uniforms | undefined>(propUniforms)
const setupChannelRes = (texture: TextureParams | Texture, id: number) => {
const width = 'width' in texture ? (texture.width ?? 0) : 0
const height = 'height' in texture ? (texture.height ?? 0) : 0
const channelResUniform = uniformsRef.current.iChannelResolution
if (!channelResUniform) return
const channelResValue = Array.isArray(channelResUniform.value)
? channelResUniform.value
: (channelResUniform.value = [])
channelResValue[id * 3] = width * devicePixelRatio
channelResValue[id * 3 + 1] = height * devicePixelRatio
channelResValue[id * 3 + 2] = 0
}
const initWebGL = () => {
if (!canvasRef.current) return
glRef.current = (canvasRef.current.getContext('webgl', contextAttributes) ||
canvasRef.current.getContext('experimental-webgl', contextAttributes)) as WebGLRenderingContext | null
glRef.current?.getExtension('OES_standard_derivatives')
glRef.current?.getExtension('EXT_shader_texture_lod')
}
const initBuffers = () => {
const gl = glRef.current
squareVerticesBufferRef.current = gl?.createBuffer() ?? null
gl?.bindBuffer(gl.ARRAY_BUFFER, squareVerticesBufferRef.current)
const vertices = [1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0]
gl?.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW)
}
const onDeviceOrientationChange = ({ alpha, beta, gamma }: DeviceOrientationEvent) => {
uniformsRef.current.iDeviceOrientation!.value = [alpha ?? 0, beta ?? 0, gamma ?? 0, window.orientation ?? 0]
}
const mouseDown = (e: MouseEvent | TouchEvent) => {
const [clientX = 0, clientY = 0] = latestPointerClientCoords(e)
const mouseX = clientX - (canvasPositionRef.current?.left ?? 0) - window.pageXOffset
const mouseY =
(canvasPositionRef.current?.height ?? 0) - clientY - (canvasPositionRef.current?.top ?? 0) - window.pageYOffset
mousedownRef.current = true
const mouseValue = Array.isArray(uniformsRef.current.iMouse?.value) ? uniformsRef.current.iMouse.value : undefined
if (mouseValue) {
mouseValue[2] = mouseX
mouseValue[3] = mouseY
}
lastMouseArrRef.current[0] = mouseX
lastMouseArrRef.current[1] = mouseY
}
const mouseMove = (e: MouseEvent | TouchEvent) => {
canvasPositionRef.current = canvasRef.current?.getBoundingClientRect()
const [clientX = 0, clientY = 0] = latestPointerClientCoords(e)
const mouseX = clientX - (canvasPositionRef.current?.left ?? 0)
const mouseY = (canvasPositionRef.current?.height ?? 0) - clientY - (canvasPositionRef.current?.top ?? 0)
if (lerp !== 1) {
lastMouseArrRef.current[0] = mouseX
lastMouseArrRef.current[1] = mouseY
} else {
const mouseValue = Array.isArray(uniformsRef.current.iMouse?.value) ? uniformsRef.current.iMouse.value : undefined
if (mouseValue) {
mouseValue[0] = mouseX
mouseValue[1] = mouseY
}
}
}
const mouseUp = () => {
const mouseValue = Array.isArray(uniformsRef.current.iMouse?.value) ? uniformsRef.current.iMouse.value : undefined
if (mouseValue) {
mouseValue[2] = 0
mouseValue[3] = 0
}
}
const onResize = () => {
const gl = glRef.current
if (!gl) return
canvasPositionRef.current = canvasRef.current?.getBoundingClientRect()
// Force pixel ratio to be one to avoid expensive calculus on retina display.
const realToCSSPixels = devicePixelRatio
const displayWidth = Math.floor((canvasPositionRef.current?.width ?? 1) * realToCSSPixels)
const displayHeight = Math.floor((canvasPositionRef.current?.height ?? 1) * realToCSSPixels)
gl.canvas.width = displayWidth
gl.canvas.height = displayHeight
if (uniformsRef.current.iResolution?.isNeeded && shaderProgramRef.current) {
const rUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_RESOLUTION)
gl.uniform2fv(rUniform, [gl.canvas.width, gl.canvas.height])
}
}
const createShader = (type: number, shaderCodeAsText: string) => {
const gl = glRef.current
if (!gl) return null
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, shaderCodeAsText)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
onWarning?.(log(`Error compiling the shader:\n${shaderCodeAsText}`))
const compilationLog = gl.getShaderInfoLog(shader)
gl.deleteShader(shader)
onError?.(log(`Shader compiler log: ${compilationLog}`))
}
return shader
}
const initShaders = (fragmentShader: string, vertexShader: string) => {
const gl = glRef.current
if (!gl) return
const fragmentShaderObj = createShader(gl.FRAGMENT_SHADER, fragmentShader)
const vertexShaderObj = createShader(gl.VERTEX_SHADER, vertexShader)
shaderProgramRef.current = gl.createProgram()
if (!shaderProgramRef.current || !vertexShaderObj || !fragmentShaderObj) return
gl.attachShader(shaderProgramRef.current, vertexShaderObj)
gl.attachShader(shaderProgramRef.current, fragmentShaderObj)
gl.linkProgram(shaderProgramRef.current)
if (!gl.getProgramParameter(shaderProgramRef.current, gl.LINK_STATUS)) {
onError?.(log(`Unable to initialize the shader program: ${gl.getProgramInfoLog(shaderProgramRef.current)}`))
return
}
gl.useProgram(shaderProgramRef.current)
vertexPositionAttributeRef.current = gl.getAttribLocation(shaderProgramRef.current, 'aVertexPosition')
gl.enableVertexAttribArray(vertexPositionAttributeRef.current)
}
const processCustomUniforms = () => {
if (propUniforms) {
for (const name of Object.keys(propUniforms)) {
const uniform = propUniforms[name]
if (!uniform) continue
const { value, type } = uniform
const glslType = uniformTypeToGLSLType(type)
if (!glslType) continue
const tempObject: { arraySize?: string } = {}
if (isMatrixType(type, value)) {
const arrayLength = type.length
const val = Number.parseInt(type.charAt(arrayLength - 3))
const numberOfMatrices = Math.floor(value.length / (val * val))
if (value.length > val * val) tempObject.arraySize = `[${numberOfMatrices}]`
} else if (isVectorListType(type, value)) {
tempObject.arraySize = `[${Math.floor(value.length / Number.parseInt(type.charAt(0)))}]`
}
uniformsRef.current[name] = { type: glslType, isNeeded: false, value, ...tempObject }
}
}
}
const processTextures = () => {
const gl = glRef.current
if (!gl) return
if (textures && textures.length > 0) {
uniformsRef.current[`${UNIFORM_CHANNELRESOLUTION}`] = {
type: 'vec3',
isNeeded: false,
arraySize: `[${textures.length}]`,
value: [],
}
const texturePromisesArr = textures.map((texture: TextureParams, id: number) => {
// Dynamically add textures uniforms.
uniformsRef.current[`${UNIFORM_CHANNEL}${id}`] = {
type: 'sampler2D',
isNeeded: false,
}
setupChannelRes(texture, id)
texturesArrRef.current[id] = new Texture(gl)
return texturesArrRef.current[id]?.load(texture).then((t: Texture) => {
setupChannelRes(t, id)
})
})
Promise.all(texturePromisesArr)
.then(() => {
if (onDoneLoadingTextures) onDoneLoadingTextures()
})
.catch((e) => {
onError?.(e)
if (onDoneLoadingTextures) onDoneLoadingTextures()
})
} else if (onDoneLoadingTextures) onDoneLoadingTextures()
}
const preProcessFragment = (fragment: string) => {
const isValidPrecision = PRECISIONS.includes(precision ?? 'highp')
const precisionString = `precision ${isValidPrecision ? precision : PRECISIONS[1]} float;\n`
if (!isValidPrecision) {
onWarning?.(
log(
`wrong precision type ${precision}, please make sure to pass one of a valid precision lowp, mediump, highp, by default you shader precision will be set to highp.`
)
)
}
let fragmentShader = precisionString
.concat(`#define DPR ${devicePixelRatio.toFixed(1)}\n`)
.concat(fragment.replace(/texture\(/g, 'texture2D('))
for (const uniform of Object.keys(uniformsRef.current)) {
if (fragment.includes(uniform)) {
const u = uniformsRef.current[uniform]
if (!u) continue
fragmentShader = insertStringAtIndex(
fragmentShader,
`uniform ${u.type} ${uniform}${u.arraySize || ''}; \n`,
fragmentShader.lastIndexOf(precisionString) + precisionString.length
)
u.isNeeded = true
}
}
const isShadertoy = fragment.includes('mainImage')
if (isShadertoy) fragmentShader = fragmentShader.concat(FS_MAIN_SHADER)
return fragmentShader
}
const setUniforms = (timestamp: number) => {
const gl = glRef.current
if (!gl || !shaderProgramRef.current) return
const delta = lastTimeRef.current ? (timestamp - lastTimeRef.current) / 1000 : 0
lastTimeRef.current = timestamp
const propUniforms = propsUniformsRef.current
if (propUniforms) {
for (const name of Object.keys(propUniforms)) {
const currentUniform = propUniforms[name]
if (!currentUniform) continue
if (uniformsRef.current[name]?.isNeeded) {
if (!shaderProgramRef.current) return
const customUniformLocation = gl.getUniformLocation(shaderProgramRef.current, name)
if (!customUniformLocation) return
processUniform(gl, customUniformLocation, currentUniform.type as UniformType, currentUniform.value)
}
}
}
if (uniformsRef.current.iMouse?.isNeeded) {
const mouseUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_MOUSE)
gl.uniform4fv(mouseUniform, uniformsRef.current.iMouse.value as number[])
}
if (uniformsRef.current.iChannelResolution?.isNeeded) {
const channelResUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_CHANNELRESOLUTION)
gl.uniform3fv(channelResUniform, uniformsRef.current.iChannelResolution.value as number[])
}
if (uniformsRef.current.iDeviceOrientation?.isNeeded) {
const deviceOrientationUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_DEVICEORIENTATION)
gl.uniform4fv(deviceOrientationUniform, uniformsRef.current.iDeviceOrientation.value as number[])
}
if (uniformsRef.current.iTime?.isNeeded) {
const timeUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_TIME)
gl.uniform1f(timeUniform, (timerRef.current += delta))
}
if (uniformsRef.current.iTimeDelta?.isNeeded) {
const timeDeltaUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_TIMEDELTA)
gl.uniform1f(timeDeltaUniform, delta)
}
if (uniformsRef.current.iDate?.isNeeded) {
const d = new Date()
const month = d.getMonth() + 1
const day = d.getDate()
const year = d.getFullYear()
const time = d.getHours() * 60 * 60 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() * 0.001
const dateUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_DATE)
gl.uniform4fv(dateUniform, [year, month, day, time])
}
if (uniformsRef.current.iFrame?.isNeeded) {
const timeDeltaUniform = gl.getUniformLocation(shaderProgramRef.current, UNIFORM_FRAME)
const frame = uniformsRef.current.iFrame.value as number
gl.uniform1i(timeDeltaUniform, frame)
uniformsRef.current.iFrame.value = frame + 1
}
if (texturesArrRef.current.length > 0) {
for (let index = 0; index < texturesArrRef.current.length; index++) {
const texture = texturesArrRef.current[index]
if (!texture) return
const { isVideo, _webglTexture, source, flipY, isLoaded } = texture
if (!isLoaded || !_webglTexture || !source) return
if (uniformsRef.current[`iChannel${index}`]?.isNeeded) {
if (!shaderProgramRef.current) return
const iChannel = gl.getUniformLocation(shaderProgramRef.current, `iChannel${index}`)
gl.activeTexture(gl.TEXTURE0 + index)
gl.bindTexture(gl.TEXTURE_2D, _webglTexture)
gl.uniform1i(iChannel, index)
if (isVideo) {
texture.updateTexture(_webglTexture, source as HTMLVideoElement, flipY)
}
}
}
}
}
const drawScene = (timestamp: number) => {
const gl = glRef.current
if (!gl) return
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.bindBuffer(gl.ARRAY_BUFFER, squareVerticesBufferRef.current)
gl.vertexAttribPointer(vertexPositionAttributeRef.current ?? 0, 3, gl.FLOAT, false, 0, 0)
setUniforms(timestamp)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
const mouseValue = uniformsRef.current.iMouse?.value
if (uniformsRef.current.iMouse?.isNeeded && lerp !== 1 && Array.isArray(mouseValue)) {
const currentX = mouseValue[0] ?? 0
const currentY = mouseValue[1] ?? 0
mouseValue[0] = lerpVal(currentX, lastMouseArrRef.current[0] ?? 0, lerp)
mouseValue[1] = lerpVal(currentY, lastMouseArrRef.current[1] ?? 0, lerp)
}
if (animateWhenNotVisibleRef.current || isVisibleRef.current) {
animFrameIdRef.current = requestAnimationFrame(drawScene)
}
}
const addEventListeners = () => {
const options = { passive: true }
if (uniformsRef.current.iMouse?.isNeeded && canvasRef.current) {
canvasRef.current.addEventListener('mousemove', mouseMove, options)
canvasRef.current.addEventListener('mouseout', mouseUp, options)
canvasRef.current.addEventListener('mouseup', mouseUp, options)
canvasRef.current.addEventListener('mousedown', mouseDown, options)
canvasRef.current.addEventListener('touchmove', mouseMove, options)
canvasRef.current.addEventListener('touchend', mouseUp, options)
canvasRef.current.addEventListener('touchstart', mouseDown, options)
}
if (uniformsRef.current.iDeviceOrientation?.isNeeded) {
window.addEventListener('deviceorientation', onDeviceOrientationChange, options)
}
if (canvasRef.current) {
resizeObserverRef.current = new ResizeObserver(onResize)
resizeObserverRef.current.observe(canvasRef.current)
window.addEventListener('resize', onResize, options)
}
}
const removeEventListeners = () => {
const options = { passive: true } as EventListenerOptions
if (uniformsRef.current.iMouse?.isNeeded && canvasRef.current) {
canvasRef.current.removeEventListener('mousemove', mouseMove, options)
canvasRef.current.removeEventListener('mouseout', mouseUp, options)
canvasRef.current.removeEventListener('mouseup', mouseUp, options)
canvasRef.current.removeEventListener('mousedown', mouseDown, options)
canvasRef.current.removeEventListener('touchmove', mouseMove, options)
canvasRef.current.removeEventListener('touchend', mouseUp, options)
canvasRef.current.removeEventListener('touchstart', mouseDown, options)
}
if (uniformsRef.current.iDeviceOrientation?.isNeeded) {
window.removeEventListener('deviceorientation', onDeviceOrientationChange, options)
}
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect()
window.removeEventListener('resize', onResize, options)
}
}
useEffect(() => {
propsUniformsRef.current = propUniforms
}, [propUniforms])
useEffect(() => {
animateWhenNotVisibleRef.current = animateWhenNotVisible
if (animateWhenNotVisible) {
isVisibleRef.current = true
}
}, [animateWhenNotVisible])
// Intersection Observer: pause animation when off-screen when animateWhenNotVisible is false
useEffect(() => {
if (animateWhenNotVisible || !canvasRef.current) return
const canvas = canvasRef.current
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
isVisibleRef.current = entry.isIntersecting
if (entry.isIntersecting) {
requestAnimationFrame(drawScene)
}
}
},
{ threshold: 0 }
)
observer.observe(canvas)
return () => observer.disconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animateWhenNotVisible])
// Main effect for initialization and cleanup
useEffect(() => {
const textures = texturesArrRef.current
function init() {
initWebGL()
const gl = glRef.current
if (gl && canvasRef.current) {
gl.clearColor(...clearColor)
gl.clearDepth(1.0)
gl.enable(gl.DEPTH_TEST)
gl.depthFunc(gl.LEQUAL)
gl.viewport(0, 0, canvasRef.current.width, canvasRef.current.height)
canvasRef.current.height = canvasRef.current.clientHeight
canvasRef.current.width = canvasRef.current.clientWidth
processCustomUniforms()
processTextures()
initShaders(preProcessFragment(fs || BASIC_FS), vs || BASIC_VS)
initBuffers()
requestAnimationFrame(drawScene)
addEventListeners()
onResize()
}
}
initFrameIdRef.current = requestAnimationFrame(init)
// Cleanup function
return () => {
const gl = glRef.current
if (gl) {
gl.getExtension('WEBGL_lose_context')?.loseContext()
gl.useProgram(null)
gl.deleteProgram(shaderProgramRef.current ?? null)
if (textures.length > 0) {
for (const texture of textures as Texture[]) {
gl.deleteTexture(texture._webglTexture)
}
}
shaderProgramRef.current = null
}
removeEventListeners()
cancelAnimationFrame(initFrameIdRef.current ?? 0)
cancelAnimationFrame(animFrameIdRef.current ?? 0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Empty dependency array to run only once on mount
return <canvas ref={canvasRef} style={{ height: '100%', width: '100%', ...style }} {...canvasProps} />
}
@@ -0,0 +1,57 @@
import { type ComponentProps } from 'react'
import { useEnsureRoom, useStartAudio } from '@livekit/components-react'
import { Button } from '@/components/ui/button'
import { Room } from 'livekit-client'
/**
* Props for the StartAudioButton component.
*/
export interface StartAudioButtonProps extends ComponentProps<'button'> {
/**
* The size of the button.
* @defaultValue 'default'
*/
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg'
/**
* The variant of the button.
* @defaultValue 'default'
*/
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
/**
* The LiveKit room instance. If not provided, uses the room from context.
*/
room?: Room
/**
* The label text to display on the button.
*/
label: string
}
/**
* A button that allows users to start audio playback.
* Required for browsers that block autoplay of audio.
* Only renders when audio playback is blocked.
*
* @extends ComponentProps<'button'>
*
* @example
* ```tsx
* <StartAudioButton label="Click to allow audio playback" />
* ```
*/
export function StartAudioButton({
size = 'default',
variant = 'default',
label,
room,
...props
}: StartAudioButtonProps) {
const roomEnsured = useEnsureRoom(room)
const { mergedProps } = useStartAudio({ room: roomEnsured, props })
return (
<Button size={size} variant={variant} {...props} {...mergedProps}>
{label}
</Button>
)
}
+142
View File
@@ -0,0 +1,142 @@
'use client'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { UIMessage } from 'ai'
import { ArrowDownIcon, DownloadIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import { useCallback } from 'react'
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
export type ConversationProps = ComponentProps<typeof StickToBottom>
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
)
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content className={cn('flex flex-col gap-8 p-4', className)} {...props} />
)
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
title?: string
description?: string
icon?: React.ReactNode
}
export const ConversationEmptyState = ({
className,
title = 'No messages yet',
description = 'Start a conversation to see messages here',
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn('flex size-full flex-col items-center justify-center gap-3 p-8 text-center', className)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="text-sm font-medium">{title}</h3>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
</>
)}
</div>
)
export type ConversationScrollButtonProps = ComponentProps<typeof Button>
export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
const handleScrollToBottom = useCallback(() => {
scrollToBottom()
}, [scrollToBottom])
return (
!isAtBottom && (
<Button
className={cn(
'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted',
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
)
}
const getMessageText = (message: UIMessage): string =>
message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('')
export type ConversationDownloadProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
messages: UIMessage[]
filename?: string
formatMessage?: (message: UIMessage, index: number) => string
}
const defaultFormatMessage = (message: UIMessage): string => {
const roleLabel = message.role.charAt(0).toUpperCase() + message.role.slice(1)
return `**${roleLabel}:** ${getMessageText(message)}`
}
export const messagesToMarkdown = (
messages: UIMessage[],
formatMessage: (message: UIMessage, index: number) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join('\n\n')
export const ConversationDownload = ({
messages,
filename = 'conversation.md',
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage)
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.append(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}, [messages, filename, formatMessage])
return (
<Button
className={cn('absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted', className)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
)
}
+280
View File
@@ -0,0 +1,280 @@
'use client'
import { Button } from '@/components/ui/button'
import { ButtonGroup, ButtonGroupText } from '@/components/ui/button-group'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { cjk } from '@streamdown/cjk'
import { code } from '@streamdown/code'
import { math } from '@streamdown/math'
import { mermaid } from '@streamdown/mermaid'
import type { UIMessage } from 'ai'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { Streamdown } from 'streamdown'
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage['role']
}
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full max-w-[95%] flex-col gap-2',
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
className
)}
{...props}
/>
)
export type MessageContentProps = HTMLAttributes<HTMLDivElement>
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
'is-user:dark flex w-fit max-w-full min-w-0 flex-col gap-2 overflow-hidden text-sm',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground',
'group-[.is-assistant]:text-foreground',
className
)}
{...props}
>
{children}
</div>
)
export type MessageActionsProps = ComponentProps<'div'>
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div className={cn('flex items-center gap-1', className)} {...props}>
{children}
</div>
)
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string
label?: string
}
export const MessageAction = ({
tooltip,
children,
label,
variant = 'ghost',
size = 'icon-sm',
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
)
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return button
}
interface MessageBranchContextType {
currentBranch: number
totalBranches: number
goToPrevious: () => void
goToNext: () => void
branches: ReactElement[]
setBranches: (branches: ReactElement[]) => void
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(null)
const useMessageBranch = () => {
const context = useContext(MessageBranchContext)
if (!context) {
throw new Error('MessageBranch components must be used within MessageBranch')
}
return context
}
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number
onBranchChange?: (branchIndex: number) => void
}
export const MessageBranch = ({ defaultBranch = 0, onBranchChange, className, ...props }: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch)
const [branches, setBranches] = useState<ReactElement[]>([])
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch)
onBranchChange?.(newBranch)
},
[onBranchChange]
)
const goToPrevious = useCallback(() => {
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1
handleBranchChange(newBranch)
}, [currentBranch, branches.length, handleBranchChange])
const goToNext = useCallback(() => {
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0
handleBranchChange(newBranch)
}, [currentBranch, branches.length, handleBranchChange])
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
)
return (
<MessageBranchContext.Provider value={contextValue}>
<div className={cn('grid w-full gap-2 [&>div]:pb-0', className)} {...props} />
</MessageBranchContext.Provider>
)
}
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch()
const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children])
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray)
}
}, [childrenArray, branches, setBranches])
return childrenArray.map((branch, index) => (
<div
className={cn('grid gap-2 overflow-hidden [&>div]:pb-0', index === currentBranch ? 'block' : 'hidden')}
key={branch.key}
{...props}
>
{branch}
</div>
))
}
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>
export const MessageBranchSelector = ({ className, ...props }: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch()
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null
}
return (
<ButtonGroup
className={cn('[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md', className)}
orientation="horizontal"
{...props}
/>
)
}
export type MessageBranchPreviousProps = ComponentProps<typeof Button>
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch()
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
)
}
export type MessageBranchNextProps = ComponentProps<typeof Button>
export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch()
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
)
}
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch()
return (
<ButtonGroupText
className={cn('border-none bg-transparent text-muted-foreground shadow-none', className)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
)
}
export type MessageResponseProps = ComponentProps<typeof Streamdown>
const streamdownPlugins = { cjk, code, math, mermaid }
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children && nextProps.isAnimating === prevProps.isAnimating
)
MessageResponse.displayName = 'MessageResponse'
export type MessageToolbarProps = ComponentProps<'div'>
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
<div className={cn('mt-4 flex w-full items-center justify-between gap-4', className)} {...props}>
{children}
</div>
)
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { cn } from '@/lib/utils'
import type { MotionProps } from 'motion/react'
import { motion } from 'motion/react'
import type { CSSProperties, ElementType, JSX } from 'react'
import { memo, useMemo } from 'react'
type MotionHTMLProps = MotionProps & Record<string, unknown>
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<keyof JSX.IntrinsicElements, React.ComponentType<MotionHTMLProps>>()
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element)
if (!component) {
component = motion.create(element)
motionComponentCache.set(element, component)
}
return component
}
export interface TextShimmerProps {
children: string
as?: ElementType
className?: string
duration?: number
spread?: number
}
const ShimmerComponent = ({ children, as: Component = 'p', className, duration = 2, spread = 2 }: TextShimmerProps) => {
const MotionComponent = getMotionComponent(Component as keyof JSX.IntrinsicElements)
const dynamicSpread = useMemo(() => (children?.length ?? 0) * spread, [children, spread])
return (
<MotionComponent
animate={{ backgroundPosition: '0% center' }}
className={cn(
'relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent',
'[background-repeat:no-repeat,padding-box] [--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))]',
className
)}
initial={{ backgroundPosition: '100% center' }}
style={
{
'--spread': `${dynamicSpread}px`,
'backgroundImage': 'var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))',
} as CSSProperties
}
transition={{
duration,
ease: 'linear',
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
)
}
export const Shimmer = memo(ShimmerComponent)
+78
View File
@@ -0,0 +1,78 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
'*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0',
vertical:
'flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0',
},
},
defaultVariants: {
orientation: 'horizontal',
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: 'button-group-text',
},
})
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto',
className
)}
{...props}
/>
)
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed inset-0 z-50 grid w-full gap-4 bg-popover p-4 text-sm text-popover-foreground duration-100 outline-none data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 sm:inset-auto sm:top-1/2 sm:left-1/2 sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:ring-1 sm:ring-foreground/10 sm:data-open:zoom-in-95 sm:data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+132
View File
@@ -0,0 +1,132 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+23
View File
@@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+64
View File
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -1,8 +1,8 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react"
import * as React from 'react'
type Theme = "dark" | "light" | "system"
type ResolvedTheme = "dark" | "light"
type Theme = 'dark' | 'light' | 'system'
type ResolvedTheme = 'dark' | 'light'
type ThemeProviderProps = {
children: React.ReactNode
@@ -16,12 +16,10 @@ type ThemeProviderState = {
setTheme: (theme: Theme) => void
}
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
const THEME_VALUES: Theme[] = ["dark", "light", "system"]
const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'
const THEME_VALUES: Theme[] = ['dark', 'light', 'system']
const ThemeProviderContext = React.createContext<
ThemeProviderState | undefined
>(undefined)
const ThemeProviderContext = React.createContext<ThemeProviderState | undefined>(undefined)
function isTheme(value: string | null): value is Theme {
if (value === null) {
@@ -33,18 +31,16 @@ function isTheme(value: string | null): value is Theme {
function getSystemTheme(): ResolvedTheme {
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
return "dark"
return 'dark'
}
return "light"
return 'light'
}
function disableTransitionsTemporarily() {
const style = document.createElement("style")
const style = document.createElement('style')
style.appendChild(
document.createTextNode(
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
)
document.createTextNode('*,*::before,*::after{-webkit-transition:none!important;transition:none!important}')
)
document.head.appendChild(style)
@@ -67,9 +63,7 @@ function isEditableTarget(target: EventTarget | null) {
return true
}
const editableParent = target.closest(
"input, textarea, select, [contenteditable='true']"
)
const editableParent = target.closest("input, textarea, select, [contenteditable='true']")
if (editableParent) {
return true
}
@@ -79,8 +73,8 @@ function isEditableTarget(target: EventTarget | null) {
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "theme",
defaultTheme = 'system',
storageKey = 'theme',
disableTransitionOnChange = true,
...props
}: ThemeProviderProps) {
@@ -104,13 +98,10 @@ export function ThemeProvider({
const applyTheme = React.useCallback(
(nextTheme: Theme) => {
const root = document.documentElement
const resolvedTheme =
nextTheme === "system" ? getSystemTheme() : nextTheme
const restoreTransitions = disableTransitionOnChange
? disableTransitionsTemporarily()
: null
const resolvedTheme = nextTheme === 'system' ? getSystemTheme() : nextTheme
const restoreTransitions = disableTransitionOnChange ? disableTransitionsTemporarily() : null
root.classList.remove("light", "dark")
root.classList.remove('light', 'dark')
root.classList.add(resolvedTheme)
if (restoreTransitions) {
@@ -123,19 +114,19 @@ export function ThemeProvider({
React.useEffect(() => {
applyTheme(theme)
if (theme !== "system") {
if (theme !== 'system') {
return undefined
}
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
const handleChange = () => {
applyTheme("system")
applyTheme('system')
}
mediaQuery.addEventListener("change", handleChange)
mediaQuery.addEventListener('change', handleChange)
return () => {
mediaQuery.removeEventListener("change", handleChange)
mediaQuery.removeEventListener('change', handleChange)
}
}, [theme, applyTheme])
@@ -153,29 +144,29 @@ export function ThemeProvider({
return
}
if (event.key.toLowerCase() !== "d") {
if (event.key.toLowerCase() !== 'd') {
return
}
setThemeState((currentTheme) => {
const nextTheme =
currentTheme === "dark"
? "light"
: currentTheme === "light"
? "dark"
: getSystemTheme() === "dark"
? "light"
: "dark"
currentTheme === 'dark'
? 'light'
: currentTheme === 'light'
? 'dark'
: getSystemTheme() === 'dark'
? 'light'
: 'dark'
localStorage.setItem(storageKey, nextTheme)
return nextTheme
})
}
window.addEventListener("keydown", handleKeyDown)
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener("keydown", handleKeyDown)
window.removeEventListener('keydown', handleKeyDown)
}
}, [storageKey])
@@ -197,10 +188,10 @@ export function ThemeProvider({
setThemeState(defaultTheme)
}
window.addEventListener("storage", handleStorageChange)
window.addEventListener('storage', handleStorageChange)
return () => {
window.removeEventListener("storage", handleStorageChange)
window.removeEventListener('storage', handleStorageChange)
}
}, [defaultTheme, storageKey])
@@ -223,7 +214,7 @@ export const useTheme = () => {
const context = React.useContext(ThemeProviderContext)
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider")
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client'
import {
type AnimationPlaybackControlsWithThen,
type ValueAnimationTransition,
animate,
useMotionValue,
useMotionValueEvent,
} from 'motion/react'
import {
type AgentState,
type TrackReference,
type TrackReferenceOrPlaceholder,
useTrackVolume,
} from '@livekit/components-react'
const DEFAULT_SPEED = 10
const DEFAULT_AMPLITUDE = 2
const DEFAULT_FREQUENCY = 0.5
const DEFAULT_SCALE = 0.2
const DEFAULT_BRIGHTNESS = 1.5
const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.5, ease: 'easeOut' }
const DEFAULT_PULSE_TRANSITION: ValueAnimationTransition = {
duration: 0.35,
ease: 'easeOut',
repeat: Infinity,
repeatType: 'mirror',
}
function useAnimatedValue<T>(initialValue: T) {
const [value, setValue] = useState(initialValue)
const motionValue = useMotionValue(initialValue)
const controlsRef = useRef<AnimationPlaybackControlsWithThen | null>(null)
useMotionValueEvent(motionValue, 'change', (value) => setValue(value as T))
const animateFn = useCallback(
(targetValue: T | T[], transition: ValueAnimationTransition) => {
controlsRef.current = animate(motionValue, targetValue, transition)
},
[motionValue]
)
return { value, motionValue, controls: controlsRef, animate: animateFn }
}
export function useAgentAudioVisualizerAura(
state: AgentState | undefined,
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
) {
const [speed, setSpeed] = useState(DEFAULT_SPEED)
const { value: scale, animate: animateScale, motionValue: scaleMotionValue } = useAnimatedValue(DEFAULT_SCALE)
const { value: amplitude, animate: animateAmplitude } = useAnimatedValue(DEFAULT_AMPLITUDE)
const { value: frequency, animate: animateFrequency } = useAnimatedValue(DEFAULT_FREQUENCY)
const { value: brightness, animate: animateBrightness } = useAnimatedValue(DEFAULT_BRIGHTNESS)
const volume = useTrackVolume(audioTrack as TrackReference, {
fftSize: 512,
smoothingTimeConstant: 0.55,
})
useEffect(() => {
switch (state) {
case 'idle':
case 'failed':
case 'disconnected':
setSpeed(10)
animateScale(0.2, DEFAULT_TRANSITION)
animateAmplitude(1.2, DEFAULT_TRANSITION)
animateFrequency(0.4, DEFAULT_TRANSITION)
animateBrightness(1.0, DEFAULT_TRANSITION)
return
case 'listening':
case 'pre-connect-buffering':
setSpeed(20)
animateScale(0.3, { type: 'spring', duration: 1.0, bounce: 0.35 })
animateAmplitude(1.0, DEFAULT_TRANSITION)
animateFrequency(0.7, DEFAULT_TRANSITION)
animateBrightness([1.5, 2.0], DEFAULT_PULSE_TRANSITION)
return
case 'thinking':
case 'connecting':
case 'initializing':
setSpeed(30)
animateScale(0.3, DEFAULT_TRANSITION)
animateAmplitude(0.5, DEFAULT_TRANSITION)
animateFrequency(1, DEFAULT_TRANSITION)
animateBrightness([0.5, 2.5], DEFAULT_PULSE_TRANSITION)
return
case 'speaking':
setSpeed(70)
animateScale(0.3, DEFAULT_TRANSITION)
animateAmplitude(0.75, DEFAULT_TRANSITION)
animateFrequency(1.25, DEFAULT_TRANSITION)
animateBrightness(1.5, DEFAULT_TRANSITION)
return
}
}, [state, animateScale, animateAmplitude, animateFrequency, animateBrightness])
useEffect(() => {
if (state === 'speaking' && volume > 0 && !scaleMotionValue.isAnimating()) {
animateScale(0.2 + 0.2 * volume, { duration: 0 })
}
}, [state, volume, scaleMotionValue, animateScale, animateAmplitude, animateFrequency, animateBrightness])
return {
speed,
scale,
amplitude,
frequency,
brightness,
}
}
@@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from 'react'
import { type AgentState } from '@livekit/components-react'
function generateConnectingSequenceBar(columns: number): number[][] {
const seq = []
for (let x = 0; x < columns; x++) {
seq.push([x, columns - 1 - x])
}
return seq
}
function generateListeningSequenceBar(columns: number): number[][] {
const center = Math.floor(columns / 2)
const noIndex = -1
return [[center], [noIndex]]
}
export function useAgentAudioVisualizerBarAnimator(
state: AgentState | undefined,
columns: number,
interval: number
): number[] {
const [index, setIndex] = useState(0)
const [sequence, setSequence] = useState<number[][]>([[]])
useEffect(() => {
if (state === 'thinking') {
setSequence(generateListeningSequenceBar(columns))
} else if (state === 'connecting' || state === 'initializing') {
const sequence = [...generateConnectingSequenceBar(columns)]
setSequence(sequence)
} else if (state === 'listening') {
setSequence(generateListeningSequenceBar(columns))
} else if (state === undefined || state === 'speaking') {
setSequence([new Array(columns).fill(0).map((_, idx) => idx)])
} else {
setSequence([[]])
}
setIndex(0)
}, [state, columns])
const animationFrameId = useRef<number | null>(null)
useEffect(() => {
let startTime = performance.now()
const animate = (time: DOMHighResTimeStamp) => {
const timeElapsed = time - startTime
if (timeElapsed >= interval) {
setIndex((prev) => prev + 1)
startTime = time
}
animationFrameId.current = requestAnimationFrame(animate)
}
animationFrameId.current = requestAnimationFrame(animate)
return () => {
if (animationFrameId.current !== null) {
cancelAnimationFrame(animationFrameId.current)
}
}
}, [interval, columns, state, sequence.length])
return sequence[index % sequence.length] ?? []
}
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react'
import { type AgentState } from '@livekit/components-react'
export interface Coordinate {
x: number
y: number
}
export function generateConnectingSequence(rows: number, columns: number, radius: number) {
const seq = []
const centerY = Math.floor(rows / 2)
// Calculate the boundaries of the ring based on the ring distance
const topLeft = {
x: Math.max(0, centerY - radius),
y: Math.max(0, centerY - radius),
}
const bottomRight = {
x: columns - 1 - topLeft.x,
y: Math.min(rows - 1, centerY + radius),
}
// Top edge
for (let x = topLeft.x; x <= bottomRight.x; x++) {
seq.push({ x, y: topLeft.y })
}
// Right edge
for (let y = topLeft.y + 1; y <= bottomRight.y; y++) {
seq.push({ x: bottomRight.x, y })
}
// Bottom edge
for (let x = bottomRight.x - 1; x >= topLeft.x; x--) {
seq.push({ x, y: bottomRight.y })
}
// Left edge
for (let y = bottomRight.y - 1; y > topLeft.y; y--) {
seq.push({ x: topLeft.x, y })
}
return seq
}
export function generateListeningSequence(rows: number, columns: number) {
const center = { x: Math.floor(columns / 2), y: Math.floor(rows / 2) }
const noIndex = { x: -1, y: -1 }
return [center, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex, noIndex]
}
export function generateThinkingSequence(rows: number, columns: number) {
const seq = []
const y = Math.floor(rows / 2)
for (let x = 0; x < columns; x++) {
seq.push({ x, y })
}
for (let x = columns - 1; x >= 0; x--) {
seq.push({ x, y })
}
return seq
}
export function useAgentAudioVisualizerGridAnimator(
state: AgentState,
rows: number,
columns: number,
interval: number,
radius?: number
): Coordinate {
const [index, setIndex] = useState(0)
const [sequence, setSequence] = useState<Coordinate[]>(() => [
{
x: Math.floor(columns / 2),
y: Math.floor(rows / 2),
},
])
useEffect(() => {
const clampedRadius = radius
? Math.min(radius, Math.floor(Math.max(rows, columns) / 2))
: Math.floor(Math.max(rows, columns) / 2)
if (state === 'thinking') {
setSequence(generateThinkingSequence(rows, columns))
} else if (state === 'connecting' || state === 'initializing') {
const sequence = [...generateConnectingSequence(rows, columns, clampedRadius)]
setSequence(sequence)
} else if (state === 'listening') {
setSequence(generateListeningSequence(rows, columns))
} else {
setSequence([{ x: Math.floor(columns / 2), y: Math.floor(rows / 2) }])
}
setIndex(0)
}, [state, rows, columns, radius])
useEffect(() => {
if (state === 'speaking') {
return
}
const indexInterval = setInterval(() => {
setIndex((prev) => {
return prev + 1
})
}, interval)
return () => clearInterval(indexInterval)
}, [interval, columns, rows, state, sequence.length])
return sequence[index % sequence.length] ?? { x: Math.floor(columns / 2), y: Math.floor(rows / 2) }
}
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react'
import { type AgentState } from '@livekit/components-react'
function findGcdLessThan(columns: number, max: number = columns): number {
function gcd(a: number, b: number): number {
while (b !== 0) {
const t = b
b = a % b
a = t
}
return a
}
for (let i = max; i >= 1; i--) {
if (gcd(columns, i) === i) {
return i
}
}
return 1
}
function generateConnectingSequenceBar(columns: number): number[][] {
const seq = []
const center = Math.floor(columns / 2)
for (let x = 0; x < columns; x++) {
seq.push([x, (x + center) % columns])
}
return seq
}
function generateListeningSequenceBar(columns: number): number[][] {
const divisor = columns > 8 ? columns / findGcdLessThan(columns, 4) : findGcdLessThan(columns, 2)
return Array.from({ length: divisor }, (_, idx) => [
...Array(Math.floor(columns / divisor))
.fill(1)
.map((_, idx2) => idx2 * divisor + idx),
])
}
export const useAgentAudioVisualizerRadialAnimator = (
state: AgentState | undefined,
barCount: number,
interval: number
): number[] => {
const [index, setIndex] = useState(0)
const [sequence, setSequence] = useState<number[][]>([[]])
useEffect(() => {
if (state === 'thinking') {
setSequence(generateListeningSequenceBar(barCount))
} else if (state === 'connecting' || state === 'initializing') {
setSequence(generateConnectingSequenceBar(barCount))
} else if (state === 'listening') {
setSequence(generateListeningSequenceBar(barCount))
} else if (state === undefined || state === 'speaking') {
setSequence([new Array(barCount).fill(0).map((_, idx) => idx)])
} else {
setSequence([[]])
}
setIndex(0)
}, [state, barCount])
const animationFrameId = useRef<number | null>(null)
useEffect(() => {
let startTime = performance.now()
const animate = (time: DOMHighResTimeStamp) => {
const timeElapsed = time - startTime
if (timeElapsed >= interval) {
setIndex((prev) => prev + 1)
startTime = time
}
animationFrameId.current = requestAnimationFrame(animate)
}
animationFrameId.current = requestAnimationFrame(animate)
return () => {
if (animationFrameId.current !== null) {
cancelAnimationFrame(animationFrameId.current)
}
}
}, [interval, barCount, state, sequence.length])
return sequence[index % sequence.length] ?? []
}
@@ -0,0 +1,107 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import {
type AnimationPlaybackControlsWithThen,
type ValueAnimationTransition,
animate,
useMotionValue,
useMotionValueEvent,
} from 'motion/react'
import {
type AgentState,
type TrackReference,
type TrackReferenceOrPlaceholder,
useTrackVolume,
} from '@livekit/components-react'
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client'
const DEFAULT_SPEED = 5
const DEFAULT_AMPLITUDE = 0.025
const DEFAULT_FREQUENCY = 10
const DEFAULT_TRANSITION: ValueAnimationTransition = { duration: 0.2, ease: 'easeOut' }
function useAnimatedValue<T>(initialValue: T) {
const [value, setValue] = useState(initialValue)
const motionValue = useMotionValue(initialValue)
const controlsRef = useRef<AnimationPlaybackControlsWithThen | null>(null)
useMotionValueEvent(motionValue, 'change', (value) => setValue(value as T))
const animateFn = useCallback(
(targetValue: T | T[], transition: ValueAnimationTransition) => {
controlsRef.current = animate(motionValue, targetValue, transition)
},
[motionValue]
)
return { value, controls: controlsRef, animate: animateFn }
}
interface UseAgentAudioVisualizerWaveAnimatorArgs {
state?: AgentState
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder
}
export function useAgentAudioVisualizerWave({ state, audioTrack }: UseAgentAudioVisualizerWaveAnimatorArgs) {
const [speed, setSpeed] = useState(DEFAULT_SPEED)
const { value: amplitude, animate: animateAmplitude } = useAnimatedValue(DEFAULT_AMPLITUDE)
const { value: frequency, animate: animateFrequency } = useAnimatedValue(DEFAULT_FREQUENCY)
const { value: opacity, animate: animateOpacity } = useAnimatedValue(1.0)
const volume = useTrackVolume(audioTrack as TrackReference, {
fftSize: 512,
smoothingTimeConstant: 0.55,
})
useEffect(() => {
switch (state) {
case 'disconnected':
setSpeed(DEFAULT_SPEED)
animateAmplitude(0, DEFAULT_TRANSITION)
animateFrequency(0, DEFAULT_TRANSITION)
animateOpacity(1.0, DEFAULT_TRANSITION)
return
case 'listening':
setSpeed(DEFAULT_SPEED)
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION)
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION)
animateOpacity([1.0, 0.3], {
duration: 0.75,
repeat: Infinity,
repeatType: 'mirror',
})
return
case 'thinking':
case 'connecting':
case 'initializing':
setSpeed(DEFAULT_SPEED * 4)
animateAmplitude(DEFAULT_AMPLITUDE / 4, DEFAULT_TRANSITION)
animateFrequency(DEFAULT_FREQUENCY * 4, DEFAULT_TRANSITION)
animateOpacity([1.0, 0.3], {
duration: 0.4,
repeat: Infinity,
repeatType: 'mirror',
})
return
case 'speaking':
default:
setSpeed(DEFAULT_SPEED * 2)
animateAmplitude(DEFAULT_AMPLITUDE, DEFAULT_TRANSITION)
animateFrequency(DEFAULT_FREQUENCY, DEFAULT_TRANSITION)
animateOpacity(1.0, DEFAULT_TRANSITION)
return
}
}, [state, setSpeed, animateAmplitude, animateFrequency, animateOpacity])
useEffect(() => {
if (state === 'speaking') {
animateAmplitude(0.015 + 0.4 * volume, { duration: 0 })
animateFrequency(20 + 60 * volume, { duration: 0 })
}
}, [state, volume, animateAmplitude, animateFrequency])
return {
speed,
amplitude,
frequency,
opacity,
}
}
@@ -0,0 +1,167 @@
import { useCallback } from 'react'
import { Track } from 'livekit-client'
import {
type TrackReference,
useTrackToggle,
usePersistentUserChoices,
useLocalParticipantPermissions,
useSessionContext,
} from '@livekit/components-react'
const trackSourceToProtocol = (source: Track.Source) => {
// NOTE: this mapping avoids importing the protocol package as that leads to a significant bundle size increase
switch (source) {
case Track.Source.Camera:
return 1
case Track.Source.Microphone:
return 2
case Track.Source.ScreenShare:
return 3
default:
return 0
}
}
export interface PublishPermissions {
camera: boolean
microphone: boolean
screenShare: boolean
data: boolean
}
export function usePublishPermissions(): PublishPermissions {
const localPermissions = useLocalParticipantPermissions()
const canPublishSource = (source: Track.Source) => {
return (
!!localPermissions?.canPublish &&
(localPermissions.canPublishSources.length === 0 ||
localPermissions.canPublishSources.includes(trackSourceToProtocol(source)))
)
}
return {
camera: canPublishSource(Track.Source.Camera),
microphone: canPublishSource(Track.Source.Microphone),
screenShare: canPublishSource(Track.Source.ScreenShare),
data: localPermissions?.canPublishData ?? false,
}
}
export interface UseInputControlsProps {
saveUserChoices?: boolean
onDisconnect?: () => void
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
}
export interface UseInputControlsReturn {
microphoneTrack?: TrackReference
microphoneToggle: ReturnType<typeof useTrackToggle<Track.Source.Microphone>>
cameraToggle: ReturnType<typeof useTrackToggle<Track.Source.Camera>>
screenShareToggle: ReturnType<typeof useTrackToggle<Track.Source.ScreenShare>>
handleAudioDeviceChange: (deviceId: string) => void
handleVideoDeviceChange: (deviceId: string) => void
handleMicrophoneDeviceSelectError: (error: Error) => void
handleCameraDeviceSelectError: (error: Error) => void
}
export function useInputControls({
saveUserChoices = true,
onDeviceError,
}: UseInputControlsProps = {}): UseInputControlsReturn {
const {
local: { microphoneTrack },
} = useSessionContext()
const microphoneToggle = useTrackToggle({
source: Track.Source.Microphone,
onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
})
const cameraToggle = useTrackToggle({
source: Track.Source.Camera,
onDeviceError: (error) => onDeviceError?.({ source: Track.Source.Camera, error }),
})
const screenShareToggle = useTrackToggle({
source: Track.Source.ScreenShare,
onDeviceError: (error) => onDeviceError?.({ source: Track.Source.ScreenShare, error }),
})
const { saveAudioInputEnabled, saveVideoInputEnabled, saveAudioInputDeviceId, saveVideoInputDeviceId } =
usePersistentUserChoices({ preventSave: !saveUserChoices })
const handleAudioDeviceChange = useCallback(
(deviceId: string) => {
saveAudioInputDeviceId(deviceId ?? 'default')
},
[saveAudioInputDeviceId]
)
const handleVideoDeviceChange = useCallback(
(deviceId: string) => {
saveVideoInputDeviceId(deviceId ?? 'default')
},
[saveVideoInputDeviceId]
)
const handleToggleCamera = useCallback(
async (enabled?: boolean) => {
if (screenShareToggle.enabled) {
screenShareToggle.toggle(false)
}
await cameraToggle.toggle(enabled)
// persist video input enabled preference
saveVideoInputEnabled(!cameraToggle.enabled)
},
[cameraToggle, screenShareToggle, saveVideoInputEnabled]
)
const handleToggleMicrophone = useCallback(
async (enabled?: boolean) => {
await microphoneToggle.toggle(enabled)
// persist audio input enabled preference
saveAudioInputEnabled(!microphoneToggle.enabled)
},
[microphoneToggle, saveAudioInputEnabled]
)
const handleToggleScreenShare = useCallback(
async (enabled?: boolean) => {
if (cameraToggle.enabled) {
cameraToggle.toggle(false)
}
await screenShareToggle.toggle(enabled)
},
[cameraToggle, screenShareToggle]
)
const handleMicrophoneDeviceSelectError = useCallback(
(error: Error) => onDeviceError?.({ source: Track.Source.Microphone, error }),
[onDeviceError]
)
const handleCameraDeviceSelectError = useCallback(
(error: Error) => onDeviceError?.({ source: Track.Source.Camera, error }),
[onDeviceError]
)
return {
microphoneTrack,
cameraToggle: {
...cameraToggle,
toggle: handleToggleCamera,
},
microphoneToggle: {
...microphoneToggle,
toggle: handleToggleMicrophone,
},
screenShareToggle: {
...screenShareToggle,
toggle: handleToggleScreenShare,
},
handleAudioDeviceChange,
handleVideoDeviceChange,
handleMicrophoneDeviceSelectError,
handleCameraDeviceSelectError,
}
}
+10 -7
View File
@@ -1,14 +1,17 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import "./index.css"
import App from "./App.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from '@/contexts/ThemeProvider.tsx'
import { TooltipProvider } from '@/components/ui/tooltip'
createRoot(document.getElementById("root")!).render(
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
<TooltipProvider>
<App />
</TooltipProvider>
</ThemeProvider>
</StrictMode>
)
+138
View File
@@ -0,0 +1,138 @@
import { useRef, useState, useEffect } from 'react'
import { useSession, useAgent, useChat, useSessionMessages } from '@livekit/components-react'
import { Keyboard, Loader, SendHorizontal } from 'lucide-react'
import { TokenSource } from 'livekit-client'
import { AgentAudioVisualizerAura } from '@/components/agents-ui/agent-audio-visualizer-aura'
import { AgentChatTranscript } from '@/components/agents-ui/agent-chat-transcript'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
import { AppBar } from '@/components/AppBar'
interface HomeViewProps {
onSettingsClick: () => void
onJobsClick: () => void
onNotificationsClick: () => void
}
export function HomeView({ onSettingsClick, onJobsClick, onNotificationsClick }: HomeViewProps) {
const session = useSession(TokenSource.sandboxTokenServer('your-sandbox-id'))
const { microphoneTrack, state: agentState } = useAgent(session)
const { messages } = useSessionMessages(session)
const { send } = useChat({ room: session.room })
const [isOpen, setIsOpen] = useState(false)
const [message, setMessage] = useState('')
const [isSending, setIsSending] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const isDisabled = isSending || message.trim().length === 0
const handleSend = async () => {
if (isDisabled) return
try {
setIsSending(true)
await send(message.trim())
setMessage('')
} catch (error) {
console.error(error)
} finally {
setIsSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isOpen])
// Auto-open when first message arrives
const hasAutoOpened = useRef(false)
useEffect(() => {
if (messages.length > 0 && !hasAutoOpened.current) {
hasAutoOpened.current = true
setIsOpen(true)
}
}, [messages.length])
return (
<div className="flex min-h-svh flex-col">
<AppBar onSettingsClick={onSettingsClick} onJobsClick={onJobsClick} onNotificationsClick={onNotificationsClick} />
<div className="flex flex-1 items-center justify-center p-6 pb-20">
<div className="flex min-w-0 flex-col items-center gap-4 text-sm leading-loose">
<div>
<AgentAudioVisualizerAura size="lg" state={agentState} audioTrack={microphoneTrack} />
</div>
<div className="font-mono text-xs text-muted-foreground">
(Press <kbd>d</kbd> to toggle dark mode)
</div>
</div>
</div>
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerTrigger asChild>
<Button variant="outline" size="lg" className="fixed bottom-4 left-1/2 z-50 size-12 -translate-x-1/2">
<Keyboard className="size-6" />
{messages.length > 0 && (
<span className="absolute -right-1.5 -top-1.5 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{messages.length}
</span>
)}
</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex h-[50svh] w-full max-w-2xl flex-col">
<DrawerHeader>
<DrawerTitle>Chat</DrawerTitle>
</DrawerHeader>
{/* Messages area */}
<div className="flex-1 overflow-hidden">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No messages yet. Start a conversation!
</div>
) : (
<AgentChatTranscript agentState={agentState} messages={messages} className="h-full" />
)}
</div>
{/* Input area */}
<div className="border-t border-border p-4">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={message}
disabled={isSending}
placeholder="Type a message..."
onKeyDown={handleKeyDown}
onChange={(e) => setMessage(e.target.value)}
className="field-sizing-content max-h-20 min-h-9 flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm [scrollbar-width:thin] placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
size="icon"
type="button"
disabled={isDisabled}
variant={isDisabled ? 'secondary' : 'default'}
title={isSending ? 'Sending...' : 'Send'}
onClick={handleSend}
className="shrink-0"
>
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
</Button>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
export function JobsView() {
return (
<div className="flex flex-1 flex-col gap-1 p-4">
<p className="text-sm text-muted-foreground">Running jobs coming soon.</p>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
export function NotificationsView() {
return (
<div className="flex flex-1 flex-col gap-1 p-4">
<p className="text-sm text-muted-foreground">Notifications coming soon.</p>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
export function SettingsView() {
return (
<div className="flex flex-1 flex-col gap-1 p-4">
<p className="text-sm text-muted-foreground">Settings coming soon.</p>
</div>
)
}