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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/open-source/frontend/app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ export default async function Page() {
const appConfig = await getAppConfig(hdrs);

// Correctly read the environment variables on the server side
const livekitUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

return <App appConfig={appConfig} livekitUrl={livekitUrl} apiUrl={apiUrl} />;
}
return <App appConfig={appConfig} />;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { NextResponse } from 'next/server';
import { AccessToken, type AccessTokenOptions, type VideoGrant } from 'livekit-server-sdk';
import { envVars } from '@/configs/env';

// NOTE: you are expected to define the following environment variables in `.env.local`:
const API_KEY = process.env.LIVEKIT_API_KEY;
const API_SECRET = process.env.LIVEKIT_API_SECRET;
const LIVEKIT_URL = process.env.LIVEKIT_URL;
// const API_KEY = process.env.LIVEKIT_API_KEY;
// const API_SECRET = process.env.LIVEKIT_API_SECRET;
// const LIVEKIT_URL = process.env.LIVEKIT_URL;

// don't cache the results
export const revalidate = 0;
Expand All @@ -18,13 +19,13 @@ export type ConnectionDetails = {

export async function GET() {
try {
if (LIVEKIT_URL === undefined) {
if (envVars.LIVEKIT_URL === undefined) {
throw new Error('LIVEKIT_URL is not defined');
}
if (API_KEY === undefined) {
if (envVars.LIVEKIT_API_KEY === undefined) {
throw new Error('LIVEKIT_API_KEY is not defined');
}
if (API_SECRET === undefined) {
if (envVars.LIVEKIT_API_SECRET === undefined) {
throw new Error('LIVEKIT_API_SECRET is not defined');
}

Expand All @@ -39,14 +40,16 @@ export async function GET() {

// Return connection details
const data: ConnectionDetails = {
serverUrl: LIVEKIT_URL,
serverUrl: envVars.LIVEKIT_URL,
roomName,
participantToken: participantToken,
participantName,
};
const headers = new Headers({
'Cache-Control': 'no-store',
});

console.log({ data });
return NextResponse.json(data, { headers });
} catch (error) {
if (error instanceof Error) {
Expand All @@ -56,11 +59,18 @@ export async function GET() {
}
}

function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
const at = new AccessToken(API_KEY, API_SECRET, {
...userInfo,
ttl: '15m',
});
async function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) {
// if(!envVars.LIVEKIT_API_KEY || !envVars.LIVEKIT_API_SECRET){
// throw new Error('LIVEKIT_API_KEY or LIVEKIT_API_SECRET is not defined');
// }
const at = new AccessToken(
envVars.LIVEKIT_API_KEY as string,
envVars.LIVEKIT_API_SECRET as string,
{
...userInfo,
ttl: '15m',
}
);
const grant: VideoGrant = {
room: roomName,
roomJoin: true,
Expand All @@ -69,5 +79,6 @@ function createParticipantToken(userInfo: AccessTokenOptions, roomName: string)
canSubscribe: true,
};
at.addGrant(grant);
return at.toJwt();
const token = await at.toJwt();
return token;
}
41 changes: 21 additions & 20 deletions apps/open-source/frontend/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,56 @@
'use client';

import { LeadCaptureForm } from '@/src/LeadCaptureForm';
import { useState } from 'react';
import { Room } from 'livekit-client';
import { motion } from 'motion/react';
import { LiveKitRoom, RoomAudioRenderer, StartAudio } from '@livekit/components-react';
import { toastAlert } from '@/components/alert-toast';import { SessionView } from '@/components/session-view';
import { toastAlert } from '@/components/alert-toast';
import { SessionView } from '@/components/session-view';
import { Toaster } from '@/components/ui/sonner';
import { Welcome } from '@/components/welcome';
import useConnectionDetails from '@/hooks/useConnectionDetails';
import type { AppConfig } from '@/lib/types';
import { LeadCaptureForm } from '@/src/LeadCaptureForm';
import { LiveKitSessionManager } from './livekit-session-manager';

const MotionWelcome = motion.create(Welcome);


interface AppProps {
appConfig: AppConfig;
livekitUrl?: string;
apiUrl?: string;
}

export function App({ appConfig, livekitUrl, apiUrl }: AppProps) {
export function App({ appConfig }: AppProps) {
const [sessionStarted, setSessionStarted] = useState(false);
const { connectionDetails, refreshConnectionDetails } = useConnectionDetails({ livekitUrl, apiUrl });
const { connectionDetails, refreshConnectionDetails } = useConnectionDetails();
const [isFormVisible, setIsFormVisible] = useState(false);
const [leadData, setLeadData] = useState(null);

const onDisconnected = () => {
console.log(`[${new Date().toISOString()}] APP: Disconnected from room.`);
setSessionStarted(false);
refreshConnectionDetails();

};

const onMediaDevicesError = (error: Error) => {
toastAlert({
title: 'Encountered an error with your media devices',
description: `${error.name}: ${error.message}`,
});
console.log('Media devices error:', error);
};

const handleFormSubmit = async (room: Room, data: any) => {
const handleFormSubmit = async (room: Room, data: any) => {
if (!room || !room.localParticipant) {
console.error("Room instance not available for RPC.");
console.error('Room instance not available for RPC.');
return;
}
try {
const payload = JSON.stringify(data);
await room.localParticipant.performRpc({
destinationIdentity: "input-right-agent", // Corrected agent identity
method: "submit_lead_form",
destinationIdentity: 'input-right-agent', // Corrected agent identity
method: 'submit_lead_form',
payload: payload,
});
toastAlert({
Expand All @@ -73,6 +73,8 @@ export function App({ appConfig, livekitUrl, apiUrl }: AppProps) {
setLeadData(null);
};

console.log({ connectionDetails });

return (
<>
<MotionWelcome
Expand All @@ -85,14 +87,13 @@ export function App({ appConfig, livekitUrl, apiUrl }: AppProps) {
transition={{ duration: 0.5, ease: 'linear', delay: sessionStarted ? 0 : 0.5 }}
/>

{sessionStarted && connectionDetails && (
<LiveKitRoom
{sessionStarted && connectionDetails && (
<LiveKitRoom
serverUrl={connectionDetails.serverUrl}
token={connectionDetails.participantToken}
audio={true}
onConnected={(room) => {
onConnected={() => {
console.log(`[${new Date().toISOString()}] APP: LiveKitRoom connected.`);

}}
onDisconnected={onDisconnected}
onError={onMediaDevicesError}
Expand All @@ -111,25 +112,25 @@ export function App({ appConfig, livekitUrl, apiUrl }: AppProps) {
<SessionView appConfig={appConfig} />
</motion.div>

<LiveKitSessionManager
appConfig={appConfig}
<LiveKitSessionManager
appConfig={appConfig}
onDisplayForm={(data) => {
setLeadData(data);
setIsFormVisible(true);
}}
}}
/>

{isFormVisible && leadData && (
<LeadCaptureForm
initialData={leadData as any}
onSubmit={(room, data) => handleFormSubmit(room, data)}
onSubmit={(room, data) => handleFormSubmit(room, data)}
onCancel={handleFormCancel}
/>
)}
</LiveKitRoom>
)}

<Toaster />
</>
</>
);
}
}
55 changes: 55 additions & 0 deletions apps/open-source/frontend/configs/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as z from 'zod';

const createEnv = () => {
const isServer = typeof window === 'undefined';

// Base schema for client-side variables (always available)
const baseSchema = {
TOKEN_SERVER_URL: z.string().default('http://localhost:3000'),
LIVEKIT_URL: z.string(),
};

// Server-only schema (only when running on server)
const serverSchema = isServer ? {
LIVEKIT_API_KEY: z.string(),
LIVEKIT_API_SECRET: z.string(),
} : {};

// Combine schemas based on environment
const EnvSchema = z.object({
...baseSchema,
...serverSchema,
});

// Base environment variables (always available)
const baseEnvVars = {
TOKEN_SERVER_URL: process.env.NEXT_PUBLIC_TOKEN_SERVER_URL,
LIVEKIT_URL: process.env.NEXT_PUBLIC_LIVEKIT_URL,
};

// Server-only environment variables
const serverEnvVars = isServer ? {
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY,
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET,
} : {};

// Combine environment variables based on environment
const envVars = {
...baseEnvVars,
...serverEnvVars,
};

const parsedEnv = EnvSchema.safeParse(envVars);

if (!parsedEnv.success) {
throw new Error(`Invalid env provided.
The following variables are missing or invalid:
${Object.entries(parsedEnv.error.flatten().fieldErrors)
.map(([k, v]) => `- ${k}: ${v}`)
.join('\n')}`);
}

return parsedEnv.data ?? {};
};

export const envVars = createEnv();
105 changes: 53 additions & 52 deletions apps/open-source/frontend/hooks/useConnectionDetails.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,65 @@
import { useCallback, useEffect, useState } from 'react';
import { envVars } from '@/configs/env';

export type ConnectionDetails = {
serverUrl: string;
roomName: string;
participantName: string;
participantToken: string;
};
export type ConnectionDetails = {
serverUrl: string;
roomName: string;
participantName: string;
participantToken: string;
};

export default function useConnectionDetails() {
const [connectionDetails, setConnectionDetails] = useState<ConnectionDetails | null>(null);
export default function useConnectionDetails() {
const [connectionDetails, setConnectionDetails] = useState<ConnectionDetails | null>(null);

const fetchConnectionDetails = useCallback(() => {
setConnectionDetails(null);
const getDetails = async () => {
try {
// --- START OF HARDCODED VALUES ---
const businessId = "bob-the-builder-123"; // Renamed variable
const apiUrl = "http://127.0.0.1:8002"; // The open-source token server
const livekitUrl = "wss://contractor-leads-bot-d8djm77w.livekit.cloud"; // Your LiveKit URL
// --- END OF HARDCODED VALUES ---
const fetchConnectionDetails = useCallback(() => {
setConnectionDetails(null);
const getDetails = async () => {
try {
// --- START OF HARDCODED VALUES ---
const businessId = 'bob-the-builder-123'; // Renamed variable
const apiUrl = envVars.TOKEN_SERVER_URL; // The open-source token server

// 1. Generate a unique identifier for this specific conversation
const conversationId = crypto.randomUUID();
const roomName = `${businessId}_${conversationId}`; // Updated room name construction
const livekitUrl = envVars.LIVEKIT_URL; // Your LiveKit URL
// --- END OF HARDCODED VALUES ---

const resp = await fetch(`${apiUrl}/api/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 2. Send both the business_id and the unique roomName to the backend
body: JSON.stringify({
business_id: businessId, // Renamed field in the request body
room_name: roomName
}),
});
// 1. Generate a unique identifier for this specific conversation
const conversationId = crypto.randomUUID();
const roomName = `${businessId}_${conversationId}`; // Updated room name construction

if (!resp.ok) {
const errorBody = await resp.text();
throw new Error(`Failed to fetch token: ${resp.statusText}. Body: ${errorBody}`);
}
const resp = await fetch(`${apiUrl}/api/token`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
// 2. Send both the business_id and the unique roomName to the backend
// body: JSON.stringify({
// business_id: businessId, // Renamed field in the request body
// room_name: roomName,
// }),
});

const data = await resp.json();
if (!resp.ok) {
const errorBody = await resp.text();
throw new Error(`Failed to fetch token: ${resp.statusText}. Body: ${errorBody}`);
}

const details: ConnectionDetails = {
serverUrl: livekitUrl,
roomName: roomName, // 3. Use the unique roomName to connect
participantName: 'Website Visitor',
participantToken: data.token,
};
setConnectionDetails(details);
const data = await resp.json();

} catch (error) {
console.error('Error fetching connection details:', error);
}
};
getDetails();
}, []);
const details: ConnectionDetails = {
serverUrl: livekitUrl,
roomName: roomName, // 3. Use the unique roomName to connect
participantName: 'Website Visitor',
participantToken: data.participantToken,
};
setConnectionDetails(details);
} catch (error) {
console.error('Error fetching connection details:', error);
}
};
getDetails();
}, []);

useEffect(() => {
fetchConnectionDetails();
}, [fetchConnectionDetails]);
useEffect(() => {
fetchConnectionDetails();
}, [fetchConnectionDetails]);

return { connectionDetails, refreshConnectionDetails: fetchConnectionDetails };
}
return { connectionDetails, refreshConnectionDetails: fetchConnectionDetails };
}
Loading