updating frontend and removing superfluous files
This commit is contained in:
parent
9bfd91d39f
commit
36762af913
21 changed files with 263 additions and 943 deletions
34
README.md
34
README.md
|
|
@ -23,6 +23,7 @@ supabase start # Starts your local Supabase server with the default migrations a
|
|||
npm install
|
||||
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)
|
||||
```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.
|
||||
|
||||
|
||||
## 🌟 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
|
||||
|
||||
ElatoAI consists of three main components:
|
||||
|
||||
1. **Frontend Client** (`Next.js` hosted on Vercel)
|
||||
2. **Edge Server Functions** (`Deno` running on Deno/Supabase Edge)
|
||||
3. **ESP32 IoT Client** (`PlatformIO/Arduino`)
|
||||
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) - to handle the websocket connections from the ESP32 device and the OpenAI API calls
|
||||
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
|
||||
|
||||
| Component | Technology Used |
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ import { encodedRedirect } from "@/utils/utils";
|
|||
import { createClient } from "@/utils/supabase/server";
|
||||
import { headers } from "next/headers";
|
||||
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 { getSimpleUserById, updateUser } from "@/db/users";
|
||||
|
||||
export async function deleteUserApiKey(userId: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +44,13 @@ export const forgotPasswordAction = async (formData: FormData) => {
|
|||
return encodedRedirect(
|
||||
"error",
|
||||
"/forgot-password",
|
||||
"Email is required"
|
||||
"Email is required",
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -54,7 +58,7 @@ export const forgotPasswordAction = async (formData: FormData) => {
|
|||
return encodedRedirect(
|
||||
"error",
|
||||
"/forgot-password",
|
||||
"Could not reset password"
|
||||
"Could not reset password",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +69,7 @@ export const forgotPasswordAction = async (formData: FormData) => {
|
|||
return encodedRedirect(
|
||||
"success",
|
||||
"/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(
|
||||
"error",
|
||||
"/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(
|
||||
"error",
|
||||
"/protected/reset-password",
|
||||
"Passwords do not match"
|
||||
"Passwords do not match",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +103,7 @@ export const resetPasswordAction = async (formData: FormData) => {
|
|||
encodedRedirect(
|
||||
"error",
|
||||
"/protected/reset-password",
|
||||
"Password update failed"
|
||||
"Password update failed",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +122,7 @@ export const checkDoctorAction = async (authCode: string) => {
|
|||
|
||||
export const connectUserToDevice = async (
|
||||
userId: string,
|
||||
userDeviceCode: string
|
||||
userDeviceCode: string,
|
||||
) => {
|
||||
const supabase = createClient();
|
||||
|
||||
|
|
@ -131,7 +135,7 @@ export const connectUserToDevice = async (
|
|||
const successfullyAdded = await addUserToDevice(
|
||||
supabase,
|
||||
userDeviceCode,
|
||||
userId
|
||||
userId,
|
||||
);
|
||||
return successfullyAdded;
|
||||
};
|
||||
|
|
@ -171,69 +175,23 @@ export const isPremiumUser = async (userId: string) => {
|
|||
return dbUser?.is_premium;
|
||||
};
|
||||
|
||||
export const setDeviceReset = async (userId: 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) {
|
||||
export async function registerDevice(userId: string, deviceCode: string) {
|
||||
// check if deviceCode is valid mac address
|
||||
if (!isValidMacAddress(deviceCode)) {
|
||||
return { error: "Invalid device code" };
|
||||
return { error: "Invalid device code" };
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from('devices')
|
||||
.insert({ user_id: userId, user_code: deviceCode.toLowerCase(), mac_address: getMacAddressFromDeviceCode(deviceCode).toUpperCase() }).select();
|
||||
|
||||
.from("devices")
|
||||
.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) {
|
||||
console.log(error)
|
||||
console.log(error);
|
||||
return { error: "Error registering device" };
|
||||
}
|
||||
|
||||
|
|
@ -242,21 +200,4 @@ export async function storeUserApiKey(userId: string, rawApiKey: string) {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,6 @@ interface IPayload {
|
|||
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 (
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
|
|
@ -27,25 +17,27 @@ const getChatHistory = async (
|
|||
): Promise<string> => {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('conversations')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
.from("conversations")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (personalityKey) {
|
||||
query = query.eq('personality_key', personalityKey);
|
||||
}
|
||||
if (personalityKey) {
|
||||
query = query.eq("personality_key", personalityKey);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const messages = data.map((chat: IConversation) => `${chat.role}: ${chat.content}`)
|
||||
.join('\n');
|
||||
const messages = data.map((chat: IConversation) =>
|
||||
`${chat.role}: ${chat.content}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return messages;
|
||||
return messages;
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to get chat history: ${error.message}`);
|
||||
throw new Error(`Failed to get chat history: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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.
|
||||
`;
|
||||
|
||||
const DoctorPromptTemplate = (user: IUser) => {
|
||||
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';
|
||||
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) => `
|
||||
const getCommonPromptTemplate = (
|
||||
chatHistory: string,
|
||||
user: IUser,
|
||||
timestamp: string,
|
||||
) => `
|
||||
YOUR VOICE IS: ${user.personality?.voice_prompt}
|
||||
|
||||
YOUR CHARACTER PROMPT IS: ${user.personality?.character_prompt}
|
||||
|
|
@ -83,7 +63,9 @@ ${chatHistory}
|
|||
USER'S CURRENT TIME IS: ${timestamp}
|
||||
|
||||
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) => {
|
||||
|
|
@ -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 (
|
||||
payload: IPayload,
|
||||
): Promise<string> => {
|
||||
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 isStory = user.personality?.is_story;
|
||||
|
|
@ -163,14 +123,11 @@ const createSystemPrompt = async (
|
|||
|
||||
let systemPrompt;
|
||||
switch (user.user_info.user_type) {
|
||||
case 'user':
|
||||
systemPrompt = UserPromptTemplate(user);
|
||||
break;
|
||||
case 'doctor':
|
||||
systemPrompt = DoctorPromptTemplate(user);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid user type');
|
||||
case "user":
|
||||
systemPrompt = UserPromptTemplate(user);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid user type");
|
||||
}
|
||||
return systemPrompt + commonPrompt;
|
||||
};
|
||||
|
|
@ -184,46 +141,27 @@ const createSystemPrompt = async (
|
|||
*/
|
||||
function decryptSecret(encryptedData: string, iv: string, masterKey: string) {
|
||||
// Decode the base64 master key
|
||||
const decodedKey = Buffer.from(masterKey, 'base64');
|
||||
const decodedKey = Buffer.from(masterKey, "base64");
|
||||
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(
|
||||
'aes-256-cbc' as any,
|
||||
Buffer.from(masterKey, 'base64') as any,
|
||||
Buffer.from(iv, 'base64') as any
|
||||
"aes-256-cbc" as any,
|
||||
Buffer.from(masterKey, "base64") as any,
|
||||
Buffer.from(iv, "base64") as any,
|
||||
);
|
||||
|
||||
let decrypted = decipher.update(encryptedData, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
let decrypted = decipher.update(encryptedData, "base64", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
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) {
|
||||
const supabase = createClient();
|
||||
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
|
@ -234,9 +172,12 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const isDoctor = dbUser.user_info.user_type === "doctor";
|
||||
const openAiApiKey = await getOpenAiApiKey(supabase, user.id);
|
||||
const systemPrompt = isDoctor ? await getDoctorGuidanceTemplate({ user: dbUser, supabase, timestamp: new Date().toISOString() }) : await createSystemPrompt({ user: dbUser, supabase, timestamp: new Date().toISOString() });
|
||||
const openAiApiKey = process.env.OPENAI_API_KEY;
|
||||
const systemPrompt = await createSystemPrompt({
|
||||
user: dbUser,
|
||||
supabase,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
|
|
@ -248,11 +189,11 @@ export async function GET(request: NextRequest) {
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini-realtime-preview-2024-12-17',
|
||||
model: "gpt-4o-mini-realtime-preview-2024-12-17",
|
||||
instructions: systemPrompt,
|
||||
voice: isDoctor ? 'ballad' : dbUser.personality?.oai_voice ?? 'ballad',
|
||||
voice: dbUser.personality?.oai_voice ?? "ballad",
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data);
|
||||
|
|
@ -260,7 +201,7 @@ export async function GET(request: NextRequest) {
|
|||
console.error("Error in /session:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react";
|
||||
import Twemoji from "react-twemoji";
|
||||
import { createPersonality } from "../../actions";
|
||||
import { createPersonality } from "@/db/personalities";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -163,7 +163,7 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
|
|||
}
|
||||
|
||||
try {
|
||||
const personality = await createPersonality(selectedUser.user_id, {
|
||||
const personality = await createPersonality(supabase, selectedUser.user_id, {
|
||||
title: formData.title,
|
||||
subtitle: "",
|
||||
character_prompt: formData.prompt,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import CreditsRemaining from "./CreditsRemaining";
|
||||
interface HomePageSubtitlesProps {
|
||||
user: IUser;
|
||||
page: "home" | "settings" | "create";
|
||||
|
|
|
|||
|
|
@ -1,59 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { motion } from 'framer-motion';
|
||||
import SheetWrapper from '../Playground/SheetWrapper';
|
||||
import Image from "next/image";
|
||||
import { cn, getPersonalityImageSrc } from "@/lib/utils";
|
||||
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 {
|
||||
allPersonalities: IPersonality[];
|
||||
}
|
||||
|
||||
export const CharacterShowcase = ({ allPersonalities }: CharacterShowcaseProps) => {
|
||||
const [hoveredCharacter, setHoveredCharacter] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<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="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="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="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">
|
||||
{allPersonalities.map((personality, index) => (
|
||||
<SheetWrapper
|
||||
languageState={'en-US'}
|
||||
key={index + personality.personality_id!}
|
||||
personality={personality}
|
||||
personalityIdState={''}
|
||||
onPersonalityPicked={() => {}}
|
||||
disableButtons={true}
|
||||
/>
|
||||
<Card
|
||||
className={cn(
|
||||
"p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Text Content - On right for desktop, top for mobile */}
|
||||
<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">
|
||||
Meet Our AI Characters
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { EnglishCopy } from "@/utils/i18n";
|
||||
import SheetWrapper from "./SheetWrapper";
|
||||
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 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmojiComponent } from "./EmojiImage";
|
||||
|
||||
interface CharacterSectionProps {
|
||||
allPersonalities: IPersonality[];
|
||||
|
|
@ -20,6 +31,7 @@ const CharacterSection = ({
|
|||
disableButtons,
|
||||
selectedFilters,
|
||||
}: CharacterSectionProps) => {
|
||||
|
||||
const filteredPersonalities = allPersonalities.filter((personality) => {
|
||||
return selectedFilters.every((filter) => {
|
||||
return personality[filter] === true;
|
||||
|
|
@ -37,16 +49,66 @@ const CharacterSection = ({
|
|||
</p>
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 md:gap-6 gap-4">
|
||||
{filteredPersonalities.map((personality, index) => (
|
||||
<SheetWrapper
|
||||
languageState={languageState}
|
||||
key={index + personality.personality_id!}
|
||||
personality={personality}
|
||||
personalityIdState={personalityIdState}
|
||||
onPersonalityPicked={onPersonalityPicked}
|
||||
disableButtons={disableButtons}
|
||||
/>
|
||||
))}
|
||||
{filteredPersonalities.map((personality, index) => {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,8 +41,6 @@ const ModifyCharacterSheet: React.FC<ModifyCharacterSheetProps> = ({
|
|||
return (
|
||||
<div className="flex flex-row gap-4 p-4 ">
|
||||
<Button
|
||||
// tabIndex={-1}
|
||||
// autoFocus={false}
|
||||
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 ${
|
||||
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" />
|
||||
{isSent || isCurrentPersonality
|
||||
? "Live character"
|
||||
: "Chat on Elato"}
|
||||
: "Click to chat"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { getCreditsRemaining } from "@/lib/utils";
|
||||
import PickPersonality from "./PickPersonality";
|
||||
import { updateUser } from "@/db/users";
|
||||
import _ from "lodash";
|
||||
import HomePageSubtitles from "../HomePageSubtitles";
|
||||
|
|
@ -11,8 +9,8 @@ import PersonalityFilters from "./PersonalityFilters";
|
|||
import { TranscriptProvider } from "../Realtime/contexts/TranscriptContext";
|
||||
import { EventProvider } from "../Realtime/contexts/EventContext";
|
||||
import App from "../Realtime/App";
|
||||
import { checkIfUserHasApiKey } from "@/app/actions";
|
||||
import { defaultPersonalityId } from "@/lib/data";
|
||||
import UserPersonalities from "./UserPersonalities";
|
||||
|
||||
interface PlaygroundProps {
|
||||
currentUser: IUser;
|
||||
|
|
@ -25,20 +23,10 @@ const Playground: React.FC<PlaygroundProps> = ({
|
|||
allPersonalities,
|
||||
myPersonalities,
|
||||
}) => {
|
||||
const [hasApiKey, setHasApiKey] = useState<boolean>(false);
|
||||
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();
|
||||
|
||||
// Remove userState entirely and just use personalityState
|
||||
const [personalityIdState, setPersonalityIdState] = useState<string>(
|
||||
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) => {
|
||||
// Instantaneously update the state variable
|
||||
setPersonalityIdState(personalityIdPicked);
|
||||
await updateUser(
|
||||
supabase,
|
||||
|
|
@ -74,7 +57,7 @@ const Playground: React.FC<PlaygroundProps> = ({
|
|||
<div className="flex flex-col gap-8 items-center justify-center">
|
||||
<TranscriptProvider>
|
||||
<EventProvider>
|
||||
<App hasApiKey={hasApiKey} personalityIdState={personalityIdState} isDoctor={isDoctor} userId={currentUser.user_id} />
|
||||
<App personalityIdState={personalityIdState} isDoctor={isDoctor} userId={currentUser.user_id} />
|
||||
</EventProvider>
|
||||
</TranscriptProvider>
|
||||
</div>
|
||||
|
|
@ -93,11 +76,10 @@ const Playground: React.FC<PlaygroundProps> = ({
|
|||
languageState={'en-US'}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
<PickPersonality
|
||||
<UserPersonalities
|
||||
selectedFilters={selectedFilters}
|
||||
onPersonalityPicked={onPersonalityPicked}
|
||||
personalityIdState={personalityIdState}
|
||||
currentUser={currentUser}
|
||||
languageState={'en-US'}
|
||||
disableButtons={false}
|
||||
allPersonalities={isDoctor
|
||||
|
|
@ -107,16 +89,6 @@ const Playground: React.FC<PlaygroundProps> = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* <ControlPanel
|
||||
connectionStatus={connectionStatus}
|
||||
isMuted={isMuted}
|
||||
muteMicrophone={muteMicrophone}
|
||||
unmuteMicrophone={unmuteMicrophone}
|
||||
handleClickInterrupt={handleClickInterrupt}
|
||||
handleClickCloseConnection={handleClickCloseConnection}
|
||||
microphoneStream={microphoneStream}
|
||||
audioBuffer={audioBuffer}
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -32,7 +32,7 @@ const UserPersonalities: React.FC<UserPersonalitiesProps> = ({
|
|||
disableButtons={disableButtons}
|
||||
/>
|
||||
)}
|
||||
<CharacterSection
|
||||
<CharacterSection
|
||||
allPersonalities={allPersonalities}
|
||||
languageState={languageState}
|
||||
personalityIdState={personalityIdState}
|
||||
|
|
@ -42,7 +42,7 @@ const UserPersonalities: React.FC<UserPersonalitiesProps> = ({
|
|||
selectedFilters={selectedFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,17 +25,14 @@ import { getPersonalityById } from "@/db/personalities";
|
|||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
interface AppProps {
|
||||
hasApiKey: boolean;
|
||||
personalityIdState: string;
|
||||
isDoctor: boolean;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
function App({ hasApiKey, personalityIdState, isDoctor, userId }: AppProps) {
|
||||
function App({ personalityIdState, isDoctor, userId }: AppProps) {
|
||||
const supabase = createClient();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } =
|
||||
useTranscript();
|
||||
const { logClientEvent, logServerEvent } = useEvent();
|
||||
|
|
@ -376,8 +373,6 @@ function App({ hasApiKey, personalityIdState, isDoctor, userId }: AppProps) {
|
|||
<BottomToolbar
|
||||
sessionStatus={sessionStatus}
|
||||
onToggleConnection={onToggleConnection}
|
||||
hasApiKey={hasApiKey}
|
||||
personality={personality}
|
||||
isDoctor={isDoctor}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,24 +3,16 @@ import { SessionStatus } from "@/app/components/Realtime/types";
|
|||
import { Paperclip, PhoneCall, Play, Stethoscope } from "lucide-react";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
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 {
|
||||
sessionStatus: SessionStatus;
|
||||
onToggleConnection: () => void;
|
||||
hasApiKey: boolean;
|
||||
personality: IPersonality;
|
||||
isDoctor: boolean;
|
||||
}
|
||||
|
||||
function BottomToolbar({
|
||||
sessionStatus,
|
||||
onToggleConnection,
|
||||
hasApiKey,
|
||||
personality,
|
||||
isDoctor,
|
||||
}: BottomToolbarProps) {
|
||||
const isConnected = sessionStatus === "CONNECTED";
|
||||
|
|
@ -44,7 +36,7 @@ function BottomToolbar({
|
|||
return "Doctor chat";
|
||||
}
|
||||
|
||||
const isDisabled = isConnecting || !hasApiKey;
|
||||
const isDisabled = isConnecting;
|
||||
|
||||
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";
|
||||
|
|
@ -69,27 +61,13 @@ function BottomToolbar({
|
|||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hasApiKey) {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
onToggleConnection();
|
||||
}
|
||||
}}
|
||||
className={getConnectionButtonClasses()}
|
||||
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()}
|
||||
{isDoctor ? getConnectionButtonLabelForDoctor() : getConnectionButtonLabel()}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { checkIfUserHasApiKey, registerDevice, signOutAction } from "@/app/actions";
|
||||
import { registerDevice, signOutAction } from "@/app/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -35,15 +35,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
|
|||
const userFormRef = React.useRef<{ submitForm: () => void } | null>(null);
|
||||
const [deviceCode, setDeviceCode] = 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 = () => {
|
||||
if (selectedUser.user_info.user_type === "doctor") {
|
||||
doctorFormRef.current?.submitForm();
|
||||
|
|
@ -58,16 +50,9 @@ const AppSettings: React.FC<AppSettingsProps> = ({
|
|||
);
|
||||
}, [selectedUser.user_id, supabase]);
|
||||
|
||||
React.useEffect(() => {
|
||||
checkIfUserHasDevice();
|
||||
userHasApiKey();
|
||||
}, [checkIfUserHasDevice, userHasApiKey]);
|
||||
|
||||
const [volume, setVolume] = React.useState([
|
||||
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 () => {
|
||||
if (selectedUser.device?.device_id) {
|
||||
|
|
@ -137,24 +122,6 @@ const AppSettings: React.FC<AppSettingsProps> = ({
|
|||
Device settings
|
||||
</h2>
|
||||
<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-row items-center gap-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
|
|
@ -166,7 +133,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
|
|||
?
|
||||
</TooltipTrigger>
|
||||
<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>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
@ -183,7 +150,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
|
|||
value={deviceCode}
|
||||
disabled={isConnected}
|
||||
onChange={(e) => setDeviceCode(e.target.value)}
|
||||
placeholder={isConnected ? "**********" : "Enter your device code"}
|
||||
placeholder={isConnected ? "**********" : "Enter your ESP32-S3 MAC address"}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -203,81 +170,10 @@ const AppSettings: React.FC<AppSettingsProps> = ({
|
|||
<p className="text-xs text-gray-400">
|
||||
{isConnected ? <span className="font-medium text-gray-800">Registered!</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>
|
||||
</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">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
Logged in as
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,6 +1,29 @@
|
|||
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
|
||||
.from("personalities")
|
||||
.select(`*`)
|
||||
|
|
@ -12,7 +35,7 @@ export const getPersonalityById = async (supabase: SupabaseClient, personalityId
|
|||
}
|
||||
|
||||
return data[0] as IPersonality;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllPersonalities = async (supabase: SupabaseClient) => {
|
||||
const { data, error } = await supabase
|
||||
|
|
@ -20,7 +43,7 @@ export const getAllPersonalities = async (supabase: SupabaseClient) => {
|
|||
.select(`*`)
|
||||
.is("creator_id", null)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
|
||||
if (error) {
|
||||
console.log("error getAllPersonalities", error);
|
||||
return [];
|
||||
|
|
@ -29,12 +52,15 @@ export const getAllPersonalities = async (supabase: SupabaseClient) => {
|
|||
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
|
||||
.from("personalities")
|
||||
.select(`*`)
|
||||
.eq("creator_id", userId);
|
||||
|
||||
|
||||
if (error) {
|
||||
console.log("error getMyPersonalities", error);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import {
|
||||
defaultPersonalityId,
|
||||
defaultToyId,
|
||||
INITIAL_CREDITS,
|
||||
SECONDS_PER_CREDIT,
|
||||
} from "./data";
|
||||
import crypto from "crypto";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { defaultPersonalityId } from "./data";
|
||||
|
||||
export const getOpenGraphMetadata = (title: string) => {
|
||||
return {
|
||||
openGraph: {
|
||||
|
|
@ -18,14 +12,16 @@ export const getOpenGraphMetadata = (title: string) => {
|
|||
|
||||
// code in the form: aabbccddeeff
|
||||
export const isValidMacAddress = (macAddress: string): boolean => {
|
||||
// Check if macAddress is a valid MAC address without separators
|
||||
const macRegex = /^[0-9A-Fa-f]{12}$/;
|
||||
// Check if macAddress is a valid MAC address with colon separators
|
||||
const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
|
||||
return macRegex.test(macAddress);
|
||||
};
|
||||
|
||||
export const getMacAddressFromDeviceCode = (deviceCode: string): string => {
|
||||
// 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) => {
|
||||
|
|
@ -53,46 +49,9 @@ export const getBaseUrl = () => {
|
|||
};
|
||||
|
||||
export const getUserAvatar = (avatar_url: string) => {
|
||||
// return `/kidAvatar_boy_1.png`;
|
||||
|
||||
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) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue