setup basic components
This commit is contained in:
+47
-14
@@ -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'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+275
@@ -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>
|
||||
)
|
||||
}
|
||||
+154
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user