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 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 |
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
interface HomePageSubtitlesProps {
|
||||||
user: IUser;
|
user: IUser;
|
||||||
page: "home" | "settings" | "create";
|
page: "home" | "settings" | "create";
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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";
|
"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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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(`*`)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue