updating frontend and removing superfluous files

This commit is contained in:
akdeb 2025-04-18 15:32:47 +01:00
parent 9bfd91d39f
commit 36762af913
21 changed files with 263 additions and 943 deletions

View file

@ -23,6 +23,7 @@ supabase start # Starts your local Supabase server with the default migrations a
npm install npm install
npm run dev npm run dev
``` ```
> **Login creds:** Email: admin@elatoai.com, Password: admin
3. Add your ESP32-S3 Device MAC Address to the Settings page in the NextJS Frontend. (Remove colons and convert to lowercase, useful for adding friendly user codes when registering multiple devices) 3. Add your ESP32-S3 Device MAC Address to the Settings page in the NextJS Frontend. (Remove colons and convert to lowercase, useful for adding friendly user codes when registering multiple devices)
```bash ```bash
@ -40,27 +41,30 @@ OPENAI_API_KEY=your_openai_api_key
5. Set up your ESP32 Arduino Client. On PlatformIO, first `Build` the project, then `Upload` the project to your ESP32. 5. Set up your ESP32 Arduino Client. On PlatformIO, first `Build` the project, then `Upload` the project to your ESP32.
## 🌟 Features
- **Realtime Speech-to-Speech**: Instant speech conversion powered by OpenAI's Realtime APIs.
- **Create Custom AI Agents**: Create custom agents with different personalities and voices.
- **Secure WebSockets**: Reliable, encrypted WebSocket communication.
- **Server Turn Detection**: Intelligent conversation flow handling for smooth interactions.
- **Opus Audio Compression**: High-quality audio streaming with minimal bandwidth.
- **Global Edge Performance**: Low latency Deno Edge Functions ensuring seamless global conversations.
- **ESP32 Arduino Framework**: Optimized and easy-to-use hardware integration.
## 📌 Project Architecture ## 📌 Project Architecture
ElatoAI consists of three main components: ElatoAI consists of three main components:
1. **Frontend Client** (`Next.js` hosted on Vercel) 1. **Frontend Client** (`Next.js` hosted on Vercel) - to create and talk to your AI agents and 'send' it to your ESP32 device
2. **Edge Server Functions** (`Deno` running on Deno/Supabase Edge) 2. **Edge Server Functions** (`Deno` running on Deno/Supabase Edge) - to handle the websocket connections from the ESP32 device and the OpenAI API calls
3. **ESP32 IoT Client** (`PlatformIO/Arduino`) 3. **ESP32 IoT Client** (`PlatformIO/Arduino`) - to receive the websocket connections from the Edge Server Functions and send audio to the OpenAI API via the Deno edge server.
## 🌟 Features
1. **Realtime Speech-to-Speech**: Instant speech conversion powered by OpenAI's Realtime APIs.
2. **Create Custom AI Agents**: Create custom agents with different personalities and voices.
3. **Customizable Voices**: Choose from a variety of voices and personalities.
4. **Secure WebSockets**: Reliable, encrypted WebSocket communication.
5. **Server Turn Detection**: Intelligent conversation flow handling for smooth interactions.
6. **Opus Audio Compression**: High-quality audio streaming with minimal bandwidth.
7. **Global Edge Performance**: Low latency Deno Edge Functions ensuring seamless global conversations.
8. **ESP32 Arduino Framework**: Optimized and easy-to-use hardware integration.
9. **Conversation History**: View your conversation history.
10. **Device Management**: Register and manage your devices.
11. **User Authentication**: Secure user authentication and authorization.
12. **Conversations with WebRTC and Websockets**: Talk to your AI with WebRTC on the NextJS webapp and with websockets on the ESP32.
## 🛠 Tech Stack ## 🛠 Tech Stack
| Component | Technology Used | | Component | Technology Used |

View file

@ -4,13 +4,16 @@ import { encodedRedirect } from "@/utils/utils";
import { createClient } from "@/utils/supabase/server"; import { createClient } from "@/utils/supabase/server";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { encryptSecret, getMacAddressFromDeviceCode, isValidMacAddress } from "@/lib/utils"; import { getMacAddressFromDeviceCode, isValidMacAddress } from "@/lib/utils";
import { addUserToDevice, dbCheckUserCode } from "@/db/devices"; import { addUserToDevice, dbCheckUserCode } from "@/db/devices";
import { getSimpleUserById, updateUser } from "@/db/users"; import { getSimpleUserById, updateUser } from "@/db/users";
export async function deleteUserApiKey(userId: string) { export async function deleteUserApiKey(userId: string) {
const supabase = createClient(); const supabase = createClient();
const { error } = await supabase.from('api_keys').delete().eq('user_id', userId); const { error } = await supabase.from("api_keys").delete().eq(
"user_id",
userId,
);
return error; return error;
} }
@ -41,12 +44,13 @@ export const forgotPasswordAction = async (formData: FormData) => {
return encodedRedirect( return encodedRedirect(
"error", "error",
"/forgot-password", "/forgot-password",
"Email is required" "Email is required",
); );
} }
const { error } = await supabase.auth.resetPasswordForEmail(email, { const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`, redirectTo:
`${origin}/auth/callback?redirect_to=/protected/reset-password`,
}); });
if (error) { if (error) {
@ -54,7 +58,7 @@ export const forgotPasswordAction = async (formData: FormData) => {
return encodedRedirect( return encodedRedirect(
"error", "error",
"/forgot-password", "/forgot-password",
"Could not reset password" "Could not reset password",
); );
} }
@ -65,7 +69,7 @@ export const forgotPasswordAction = async (formData: FormData) => {
return encodedRedirect( return encodedRedirect(
"success", "success",
"/forgot-password", "/forgot-password",
"Check your email for a link to reset your password." "Check your email for a link to reset your password.",
); );
}; };
@ -79,7 +83,7 @@ export const resetPasswordAction = async (formData: FormData) => {
encodedRedirect( encodedRedirect(
"error", "error",
"/protected/reset-password", "/protected/reset-password",
"Password and confirm password are required" "Password and confirm password are required",
); );
} }
@ -87,7 +91,7 @@ export const resetPasswordAction = async (formData: FormData) => {
encodedRedirect( encodedRedirect(
"error", "error",
"/protected/reset-password", "/protected/reset-password",
"Passwords do not match" "Passwords do not match",
); );
} }
@ -99,7 +103,7 @@ export const resetPasswordAction = async (formData: FormData) => {
encodedRedirect( encodedRedirect(
"error", "error",
"/protected/reset-password", "/protected/reset-password",
"Password update failed" "Password update failed",
); );
} }
@ -118,7 +122,7 @@ export const checkDoctorAction = async (authCode: string) => {
export const connectUserToDevice = async ( export const connectUserToDevice = async (
userId: string, userId: string,
userDeviceCode: string userDeviceCode: string,
) => { ) => {
const supabase = createClient(); const supabase = createClient();
@ -131,7 +135,7 @@ export const connectUserToDevice = async (
const successfullyAdded = await addUserToDevice( const successfullyAdded = await addUserToDevice(
supabase, supabase,
userDeviceCode, userDeviceCode,
userId userId,
); );
return successfullyAdded; return successfullyAdded;
}; };
@ -171,56 +175,7 @@ export const isPremiumUser = async (userId: string) => {
return dbUser?.is_premium; return dbUser?.is_premium;
}; };
export const setDeviceReset = async (userId: string) => { export async function registerDevice(userId: string, deviceCode: string) {
const supabase = createClient();
await supabase
.from("users")
.update({ is_reset: true })
.eq("user_id", userId);
};
export const setDeviceOta = async (userId: string) => {
const supabase = createClient();
await supabase.from("users").update({ is_ota: true }).eq("user_id", userId);
};
export async function storeUserApiKey(userId: string, rawApiKey: string) {
const supabase = createClient();
const { iv, encryptedData } = encryptSecret(rawApiKey, process.env.ENCRYPTION_KEY!);
const { error } = await supabase
.from('api_keys')
.upsert({
user_id: userId,
encrypted_key: encryptedData,
iv: iv,
});
if (error) {
console.error('Error inserting or updating user secret:', error);
throw error;
}
console.log(`Encrypted API key for user ${userId} stored successfully.`);
}
export async function checkIfUserHasApiKey(userId: string): Promise<boolean> {
const supabase = createClient();
const { data, error } = await supabase
.from('api_keys')
.select('*', { count: 'exact' })
.eq('user_id', userId);
if (error) {
console.error('Error checking if user has API key:', error);
throw error;
}
return data.length > 0;
}
export async function registerDevice(userId: string, deviceCode: string) {
// check if deviceCode is valid mac address // check if deviceCode is valid mac address
if (!isValidMacAddress(deviceCode)) { if (!isValidMacAddress(deviceCode)) {
return { error: "Invalid device code" }; return { error: "Invalid device code" };
@ -228,12 +183,15 @@ export async function storeUserApiKey(userId: string, rawApiKey: string) {
const supabase = createClient(); const supabase = createClient();
const { data, error } = await supabase const { data, error } = await supabase
.from('devices') .from("devices")
.insert({ user_id: userId, user_code: deviceCode.toLowerCase(), mac_address: getMacAddressFromDeviceCode(deviceCode).toUpperCase() }).select(); .insert({
user_id: userId,
user_code: deviceCode, // this is the device code that the user will use to register their device (friendly code preferred)
mac_address: deviceCode,
}).select();
if (error) { if (error) {
console.log(error) console.log(error);
return { error: "Error registering device" }; return { error: "Error registering device" };
} }
@ -242,21 +200,4 @@ export async function storeUserApiKey(userId: string, rawApiKey: string) {
} }
return { error: null }; return { error: null };
} }
export const createPersonality = async (userId: string, personality: IPersonality): Promise<IPersonality | null> => {
const supabase = createClient();
const { data, error } = await supabase
.from('personalities')
.insert({
...personality,
creator_id: userId
}).select();
if (error) {
console.error('Error creating personality:', error);
throw error;
}
return data ? data[0] : null;
}

View file

@ -10,16 +10,6 @@ interface IPayload {
timestamp: string; timestamp: string;
} }
const getDoctorGuidanceHistory = async (
supabase: SupabaseClient,
userId: string,
): Promise<string> => {
const { data, error } = await supabase.from('conversations').select('*').eq('user_id', userId).eq('role', 'doctor').order('created_at', { ascending: false }).limit(10);
return data?.map((chat: IConversation) => {
const timestamp = chat.created_at ? new Date(chat.created_at).toLocaleString() : "";
return `${chat.role} [${timestamp}]: ${chat.content}`;
}).join("") ?? "";}
const getChatHistory = async ( const getChatHistory = async (
supabase: SupabaseClient, supabase: SupabaseClient,
userId: string, userId: string,
@ -27,21 +17,23 @@ const getChatHistory = async (
): Promise<string> => { ): Promise<string> => {
try { try {
let query = supabase let query = supabase
.from('conversations') .from("conversations")
.select('*') .select("*")
.eq('user_id', userId) .eq("user_id", userId)
.order('created_at', { ascending: false }) .order("created_at", { ascending: false })
.limit(10); .limit(10);
if (personalityKey) { if (personalityKey) {
query = query.eq('personality_key', personalityKey); query = query.eq("personality_key", personalityKey);
} }
const { data, error } = await query; const { data, error } = await query;
if (error) throw error; if (error) throw error;
const messages = data.map((chat: IConversation) => `${chat.role}: ${chat.content}`) const messages = data.map((chat: IConversation) =>
.join('\n'); `${chat.role}: ${chat.content}`
)
.join("\n");
return messages; return messages;
} catch (error: any) { } catch (error: any) {
@ -57,23 +49,11 @@ Your physical form is in the form of a physical object or a toy.
A person interacts with you by pressing a button, sends you instructions and you must respond in a concise conversational style. A person interacts with you by pressing a button, sends you instructions and you must respond in a concise conversational style.
`; `;
const DoctorPromptTemplate = (user: IUser) => { const getCommonPromptTemplate = (
const userMetadata = user.user_info.user_metadata as IDoctorMetadata; chatHistory: string,
const doctorName = userMetadata.doctor_name || 'Doctor'; user: IUser,
const hospitalName = userMetadata.hospital_name || 'An amazing hospital'; timestamp: string,
const specialization = userMetadata.specialization || 'general medicine'; ) => `
const favoritePhrases = userMetadata.favorite_phrases || "You're doing an amazing job";
return `
YOU ARE TALKING TO a patient under the care of doctor ${doctorName} from hospital or clinic ${hospitalName}. The child may be undergoing procedures such as ${specialization}.
YOU ARE: A friendly, compassionate toy designed to offer comfort and care. You specialize in calming children and answering their questions with simple, concise and soothing explanations.
YOUR FAVORITE PHRASES ARE: ${favoritePhrases}
`;
};
const getCommonPromptTemplate = (chatHistory: string, user: IUser, timestamp: string) => `
YOUR VOICE IS: ${user.personality?.voice_prompt} YOUR VOICE IS: ${user.personality?.voice_prompt}
YOUR CHARACTER PROMPT IS: ${user.personality?.character_prompt} YOUR CHARACTER PROMPT IS: ${user.personality?.character_prompt}
@ -83,7 +63,9 @@ ${chatHistory}
USER'S CURRENT TIME IS: ${timestamp} USER'S CURRENT TIME IS: ${timestamp}
LANGUAGE: LANGUAGE:
You may talk in any language the user would like, but the default language is ${user?.language?.name ?? 'English'}. You may talk in any language the user would like, but the default language is ${
user?.language?.name ?? "English"
}.
`; `;
const getStoryPromptTemplate = (user: IUser, chatHistory: string) => { const getStoryPromptTemplate = (user: IUser, chatHistory: string) => {
@ -122,37 +104,15 @@ Let's begin the adventure now!
`; `;
}; };
const getDoctorGuidanceTemplate = async ({user, supabase, timestamp}: IPayload) => {
const chatHistory = await getDoctorGuidanceHistory(supabase, user.user_id);
const userMetadata = user.user_info.user_metadata as IDoctorMetadata;
const doctorName = userMetadata.doctor_name || 'Doctor';
const hospitalName = userMetadata.hospital_name || 'An amazing hospital';
const specialization = userMetadata.specialization || 'general medicine';
return `
- You are talking to the doctor. Your physical form is actually a medical wellness toy for children.
- The doctor will either ask you questions or give you instructions on how to help this child.
- You must respond in a concise conversational style.
Your voice:
- Talk in a serious, sincere and professional tone.
- Do not add exclamations or excited words.
Current time:
${new Date(timestamp).toLocaleString()}.
Chat history with the doctor:
${chatHistory}
Doctor background:
The doctor's name is ${doctorName} and the hospital is ${hospitalName}. The doctor is a specialist in ${specialization}.
`
};
const createSystemPrompt = async ( const createSystemPrompt = async (
payload: IPayload, payload: IPayload,
): Promise<string> => { ): Promise<string> => {
const { user, supabase, timestamp } = payload; const { user, supabase, timestamp } = payload;
const chatHistory = await getChatHistory(supabase, user.user_id, user.personality?.key ?? null); const chatHistory = await getChatHistory(
supabase,
user.user_id,
user.personality?.key ?? null,
);
const commonPrompt = getCommonPromptTemplate(chatHistory, user, timestamp); const commonPrompt = getCommonPromptTemplate(chatHistory, user, timestamp);
const isStory = user.personality?.is_story; const isStory = user.personality?.is_story;
@ -163,14 +123,11 @@ const createSystemPrompt = async (
let systemPrompt; let systemPrompt;
switch (user.user_info.user_type) { switch (user.user_info.user_type) {
case 'user': case "user":
systemPrompt = UserPromptTemplate(user); systemPrompt = UserPromptTemplate(user);
break; break;
case 'doctor':
systemPrompt = DoctorPromptTemplate(user);
break;
default: default:
throw new Error('Invalid user type'); throw new Error("Invalid user type");
} }
return systemPrompt + commonPrompt; return systemPrompt + commonPrompt;
}; };
@ -184,43 +141,24 @@ const createSystemPrompt = async (
*/ */
function decryptSecret(encryptedData: string, iv: string, masterKey: string) { function decryptSecret(encryptedData: string, iv: string, masterKey: string) {
// Decode the base64 master key // Decode the base64 master key
const decodedKey = Buffer.from(masterKey, 'base64'); const decodedKey = Buffer.from(masterKey, "base64");
if (decodedKey.length !== 32) { if (decodedKey.length !== 32) {
throw new Error('ENCRYPTION_KEY must be 32 bytes when decoded from base64.'); throw new Error(
"ENCRYPTION_KEY must be 32 bytes when decoded from base64.",
);
} }
const decipher = crypto.createDecipheriv( const decipher = crypto.createDecipheriv(
'aes-256-cbc' as any, "aes-256-cbc" as any,
Buffer.from(masterKey, 'base64') as any, Buffer.from(masterKey, "base64") as any,
Buffer.from(iv, 'base64') as any Buffer.from(iv, "base64") as any,
); );
let decrypted = decipher.update(encryptedData, 'base64', 'utf8'); let decrypted = decipher.update(encryptedData, "base64", "utf8");
decrypted += decipher.final('utf8'); decrypted += decipher.final("utf8");
return decrypted; return decrypted;
} }
const getOpenAiApiKey = async (
supabase: SupabaseClient,
userId: string,
): Promise<string> => {
const { data, error } = await supabase
.from('api_keys')
.select('encrypted_key, iv')
.eq('user_id', userId)
.single();
if (error) throw error;
const { encrypted_key, iv } = data;
const masterKey = process.env.ENCRYPTION_KEY!;
const decryptedKey = decryptSecret(encrypted_key, iv, masterKey);
return decryptedKey;
};
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = createClient(); const supabase = createClient();
@ -234,9 +172,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "User not found" }, { status: 404 }); return NextResponse.json({ error: "User not found" }, { status: 404 });
} }
const isDoctor = dbUser.user_info.user_type === "doctor"; const openAiApiKey = process.env.OPENAI_API_KEY;
const openAiApiKey = await getOpenAiApiKey(supabase, user.id); const systemPrompt = await createSystemPrompt({
const systemPrompt = isDoctor ? await getDoctorGuidanceTemplate({ user: dbUser, supabase, timestamp: new Date().toISOString() }) : await createSystemPrompt({ user: dbUser, supabase, timestamp: new Date().toISOString() }); user: dbUser,
supabase,
timestamp: new Date().toISOString(),
});
try { try {
const response = await fetch( const response = await fetch(
@ -248,11 +189,11 @@ export async function GET(request: NextRequest) {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'gpt-4o-mini-realtime-preview-2024-12-17', model: "gpt-4o-mini-realtime-preview-2024-12-17",
instructions: systemPrompt, instructions: systemPrompt,
voice: isDoctor ? 'ballad' : dbUser.personality?.oai_voice ?? 'ballad', voice: dbUser.personality?.oai_voice ?? "ballad",
}), }),
} },
); );
const data = await response.json(); const data = await response.json();
return NextResponse.json(data); return NextResponse.json(data);
@ -260,7 +201,7 @@ export async function GET(request: NextRequest) {
console.error("Error in /session:", error); console.error("Error in /session:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal Server Error" }, { error: "Internal Server Error" },
{ status: 500 } { status: 500 },
); );
} }
} }

View file

@ -1,92 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Check, Key, Trash } from "lucide-react";
import { checkIfUserHasApiKey, storeUserApiKey, deleteUserApiKey } from "../actions";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
interface AuthTokenModalProps {
user: IUser;
userHasApiKey: () => void;
hasApiKey: boolean;
setHasApiKey: (hasApiKey: boolean) => void;
}
const AuthTokenModal: React.FC<AuthTokenModalProps> = ({ user, userHasApiKey, hasApiKey, setHasApiKey }) => {
const { toast } = useToast();
const [apiKey, setApiKey] = useState<string>("");
return (
<Dialog>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex flex-row items-center gap-2"
onClick={async () => {
userHasApiKey();
}}
>
<Key size={16} />
<span>Set your key</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Set your OpenAI API Key</DialogTitle>
<DialogDescription>
This key is kept encrypted and never stored on our servers as plain text.
</DialogDescription>
</DialogHeader>
<div className="flex flex-row gap-2 py-4">
<Input
id="api_key"
value={apiKey}
disabled={hasApiKey}
placeholder={hasApiKey ? "sk-... (OpenAI API Key already set)" : "sk-..."}
onChange={(e) => {
setApiKey(e.target.value);
}}
/>
<Button
size="icon"
variant="ghost"
// disabled={!apiKey || hasApiKey}
onClick={async () => {
if (!hasApiKey) {
await storeUserApiKey(user.user_id, apiKey);
setHasApiKey(true); // Set this immediately
userHasApiKey();
toast({
description: "OpenAI API Key added",
});
setApiKey("********************");
} else {
await deleteUserApiKey(user.user_id);
setHasApiKey(false);
userHasApiKey();
toast({
description: "OpenAI API Key removed",
});
}
}}
className="flex-shrink-0"
>
{!hasApiKey ?<Check className="flex-shrink-0" size={18} /> : <Trash className="flex-shrink-0" size={18} />}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AuthTokenModal;

View file

@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react"; import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react";
import Twemoji from "react-twemoji"; import Twemoji from "react-twemoji";
import { createPersonality } from "../../actions"; import { createPersonality } from "@/db/personalities";
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -163,7 +163,7 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
} }
try { try {
const personality = await createPersonality(selectedUser.user_id, { const personality = await createPersonality(supabase, selectedUser.user_id, {
title: formData.title, title: formData.title,
subtitle: "", subtitle: "",
character_prompt: formData.prompt, character_prompt: formData.prompt,

View file

@ -1,44 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { getCreditsRemaining } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import AddCreditsModal from "./Upsell/AddCreditsModal";
const CreditsRemaining: React.FC<{
user: IUser;
languageCode: string;
}> = ({ user, languageCode }) => {
const creditsRemaining = getCreditsRemaining(user);
const hasNoCredits = creditsRemaining <= 0;
if (user.is_premium) {
return null;
}
return (
<div className="flex flex-row items-center gap-4">
<p
className={`text-sm ${hasNoCredits ? "text-gray-400" : "text-gray-400"}`}
>
{creditsRemaining} {"credits remaining"}
</p>
{creditsRemaining <= 50 && (
<AddCreditsModal>
<Button
variant="upsell_link"
className="flex flex-row items-center gap-2 pl-0"
size="sm"
>
<Sparkles size={16} />
{hasNoCredits
? "Upgrade to continue"
: "Get unlimited access"}
</Button>
</AddCreditsModal>
)}
</div>
);
};
export default CreditsRemaining;

View file

@ -1,4 +1,3 @@
import CreditsRemaining from "./CreditsRemaining";
interface HomePageSubtitlesProps { interface HomePageSubtitlesProps {
user: IUser; user: IUser;
page: "home" | "settings" | "create"; page: "home" | "settings" | "create";

View file

@ -1,59 +1,71 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import Image from "next/image";
import { motion } from 'framer-motion'; import { cn, getPersonalityImageSrc } from "@/lib/utils";
import SheetWrapper from '../Playground/SheetWrapper'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmojiComponent } from "../Playground/EmojiImage";
// Example character data - replace with your actual data
const characters = [
{ id: 1, name: 'Assistant', description: 'Helpful AI assistant', image: '/characters/assistant.png' },
{ id: 2, name: 'Storyteller', description: 'Creative storytelling companion', image: '/characters/storyteller.png' },
{ id: 3, name: 'Tutor', description: 'Patient educational guide', image: '/characters/tutor.png' },
{ id: 4, name: 'Comedian', description: 'Witty joke-telling friend', image: '/characters/comedian.png' },
{ id: 5, name: 'Chef', description: 'Culinary expert and recipe advisor', image: '/characters/chef.png' },
{ id: 6, name: 'Fitness Coach', description: 'Motivational exercise companion', image: '/characters/fitness.png' },
// Add more characters as needed
];
interface CharacterShowcaseProps { interface CharacterShowcaseProps {
allPersonalities: IPersonality[]; allPersonalities: IPersonality[];
} }
export const CharacterShowcase = ({ allPersonalities }: CharacterShowcaseProps) => { export const CharacterShowcase = ({ allPersonalities }: CharacterShowcaseProps) => {
const [hoveredCharacter, setHoveredCharacter] = useState<number | null>(null);
return ( return (
<section className="py-16 bg-gradient-to-b from-gray-50 to-white"> <section className="py-16 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4 max-w-screen-lg"> <div className="container mx-auto px-4 max-w-screen-lg">
<div className="flex flex-col lg:flex-row items-center gap-12"> <div className="flex flex-col lg:flex-row items-center gap-12">
{/* Character List - On left for desktop, bottom for mobile */}
<div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto"> <div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto">
<div className="relative"> <div className="relative">
{/* Top scroll indicator/halo */}
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-gray-50 to-transparent z-10 pointer-events-none rounded-t-lg"></div> <div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-gray-50 to-transparent z-10 pointer-events-none rounded-t-lg"></div>
<div className="h-[500px] mx-auto overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 rounded-lg"> <div className="h-[500px] mx-auto overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 rounded-lg">
<div className="grid grid-cols-2 gap-4 md:gap-6 p-4"> <div className="grid grid-cols-2 gap-4 md:gap-6 p-4">
{allPersonalities.map((personality, index) => ( {allPersonalities.map((personality, index) => (
<SheetWrapper <Card
languageState={'en-US'} className={cn(
key={index + personality.personality_id!} "p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col hover:border-primary/50",
personality={personality} )}
personalityIdState={''} >
onPersonalityPicked={() => {}} <CardContent className="flex-shrink-0 p-0 h-[160px] sm:h-[180px] relative">
disableButtons={true} {personality.creator_id === null ? (
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={180}
className="rounded-3xl rounded-br-none rounded-bl-none w-full h-full object-cover"
/> />
) : (
<div className="flex flex-row items-center justify-center h-full">
<EmojiComponent personality={personality} />
</div>
)}
</CardContent>
<CardHeader className="flex-shrink-0 gap-0 px-4 py-2">
<CardTitle className="font-semibold text-md flex flex-row items-center gap-2">
{personality.title}
</CardTitle>
<CardDescription className="text-sm font-normal">
{personality.subtitle}
</CardDescription>
</CardHeader>
</Card>
))} ))}
</div> </div>
</div> </div>
{/* Bottom scroll indicator/halo */}
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-50 to-transparent z-10 pointer-events-none rounded-b-lg"></div> <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-50 to-transparent z-10 pointer-events-none rounded-b-lg"></div>
</div> </div>
</div> </div>
{/* Text Content - On right for desktop, top for mobile */}
<div className="order-1 lg:order-2 w-full lg:w-2/5"> <div className="order-1 lg:order-2 w-full lg:w-2/5">
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800"> <h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800">
Meet Our AI Characters Meet Our AI Characters

View file

@ -1,5 +1,16 @@
import { EnglishCopy } from "@/utils/i18n"; import ModifyCharacterSheet from "./ModifyCharacterSheet";
import SheetWrapper from "./SheetWrapper"; import Image from "next/image";
import { cn, getPersonalityImageSrc } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Check, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmojiComponent } from "./EmojiImage";
interface CharacterSectionProps { interface CharacterSectionProps {
allPersonalities: IPersonality[]; allPersonalities: IPersonality[];
@ -20,6 +31,7 @@ const CharacterSection = ({
disableButtons, disableButtons,
selectedFilters, selectedFilters,
}: CharacterSectionProps) => { }: CharacterSectionProps) => {
const filteredPersonalities = allPersonalities.filter((personality) => { const filteredPersonalities = allPersonalities.filter((personality) => {
return selectedFilters.every((filter) => { return selectedFilters.every((filter) => {
return personality[filter] === true; return personality[filter] === true;
@ -37,16 +49,66 @@ const CharacterSection = ({
</p> </p>
<div className="w-full"> <div className="w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 md:gap-6 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 md:gap-6 gap-4">
{filteredPersonalities.map((personality, index) => ( {filteredPersonalities.map((personality, index) => {
<SheetWrapper const isCurrentPersonality = personalityIdState === personality.personality_id;
return (
<ModifyCharacterSheet
key={personality.personality_id}
openPersonality={personality}
languageState={languageState} languageState={languageState}
key={index + personality.personality_id!} isCurrentPersonality={isCurrentPersonality}
personality={personality}
personalityIdState={personalityIdState}
onPersonalityPicked={onPersonalityPicked} onPersonalityPicked={onPersonalityPicked}
disableButtons={disableButtons} disableButtons={disableButtons}
>
<Card
className={cn(
"p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col",
personalityIdState === personality.personality_id
? "border-primary border-2"
: "hover:border-primary/50"
)}
// onClick={() => onPersonalityPicked(personality)}
>
<CardContent className="flex-shrink-0 p-0 h-[160px] sm:h-[180px] relative">
{personality.creator_id === null ? (
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={180}
className="rounded-3xl rounded-br-none rounded-bl-none w-full h-full object-cover"
/> />
))} ) : (
<div className="flex flex-row items-center justify-center h-full">
<EmojiComponent personality={personality} />
</div>
)}
<Button
size="sm"
variant={isCurrentPersonality ? "default" : "outline"}
className={`absolute shadow-lg top-2 right-2 rounded-full h-9 w-9 p-0 ${isCurrentPersonality ? "bg-primary text-primary-foreground" : ""}`}
onClick={() => onPersonalityPicked(personality.personality_id!)}
aria-label={isCurrentPersonality ? "Deselect feature" : "Select feature"}
>
{true ? (
<Check className="h-4 w-4" strokeWidth={3} />
) : (
<CheckCircle className="h-4 w-4" strokeWidth={3} />
)}
</Button>
</CardContent>
<CardHeader className="flex-shrink-0 gap-0 px-4 py-2">
<CardTitle className="font-semibold text-md flex flex-row items-center gap-2">
{personality.title}
</CardTitle>
<CardDescription className="text-sm font-normal">
{personality.subtitle}
</CardDescription>
</CardHeader>
</Card>
</ModifyCharacterSheet>
);
})}
</div> </div>
</div> </div>
</div> </div>

View file

@ -41,8 +41,6 @@ const ModifyCharacterSheet: React.FC<ModifyCharacterSheetProps> = ({
return ( return (
<div className="flex flex-row gap-4 p-4 "> <div className="flex flex-row gap-4 p-4 ">
<Button <Button
// tabIndex={-1}
// autoFocus={false}
size="lg" size="lg"
className={`w-full rounded-full text-sm md:text-lg flex flex-row items-center gap-1 md:gap-2 transition-colors duration-300 ${ className={`w-full rounded-full text-sm md:text-lg flex flex-row items-center gap-1 md:gap-2 transition-colors duration-300 ${
isSent || isCurrentPersonality isSent || isCurrentPersonality
@ -60,7 +58,7 @@ const ModifyCharacterSheet: React.FC<ModifyCharacterSheetProps> = ({
<Check className="flex-shrink-0 h-5 w-5 md:h-6 md:w-6" /> <Check className="flex-shrink-0 h-5 w-5 md:h-6 md:w-6" />
{isSent || isCurrentPersonality {isSent || isCurrentPersonality
? "Live character" ? "Live character"
: "Chat on Elato"} : "Click to chat"}
</Button> </Button>
</div> </div>
); );

View file

@ -1,52 +0,0 @@
import DoctorPersonalities from "./DoctorPersonalities";
import UserPersonalities from "./UserPersonalities";
interface PickPersonalityProps {
onPersonalityPicked: (personalityIdPicked: string) => void;
allPersonalities: IPersonality[];
personalityIdState: string;
currentUser?: IUser;
languageState: string;
disableButtons: boolean;
selectedFilters: PersonalityFilter[];
myPersonalities: IPersonality[];
}
const PickPersonality: React.FC<PickPersonalityProps> = ({
onPersonalityPicked,
allPersonalities,
personalityIdState,
currentUser,
languageState,
disableButtons,
selectedFilters,
myPersonalities,
}) => {
if (currentUser?.user_info?.user_type === "doctor") {
return (
<DoctorPersonalities
onPersonalityPicked={onPersonalityPicked}
allPersonalities={allPersonalities}
personalityIdState={personalityIdState}
languageState={languageState}
disableButtons={disableButtons}
selectedFilters={selectedFilters}
myPersonalities={myPersonalities}
/>
);
}
return (
<UserPersonalities
myPersonalities={myPersonalities}
onPersonalityPicked={onPersonalityPicked}
allPersonalities={allPersonalities}
personalityIdState={personalityIdState}
languageState={languageState}
disableButtons={disableButtons}
selectedFilters={selectedFilters}
/>
);
};
export default PickPersonality;

View file

@ -1,9 +1,7 @@
"use client"; "use client";
import React, { useCallback, useEffect, useState } from "react"; import React, { useState } from "react";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
import { getCreditsRemaining } from "@/lib/utils";
import PickPersonality from "./PickPersonality";
import { updateUser } from "@/db/users"; import { updateUser } from "@/db/users";
import _ from "lodash"; import _ from "lodash";
import HomePageSubtitles from "../HomePageSubtitles"; import HomePageSubtitles from "../HomePageSubtitles";
@ -11,8 +9,8 @@ import PersonalityFilters from "./PersonalityFilters";
import { TranscriptProvider } from "../Realtime/contexts/TranscriptContext"; import { TranscriptProvider } from "../Realtime/contexts/TranscriptContext";
import { EventProvider } from "../Realtime/contexts/EventContext"; import { EventProvider } from "../Realtime/contexts/EventContext";
import App from "../Realtime/App"; import App from "../Realtime/App";
import { checkIfUserHasApiKey } from "@/app/actions";
import { defaultPersonalityId } from "@/lib/data"; import { defaultPersonalityId } from "@/lib/data";
import UserPersonalities from "./UserPersonalities";
interface PlaygroundProps { interface PlaygroundProps {
currentUser: IUser; currentUser: IUser;
@ -25,20 +23,10 @@ const Playground: React.FC<PlaygroundProps> = ({
allPersonalities, allPersonalities,
myPersonalities, myPersonalities,
}) => { }) => {
const [hasApiKey, setHasApiKey] = useState<boolean>(false);
const isDoctor = currentUser.user_info.user_type === "doctor"; const isDoctor = currentUser.user_info.user_type === "doctor";
useEffect(() => {
const checkApiKey = async () => {
const hasApiKey = await checkIfUserHasApiKey(currentUser.user_id);
setHasApiKey(hasApiKey);
};
checkApiKey();
}, [currentUser.user_id]);
const supabase = createClient(); const supabase = createClient();
// Remove userState entirely and just use personalityState
const [personalityIdState, setPersonalityIdState] = useState<string>( const [personalityIdState, setPersonalityIdState] = useState<string>(
currentUser.personality!.personality_id ?? defaultPersonalityId // Initial value from props currentUser.personality!.personality_id ?? defaultPersonalityId // Initial value from props
); );
@ -47,12 +35,7 @@ const Playground: React.FC<PlaygroundProps> = ({
[] []
); );
const creditsRemaining = getCreditsRemaining(currentUser);
const outOfCredits = creditsRemaining <= 0 && !currentUser.is_premium;
// const ref: any = useRef<ComponentRef<typeof Messages> | null>(null);
const onPersonalityPicked = async (personalityIdPicked: string) => { const onPersonalityPicked = async (personalityIdPicked: string) => {
// Instantaneously update the state variable
setPersonalityIdState(personalityIdPicked); setPersonalityIdState(personalityIdPicked);
await updateUser( await updateUser(
supabase, supabase,
@ -74,7 +57,7 @@ const Playground: React.FC<PlaygroundProps> = ({
<div className="flex flex-col gap-8 items-center justify-center"> <div className="flex flex-col gap-8 items-center justify-center">
<TranscriptProvider> <TranscriptProvider>
<EventProvider> <EventProvider>
<App hasApiKey={hasApiKey} personalityIdState={personalityIdState} isDoctor={isDoctor} userId={currentUser.user_id} /> <App personalityIdState={personalityIdState} isDoctor={isDoctor} userId={currentUser.user_id} />
</EventProvider> </EventProvider>
</TranscriptProvider> </TranscriptProvider>
</div> </div>
@ -93,11 +76,10 @@ const Playground: React.FC<PlaygroundProps> = ({
languageState={'en-US'} languageState={'en-US'}
currentUser={currentUser} currentUser={currentUser}
/> />
<PickPersonality <UserPersonalities
selectedFilters={selectedFilters} selectedFilters={selectedFilters}
onPersonalityPicked={onPersonalityPicked} onPersonalityPicked={onPersonalityPicked}
personalityIdState={personalityIdState} personalityIdState={personalityIdState}
currentUser={currentUser}
languageState={'en-US'} languageState={'en-US'}
disableButtons={false} disableButtons={false}
allPersonalities={isDoctor allPersonalities={isDoctor
@ -107,16 +89,6 @@ const Playground: React.FC<PlaygroundProps> = ({
/> />
</div> </div>
</div> </div>
{/* <ControlPanel
connectionStatus={connectionStatus}
isMuted={isMuted}
muteMicrophone={muteMicrophone}
unmuteMicrophone={unmuteMicrophone}
handleClickInterrupt={handleClickInterrupt}
handleClickCloseConnection={handleClickCloseConnection}
microphoneStream={microphoneStream}
audioBuffer={audioBuffer}
/> */}
</div> </div>
); );
}; };

View file

@ -1,90 +0,0 @@
import ModifyCharacterSheet from "./ModifyCharacterSheet";
import Image from "next/image";
import { cn, getPersonalityImageSrc } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Check, CheckCircle, CircleCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmojiComponent } from "./EmojiImage";
interface SheetWrapperProps {
personality: IPersonality;
personalityIdState: string;
onPersonalityPicked: (personalityId: string) => void;
languageState: string;
disableButtons: boolean;
}
const SheetWrapper: React.FC<SheetWrapperProps> = ({
personality,
personalityIdState,
onPersonalityPicked,
languageState,
disableButtons,
}) => {
const isCurrentPersonality = personalityIdState === personality.personality_id;
return (
<ModifyCharacterSheet
key={personality.personality_id}
openPersonality={personality}
languageState={languageState}
isCurrentPersonality={isCurrentPersonality}
onPersonalityPicked={onPersonalityPicked}
disableButtons={disableButtons}
>
<Card
className={cn(
"p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col",
personalityIdState === personality.personality_id
? "border-primary border-2"
: "hover:border-primary/50"
)}
// onClick={() => onPersonalityPicked(personality)}
>
<CardContent className="flex-shrink-0 p-0 h-[160px] sm:h-[180px] relative">
{personality.creator_id === null ? (
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={180}
className="rounded-3xl rounded-br-none rounded-bl-none w-full h-full object-cover"
/>
) : (
<div className="flex flex-row items-center justify-center h-full">
<EmojiComponent personality={personality} />
</div>
)}
<Button
size="sm"
variant={isCurrentPersonality ? "default" : "outline"}
className={`absolute shadow-lg top-2 right-2 rounded-full h-9 w-9 p-0 ${isCurrentPersonality ? "bg-primary text-primary-foreground" : ""}`}
onClick={() => onPersonalityPicked(personality.personality_id!)}
aria-label={isCurrentPersonality ? "Deselect feature" : "Select feature"}
>
{true ? (
<Check className="h-4 w-4" strokeWidth={3} />
) : (
<CheckCircle className="h-4 w-4" strokeWidth={3} />
)}
</Button>
</CardContent>
<CardHeader className="flex-shrink-0 gap-0 px-4 py-2">
<CardTitle className="font-semibold text-md flex flex-row items-center gap-2">
{personality.title}
</CardTitle>
<CardDescription className="text-sm font-normal">
{personality.subtitle}
</CardDescription>
</CardHeader>
</Card>
</ModifyCharacterSheet>
);
};
export default SheetWrapper;

View file

@ -25,17 +25,14 @@ import { getPersonalityById } from "@/db/personalities";
import { createClient } from "@/utils/supabase/client"; import { createClient } from "@/utils/supabase/client";
interface AppProps { interface AppProps {
hasApiKey: boolean;
personalityIdState: string; personalityIdState: string;
isDoctor: boolean; isDoctor: boolean;
userId: string; userId: string;
} }
function App({ hasApiKey, personalityIdState, isDoctor, userId }: AppProps) { function App({ personalityIdState, isDoctor, userId }: AppProps) {
const supabase = createClient(); const supabase = createClient();
const searchParams = useSearchParams();
const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } = const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } =
useTranscript(); useTranscript();
const { logClientEvent, logServerEvent } = useEvent(); const { logClientEvent, logServerEvent } = useEvent();
@ -376,8 +373,6 @@ function App({ hasApiKey, personalityIdState, isDoctor, userId }: AppProps) {
<BottomToolbar <BottomToolbar
sessionStatus={sessionStatus} sessionStatus={sessionStatus}
onToggleConnection={onToggleConnection} onToggleConnection={onToggleConnection}
hasApiKey={hasApiKey}
personality={personality}
isDoctor={isDoctor} isDoctor={isDoctor}
/> />
</div> </div>

View file

@ -3,24 +3,16 @@ import { SessionStatus } from "@/app/components/Realtime/types";
import { Paperclip, PhoneCall, Play, Stethoscope } from "lucide-react"; import { Paperclip, PhoneCall, Play, Stethoscope } from "lucide-react";
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { getPersonalityById } from "@/db/personalities";
import { createClient } from "@/utils/supabase/client";
import Image from "next/image";
import { EmojiComponent } from "../../Playground/EmojiImage";
interface BottomToolbarProps { interface BottomToolbarProps {
sessionStatus: SessionStatus; sessionStatus: SessionStatus;
onToggleConnection: () => void; onToggleConnection: () => void;
hasApiKey: boolean;
personality: IPersonality;
isDoctor: boolean; isDoctor: boolean;
} }
function BottomToolbar({ function BottomToolbar({
sessionStatus, sessionStatus,
onToggleConnection, onToggleConnection,
hasApiKey,
personality,
isDoctor, isDoctor,
}: BottomToolbarProps) { }: BottomToolbarProps) {
const isConnected = sessionStatus === "CONNECTED"; const isConnected = sessionStatus === "CONNECTED";
@ -44,7 +36,7 @@ function BottomToolbar({
return "Doctor chat"; return "Doctor chat";
} }
const isDisabled = isConnecting || !hasApiKey; const isDisabled = isConnecting;
function getConnectionButtonClasses() { function getConnectionButtonClasses() {
const baseClasses = "text-white text-base p-2 w-fit rounded-full shadow-lg flex flex-row items-center justify-center gap-2 px-4"; const baseClasses = "text-white text-base p-2 w-fit rounded-full shadow-lg flex flex-row items-center justify-center gap-2 px-4";
@ -69,27 +61,13 @@ function BottomToolbar({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
onClick={() => { onClick={() => {
if (hasApiKey) { if (process.env.OPENAI_API_KEY) {
onToggleConnection(); onToggleConnection();
} }
}} }}
className={getConnectionButtonClasses()} className={getConnectionButtonClasses()}
disabled={isDisabled} disabled={isDisabled}
> >
{/* {personality?.creator_id == null ? (
<Image
src={`/personality/${personality?.key}.jpeg`}
alt={personality?.title || "Personality avatar"}
className="w-10 h-10 rounded-tl-full rounded-bl-full mr-2 object-cover"
width={40}
height={40}
quality={100}
onError={(e) => {
// Fallback if image fails to load
e.currentTarget.style.display = 'none';
}}
/>
) : <EmojiComponent personality={personality} size={28} />} */}
{getConnectionButtonIcon()} {getConnectionButtonIcon()}
{isDoctor ? getConnectionButtonLabelForDoctor() : getConnectionButtonLabel()} {isDoctor ? getConnectionButtonLabelForDoctor() : getConnectionButtonLabel()}
</button> </button>

View file

@ -1,4 +1,4 @@
import { checkIfUserHasApiKey, registerDevice, signOutAction } from "@/app/actions"; import { registerDevice, signOutAction } from "@/app/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -35,14 +35,6 @@ const AppSettings: React.FC<AppSettingsProps> = ({
const userFormRef = React.useRef<{ submitForm: () => void } | null>(null); const userFormRef = React.useRef<{ submitForm: () => void } | null>(null);
const [deviceCode, setDeviceCode] = React.useState(""); const [deviceCode, setDeviceCode] = React.useState("");
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [hasApiKey, setHasApiKey] = React.useState<boolean>(false);
const userHasApiKey = useCallback(async () => {
const hasApiKey = await checkIfUserHasApiKey(selectedUser.user_id);
setHasApiKey(hasApiKey);
}, [selectedUser.user_id]);
// ... existing code ...
const handleSave = () => { const handleSave = () => {
if (selectedUser.user_info.user_type === "doctor") { if (selectedUser.user_info.user_type === "doctor") {
@ -58,16 +50,9 @@ const AppSettings: React.FC<AppSettingsProps> = ({
); );
}, [selectedUser.user_id, supabase]); }, [selectedUser.user_id, supabase]);
React.useEffect(() => {
checkIfUserHasDevice();
userHasApiKey();
}, [checkIfUserHasDevice, userHasApiKey]);
const [volume, setVolume] = React.useState([ const [volume, setVolume] = React.useState([
selectedUser.device?.volume ?? 50, selectedUser.device?.volume ?? 50,
]); ]);
const [isReset, setIsReset] = React.useState(selectedUser.device?.is_reset ?? false);
const [isOta, setIsOta] = React.useState(selectedUser.device?.is_ota ?? false);
const debouncedUpdateVolume = _.debounce(async () => { const debouncedUpdateVolume = _.debounce(async () => {
if (selectedUser.device?.device_id) { if (selectedUser.device?.device_id) {
@ -137,24 +122,6 @@ const AppSettings: React.FC<AppSettingsProps> = ({
Device settings Device settings
</h2> </h2>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* <div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
<Label className="text-sm font-medium text-gray-700">
Set your OpenAI API Key
</Label>
<div
className={`rounded-full flex-shrink-0 h-2 w-2 ${
hasApiKey ? 'bg-green-500' : 'bg-amber-500'
}`}
/>
</div>
<div className="flex flex-row items-center gap-2 mt-2">
<AuthTokenModal user={selectedUser} userHasApiKey={userHasApiKey} hasApiKey={hasApiKey} setHasApiKey={setHasApiKey} />
</div>
<p className="text-xs text-gray-400">
Your keys are E2E encrypted and never stored on our servers as plain text.
</p>
</div> */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Label className="text-sm font-medium text-gray-700"> <Label className="text-sm font-medium text-gray-700">
@ -166,7 +133,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
? ?
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>For simplicity, you can register your ESP32 MAC address here without colons</p> <p>For simplicity, you can register your ESP32 MAC address here. <br /> Ideally you want this to be a friendly code for your device.</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -183,7 +150,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
value={deviceCode} value={deviceCode}
disabled={isConnected} disabled={isConnected}
onChange={(e) => setDeviceCode(e.target.value)} onChange={(e) => setDeviceCode(e.target.value)}
placeholder={isConnected ? "**********" : "Enter your device code"} placeholder={isConnected ? "**********" : "Enter your ESP32-S3 MAC address"}
/> />
<Button <Button
size="sm" size="sm"
@ -203,81 +170,10 @@ const AppSettings: React.FC<AppSettingsProps> = ({
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
{isConnected ? <span className="font-medium text-gray-800">Registered!</span> : {isConnected ? <span className="font-medium text-gray-800">Registered!</span> :
error ? <span className="text-red-500">{error}.</span> : error ? <span className="text-red-500">{error}.</span> :
"Add your device code to your account to register it." "Add your ESP32-S3 MAC address (e.g. 12:34:56:78:9A:BC) to your account to register it."
} }
</p> </p>
</div> </div>
{/* <div className="flex flex-col gap-4 flex-nowrap">
<Label className="text-sm font-medium text-gray-700">
Over-the-air (OTA) updates
</Label>
<div className="flex flex-col gap-2">
<Button
size="sm"
variant="outline"
className="font-normal flex flex-row items-center gap-2 w-fit"
onClick={async () => {
setIsOta(true);
await setDeviceOta(
selectedUser.user_id
);
}}
disabled={isOta}
>
<RefreshCw size={16} />
<span>Update</span>
</Button>
{isOta ? (
<p className="text-xs text-gray-400 inline">
<Check
size={16}
className="inline-block mr-1"
/>
Your device will be updated on next
start
</p>
) : (
<p className="text-xs text-gray-400">
This will update your device software to
the latest version.
</p>
)}
</div>
<Label className="text-sm font-medium text-gray-700">
Factory reset
</Label>
<div className="flex flex-col gap-2">
<Button
size="sm"
variant="outline"
className="font-normal flex flex-row items-center gap-2 w-fit"
onClick={async () => {
setIsReset(true);
await setDeviceReset(
selectedUser.user_id
);
}}
disabled={isReset}
>
<Cog size={16} />
<span>Factory reset</span>
</Button>
{isReset ? (
<p className="text-xs text-gray-400 inline">
<Check
size={16}
className="inline-block mr-1"
/>
Your device will be factory reset on next start
</p>
) : (
<p className="text-xs text-gray-400">
Caution: This will reset your wifi and authentication details on your device.
</p>
)}
</div>
</div> */}
<div className="flex flex-col gap-2 mt-2"> <div className="flex flex-col gap-2 mt-2">
<Label className="text-sm font-medium text-gray-700"> <Label className="text-sm font-medium text-gray-700">
Logged in as Logged in as

View file

@ -1,90 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import StripePricingTable from "../PricingTable";
interface AddCreditsModalProps {
children: React.ReactNode;
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
const buttonText = "Proceed to Payment";
return (
<form className={cn("grid items-start gap-4", className)}>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
defaultValue="shadcn@example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@shadcn" />
</div>
<Button type="submit" variant="upsell_primary">
{buttonText}
</Button>
</form>
);
}
const AddCreditsModal: React.FC<AddCreditsModalProps> = ({ children }) => {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const title = "Explore Elato's Voice Subscription Plans";
const subtitle =
"Unlock more features and get more done with Elato Premium Plans.";
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[670px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{subtitle}</DialogDescription>
</DialogHeader>
<StripePricingTable />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{subtitle}</DrawerDescription>
</DrawerHeader>
<StripePricingTable />
</DrawerContent>
</Drawer>
);
};
export default AddCreditsModal;

View file

@ -1,95 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface AddCreditsModalProps {
children: React.ReactNode;
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
const buttonText = "Proceed to your Elato Kit - $59";
return (
<form className={cn("grid items-start gap-4", className)}>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
defaultValue="shadcn@example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@shadcn" />
</div>
<Button type="submit" variant="upsell_primary">
{buttonText}
</Button>
</form>
);
}
const PreorderModal: React.FC<AddCreditsModalProps> = ({ children }) => {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const title = "Preorder a Elato Kit";
const subtitle = "Get early access to our latest product.";
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{subtitle}</DialogDescription>
</DialogHeader>
<ProfileForm />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{subtitle}</DrawerDescription>
</DrawerHeader>
<ProfileForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
export default PreorderModal;

View file

@ -1,6 +1,29 @@
import { SupabaseClient } from "@supabase/supabase-js"; import { SupabaseClient } from "@supabase/supabase-js";
export const getPersonalityById = async (supabase: SupabaseClient, personalityId: string) => { export const createPersonality = async (
supabase: SupabaseClient,
userId: string,
personality: IPersonality,
): Promise<IPersonality | null> => {
const { data, error } = await supabase
.from("personalities")
.insert({
...personality,
creator_id: userId,
}).select();
if (error) {
console.error("Error creating personality:", error);
throw error;
}
return data ? data[0] : null;
};
export const getPersonalityById = async (
supabase: SupabaseClient,
personalityId: string,
) => {
const { data, error } = await supabase const { data, error } = await supabase
.from("personalities") .from("personalities")
.select(`*`) .select(`*`)
@ -12,7 +35,7 @@ export const getPersonalityById = async (supabase: SupabaseClient, personalityId
} }
return data[0] as IPersonality; return data[0] as IPersonality;
} };
export const getAllPersonalities = async (supabase: SupabaseClient) => { export const getAllPersonalities = async (supabase: SupabaseClient) => {
const { data, error } = await supabase const { data, error } = await supabase
@ -29,7 +52,10 @@ export const getAllPersonalities = async (supabase: SupabaseClient) => {
return data as IPersonality[]; return data as IPersonality[];
}; };
export const getMyPersonalities = async (supabase: SupabaseClient, userId: string) => { export const getMyPersonalities = async (
supabase: SupabaseClient,
userId: string,
) => {
const { data, error } = await supabase const { data, error } = await supabase
.from("personalities") .from("personalities")
.select(`*`) .select(`*`)

View file

@ -1,13 +1,7 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { import { defaultPersonalityId } from "./data";
defaultPersonalityId,
defaultToyId,
INITIAL_CREDITS,
SECONDS_PER_CREDIT,
} from "./data";
import crypto from "crypto";
import { createClient } from "@/utils/supabase/client";
export const getOpenGraphMetadata = (title: string) => { export const getOpenGraphMetadata = (title: string) => {
return { return {
openGraph: { openGraph: {
@ -18,14 +12,16 @@ export const getOpenGraphMetadata = (title: string) => {
// code in the form: aabbccddeeff // code in the form: aabbccddeeff
export const isValidMacAddress = (macAddress: string): boolean => { export const isValidMacAddress = (macAddress: string): boolean => {
// Check if macAddress is a valid MAC address without separators // Check if macAddress is a valid MAC address with colon separators
const macRegex = /^[0-9A-Fa-f]{12}$/; const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
return macRegex.test(macAddress); return macRegex.test(macAddress);
}; };
export const getMacAddressFromDeviceCode = (deviceCode: string): string => { export const getMacAddressFromDeviceCode = (deviceCode: string): string => {
// add colons to the device code // add colons to the device code
return deviceCode.substring(0, 2) + ":" + deviceCode.substring(2, 4) + ":" + deviceCode.substring(4, 6) + ":" + deviceCode.substring(6, 8) + ":" + deviceCode.substring(8, 10) + ":" + deviceCode.substring(10, 12); return deviceCode.substring(0, 2) + ":" + deviceCode.substring(2, 4) + ":" +
deviceCode.substring(4, 6) + ":" + deviceCode.substring(6, 8) + ":" +
deviceCode.substring(8, 10) + ":" + deviceCode.substring(10, 12);
}; };
export const getPersonalityImageSrc = (title: string) => { export const getPersonalityImageSrc = (title: string) => {
@ -53,46 +49,9 @@ export const getBaseUrl = () => {
}; };
export const getUserAvatar = (avatar_url: string) => { export const getUserAvatar = (avatar_url: string) => {
// return `/kidAvatar_boy_1.png`;
return avatar_url; return avatar_url;
// get random number between 0 and 9
// const randomNum = Math.floor(Math.random() * 10);
// return `/user_avatar/user_avatar_${randomNum}.png`;
}; };
export const getAssistantAvatar = (imageSrc: string) => { export const getAssistantAvatar = (imageSrc: string) => {
return "/" + imageSrc + ".png"; return "/" + imageSrc + ".png";
}; };
export const getCreditsRemaining = (user: IUser): number => {
const usedCredits = user.session_time / SECONDS_PER_CREDIT;
const remainingCredits = Math.round(INITIAL_CREDITS - usedCredits);
return Math.max(0, remainingCredits); // Ensure credits don't go below 0
};
export function encryptSecret(secret: string, masterKey: string) {
// Decode the base64 master key
const decodedKey = Buffer.from(masterKey, 'base64');
if (decodedKey.length !== 32) {
throw new Error('ENCRYPTION_KEY must be 32 bytes when decoded from base64.');
}
// Generate a 16-byte IV
const ivBuf = crypto.randomBytes(16);
// Use type assertion to string for algorithm and force the types for key and iv
const cipher = crypto.createCipheriv(
'aes-256-cbc' as string,
decodedKey as unknown as crypto.CipherKey,
ivBuf as unknown as crypto.BinaryLike
);
let encrypted = cipher.update(secret, 'utf8', 'base64');
encrypted += cipher.final('base64');
return {
iv: ivBuf.toString('base64'),
encryptedData: encrypted,
};
}