diff --git a/src/App.tsx b/src/App.tsx index 4fddc48..efa0c10 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( -
-
-
-

Project ready!

-

You may now add components and start building.

-

We've already added the button component for you.

- -
-
- (Press d to toggle dark mode) -
-
-
+ <> + setShowSettings(true)} + onJobsClick={() => setShowJobs(true)} + onNotificationsClick={() => setShowNotifications(true)} + /> + + + + Settings + + + + + + + + Running Jobs + + + + + + + + Notifications + + + + + ) } diff --git a/src/components/AppBar.tsx b/src/components/AppBar.tsx new file mode 100644 index 0000000..8972373 --- /dev/null +++ b/src/components/AppBar.tsx @@ -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 ( +
+
+ MAIA2 +
+ + + +
+
+
+ ) +} diff --git a/src/components/agents-ui/agent-audio-visualizer-aura.tsx b/src/components/agents-ui/agent-audio-visualizer-aura.tsx new file mode 100644 index 0000000..6e6af17 --- /dev/null +++ b/src/components/agents-ui/agent-audio-visualizer-aura.tsx @@ -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 ( +
+ { + console.error('Shader error:', error) + }} + onWarning={(warning) => { + console.warn('Shader warning:', warning) + }} + style={{ width: '100%', height: '100%' }} + /> +
+ ) +} + +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 + * + * ``` + */ +export function AgentAudioVisualizerAura({ + size = 'lg', + state = 'connecting', + color = DEFAULT_COLOR, + colorShift = 0.05, + audioTrack, + themeMode, + className, + ref, + ...props +}: AgentAudioVisualizerAuraProps & ComponentProps<'div'> & VariantProps) { + const { speed, scale, amplitude, frequency, brightness } = useAgentAudioVisualizerAura(state, audioTrack) + + return ( + + ) +} diff --git a/src/components/agents-ui/agent-audio-visualizer-bar.tsx b/src/components/agents-ui/agent-audio-visualizer-bar.tsx new file mode 100644 index 0000000..041ced0 --- /dev/null +++ b/src/components/agents-ui/agent-audio-visualizer-bar.tsx @@ -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, 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 + 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 + * + * ``` + */ +export function AgentAudioVisualizerBar({ + size = 'md', + state = 'connecting', + color, + barCount, + audioTrack, + className, + children, + style, + ...props +}: AgentAudioVisualizerBarProps & VariantProps & 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 ( +
+ {bands.map((band: number, idx: number) => + children ? ( + + {cloneSingleChild(children, { + 'data-lk-index': idx, + 'data-lk-highlighted': highlightedIndices.includes(idx), + 'style': { height: `${band * 100}%` }, + })} + + ) : ( +
+ ) + )} +
+ ) +} diff --git a/src/components/agents-ui/agent-audio-visualizer-grid.tsx b/src/components/agents-ui/agent-audio-visualizer-grid.tsx new file mode 100644 index 0000000..d8e12ea --- /dev/null +++ b/src/components/agents-ui/agent-audio-visualizer-grid.tsx @@ -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, 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 + 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['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 + +/** + * 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 + * + * ``` + */ +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 ( +
+ {items.map((idx) => ( + + {children ??
} + + ))} +
+ ) +} diff --git a/src/components/agents-ui/agent-audio-visualizer-radial.tsx b/src/components/agents-ui/agent-audio-visualizer-radial.tsx new file mode 100644 index 0000000..f39300a --- /dev/null +++ b/src/components/agents-ui/agent-audio-visualizer-radial.tsx @@ -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 + * + * ``` + */ +export function AgentAudioVisualizerRadial({ + size = 'md', + state = 'connecting', + color, + radius, + barCount, + audioTrack, + className, + style, + ...props +}: AgentAudioVisualizerRadialProps & ComponentProps<'div'> & VariantProps) { + 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 ( +
+ {bands.map((band, idx) => { + const angle = (idx / _barCount) * Math.PI * 2 + + return ( +
+
+
+ ) + })} +
+ ) +} diff --git a/src/components/agents-ui/agent-audio-visualizer-wave.tsx b/src/components/agents-ui/agent-audio-visualizer-wave.tsx new file mode 100644 index 0000000..28d902d --- /dev/null +++ b/src/components/agents-ui/agent-audio-visualizer-wave.tsx @@ -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 ( +
+ { + console.error('Shader error:', error) + }} + onWarning={(warning) => { + console.warn('Shader warning:', warning) + }} + style={{ width: '100%', height: '100%' }} + /> +
+ ) +} + +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 + * + * ``` + */ +export function AgentAudioVisualizerWave({ + size = 'lg', + state = 'speaking', + color, + colorShift = 0.05, + lineWidth, + blur, + audioTrack, + className, + ref, + ...props +}: AgentAudioVisualizerWaveProps & ComponentProps<'div'> & VariantProps) { + 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 ( + + ) +} diff --git a/src/components/agents-ui/agent-chat-indicator.tsx b/src/components/agents-ui/agent-chat-indicator.tsx new file mode 100644 index 0000000..801acac --- /dev/null +++ b/src/components/agents-ui/agent-chat-indicator.tsx @@ -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 +} + +/** + * 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' && } + * ``` + */ +export function AgentChatIndicator({ + size = 'md', + className, + ...props +}: AgentChatIndicatorProps & ComponentProps<'span'> & VariantProps) { + return ( + + ) +} diff --git a/src/components/agents-ui/agent-chat-transcript.tsx b/src/components/agents-ui/agent-chat-transcript.tsx new file mode 100644 index 0000000..4a9b12a --- /dev/null +++ b/src/components/agents-ui/agent-chat-transcript.tsx @@ -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 + * + * ``` + */ +export function AgentChatTranscript({ agentState, messages = [], className, ...props }: AgentChatTranscriptProps) { + return ( + + + {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} + + + ) + })} + {agentState === 'thinking' && } + + + + ) +} diff --git a/src/components/agents-ui/agent-control-bar.tsx b/src/components/agents-ui/agent-control-bar.tsx new file mode 100644 index 0000000..22e390a --- /dev/null +++ b/src/components/agents-ui/agent-control-bar.tsx @@ -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(null) + const [isSending, setIsSending] = useState(false) + const [message, setMessage] = useState('') + 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) => { + 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 ( +
+