Compare commits

..

5 Commits

Author SHA1 Message Date
lukasIO
12cee3ed06
Update livekit dependencies (#512) 2026-02-19 17:07:12 +01:00
renovate[bot]
2220072d47
chore(deps): update dependency node to v24 (#491)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 16:56:32 +01:00
renovate[bot]
392ca136de
fix(deps): update dependency @livekit/krisp-noise-filter to v0.4.1 (#505)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-19 16:54:36 +01:00
renovate[bot]
3a75f3222f
fix(deps): update livekit dependencies (non-major) (#499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-10 16:24:35 +01:00
vercel[bot]
f80673aba8
Fix React Server Components CVE vulnerabilities (#503)
Updated dependencies to fix Next.js and React CVE vulnerabilities.

The fix-react2shell-next tool automatically updated the following packages to their secure versions:
- next
- react-server-dom-webpack
- react-server-dom-parcel  
- react-server-dom-turbopack

All package.json files have been scanned and vulnerable versions have been patched to the correct fixed versions based on the official React advisory.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2025-12-26 11:35:52 +01:00
16 changed files with 558 additions and 2119 deletions

View File

@ -16,7 +16,7 @@ jobs:
- name: Use Node.js 22
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: 'pnpm'
- name: Install dependencies

View File

@ -1,78 +0,0 @@
import { AgentDispatchClient } from 'livekit-server-sdk';
import { NextRequest, NextResponse } from 'next/server';
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
// Agent name must match the one registered in the worker
const AGENT_NAME = 'transcription-agent';
interface SpawnRequest {
roomName: string;
email?: string;
e2eePassphrase?: string;
}
export async function POST(request: NextRequest) {
try {
// Validate environment
if (!API_KEY || !API_SECRET || !LIVEKIT_URL) {
return new NextResponse('LiveKit credentials not configured', { status: 500 });
}
// Parse request body
const body: SpawnRequest = await request.json();
const { roomName, email, e2eePassphrase } = body;
if (!roomName) {
return new NextResponse('Missing required parameter: roomName', { status: 400 });
}
// Create dispatch client
const dispatchClient = new AgentDispatchClient(LIVEKIT_URL, API_KEY, API_SECRET);
// Build metadata for the agent
const metadata = JSON.stringify({
roomName,
email: email || null,
passphrase: e2eePassphrase || null,
serverUrl: LIVEKIT_URL,
// Include the base URL so agent can fetch tokens
baseUrl: getBaseUrl(request),
});
// Create dispatch request
const dispatch = await dispatchClient.createDispatch(roomName, AGENT_NAME, {
metadata,
});
console.log(`Dispatched agent ${AGENT_NAME} to room ${roomName}`, {
dispatchId: dispatch.agentName,
hasEmail: !!email,
hasE2EE: !!e2eePassphrase,
});
return NextResponse.json({
success: true,
agentName: dispatch.agentName,
room: dispatch.room,
});
} catch (error) {
console.error('Failed to spawn agent:', error);
if (error instanceof Error) {
return new NextResponse(error.message, { status: 500 });
}
return new NextResponse('Internal server error', { status: 500 });
}
}
// Get the base URL from the request
function getBaseUrl(request: NextRequest): string {
const host = request.headers.get('host') || 'localhost:3000';
const protocol = request.headers.get('x-forwarded-proto') || 'http';
return `${protocol}://${host}`;
}

View File

@ -1,7 +1,6 @@
'use client';
import { RoomContext, VideoConference } from '@livekit/components-react';
import { formatChatMessage } from '@/lib/chat-utils';
import { formatChatMessageLinks, RoomContext, VideoConference } from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
LogLevel,
@ -87,7 +86,7 @@ export function VideoConferenceClientImpl(props: {
<RoomContext.Provider value={room}>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessage}
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
}

View File

@ -2,14 +2,18 @@
import React from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import { formatChatMessage } from '@/lib/chat-utils';
import { DebugMode } from '@/lib/Debug';
import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts';
import { RecordingIndicator } from '@/lib/RecordingIndicator';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { ConnectionDetails } from '@/lib/types';
import { SubtitlesOverlay, SubtitleProvider } from '@/lib/SubtitlesOverlay';
import { LocalUserChoices, PreJoin, RoomContext, VideoConference } from '@livekit/components-react';
import {
formatChatMessageLinks,
LocalUserChoices,
PreJoin,
RoomContext,
VideoConference,
} from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
RoomOptions,
@ -28,6 +32,7 @@ import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
const SHOW_SETTINGS_MENU = process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU == 'true';
export function PageClientImpl(props: {
roomName: string;
@ -61,7 +66,7 @@ export function PageClientImpl(props: {
const connectionDetailsData = await connectionDetailsResp.json();
setConnectionDetails(connectionDetailsData);
}, []);
const handlePreJoinError = React.useCallback((e: Error) => console.error(e), []);
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
return (
<main data-lk-theme="default" style={{ height: '100%' }}>
@ -215,16 +220,13 @@ function VideoConferenceComponent(props: {
return (
<div className="lk-room-container">
<RoomContext.Provider value={room}>
<SubtitleProvider>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessage}
SettingsComponent={SettingsMenu}
/>
<SubtitlesOverlay />
<DebugMode />
<RecordingIndicator />
</SubtitleProvider>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
/>
<DebugMode />
<RecordingIndicator />
</RoomContext.Provider>
</div>
);

View File

@ -11,7 +11,6 @@ import {
import styles from '../styles/SettingsMenu.module.css';
import { CameraSettings } from './CameraSettings';
import { MicrophoneSettings } from './MicrophoneSettings';
import { SubtitlesSettings } from './SubtitlesSettings';
/**
* @alpha
*/
@ -28,7 +27,6 @@ export function SettingsMenu(props: SettingsMenuProps) {
const settings = React.useMemo(() => {
return {
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
subtitles: { label: 'Subtitles' },
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
};
}, []);
@ -127,7 +125,6 @@ export function SettingsMenu(props: SettingsMenuProps) {
)}
</>
)}
{activeTab === 'subtitles' && <SubtitlesSettings />}
{activeTab === 'recording' && (
<>
<h3>Record Meeting</h3>

View File

@ -1,78 +0,0 @@
'use client';
import * as React from 'react';
export interface SubtitleSettings {
enabled: boolean;
fontSize: number;
position: 'top' | 'center' | 'bottom';
backgroundColor: string;
}
export const defaultSubtitleSettings: SubtitleSettings = {
enabled: false,
fontSize: 24,
position: 'bottom',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
};
interface SubtitleContextType {
settings: SubtitleSettings;
updateSettings: (settings: SubtitleSettings) => void;
}
const SubtitleContext = React.createContext<SubtitleContextType | null>(null);
export function SubtitleProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = React.useState<SubtitleSettings>(defaultSubtitleSettings);
// Load visual settings from localStorage on mount (enabled always starts false)
React.useEffect(() => {
try {
const saved = localStorage.getItem('subtitle-settings');
if (saved) {
const parsed = JSON.parse(saved);
setSettings({
...defaultSubtitleSettings,
fontSize: parsed.fontSize ?? defaultSubtitleSettings.fontSize,
position: parsed.position ?? defaultSubtitleSettings.position,
backgroundColor: parsed.backgroundColor ?? defaultSubtitleSettings.backgroundColor,
enabled: false,
});
}
} catch (e) {
console.error('Failed to load subtitle settings:', e);
}
}, []);
const updateSettings = React.useCallback((newSettings: SubtitleSettings) => {
setSettings(newSettings);
// Save visual settings to localStorage (not enabled)
try {
localStorage.setItem('subtitle-settings', JSON.stringify({
fontSize: newSettings.fontSize,
position: newSettings.position,
backgroundColor: newSettings.backgroundColor,
}));
} catch (e) {
console.error('Failed to save subtitle settings:', e);
}
}, []);
return (
<SubtitleContext.Provider value={{ settings, updateSettings }}>
{children}
</SubtitleContext.Provider>
);
}
export function useSubtitleSettings() {
const context = React.useContext(SubtitleContext);
if (!context) {
throw new Error('useSubtitleSettings must be used within SubtitleProvider');
}
return context;
}

View File

@ -1,331 +0,0 @@
'use client';
import * as React from 'react';
import { useRoomContext } from '@livekit/components-react';
import { RoomEvent, DataPacket_Kind, RemoteParticipant } from 'livekit-client';
import styles from '@/styles/Subtitles.module.css';
export interface SubtitleSettings {
enabled: boolean;
fontSize: number;
position: 'top' | 'center' | 'bottom';
backgroundColor: string;
}
export const defaultSubtitleSettings: SubtitleSettings = {
enabled: false,
fontSize: 24,
position: 'bottom',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
};
interface SubtitleContextType {
settings: SubtitleSettings;
updateSettings: (settings: SubtitleSettings) => void;
hasAgent: boolean;
summaryEmail: string | null;
setSummaryEmail: (email: string | null) => void;
}
const SubtitleContext = React.createContext<SubtitleContextType | null>(null);
export function useSubtitleSettings() {
const context = React.useContext(SubtitleContext);
if (!context) {
throw new Error('useSubtitleSettings must be used within SubtitleProvider');
}
return context;
}
const AGENT_NAME = 'LiveKit Transcription';
export function SubtitleProvider({ children }: { children: React.ReactNode }) {
const room = useRoomContext();
const [settings, setSettings] = React.useState<SubtitleSettings>(defaultSubtitleSettings);
const [hasAgent, setHasAgent] = React.useState(false);
const [summaryEmail, setSummaryEmail] = React.useState<string | null>(null);
// Load visual settings and email from localStorage
React.useEffect(() => {
try {
const saved = localStorage.getItem('subtitle-settings');
if (saved) {
const parsed = JSON.parse(saved);
setSettings((prev) => ({
...prev,
fontSize: parsed.fontSize ?? prev.fontSize,
position: parsed.position ?? prev.position,
backgroundColor: parsed.backgroundColor ?? prev.backgroundColor,
}));
}
// Load saved email
const savedEmail = localStorage.getItem('summary-email');
if (savedEmail) {
setSummaryEmail(savedEmail);
}
} catch (e) {
console.error('Failed to load subtitle settings:', e);
}
}, []);
// Check for agent presence
const checkAgent = React.useCallback(() => {
if (!room) {
setHasAgent(false);
return;
}
for (const p of room.remoteParticipants.values()) {
if (p.name === AGENT_NAME) {
setHasAgent(true);
return;
}
}
setHasAgent(false);
}, [room]);
React.useEffect(() => {
if (!room) return;
checkAgent();
room.on(RoomEvent.ParticipantConnected, checkAgent);
room.on(RoomEvent.ParticipantDisconnected, checkAgent);
return () => {
room.off(RoomEvent.ParticipantConnected, checkAgent);
room.off(RoomEvent.ParticipantDisconnected, checkAgent);
};
}, [room, checkAgent]);
const updateSettings = React.useCallback((newSettings: SubtitleSettings) => {
setSettings(newSettings);
try {
localStorage.setItem(
'subtitle-settings',
JSON.stringify({
fontSize: newSettings.fontSize,
position: newSettings.position,
backgroundColor: newSettings.backgroundColor,
}),
);
} catch (e) {
console.error('Failed to save settings:', e);
}
}, []);
const updateSummaryEmail = React.useCallback((email: string | null) => {
setSummaryEmail(email);
try {
if (email) {
localStorage.setItem('summary-email', email);
} else {
localStorage.removeItem('summary-email');
}
} catch (e) {
console.error('Failed to save summary email:', e);
}
}, []);
return (
<SubtitleContext.Provider
value={{
settings,
updateSettings,
hasAgent,
summaryEmail,
setSummaryEmail: updateSummaryEmail,
}}
>
{children}
</SubtitleContext.Provider>
);
}
interface SubtitleLine {
id: string;
speaker: string;
text: string;
timestamp: number;
expireAt: number;
}
function calculateDisplayTime(text: string, queueLength: number = 0): number {
const charsPerSecond = 15;
const calculated = (text.length / charsPerSecond) * 1000;
const baseTime = Math.max(2000, Math.min(8000, calculated));
// Reduce display time when queue is backing up
// Queue 0-1: full time, Queue 2: 70%, Queue 3+: 50%
if (queueLength >= 3) return Math.max(1000, baseTime * 0.5);
if (queueLength >= 2) return Math.max(1500, baseTime * 0.7);
return baseTime;
}
export function SubtitlesOverlay() {
const room = useRoomContext();
const { settings } = useSubtitleSettings();
const [lines, setLines] = React.useState<SubtitleLine[]>([]);
const lineIdRef = React.useRef(0);
const queueRef = React.useRef<SubtitleLine[]>([]);
const rafRef = React.useRef<number | null>(null);
const hasActiveContent = React.useRef(false);
// Process subtitles - clean expired lines and show queued ones
// All logic in single setLines call to avoid race conditions
const processSubtitles = React.useCallback(() => {
const now = Date.now();
setLines((prev) => {
// Filter out expired lines
let result = prev.filter((l) => l.expireAt > now);
const queueLength = queueRef.current.length;
// When queue is building up, aggressively expire older lines
// Keep only the most recent line when queue has 2+ items waiting
if (queueLength >= 2 && result.length > 1) {
result = result.slice(-1);
}
// Show next line from queue if:
// - No lines are currently showing, OR
// - Queue is building up (show new content faster)
const shouldShowNext = result.length === 0 || queueLength > 0;
if (shouldShowNext && queueLength > 0) {
const nextLine = queueRef.current.shift()!;
nextLine.timestamp = now;
nextLine.expireAt = now + calculateDisplayTime(nextLine.text, queueRef.current.length);
result = [...result.slice(-1), nextLine]; // Keep max 2 lines
}
// Track if we have content to display (for RAF optimization)
hasActiveContent.current = result.length > 0 || queueRef.current.length > 0;
return result;
});
}, []);
// RAF loop - only runs when there's active content
const tick = React.useCallback(() => {
processSubtitles();
// Continue loop only if there's content to process
if (hasActiveContent.current) {
rafRef.current = requestAnimationFrame(tick);
} else {
rafRef.current = null;
}
}, [processSubtitles]);
// Start RAF loop when needed
const startLoop = React.useCallback(() => {
if (rafRef.current === null && settings.enabled) {
rafRef.current = requestAnimationFrame(tick);
}
}, [tick, settings.enabled]);
// Handle visibility change - immediately clean up when tab becomes visible
React.useEffect(() => {
if (!settings.enabled) return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// Process immediately when tab becomes visible
processSubtitles();
startLoop();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [settings.enabled, processSubtitles, startLoop]);
// Clean up RAF when disabled
React.useEffect(() => {
if (!settings.enabled && rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [settings.enabled]);
React.useEffect(() => {
if (!room || !settings.enabled) return;
const handleData = (
payload: Uint8Array,
_participant?: RemoteParticipant,
_kind?: DataPacket_Kind,
topic?: string,
) => {
if (topic !== 'lk.subtitle') return;
try {
const raw = new TextDecoder().decode(payload).trim();
if (!raw) return;
const data = JSON.parse(raw);
const now = Date.now();
const newLine: SubtitleLine = {
id: `sub-${lineIdRef.current++}`,
speaker: data.speaker || 'Unknown',
text: data.text || raw,
timestamp: now,
expireAt: now + calculateDisplayTime(data.text || raw),
};
queueRef.current.push(newLine);
// If queue is backing up, skip older items more aggressively
// Keep only last 2 items to prevent falling too far behind
if (queueRef.current.length > 2) {
queueRef.current = queueRef.current.slice(-2);
}
// Start processing loop
startLoop();
} catch (e) {
console.error('Failed to parse subtitle:', e);
}
};
room.on(RoomEvent.DataReceived, handleData);
return () => {
room.off(RoomEvent.DataReceived, handleData);
queueRef.current = [];
};
}, [room, settings.enabled, startLoop]);
if (!settings.enabled || lines.length === 0) return null;
const positionClass = {
top: styles.positionTop,
center: styles.positionCenter,
bottom: styles.positionBottom,
}[settings.position];
return (
<div
className={`${styles.subtitlesContainer} ${positionClass}`}
style={
{
'--subtitle-font-size': `${settings.fontSize}px`,
'--subtitle-bg': settings.backgroundColor,
} as React.CSSProperties
}
>
<div className={styles.subtitlesInner}>
{lines.map((line) => (
<div key={line.id} className={styles.subtitleLine}>
<span className={styles.speaker}>{line.speaker}</span>
<span className={styles.text}>{line.text}</span>
</div>
))}
</div>
</div>
);
}

View File

@ -1,285 +0,0 @@
'use client';
import * as React from 'react';
import { useRoomContext } from '@livekit/components-react';
import { useSubtitleSettings, SubtitleSettings } from './SubtitlesOverlay';
function EmailPopup({
onSkip,
onSubscribe,
onClose,
isLoading,
error,
}: {
onSkip: () => void;
onSubscribe: (email: string) => void;
onClose: () => void;
isLoading: boolean;
error: string | null;
}) {
const [email, setEmail] = React.useState(() => {
if (typeof window !== 'undefined') {
return localStorage.getItem('summary-email') || '';
}
return '';
});
const handleSubscribe = () => {
if (email.trim()) {
localStorage.setItem('summary-email', email.trim());
onSubscribe(email.trim());
}
};
return (
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
zIndex: 100,
background: 'var(--lk-bg2, #1a1a1a)',
border: '1px solid var(--lk-border-color, rgba(255,255,255,0.15))',
borderRadius: '0.5rem',
padding: '1rem',
minWidth: '280px',
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
}}
>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.875rem', color: 'rgba(255,255,255,0.9)' }}>
Want to receive a summary of this call?
</p>
<input
type="email"
placeholder="your@email.com (optional)"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
style={{
width: '100%',
padding: '0.5rem 0.75rem',
background: 'var(--lk-bg, #111)',
border: '1px solid var(--lk-border-color, rgba(255,255,255,0.15))',
borderRadius: '0.375rem',
color: 'white',
fontSize: '0.875rem',
marginBottom: '0.75rem',
outline: 'none',
boxSizing: 'border-box',
}}
/>
{error && (
<p style={{ color: '#ff4444', fontSize: '0.75rem', margin: '0 0 0.5rem' }}>{error}</p>
)}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="lk-button"
onClick={onSkip}
disabled={isLoading}
style={{ flex: 1, fontSize: '0.8125rem' }}
>
{isLoading ? 'Loading...' : 'Skip'}
</button>
<button
className="lk-button"
onClick={handleSubscribe}
disabled={isLoading || !email.trim()}
style={{
flex: 1,
fontSize: '0.8125rem',
background: 'var(--lk-accent-bg, #ff6352)',
border: 'none',
}}
>
{isLoading ? 'Loading...' : 'Subscribe'}
</button>
</div>
</div>
);
}
export function SubtitlesSettings() {
const room = useRoomContext();
const { settings, updateSettings, hasAgent, summaryEmail, setSummaryEmail } =
useSubtitleSettings();
const [showPopup, setShowPopup] = React.useState(false);
const [isSpawning, setIsSpawning] = React.useState(false);
const [spawnError, setSpawnError] = React.useState<string | null>(null);
const getPassphrase = () => {
if (typeof window !== 'undefined') {
const hash = window.location.hash;
return hash?.length > 1 ? hash.substring(1) : undefined;
}
return undefined;
};
const spawnAgent = async (email?: string) => {
if (!room?.name) {
setSpawnError('Room not available');
return false;
}
setIsSpawning(true);
setSpawnError(null);
try {
const payload = {
roomName: room.name,
email: email || undefined,
e2eePassphrase: getPassphrase(),
};
console.log('[SubtitlesSettings] Spawning agent with:', payload);
const response = await fetch('/api/agent/spawn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error((await response.text()) || 'Failed to spawn agent');
}
return true;
} catch (error) {
setSpawnError(error instanceof Error ? error.message : 'Failed');
return false;
} finally {
setIsSpawning(false);
}
};
const handleToggle = () => {
if (settings.enabled) {
updateSettings({ ...settings, enabled: false });
} else if (hasAgent) {
updateSettings({ ...settings, enabled: true });
} else {
setShowPopup(true);
setSpawnError(null);
}
};
const handleSkip = async () => {
if (await spawnAgent()) {
setSummaryEmail(null);
updateSettings({ ...settings, enabled: true });
setShowPopup(false);
}
};
const handleSubscribe = async (email: string) => {
if (await spawnAgent(email)) {
setSummaryEmail(email);
updateSettings({ ...settings, enabled: true });
setShowPopup(false);
}
};
const updateSetting = <K extends keyof SubtitleSettings>(key: K, value: SubtitleSettings[K]) => {
updateSettings({ ...settings, [key]: value });
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<h3 style={{ marginBottom: '0.25rem' }}>Subtitles</h3>
<section className="lk-button-group">
<span className="lk-button">Show Subtitles</span>
<div className="lk-button-group-menu" style={{ position: 'relative' }}>
<button className="lk-button" aria-pressed={settings.enabled} onClick={handleToggle}>
{settings.enabled ? 'On' : 'Off'}
</button>
{showPopup && (
<EmailPopup
onSkip={handleSkip}
onSubscribe={handleSubscribe}
onClose={() => !isSpawning && setShowPopup(false)}
isLoading={isSpawning}
error={spawnError}
/>
)}
</div>
</section>
{settings.enabled && (
<>
<section className="lk-button-group">
<span className="lk-button">Font Size</span>
<div className="lk-button-group-menu">
<select
className="lk-button"
value={settings.fontSize}
onChange={(e) => updateSetting('fontSize', Number(e.target.value))}
style={{ minWidth: '100px' }}
>
<option value={18}>Small</option>
<option value={24}>Medium</option>
<option value={32}>Large</option>
<option value={40}>Extra Large</option>
</select>
</div>
</section>
<section className="lk-button-group">
<span className="lk-button">Position</span>
<div className="lk-button-group-menu">
<select
className="lk-button"
value={settings.position}
onChange={(e) =>
updateSetting('position', e.target.value as 'top' | 'center' | 'bottom')
}
style={{ minWidth: '100px' }}
>
<option value="top">Top</option>
<option value="center">Center</option>
<option value="bottom">Bottom</option>
</select>
</div>
</section>
<section className="lk-button-group">
<span className="lk-button">Background</span>
<div className="lk-button-group-menu">
<select
className="lk-button"
value={settings.backgroundColor}
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
style={{ minWidth: '100px' }}
>
<option value="rgba(0, 0, 0, 0.85)">Dark</option>
<option value="rgba(0, 0, 0, 0.6)">Medium</option>
<option value="rgba(0, 0, 0, 0.4)">Light</option>
<option value="transparent">None</option>
</select>
</div>
</section>
{/* Summary email status */}
<div
style={{
marginTop: '0.5rem',
padding: '0.75rem',
background: 'var(--lk-bg, rgba(0,0,0,0.3))',
borderRadius: '0.375rem',
fontSize: '0.8125rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ opacity: 0.7 }}>📧</span>
<span style={{ opacity: 0.7 }}>Summary:</span>
{summaryEmail ? (
<span style={{ color: 'var(--lk-accent-bg, #1fd5f9)' }}>{summaryEmail}</span>
) : (
<span style={{ opacity: 0.5, fontStyle: 'italic' }}>No email set</span>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@ -1,205 +0,0 @@
'use client';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { useRoomContext } from '@livekit/components-react';
import { SummaryTooltip } from './SummaryTooltip';
import { useAgentPresence } from './useAgentPresence';
import { SubtitleSettings } from './SubtitlesOverlay';
interface SubtitlesToggleProps {
settings: SubtitleSettings;
onToggle: (enabled: boolean) => void;
}
/**
* Subtitles toggle button that injects itself into the LiveKit ControlBar.
* Uses React Portal to teleport into .lk-control-bar element.
*/
export function SubtitlesToggle({ settings, onToggle }: SubtitlesToggleProps) {
const room = useRoomContext();
const { hasAgent } = useAgentPresence();
const [showTooltip, setShowTooltip] = React.useState(false);
const [isSpawning, setIsSpawning] = React.useState(false);
const [spawnError, setSpawnError] = React.useState<string | null>(null);
const [controlBar, setControlBar] = React.useState<Element | null>(null);
// Find the control bar element and inject our button
React.useEffect(() => {
const findControlBar = () => {
const bar = document.querySelector('.lk-control-bar');
if (bar) {
setControlBar(bar);
}
};
// Try immediately
findControlBar();
// Also observe DOM changes in case control bar renders later
const observer = new MutationObserver(() => {
if (!controlBar) {
findControlBar();
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}, [controlBar]);
// Get E2EE passphrase from URL hash if present
const getPassphrase = React.useCallback(() => {
if (typeof window !== 'undefined') {
const hash = window.location.hash;
if (hash && hash.length > 1) {
return hash.substring(1);
}
}
return undefined;
}, []);
// Spawn agent via API
const spawnAgent = React.useCallback(
async (email?: string) => {
if (!room?.name) {
setSpawnError('Room not available');
return false;
}
setIsSpawning(true);
setSpawnError(null);
try {
const passphrase = getPassphrase();
const response = await fetch('/api/agent/spawn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
roomName: room.name,
email: email || undefined,
e2eePassphrase: passphrase,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || 'Failed to spawn agent');
}
return true;
} catch (error) {
console.error('Failed to spawn agent:', error);
setSpawnError(error instanceof Error ? error.message : 'Failed to start transcription');
return false;
} finally {
setIsSpawning(false);
}
},
[room?.name, getPassphrase],
);
// Handle button click
const handleClick = React.useCallback(() => {
if (settings.enabled) {
// Turn off subtitles
onToggle(false);
} else {
// Turning on - check if agent present
if (hasAgent) {
onToggle(true);
} else {
// Show tooltip to spawn agent
setShowTooltip(true);
setSpawnError(null);
}
}
}, [settings.enabled, hasAgent, onToggle]);
// Handle skip (no email)
const handleSkip = React.useCallback(async () => {
const success = await spawnAgent();
if (success) {
onToggle(true);
setShowTooltip(false);
}
}, [spawnAgent, onToggle]);
// Handle subscribe (with email)
const handleSubscribe = React.useCallback(
async (email: string) => {
const success = await spawnAgent(email);
if (success) {
onToggle(true);
setShowTooltip(false);
}
},
[spawnAgent, onToggle],
);
// Close tooltip
const handleCloseTooltip = React.useCallback(() => {
if (!isSpawning) {
setShowTooltip(false);
setSpawnError(null);
}
}, [isSpawning]);
const buttonContent = (
<div style={{ position: 'relative', display: 'inline-flex' }}>
<button
className="lk-button"
onClick={handleClick}
aria-pressed={settings.enabled}
title={settings.enabled ? 'Disable subtitles' : 'Enable subtitles'}
>
<SubtitlesIcon enabled={settings.enabled} />
</button>
{showTooltip && (
<SummaryTooltip
onSkip={handleSkip}
onSubscribe={handleSubscribe}
onClose={handleCloseTooltip}
isLoading={isSpawning}
error={spawnError}
/>
)}
</div>
);
// Use portal to inject into control bar
if (!controlBar) {
return null;
}
return createPortal(buttonContent, controlBar);
}
function SubtitlesIcon({ enabled }: { enabled: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
style={{ opacity: enabled ? 1 : 0.6 }}
>
<rect
x="1"
y="3"
width="14"
height="10"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
<path d="M4 8h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9 8h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M4 10.5h5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export default SubtitlesToggle;

View File

@ -1,122 +0,0 @@
'use client';
import * as React from 'react';
import styles from '@/styles/SummaryTooltip.module.css';
const STORAGE_KEY = 'summary-email';
interface SummaryTooltipProps {
onSkip: () => void;
onSubscribe: (email: string) => void;
onClose: () => void;
isLoading?: boolean;
error?: string | null;
}
export function SummaryTooltip({
onSkip,
onSubscribe,
onClose,
isLoading = false,
error = null,
}: SummaryTooltipProps) {
const [email, setEmail] = React.useState('');
const tooltipRef = React.useRef<HTMLDivElement>(null);
// Load email from localStorage on mount
React.useEffect(() => {
try {
const savedEmail = localStorage.getItem(STORAGE_KEY);
if (savedEmail) {
setEmail(savedEmail);
}
} catch (e) {
console.error('Failed to load email from localStorage:', e);
}
}, []);
// Close tooltip when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (tooltipRef.current && !tooltipRef.current.contains(event.target as Node)) {
onClose();
}
};
// Add listener with a small delay to prevent immediate close
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 100);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleSubscribe = () => {
if (email.trim()) {
// Save email to localStorage
try {
localStorage.setItem(STORAGE_KEY, email.trim());
} catch (e) {
console.error('Failed to save email to localStorage:', e);
}
onSubscribe(email.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && email.trim()) {
handleSubscribe();
} else if (e.key === 'Escape') {
onClose();
}
};
if (isLoading) {
return (
<div className={styles.tooltipContainer} ref={tooltipRef}>
<div className={styles.tooltip}>
<div className={styles.loading}>
<div className={styles.spinner} />
<span>Starting transcription...</span>
</div>
</div>
</div>
);
}
return (
<div className={styles.tooltipContainer} ref={tooltipRef}>
<div className={styles.tooltip}>
<p className={styles.title}>Want to receive a summary of this call?</p>
<input
type="email"
className={styles.emailInput}
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
<div className={styles.buttons}>
<button className={styles.skipButton} onClick={onSkip} disabled={isLoading}>
Skip
</button>
<button
className={styles.subscribeButton}
onClick={handleSubscribe}
disabled={isLoading || !email.trim()}
>
Subscribe
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
</div>
</div>
);
}
export default SummaryTooltip;

View File

@ -1,18 +0,0 @@
'use client';
import React from 'react';
import { formatChatMessageLinks } from '@livekit/components-react';
/**
* Chat message formatter with newline support.
* Wraps LiveKit's formatChatMessageLinks and adds <br/> for line breaks.
*/
export function formatChatMessage(message: string): React.ReactNode {
return message
.split('\n')
.flatMap((line, i) =>
i === 0
? [formatChatMessageLinks(line)]
: [React.createElement('br', { key: `br-${i}` }), formatChatMessageLinks(line)],
);
}

View File

@ -1,61 +0,0 @@
'use client';
import * as React from 'react';
import { useRoomContext } from '@livekit/components-react';
import { RoomEvent, RemoteParticipant } from 'livekit-client';
// Agent name must match config.py BOT_NAME
const TRANSCRIPTION_AGENT_NAME = 'LiveKit Transcription';
/**
* Hook to detect if a transcription agent is present in the room.
* Checks for participant with name "LiveKit Transcription".
*/
export function useAgentPresence() {
const room = useRoomContext();
const [hasAgent, setHasAgent] = React.useState(false);
const [agentParticipant, setAgentParticipant] = React.useState<RemoteParticipant | null>(null);
const checkForAgent = React.useCallback(() => {
if (!room) {
setHasAgent(false);
setAgentParticipant(null);
return;
}
// Check all remote participants for our transcription agent by name
for (const participant of room.remoteParticipants.values()) {
if (participant.name === TRANSCRIPTION_AGENT_NAME) {
setHasAgent(true);
setAgentParticipant(participant);
return;
}
}
setHasAgent(false);
setAgentParticipant(null);
}, [room]);
React.useEffect(() => {
if (!room) return;
// Initial check
checkForAgent();
// Listen for participant changes
const handleParticipantConnected = () => checkForAgent();
const handleParticipantDisconnected = () => checkForAgent();
room.on(RoomEvent.ParticipantConnected, handleParticipantConnected);
room.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected);
return () => {
room.off(RoomEvent.ParticipantConnected, handleParticipantConnected);
room.off(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected);
};
}, [room, checkForAgent]);
return { hasAgent, agentParticipant, checkForAgent };
}
export default useAgentPresence;

View File

@ -14,20 +14,20 @@
},
"dependencies": {
"@datadog/browser-logs": "^5.23.3",
"@livekit/components-react": "2.9.16",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/krisp-noise-filter": "0.3.4",
"@livekit/track-processors": "^0.6.0",
"livekit-client": "2.16.0",
"livekit-server-sdk": "2.14.2",
"next": "15.2.6",
"@livekit/krisp-noise-filter": "0.4.1",
"@livekit/track-processors": "^0.7.0",
"livekit-client": "2.17.2",
"livekit-server-sdk": "2.15.0",
"next": "15.2.8",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hot-toast": "^2.5.2",
"tinykeys": "^3.0.0"
},
"devDependencies": {
"@types/node": "22.19.1",
"@types/node": "24.10.13",
"@types/react": "18.3.27",
"@types/react-dom": "18.3.7",
"eslint": "9.39.1",

1032
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,279 +0,0 @@
/* Subtitles Overlay - Cinematic Style */
.subtitlesContainer {
position: fixed;
left: 0;
right: 0;
z-index: 100;
pointer-events: none;
padding: 1.5rem 3rem;
display: flex;
justify-content: center;
}
.positionTop {
top: 2rem;
}
.positionCenter {
top: 50%;
transform: translateY(-50%);
}
.positionBottom {
bottom: 4rem;
}
.subtitlesInner {
max-width: 85%;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
.subtitleLine {
font-family: var(--subtitle-font-family);
font-size: var(--subtitle-font-size);
font-weight: 400;
line-height: 1.5;
text-align: center;
padding: 0.6rem 1.4rem;
border-radius: 6px;
background: var(--subtitle-bg);
color: var(--subtitle-text);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05);
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: center bottom;
letter-spacing: 0.01em;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.96);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.speaker {
font-weight: 600;
margin-right: 0.5em;
text-transform: uppercase;
font-size: 0.75em;
letter-spacing: 0.08em;
color: #4ECDC4; /* Teal accent */
}
.speaker::after {
content: '';
display: inline-block;
width: 4px;
height: 4px;
background: currentColor;
border-radius: 50%;
margin-left: 0.6em;
margin-right: 0.3em;
vertical-align: middle;
opacity: 0.6;
}
.text {
font-weight: 400;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
/* Settings Panel Styles */
.settingsPanel {
background: rgba(18, 18, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 1.25rem;
width: 320px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
}
.settingsTitle {
font-family: 'TWK Everett', sans-serif;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.4);
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.settingsRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.875rem;
}
.settingsRow:last-child {
margin-bottom: 0;
}
.settingsLabel {
font-family: 'TWK Everett', sans-serif;
font-size: 0.85rem;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
.settingsControl {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Toggle Switch */
.toggle {
position: relative;
width: 44px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle.active {
background: #ff6352;
}
.toggleKnob {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle.active .toggleKnob {
transform: translateX(20px);
}
/* Slider */
.slider {
-webkit-appearance: none;
appearance: none;
width: 100px;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #ff6352;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 8px rgba(255, 99, 82, 0.4);
transition: transform 0.15s ease;
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.sliderValue {
font-family: 'TWK Everett', sans-serif;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
min-width: 2rem;
text-align: right;
}
/* Select Dropdown */
.select {
font-family: 'TWK Everett', sans-serif;
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
outline: none;
transition: all 0.15s ease;
}
.select:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
}
.select:focus {
border-color: #ff6352;
box-shadow: 0 0 0 2px rgba(255, 99, 82, 0.2);
}
/* Color Picker */
.colorPicker {
width: 32px;
height: 24px;
border: none;
border-radius: 4px;
cursor: pointer;
padding: 0;
background: none;
}
.colorPicker::-webkit-color-swatch-wrapper {
padding: 0;
}
.colorPicker::-webkit-color-swatch {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
/* Divider */
.settingsDivider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 1rem 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.subtitlesContainer {
padding: 1rem 1.5rem;
}
.subtitlesInner {
max-width: 95%;
}
.subtitleLine {
font-size: calc(var(--subtitle-font-size) * 0.85);
padding: 0.5rem 1rem;
}
}

View File

@ -1,134 +0,0 @@
.tooltipContainer {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 0.5rem;
z-index: 100;
}
.tooltip {
background: var(--lk-bg2, #1a1a1a);
border: 1px solid var(--lk-border-color, rgba(255, 255, 255, 0.15));
border-radius: 0.5rem;
padding: 1rem;
min-width: 280px;
max-width: 320px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.tooltip::after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 8px solid transparent;
border-bottom-color: var(--lk-bg2, #1a1a1a);
}
.title {
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 0.75rem 0;
line-height: 1.4;
}
.emailInput {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--lk-bg, #111);
border: 1px solid var(--lk-border-color, rgba(255, 255, 255, 0.15));
border-radius: 0.375rem;
color: white;
font-size: 0.875rem;
margin-bottom: 0.75rem;
outline: none;
transition: border-color 0.2s;
}
.emailInput:focus {
border-color: var(--lk-accent-bg, #ff6352);
}
.emailInput::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.buttons {
display: flex;
gap: 0.5rem;
}
.skipButton {
flex: 1;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid var(--lk-border-color, rgba(255, 255, 255, 0.15));
border-radius: 0.375rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s;
}
.skipButton:hover {
background: rgba(255, 255, 255, 0.05);
color: white;
}
.subscribeButton {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--lk-accent-bg, #ff6352);
border: none;
border-radius: 0.375rem;
color: white;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.subscribeButton:hover {
opacity: 0.9;
}
.subscribeButton:disabled,
.skipButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem;
color: rgba(255, 255, 255, 0.7);
font-size: 0.8125rem;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--lk-accent-bg, #ff6352);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
color: #ff4444;
font-size: 0.75rem;
margin-top: 0.5rem;
}