diff --git a/app/assets/images/calendar_logo.png b/app/assets/images/calendar_logo.png new file mode 100644 index 0000000000..8376b8b5f6 Binary files /dev/null and b/app/assets/images/calendar_logo.png differ diff --git a/app/assets/images/clone.png b/app/assets/images/clone.png new file mode 100644 index 0000000000..9278299e1a Binary files /dev/null and b/app/assets/images/clone.png differ diff --git a/app/assets/images/instagram_logo.png b/app/assets/images/instagram_logo.png new file mode 100644 index 0000000000..5a13814f14 Binary files /dev/null and b/app/assets/images/instagram_logo.png differ diff --git a/app/assets/images/linkedin_logo.png b/app/assets/images/linkedin_logo.png new file mode 100644 index 0000000000..ed8832e457 Binary files /dev/null and b/app/assets/images/linkedin_logo.png differ diff --git a/app/assets/images/new_background.png b/app/assets/images/new_background.png new file mode 100644 index 0000000000..95d3dd7652 Binary files /dev/null and b/app/assets/images/new_background.png differ diff --git a/app/assets/images/notion_logo.png b/app/assets/images/notion_logo.png new file mode 100644 index 0000000000..74e7aa9825 Binary files /dev/null and b/app/assets/images/notion_logo.png differ diff --git a/app/assets/images/x_logo.png b/app/assets/images/x_logo.png new file mode 100644 index 0000000000..2609e58006 Binary files /dev/null and b/app/assets/images/x_logo.png differ diff --git a/app/assets/images/x_logo_mini.png b/app/assets/images/x_logo_mini.png new file mode 100644 index 0000000000..b6bf32ecd1 Binary files /dev/null and b/app/assets/images/x_logo_mini.png differ diff --git a/app/lib/backend/auth.dart b/app/lib/backend/auth.dart index d60413c8a5..bf0216a21c 100644 --- a/app/lib/backend/auth.dart +++ b/app/lib/backend/auth.dart @@ -205,3 +205,11 @@ Future updateGivenName(String fullName) async { await user.updateProfile(displayName: fullName); } } + +Future signInAnonymously() async { + try { + await FirebaseAuth.instance.signInAnonymously(); + } catch (e) { + Logger.handle(e, null, message: 'An error occurred while signing in. Please try again later.'); + } +} diff --git a/app/lib/backend/http/api/apps.dart b/app/lib/backend/http/api/apps.dart index d5550f2aa2..cafaa53c46 100644 --- a/app/lib/backend/http/api/apps.dart +++ b/app/lib/backend/http/api/apps.dart @@ -414,3 +414,138 @@ Future getGenratedDescription(String name, String description) async { return ''; } } + +Future createPersonaApp(File file, Map personaData) async { + var request = http.MultipartRequest( + 'POST', + Uri.parse('${Env.apiBaseUrl}v1/personas'), + ); + request.files.add(await http.MultipartFile.fromPath('file', file.path, filename: basename(file.path))); + request.headers.addAll({'Authorization': await getAuthHeader()}); + request.fields.addAll({'persona_data': jsonEncode(personaData)}); + print(jsonEncode(personaData)); + try { + var streamedResponse = await request.send(); + var response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 200) { + debugPrint('createPersonaApp Response body: ${jsonDecode(response.body)}'); + return true; + } else { + debugPrint('Failed to submit app. Status code: ${response.statusCode}'); + if (response.body.isNotEmpty) { + return false; + } else { + return false; + } + } + } catch (e) { + debugPrint('An error occurred createPersonaApp: $e'); + return false; + } +} + +Future updatePersonaApp(File? file, Map personaData) async { + var request = http.MultipartRequest( + 'PATCH', + Uri.parse('${Env.apiBaseUrl}v1/personas/${personaData['id']}'), + ); + if (file != null) { + request.files.add(await http.MultipartFile.fromPath('file', file.path, filename: basename(file.path))); + } + request.headers.addAll({'Authorization': await getAuthHeader()}); + request.fields.addAll({'persona_data': jsonEncode(personaData)}); + debugPrint(jsonEncode(personaData)); + try { + var streamedResponse = await request.send(); + var response = await http.Response.fromStream(streamedResponse); + + if (response.statusCode == 200) { + debugPrint('updatePersonaApp Response body: ${jsonDecode(response.body)}'); + return true; + } else { + debugPrint('Failed to update app. Status code: ${response.statusCode}'); + return false; + } + } catch (e) { + debugPrint('An error occurred updatePersonaApp: $e'); + return false; + } +} + +Future checkPersonaUsername(String username) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/apps/check-username?username=$username', + headers: {}, + body: '', + method: 'GET', + ); + try { + if (response == null || response.statusCode != 200) return false; + log('checkPersonaUsernames: ${response.body}'); + return jsonDecode(response.body)['is_taken']; + } catch (e, stackTrace) { + debugPrint(e.toString()); + CrashReporting.reportHandledCrash(e, stackTrace); + return true; + } +} + +Future getTwitterProfileData(String username) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/personas/twitter/profile?username=$username', + headers: {}, + body: '', + method: 'GET', + ); + try { + if (response == null || response.statusCode != 200) return null; + log('getTwitterProfileData: ${response.body}'); + return jsonDecode(response.body); + } catch (e, stackTrace) { + debugPrint(e.toString()); + CrashReporting.reportHandledCrash(e, stackTrace); + return null; + } +} + +Future verifyTwitterOwnership(String username, String? personaId) async { + var url = '${Env.apiBaseUrl}v1/personas/twitter/verify-ownership?username=$username'; + if (personaId != null) { + url += '&persona_id=$personaId'; + } + var response = await makeApiCall( + url: url, + headers: {}, + body: '', + method: 'GET', + ); + try { + if (response == null || response.statusCode != 200) return false; + log('verifyTwitterOwnership: ${response.body}'); + return jsonDecode(response.body)['verified']; + } catch (e, stackTrace) { + debugPrint(e.toString()); + CrashReporting.reportHandledCrash(e, stackTrace); + return false; + } +} + +Future getUserPersonaServer() async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/personas', + headers: {}, + body: '', + method: 'GET', + ); + try { + if (response == null || response.statusCode != 200) return null; + log('getPersonaProfile: ${response.body}'); + var res = jsonDecode(response.body); + return App.fromJson(res); + } catch (e, stackTrace) { + debugPrint(e.toString()); + CrashReporting.reportHandledCrash(e, stackTrace); + return null; + } +} diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 3e9896b826..316a11fc1d 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -29,6 +29,13 @@ class SharedPreferencesUtil { //-------------------------------- Device ----------------------------------// + bool? get hasOmiDevice => _preferences?.getBool('hasOmiDevice'); + set hasOmiDevice(bool? value) { + if (value != null) { + _preferences?.setBool('hasOmiDevice', value); + } + } + set btDevice(BtDevice value) { saveString('btDevice', jsonEncode(value.toJson())); } diff --git a/app/lib/backend/schema/app.dart b/app/lib/backend/schema/app.dart index e0d41b934d..d1dac002bf 100644 --- a/app/lib/backend/schema/app.dart +++ b/app/lib/backend/schema/app.dart @@ -173,6 +173,8 @@ class App { String description; String image; Set capabilities; + List connectedAccounts = []; + Map? twitter; bool private; bool approved; String? conversationPrompt; @@ -195,6 +197,7 @@ class App { String? paymentLink; List thumbnailIds; List thumbnailUrls; + String? username; App({ required this.id, @@ -229,6 +232,9 @@ class App { this.paymentLink, this.thumbnailIds = const [], this.thumbnailUrls = const [], + this.username, + this.connectedAccounts = const [], + this.twitter, }); String? getRatingAvg() => ratingAvg?.toStringAsFixed(1); @@ -237,7 +243,9 @@ class App { bool worksWithMemories() => hasCapability('memories'); - bool worksWithChat() => hasCapability('chat'); + bool worksWithChat() => hasCapability('chat') || hasCapability('persona'); + + bool isNotPersona() => !hasCapability('persona'); bool worksExternally() => hasCapability('external_integration'); @@ -278,6 +286,9 @@ class App { paymentLink: json['payment_link'], thumbnailIds: (json['thumbnails'] as List?)?.cast() ?? [], thumbnailUrls: (json['thumbnail_urls'] as List?)?.cast() ?? [], + username: json['username'], + connectedAccounts: (json['connected_accounts'] as List?)?.cast() ?? [], + twitter: json['twitter'], ); } diff --git a/app/lib/main.dart b/app/lib/main.dart index 110832e988..634d00b116 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -21,7 +21,10 @@ import 'package:friend_private/pages/apps/app_detail/app_detail.dart'; import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; import 'package:friend_private/pages/home/page.dart'; import 'package:friend_private/pages/conversation_detail/conversation_detail_provider.dart'; +import 'package:friend_private/pages/onboarding/device_selection.dart'; import 'package:friend_private/pages/onboarding/wrapper.dart'; +import 'package:friend_private/pages/persona/persona_profile.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/providers/auth_provider.dart'; import 'package:friend_private/providers/calendar_provider.dart'; @@ -205,6 +208,7 @@ class _MyAppState extends State with WidgetsBindingObserver { (previous?..setAppProvider(value)) ?? AddAppProvider(), ), ChangeNotifierProvider(create: (context) => PaymentMethodProvider()), + ChangeNotifierProvider(create: (context) => PersonaProvider()), ], builder: (context, child) { return WithForegroundTask( @@ -325,9 +329,14 @@ class _DeciderWidgetState extends State { if (context.read().user != null || (SharedPreferencesUtil().customBackendUrl.isNotEmpty && SharedPreferencesUtil().authToken.isNotEmpty)) { context.read().setupHasSpeakerProfile(); - IntercomManager.instance.intercom.loginIdentifiedUser( - userId: SharedPreferencesUtil().uid, - ); + try { + await IntercomManager.instance.intercom.loginIdentifiedUser( + userId: SharedPreferencesUtil().uid, + ); + } catch (e) { + debugPrint('Failed to login to Intercom: $e'); + } + context.read().setMessagesFromCache(); context.read().setAppsFromCache(); context.read().refreshMessages(); @@ -343,13 +352,19 @@ class _DeciderWidgetState extends State { Widget build(BuildContext context) { return Consumer( builder: (context, authProvider, child) { - if (SharedPreferencesUtil().onboardingCompleted && - (authProvider.user != null || - (SharedPreferencesUtil().customBackendUrl.isNotEmpty && - SharedPreferencesUtil().authToken.isNotEmpty))) { - return const HomePageWrapper(); + if ((authProvider.user != null || + (SharedPreferencesUtil().customBackendUrl.isNotEmpty && SharedPreferencesUtil().authToken.isNotEmpty))) { + if (SharedPreferencesUtil().hasOmiDevice == false) { + return const PersonaProfilePage(); + } else { + if (SharedPreferencesUtil().onboardingCompleted) { + return const HomePageWrapper(); + } else { + return const OnboardingWrapper(); + } + } } else { - return const OnboardingWrapper(); + return const DeviceSelectionPage(); } }, ); diff --git a/app/lib/pages/apps/add_app.dart b/app/lib/pages/apps/add_app.dart index d3da0353f8..889596a892 100644 --- a/app/lib/pages/apps/add_app.dart +++ b/app/lib/pages/apps/add_app.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:friend_private/utils/other/debouncer.dart'; import 'package:shimmer/shimmer.dart'; import 'package:friend_private/backend/preferences.dart'; import 'package:friend_private/pages/apps/widgets/full_screen_image_viewer.dart'; @@ -29,6 +30,8 @@ class AddAppPage extends StatefulWidget { class _AddAppPageState extends State { late bool showSubmitAppConfirmation; + final _debouncer = Debouncer(delay: const Duration(milliseconds: 500)); + @override void initState() { showSubmitAppConfirmation = SharedPreferencesUtil().showSubmitAppConfirmation; @@ -114,8 +117,6 @@ class _AddAppPageState extends State { appPricing: provider.isPaid ? 'Paid' : 'Free', appNameController: provider.appNameController, appDescriptionController: provider.appDescriptionController, - creatorNameController: provider.creatorNameController, - creatorEmailController: provider.creatorEmailController, categories: provider.categories, setAppCategory: provider.setAppCategory, imageFile: provider.imageFile, diff --git a/app/lib/pages/apps/app_detail/app_detail.dart b/app/lib/pages/apps/app_detail/app_detail.dart index 48cb1d9744..02b64f3533 100644 --- a/app/lib/pages/apps/app_detail/app_detail.dart +++ b/app/lib/pages/apps/app_detail/app_detail.dart @@ -199,16 +199,23 @@ class _AppDetailPageState extends State { ), const SizedBox(width: 24), ], - isLoading + isLoading || app.private ? const SizedBox.shrink() : GestureDetector( child: const Icon(Icons.share), onTap: () { MixpanelManager().track('App Shared', properties: {'appId': app.id}); - Share.share( - 'Check out this app on Omi AI: ${app.name} by ${app.author} \n\n${app.description.decodeString}\n\n\nhttps://h.omi.me/apps/${app.id}', - subject: app.name, - ); + if (app.isNotPersona()) { + Share.share( + 'Check out this app on Omi AI: ${app.name} by ${app.author} \n\n${app.description.decodeString}\n\n\nhttps://h.omi.me/apps/${app.id}', + subject: app.name, + ); + } else { + Share.share( + 'Check out this Persona on Omi AI: ${app.name} by ${app.author} \n\n${app.description.decodeString}\n\n\nhttps://persona.omi.me/u/${app.username}', + subject: app.name, + ); + } }, ), !context.watch().isAppOwner @@ -725,10 +732,13 @@ class _AppDetailPageState extends State { onTap: () { if (app.description.decodeString.characters.length > 200) { routeToPage( - context, MarkdownViewer(title: 'About the App', markdown: app.description.decodeString)); + context, + MarkdownViewer( + title: 'About the ${app.isNotPersona() ? 'App' : 'Persona'}', + markdown: app.description.decodeString)); } }, - title: 'About the App', + title: 'About the ${app.isNotPersona() ? 'App' : 'Persona'}', description: app.description, showChips: true, chips: app diff --git a/app/lib/pages/apps/explore_install_page.dart b/app/lib/pages/apps/explore_install_page.dart index 75ff4a9f59..c55c1cdf68 100644 --- a/app/lib/pages/apps/explore_install_page.dart +++ b/app/lib/pages/apps/explore_install_page.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:friend_private/backend/auth.dart'; import 'package:friend_private/backend/schema/app.dart'; -import 'package:friend_private/pages/apps/add_app.dart'; import 'package:friend_private/pages/apps/providers/add_app_provider.dart'; import 'package:friend_private/pages/apps/widgets/app_section_card.dart'; import 'package:friend_private/pages/apps/widgets/filter_sheet.dart'; import 'package:friend_private/pages/apps/list_item.dart'; import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/providers/home_provider.dart'; -import 'package:friend_private/utils/analytics/mixpanel.dart'; import 'package:friend_private/utils/other/temp.dart'; import 'package:provider/provider.dart'; +import '../persona/twitter/social_profile.dart'; +import 'widgets/create_options_sheet.dart'; + String filterValueToString(dynamic value) { if (value.runtimeType == String) { return value; @@ -189,9 +191,15 @@ class _ExploreInstallPageState extends State with AutomaticK )), SliverToBoxAdapter( child: GestureDetector( - onTap: () { - MixpanelManager().pageOpened('Submit App'); - routeToPage(context, const AddAppPage()); + onTap: () async { + // routeToPage(context, SocialHandleScreen()); + showModalBottomSheet( + context: context, + builder: (context) => const CreateOptionsSheet(), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + ); }, child: Container( padding: const EdgeInsets.all(12.0), @@ -208,7 +216,7 @@ class _ExploreInstallPageState extends State with AutomaticK Icon(Icons.add, color: Colors.white), SizedBox(width: 8), Text( - 'Create and submit a new app', + 'Create your own', textAlign: TextAlign.center, ), ], diff --git a/app/lib/pages/apps/providers/add_app_provider.dart b/app/lib/pages/apps/providers/add_app_provider.dart index 6e98018f6a..4f5355eb27 100644 --- a/app/lib/pages/apps/providers/add_app_provider.dart +++ b/app/lib/pages/apps/providers/add_app_provider.dart @@ -23,10 +23,9 @@ class AddAppProvider extends ChangeNotifier { TextEditingController appNameController = TextEditingController(); TextEditingController appDescriptionController = TextEditingController(); - TextEditingController creatorNameController = TextEditingController(); - TextEditingController creatorEmailController = TextEditingController(); TextEditingController chatPromptController = TextEditingController(); TextEditingController conversationPromptController = TextEditingController(); + String? appCategory; // Trigger Event @@ -84,8 +83,6 @@ class AddAppProvider extends ChangeNotifier { if (paymentPlans.isEmpty) { await getPaymentPlans(); } - creatorNameController.text = SharedPreferencesUtil().givenName; - creatorEmailController.text = SharedPreferencesUtil().email; setIsLoading(false); } @@ -132,9 +129,7 @@ class AddAppProvider extends ChangeNotifier { imageUrl = app.image; appNameController.text = app.name.decodeString; appDescriptionController.text = app.description.decodeString; - creatorNameController.text = app.author.decodeString; priceController.text = app.price.toString(); - creatorEmailController.text = app.email ?? ''; makeAppPublic = !app.private; selectedCapabilities = app.getCapabilitiesFromIds(capabilities); if (app.externalIntegration != null) { @@ -169,8 +164,6 @@ class AddAppProvider extends ChangeNotifier { void clear() { appNameController.clear(); appDescriptionController.clear(); - creatorNameController.clear(); - creatorEmailController.clear(); chatPromptController.clear(); conversationPromptController.clear(); triggerEvent = null; @@ -256,9 +249,6 @@ class AddAppProvider extends ChangeNotifier { if (appDescriptionController.text != app.description) { return true; } - if (creatorNameController.text != app.author) { - return true; - } if (makeAppPublic != !app.private) { return true; } @@ -424,8 +414,6 @@ class AddAppProvider extends ChangeNotifier { Map data = { 'name': appNameController.text, 'description': appDescriptionController.text, - 'author': creatorNameController.text, - 'email': creatorEmailController.text, 'capabilities': selectedCapabilities.map((e) => e.id).toList(), 'deleted': false, 'uid': SharedPreferencesUtil().uid, @@ -490,10 +478,8 @@ class AddAppProvider extends ChangeNotifier { setIsSubmitting(true); Map data = { - 'name': appNameController.text, - 'description': appDescriptionController.text, - 'author': creatorNameController.text, - 'email': creatorEmailController.text, + 'name': appNameController.text.trim(), + 'description': appDescriptionController.text.trim(), 'capabilities': selectedCapabilities.map((e) => e.id).toList(), 'deleted': false, 'uid': SharedPreferencesUtil().uid, @@ -523,10 +509,10 @@ class AddAppProvider extends ChangeNotifier { } } if (capability.id == 'chat') { - data['chat_prompt'] = chatPromptController.text; + data['chat_prompt'] = chatPromptController.text.trim(); } if (capability.id == 'memories') { - data['memory_prompt'] = conversationPromptController.text; + data['memory_prompt'] = conversationPromptController.text.trim(); } if (capability.id == 'proactive_notification') { if (data['proactive_notification'] == null) { @@ -630,7 +616,13 @@ class AddAppProvider extends ChangeNotifier { if (selectedCapabilities.contains(capability)) { selectedCapabilities.remove(capability); } else { - selectedCapabilities.add(capability); + if (selectedCapabilities.length == 1 && selectedCapabilities.first.id == 'persona') { + AppSnackbar.showSnackbarError('Other capabilities cannot be selected with Persona'); + } else if (selectedCapabilities.isNotEmpty && capability.id == 'persona') { + AppSnackbar.showSnackbarError('Persona cannot be selected with other capabilities'); + } else { + selectedCapabilities.add(capability); + } } checkValidity(); notifyListeners(); @@ -727,6 +719,5 @@ class AddAppProvider extends ChangeNotifier { void setIsGenratingDescription(bool genrating) { isGenratingDescription = genrating; - notifyListeners(); } } diff --git a/app/lib/pages/apps/update_app.dart b/app/lib/pages/apps/update_app.dart index 5f769d3c11..32d50696a7 100644 --- a/app/lib/pages/apps/update_app.dart +++ b/app/lib/pages/apps/update_app.dart @@ -114,8 +114,6 @@ class _UpdateAppPageState extends State { imageFile: provider.imageFile, appNameController: provider.appNameController, appDescriptionController: provider.appDescriptionController, - creatorNameController: provider.creatorNameController, - creatorEmailController: provider.creatorEmailController, categories: provider.categories, setAppCategory: provider.setAppCategory, imageUrl: provider.imageUrl, @@ -152,8 +150,8 @@ class _UpdateAppPageState extends State { itemCount: provider.thumbnailUrls.length + 1, itemBuilder: (context, index) { // Calculate dimensions to maintain 2:3 ratio - final width = 120.0; - final height = width * 1.5; // 2:3 ratio + const width = 120.0; + const height = width * 1.5; // 2:3 ratio if (index == provider.thumbnailUrls.length) { return GestureDetector( diff --git a/app/lib/pages/apps/widgets/app_metadata_widget.dart b/app/lib/pages/apps/widgets/app_metadata_widget.dart index 771ef845a1..5daaae75cb 100644 --- a/app/lib/pages/apps/widgets/app_metadata_widget.dart +++ b/app/lib/pages/apps/widgets/app_metadata_widget.dart @@ -15,8 +15,6 @@ class AppMetadataWidget extends StatelessWidget { final VoidCallback pickImage; final TextEditingController appNameController; final TextEditingController appDescriptionController; - final TextEditingController creatorNameController; - final TextEditingController creatorEmailController; final List categories; final Function(String?) setAppCategory; final String? category; @@ -31,8 +29,6 @@ class AppMetadataWidget extends StatelessWidget { required this.pickImage, required this.appNameController, required this.appDescriptionController, - required this.creatorNameController, - required this.creatorEmailController, required this.categories, required this.setAppCategory, this.category, @@ -270,75 +266,6 @@ class AppMetadataWidget extends StatelessWidget { const SizedBox( height: 16, ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - 'Creator Name', - style: TextStyle(color: Colors.grey.shade300, fontSize: 16), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), - decoration: BoxDecoration( - color: Colors.grey.shade800, - borderRadius: BorderRadius.circular(10.0), - ), - width: double.infinity, - child: TextFormField( - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter creator name'; - } - return null; - }, - controller: creatorNameController, - decoration: const InputDecoration( - contentPadding: EdgeInsets.only(top: 6, bottom: 6), - isDense: true, - errorText: null, - border: InputBorder.none, - hintText: 'Nik Shevchenko', - ), - ), - ), - const SizedBox( - height: 16, - ), - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - 'Email Address', - style: TextStyle(color: Colors.grey.shade300, fontSize: 16), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), - decoration: BoxDecoration( - color: Colors.grey.shade800, - borderRadius: BorderRadius.circular(10.0), - ), - width: double.infinity, - child: TextFormField( - controller: creatorEmailController, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter creator email'; - } - return null; - }, - decoration: const InputDecoration( - contentPadding: EdgeInsets.only(top: 6, bottom: 6), - isDense: true, - border: InputBorder.none, - hintText: 'nik@basedhardware.com', - ), - ), - ), - const SizedBox( - height: 16, - ), Padding( padding: const EdgeInsets.only(left: 8.0), child: Text( diff --git a/app/lib/pages/apps/widgets/create_options_sheet.dart b/app/lib/pages/apps/widgets/create_options_sheet.dart new file mode 100644 index 0000000000..75f7cdf44b --- /dev/null +++ b/app/lib/pages/apps/widgets/create_options_sheet.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/pages/apps/add_app.dart'; +import 'package:friend_private/pages/persona/add_persona.dart'; +import 'package:friend_private/utils/analytics/mixpanel.dart'; +import 'package:friend_private/utils/other/temp.dart'; + +class CreateOptionsSheet extends StatelessWidget { + const CreateOptionsSheet({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'What would you like to create?', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 24), + Card( + elevation: 0, + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + titleAlignment: ListTileTitleAlignment.center, + leading: const Icon(Icons.apps, color: Colors.white), + title: + Text('Create an App', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + subtitle: Text('Create and share your app', style: TextStyle(color: Colors.white.withOpacity(0.7))), + onTap: () { + Navigator.pop(context); + MixpanelManager().pageOpened('Submit App'); + routeToPage(context, const AddAppPage()); + }, + ), + ), + const SizedBox(height: 12), + Card( + elevation: 0, + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: const Icon(Icons.person_outline, color: Colors.white), + titleAlignment: ListTileTitleAlignment.center, + title: Text('Create my Clone', + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + subtitle: Text('Create your digital clone', style: TextStyle(color: Colors.white.withOpacity(0.7))), + onTap: () { + Navigator.pop(context); + MixpanelManager().pageOpened('Create Persona'); + routeToPage(context, const AddPersonaPage()); + }, + ), + ), + const SizedBox(height: 24), + ], + ), + ); + } +} diff --git a/app/lib/pages/apps/widgets/show_app_options_sheet.dart b/app/lib/pages/apps/widgets/show_app_options_sheet.dart index 6696ab6428..b238de59d4 100644 --- a/app/lib/pages/apps/widgets/show_app_options_sheet.dart +++ b/app/lib/pages/apps/widgets/show_app_options_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:friend_private/backend/schema/app.dart'; import 'package:friend_private/pages/apps/update_app.dart'; +import 'package:friend_private/pages/persona/update_persona.dart'; import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/utils/other/temp.dart'; import 'package:friend_private/widgets/dialog.dart'; @@ -40,9 +41,9 @@ class ShowAppOptionsSheet extends StatelessWidget { borderRadius: BorderRadius.all(Radius.circular(8)), ), child: ListTile( - title: const Text( - 'Keep App Public', - style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + title: Text( + app.isNotPersona() ? 'Keep App Public' : 'Keep Persona Public', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), ), trailing: Switch( value: provider.appPublicToggled, @@ -59,8 +60,8 @@ class ShowAppOptionsSheet extends StatelessWidget { Navigator.pop(context); Navigator.pop(context); }, - 'Make App Public?', - 'If you make the app public, it can be used by everyone', + app.isNotPersona() ? 'Make App Public?' : 'Make Persona Public?', + 'If you make the ${app.isNotPersona() ? 'app' : 'persona'} public, it can be used by everyone', okButtonText: 'Confirm', ), ); @@ -76,8 +77,8 @@ class ShowAppOptionsSheet extends StatelessWidget { Navigator.pop(context); Navigator.pop(context); }, - 'Make App Private?', - 'If you make the app private now, it will stop working for everyone and will be visible only to you', + app.isNotPersona() ? 'Make App Private?' : 'Make Persona Private?', + 'If you make the ${app.isNotPersona() ? 'app' : 'persona'} private now, it will stop working for everyone and will be visible only to you', okButtonText: 'Confirm', ), ); @@ -93,15 +94,19 @@ class ShowAppOptionsSheet extends StatelessWidget { child: Column( children: [ ListTile( - title: const Text('Update App Details'), + title: Text(app.isNotPersona() ? 'Update App Details' : 'Update Persona Details'), leading: const Icon(Icons.edit), onTap: () { Navigator.pop(context); - routeToPage(context, UpdateAppPage(app: app)); + if (app.isNotPersona()) { + routeToPage(context, UpdateAppPage(app: app)); + } else { + routeToPage(context, UpdatePersonaPage(app: app, fromNewFlow: false)); + } }, ), ListTile( - title: const Text('Delete App'), + title: Text('Delete ${app.isNotPersona() ? 'App' : 'Persona'}'), leading: const Icon( Icons.delete, ), @@ -117,8 +122,8 @@ class ShowAppOptionsSheet extends StatelessWidget { Navigator.pop(context); Navigator.pop(context); }, - 'Delete App', - 'Are you sure you want to delete this app? This action cannot be undone.', + 'Delete ${app.isNotPersona() ? 'App' : 'Persona'}?', + 'Are you sure you want to delete this ${app.isNotPersona() ? 'App' : 'Persona'}? This action cannot be undone.', okButtonText: 'Confirm', ), ); diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 5d3d80bd74..6bbf5c4159 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -343,7 +343,8 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { showTypingIndicator: provider.showTypingIndicator && chatIndex == 0, message: message, sendMessage: _sendMessageUtil, - displayOptions: provider.messages.length <= 1, + displayOptions: provider.messages.length <= 1 && + provider.messageSenderApp(message.appId)?.isNotPersona() == true, appSender: provider.messageSenderApp(message.appId), updateConversation: (ServerConversation conversation) { context.read().updateConversation(conversation); diff --git a/app/lib/pages/onboarding/device_selection.dart b/app/lib/pages/onboarding/device_selection.dart new file mode 100644 index 0000000000..326baf9753 --- /dev/null +++ b/app/lib/pages/onboarding/device_selection.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/auth.dart'; +import 'package:friend_private/backend/preferences.dart'; +import 'package:friend_private/pages/onboarding/wrapper.dart'; +import 'dart:math' as math; + +import 'package:friend_private/pages/persona/twitter/social_profile.dart'; + +class DeviceSelectionPage extends StatefulWidget { + const DeviceSelectionPage({super.key}); + + @override + State createState() => _DeviceSelectionPageState(); +} + +class _DeviceSelectionPageState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 6), + vsync: this, + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + child: Stack( + fit: StackFit.expand, + children: [ + // Background image + Image.asset( + 'assets/images/new_background.png', + fit: BoxFit.cover, + ), + Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Spacer(), + Column( + children: [ + AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: Offset(0, 10 * math.sin(_controller.value * math.pi)), + child: child, + ); + }, + child: Image.asset( + 'assets/images/clone.png', + width: 240, + height: 240, + ), + ), + const SizedBox(height: 10), + Text( + 'omi', + style: Theme.of(context).textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'scale yourself', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.white.withOpacity(0.8), + ), + textAlign: TextAlign.center, + ), + ], + ), + const Spacer(), + Column( + children: [ + ElevatedButton( + onPressed: () { + SharedPreferencesUtil().hasOmiDevice = false; + signInAnonymously(); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const SocialHandleScreen()), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[900], + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + child: const Text( + 'Get Started', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: () { + SharedPreferencesUtil().hasOmiDevice = true; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const OnboardingWrapper()), + ); + }, + child: Text( + 'I have Omi Device', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/persona/add_persona.dart b/app/lib/pages/persona/add_persona.dart new file mode 100644 index 0000000000..40ce06d214 --- /dev/null +++ b/app/lib/pages/persona/add_persona.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; +import 'package:friend_private/providers/app_provider.dart'; +import 'package:friend_private/utils/alerts/app_snackbar.dart'; +import 'package:friend_private/utils/other/debouncer.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:friend_private/utils/text_formatter.dart'; +import 'package:friend_private/widgets/animated_loading_button.dart'; +import 'package:provider/provider.dart'; + +import 'twitter/social_profile.dart'; + +class AddPersonaPage extends StatefulWidget { + const AddPersonaPage({super.key}); + + @override + State createState() => _AddPersonaPageState(); +} + +class _AddPersonaPageState extends State { + final _debouncer = Debouncer(delay: const Duration(milliseconds: 500)); + + void _showSuccessDialog(String url) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.grey[900], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: Colors.grey[800], + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 40, + ), + ), + const SizedBox(height: 24), + const Text( + 'Your Omi Persona is live!', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + const Text( + 'Share it with anyone who\nneeds to hear back from you', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + const SizedBox(height: 24), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: "https://$url")); + AppSnackbar.showSnackbarSuccess('Persona link copied to clipboard'); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.link, + color: Colors.grey, + size: 20, + ), + const SizedBox(width: 8), + Text( + url, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + context.read().getApps(); + context.read().resetForm(); + Navigator.of(context).pop(); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Done', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = Provider.of(context, listen: false); + provider.onShowSuccessDialog = _showSuccessDialog; + }); + } + + @override + Widget build(BuildContext context) { + return PopScope( + onPopInvoked: (_) { + final provider = Provider.of(context, listen: false); + provider.resetForm(); + }, + child: Consumer(builder: (context, provider, child) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + title: const Text('Create Persona', style: TextStyle(color: Colors.white)), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Form( + key: provider.formKey, + onChanged: () { + provider.validateForm(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: GestureDetector( + onTap: () async { + await provider.pickImage(); + }, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(60), + border: Border.all(color: Colors.grey.shade800), + ), + child: provider.selectedImage != null + ? ClipRRect( + borderRadius: BorderRadius.circular(60), + child: Image.file( + provider.selectedImage!, + fit: BoxFit.cover, + ), + ) + : Icon( + Icons.add_a_photo, + size: 40, + color: Colors.grey.shade400, + ), + ), + ), + ), + const SizedBox(height: 32), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(14.0), + margin: const EdgeInsets.only(top: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Persona Name', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username to access the persona'; + } + return null; + }, + controller: provider.nameController, + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'Nik AI', + ), + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Persona Username', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username to access the persona'; + } + return null; + }, + onChanged: (value) { + _debouncer.run(() async { + await provider.checkIsUsernameTaken(value); + }); + }, + controller: provider.usernameController, + inputFormatters: [ + LowerCaseTextFormatter(), + FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_]')), + ], + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'nikshevchenko', + suffix: provider.usernameController.text.isEmpty + ? null + : provider.isCheckingUsername + ? const SizedBox( + width: 16, + height: 16, + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.grey), + ), + ), + ) + : Icon( + provider.isUsernameTaken ? Icons.close : Icons.check, + color: provider.isUsernameTaken ? Colors.red : Colors.green, + size: 16, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 22), + Container( + padding: const EdgeInsets.only(left: 14.0), + child: Row( + children: [ + Text( + 'Make Persona Public', + style: TextStyle(color: Colors.grey.shade400), + ), + const Spacer(), + Switch( + value: provider.makePersonaPublic, + onChanged: (value) { + provider.setPersonaPublic(value); + }, + activeColor: Colors.white, + ), + ], + ), + ), + const SizedBox(height: 24), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 12), + child: Text( + 'Connected Knowledge Data', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Image.asset( + 'assets/images/x_logo_mini.png', + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Text( + provider.twitterProfile.isEmpty + ? 'Connect Twitter' + : provider.twitterProfile['username'] ?? '', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + const Spacer(), + if (provider.twitterProfile.isEmpty) + GestureDetector( + onTap: () { + routeToPage(context, SocialHandleScreen()); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Connect', + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Connected', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.only(left: 24, right: 24, bottom: 52), + child: SizedBox( + width: double.infinity, + child: AnimatedLoadingButton( + onPressed: !provider.isFormValid + ? () async {} + : () async { + await provider.createPersona(); + }, + color: provider.isFormValid ? Colors.white : Colors.grey[800]!, + loaderColor: Colors.black, + text: "Create Persona", + textStyle: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + }), + ); + } +} diff --git a/app/lib/pages/persona/persona_profile.dart b/app/lib/pages/persona/persona_profile.dart new file mode 100644 index 0000000000..b488163890 --- /dev/null +++ b/app/lib/pages/persona/persona_profile.dart @@ -0,0 +1,472 @@ +import 'dart:io'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/preferences.dart'; +import 'package:friend_private/gen/assets.gen.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; +import 'package:friend_private/pages/persona/update_persona.dart'; +import 'package:friend_private/providers/auth_provider.dart'; +import 'package:friend_private/utils/alerts/app_snackbar.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:friend_private/widgets/sign_in_button.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; + +class PersonaProfilePage extends StatefulWidget { + const PersonaProfilePage({ + super.key, + }); + + @override + State createState() => _PersonaProfilePageState(); +} + +class _PersonaProfilePageState extends State { + void _showAccountLinkBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) { + return Container( + padding: const EdgeInsets.only(top: 20), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Consumer( + builder: (context, authProvider, child) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 24), + const Text( + 'Link Your Account', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Link your account to clone your persona from device', + style: TextStyle( + color: Colors.grey[400], + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom + 20, + left: 16, + right: 16, + ), + child: Column( + children: [ + if (Platform.isIOS) + SignInButton( + title: 'Link with Apple', + assetPath: Assets.images.appleLogo.path, + onTap: () async { + try { + await authProvider.linkWithApple(); + if (mounted) { + SharedPreferencesUtil().hasOmiDevice = true; + var persona = context.read().userPersona; + Navigator.pop(context); + routeToPage(context, UpdatePersonaPage(app: persona, fromNewFlow: true)); + } + } catch (e) { + AppSnackbar.showSnackbarError('Failed to link Apple account: $e'); + } + }, + iconSpacing: 12, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + if (Platform.isIOS) const SizedBox(height: 12), + SignInButton( + title: 'Link with Google', + assetPath: Assets.images.googleLogo.path, + onTap: () async { + try { + await authProvider.linkWithGoogle(); + if (mounted) { + SharedPreferencesUtil().hasOmiDevice = true; + var persona = context.read().userPersona; + Navigator.pop(context); + routeToPage(context, UpdatePersonaPage(app: persona, fromNewFlow: true)); + } + } catch (e) { + AppSnackbar.showSnackbarError('Failed to link Google account: $e'); + } + }, + iconSpacing: Platform.isIOS ? 12 : 10, + padding: Platform.isIOS + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 12) + : const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ], + ), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () { + // Navigator.pop(context); + // var persona = context.read().userPersona; + // routeToPage(context, UpdatePersonaPage(app: persona)); + }, + child: Text( + "I don't have a device", + style: TextStyle( + color: Colors.grey[400], + fontSize: 14, + decoration: TextDecoration.underline, + ), + ), + ), + const SizedBox(height: 32), + ], + ); + }, + ), + ); + }, + ); + } + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + final provider = Provider.of(context, listen: false); + await provider.getUserPersona(); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/new_background.png', + fit: BoxFit.cover, + ), + ), + Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + actions: [ + IconButton( + icon: const Icon(Icons.settings, color: Colors.white), + onPressed: () { + // TODO: Implement settings + }, + ), + ], + ), + body: provider.isLoading || provider.userPersona == null + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : SingleChildScrollView( + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.grey[800]!, width: 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(50), + child: provider.userPersona == null + ? Image.asset(Assets.images.logoTransparentV2.path) + : Image.network( + provider.userPersona!.image, + fit: BoxFit.cover, + ), + ), + ), + Positioned( + right: 10, + bottom: 4, + child: Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 4), + Text( + provider.userPersona!.name, + style: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.verified, + color: Colors.blue, + size: 20, + ), + ], + ), + const SizedBox(height: 8), + Text( + "40% Clone", + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextButton( + onPressed: () { + Share.share( + 'Check out this Persona on Omi AI: ${provider.userPersona!.name} by me \n\nhttps://persona.omi.me/u/${provider.userPersona!.name}', + subject: '${provider.userPersona!.name} Persona', + ); + }, + style: TextButton.styleFrom( + backgroundColor: Colors.grey[900], + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.link, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + 'Share Public Link', + style: TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + InkWell( + onTap: () { + final user = FirebaseAuth.instance.currentUser; + if (user != null && user.isAnonymous) { + _showAccountLinkBottomSheet(); + } else if (!user!.isAnonymous) { + SharedPreferencesUtil().hasOmiDevice = true; + var persona = context.read().userPersona; + routeToPage(context, UpdatePersonaPage(app: persona, fromNewFlow: true)); + } + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Clone from device', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Create a clone from conversations', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 12), + child: Text( + 'Connected Knowledge Data', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + _buildSocialLink( + icon: 'assets/images/x_logo_mini.png', + text: 'mohsinxyz_', + isConnected: true, + ), + const SizedBox(height: 12), + _buildSocialLink( + icon: 'assets/images/instagram_logo.png', + text: '@username', + isComingSoon: true, + ), + const SizedBox(height: 12), + _buildSocialLink( + icon: 'assets/images/linkedin_logo.png', + text: 'linkedin.com/in/username', + isComingSoon: true, + ), + const SizedBox(height: 12), + _buildSocialLink( + icon: 'assets/images/notion_logo.png', + text: 'notion.so/username', + isComingSoon: true, + ), + const SizedBox(height: 12), + _buildSocialLink( + icon: 'assets/images/calendar_logo.png', + text: 'calendar id', + isComingSoon: true, + ), + ], + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ); + }); + } + + Widget _buildSocialLink({ + required String icon, + required String text, + bool isConnected = false, + bool isComingSoon = false, + bool showConnect = false, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Image.asset( + icon, + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Text( + text, + style: TextStyle( + color: isComingSoon ? Colors.grey[600] : Colors.white, + fontSize: 16, + ), + ), + const Spacer(), + if (isComingSoon) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Coming soon', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ) + else if (showConnect) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Connect', + style: TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ) + else if (isConnected) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Connected', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/pages/persona/persona_provider.dart b/app/lib/pages/persona/persona_provider.dart new file mode 100644 index 0000000000..c18c2f6bb3 --- /dev/null +++ b/app/lib/pages/persona/persona_provider.dart @@ -0,0 +1,206 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:friend_private/backend/http/api/apps.dart'; +import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/utils/alerts/app_snackbar.dart'; +import 'package:image_picker/image_picker.dart'; + +typedef ShowSuccessDialogCallback = void Function(String url); + +class PersonaProvider extends ChangeNotifier { + final GlobalKey formKey = GlobalKey(); + TextEditingController nameController = TextEditingController(); + TextEditingController usernameController = TextEditingController(); + bool isUsernameTaken = false; + bool isCheckingUsername = false; + bool makePersonaPublic = false; + ShowSuccessDialogCallback? onShowSuccessDialog; + + File? selectedImage; + String? selectedImageUrl; + + String? personaId; + + bool isFormValid = true; + bool isLoading = false; + + Map twitterProfile = {}; + App? userPersona; + + Future getTwitterProfile(String username) async { + setIsLoading(true); + var res = await getTwitterProfileData(username); + print('Twitter Profile: $res'); + if (res != null) { + if (res['status'] == 'notfound') { + AppSnackbar.showSnackbarError('Twitter handle not found'); + twitterProfile = {}; + } else { + twitterProfile = res; + } + } + setIsLoading(false); + notifyListeners(); + } + + Future verifyTweet(String username) async { + var res = await verifyTwitterOwnership(username, personaId); + if (res) { + AppSnackbar.showSnackbarSuccess('Twitter handle verified'); + } else { + AppSnackbar.showSnackbarError('Failed to verify Twitter handle'); + } + return res; + } + + Future getUserPersona() async { + setIsLoading(true); + var res = await getUserPersonaServer(); + if (res != null) { + userPersona = res; + } else { + userPersona = null; + AppSnackbar.showSnackbarError('Failed to fetch your persona'); + } + setIsLoading(false); + } + + void setPersonaPublic(bool? value) { + if (value == null) { + return; + } + makePersonaPublic = value; + notifyListeners(); + } + + void prepareUpdatePersona(App app) { + nameController.text = app.name; + usernameController.text = app.username!; + makePersonaPublic = !app.private; + selectedImageUrl = app.image; + personaId = app.id; + notifyListeners(); + } + + Future pickImage() async { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image != null) { + selectedImage = File(image.path); + validateForm(); + } + notifyListeners(); + } + + void validateForm() { + isFormValid = formKey.currentState!.validate() && (selectedImage != null || selectedImageUrl != null); + notifyListeners(); + } + + void resetForm() { + nameController.clear(); + usernameController.clear(); + selectedImage = null; + makePersonaPublic = false; + isFormValid = false; + onShowSuccessDialog = null; + personaId = null; + twitterProfile = {}; + notifyListeners(); + } + + Future updatePersona() async { + if (!formKey.currentState!.validate() || selectedImage == null) { + if (selectedImage == null) { + AppSnackbar.showSnackbarError('Please select an image'); + } + return; + } + + setIsLoading(true); + + try { + final personaData = { + 'name': nameController.text, + 'username': usernameController.text, + 'private': !makePersonaPublic, + }; + + var res = await updatePersonaApp(selectedImage, personaData); + + if (res) { + String personaUrl = 'personas.omi.me/u/${usernameController.text}'; + print('Persona URL: $personaUrl'); + if (onShowSuccessDialog != null) { + onShowSuccessDialog!(personaUrl); + } + } else { + AppSnackbar.showSnackbarError('Failed to create your persona. Please try again later.'); + } + } catch (e) { + AppSnackbar.showSnackbarError('Failed to create persona: $e'); + } finally { + setIsLoading(false); + } + } + + Future createPersona() async { + if (!formKey.currentState!.validate() || selectedImage == null) { + if (selectedImage == null) { + AppSnackbar.showSnackbarError('Please select an image'); + } + return; + } + + setIsLoading(true); + + try { + final personaData = { + 'name': nameController.text, + 'username': usernameController.text, + 'private': !makePersonaPublic, + }; + + if (twitterProfile.isNotEmpty) { + personaData['connected_accounts'] = ['omi', 'twitter']; + personaData['twitter'] = { + 'username': twitterProfile['profile'], + 'avatar': twitterProfile['avatar'], + }; + } + + var res = await createPersonaApp(selectedImage!, personaData); + + if (res) { + String personaUrl = 'personas.omi.me/u/${usernameController.text}'; + print('Persona URL: $personaUrl'); + if (onShowSuccessDialog != null) { + onShowSuccessDialog!(personaUrl); + } + } else { + AppSnackbar.showSnackbarError('Failed to create your persona. Please try again later.'); + } + } catch (e) { + AppSnackbar.showSnackbarError('Failed to create persona: $e'); + } finally { + setIsLoading(false); + } + } + + Future checkIsUsernameTaken(String username) async { + setIsCheckingUsername(true); + isUsernameTaken = await checkPersonaUsername(username); + setIsCheckingUsername(false); + } + + void setIsCheckingUsername(bool checking) { + isCheckingUsername = checking; + notifyListeners(); + } + + void setIsLoading(bool loading) { + isLoading = loading; + notifyListeners(); + } +} diff --git a/app/lib/pages/persona/twitter/clone_success_sceen.dart b/app/lib/pages/persona/twitter/clone_success_sceen.dart new file mode 100644 index 0000000000..e081d7a3c5 --- /dev/null +++ b/app/lib/pages/persona/twitter/clone_success_sceen.dart @@ -0,0 +1,255 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:provider/provider.dart'; + +import '../persona_profile.dart'; + +class CloneSuccessScreen extends StatefulWidget { + const CloneSuccessScreen({ + super.key, + }); + + @override + State createState() => _CloneSuccessScreenState(); +} + +class _CloneSuccessScreenState extends State { + bool _isLoading = false; + + void _handleNavigation() { + final user = FirebaseAuth.instance.currentUser; + + // If user is not anonymous (signed in with Google/Apple), they came from create/update flow + if (user != null && !user.isAnonymous) { + Navigator.pop(context); + Navigator.pop(context); + Navigator.pop(context); + } else { + // Anonymous user, just go to profile + routeToPage(context, const PersonaProfilePage()); + } + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return Stack( + children: [ + // Background image + Positioned.fill( + child: Image.asset( + 'assets/images/new_background.png', + fit: BoxFit.cover, + ), + ), + Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(height: 24), + Container( + margin: const EdgeInsets.only(top: 40), + padding: const EdgeInsets.fromLTRB(40, 60, 40, 24), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Column( + children: [ + Stack( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 2, + ), + ), + child: ClipOval( + child: Image.network( + provider.twitterProfile['avatar'], + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[900], + child: Icon( + Icons.person, + size: 40, + color: Colors.white.withOpacity(0.5), + ), + ); + }, + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: const Color.fromARGB(255, 85, 184, 88), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 2, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + provider.twitterProfile['name'], + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 24), + if (FirebaseAuth.instance.currentUser?.isAnonymous == false) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/x_logo_mini.png', + width: 16, + height: 16, + ), + const SizedBox(width: 8), + const Text( + 'Twitter Connected', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.check_circle, + color: Color.fromARGB(255, 85, 184, 88), + size: 16, + ), + ], + ), + const SizedBox(height: 8), + ], + Text( + FirebaseAuth.instance.currentUser?.isAnonymous == false + ? 'Twitter Connected Successfully!' + : 'Your Omi Clone is live!', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + FirebaseAuth.instance.currentUser?.isAnonymous == false + ? 'Your Twitter data has been connected\nto enhance your persona' + : 'Share it with anyone who\nneeds to hear back from you', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FirebaseAuth.instance.currentUser?.isAnonymous == false + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.link, + color: Colors.white.withOpacity(0.5), + size: 20, + ), + const SizedBox(width: 8), + Text( + 'personas.omi.me/u/${provider.twitterProfile['profile']}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ], + ), + ) + : Container(), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 50), + child: ElevatedButton( + onPressed: _handleNavigation, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Colors.grey), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + else ...[ + Text( + FirebaseAuth.instance.currentUser?.isAnonymous == false + ? 'Continue creating your persona' + : 'Check out your persona', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + }); + } +} diff --git a/app/lib/pages/persona/twitter/social_profile.dart b/app/lib/pages/persona/twitter/social_profile.dart new file mode 100644 index 0000000000..2e38b11178 --- /dev/null +++ b/app/lib/pages/persona/twitter/social_profile.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; +import 'package:friend_private/pages/persona/twitter/verify_identity_screen.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:provider/provider.dart'; + +class SocialHandleScreen extends StatefulWidget { + const SocialHandleScreen({ + super.key, + }); + + @override + State createState() => _SocialHandleScreenState(); +} + +class _SocialHandleScreenState extends State { + late final TextEditingController _controller; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return Stack( + children: [ + // Background image + Positioned.fill( + child: Image.asset( + 'assets/images/new_background.png', + fit: BoxFit.cover, + ), + ), + Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Form( + key: _formKey, + child: Column( + children: [ + const SizedBox(height: 20), + const Center( + child: Text( + '🤖', + style: TextStyle( + fontSize: 42, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Let\'s train your clone!\nWhat\'s your Twitter handle?', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 15, + color: Colors.white.withOpacity(1), + ), + Shadow( + offset: const Offset(0, 0), + blurRadius: 15, + color: Colors.white.withOpacity(0.3), + ), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'We will pre-train your Omi clone\nbased on your account\'s activity', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white.withOpacity(0.8), + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 3, + color: Colors.white.withOpacity(0.25), + ), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withOpacity(0.2), + ), + ), + child: TextFormField( + controller: _controller, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.left, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: InputBorder.none, + hintText: '@nikshevchenko', + hintStyle: TextStyle(color: Colors.white.withOpacity(0.5)), + prefixIcon: Padding( + padding: const EdgeInsets.all(12.0), + child: Image.asset( + 'assets/images/x_logo.png', + width: 24, + height: 24, + ), + ), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your Twitter handle'; + } + return null; + }, + ), + ), + ], + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), + child: ElevatedButton( + onPressed: () async { + if (_formKey.currentState!.validate()) { + await context.read().getTwitterProfile(_controller.text.trim()); + if (context.read().twitterProfile.isNotEmpty) { + // await Preferences().setTwitterHandle(_controller.text.trim()); + routeToPage(context, VerifyIdentityScreen()); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[900], + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + child: provider.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + 'Next', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + }); + } +} diff --git a/app/lib/pages/persona/twitter/verify_identity_screen.dart b/app/lib/pages/persona/twitter/verify_identity_screen.dart new file mode 100644 index 0000000000..2f95b9a136 --- /dev/null +++ b/app/lib/pages/persona/twitter/verify_identity_screen.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; +import 'package:friend_private/pages/persona/twitter/clone_success_sceen.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class VerifyIdentityScreen extends StatefulWidget { + const VerifyIdentityScreen({ + super.key, + }); + + @override + _VerifyIdentityScreenState createState() => _VerifyIdentityScreenState(); +} + +class _VerifyIdentityScreenState extends State { + bool _isVerifying = false; + String? _verificationError; + + @override + void initState() { + super.initState(); + } + + Future _openTwitterToTweet(BuildContext context) async { + final handle = context.read().twitterProfile['profile']; + final tweetText = Uri.encodeComponent('Verifying my clone: persona.omi.me/u/$handle'); + final twitterUrl = 'https://twitter.com/intent/tweet?text=$tweetText'; + + if (await canLaunchUrl(Uri.parse(twitterUrl))) { + await launchUrl(Uri.parse(twitterUrl), mode: LaunchMode.externalApplication); + } + } + + Future _verifyTweet() async { + if (_isVerifying) return; + + setState(() { + _isVerifying = true; + _verificationError = null; + }); + + try { + final handle = context.read().twitterProfile['profile']; + final isVerified = await context.read().verifyTweet(handle); + if (isVerified) { + routeToPage(context, CloneSuccessScreen()); + } else { + // Show error dialog + if (mounted) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text( + 'Verification Not Complete', + style: TextStyle(color: Colors.white), + ), + content: const Text( + 'We couldn\'t find your verification tweet. Please make sure you\'ve posted the tweet and try again.', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'OK', + style: TextStyle(color: Colors.white), + ), + ), + ], + ); + }, + ); + } + } + } catch (e) { + if (mounted) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + backgroundColor: Colors.grey[900], + title: const Text( + 'Verification Error', + style: TextStyle(color: Colors.white), + ), + content: const Text( + 'An error occurred while verifying your tweet. Please try again. Did you post the tweet?', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text( + 'OK', + style: TextStyle(color: Colors.white), + ), + ), + ], + ); + }, + ); + } + } finally { + if (mounted) { + setState(() { + _isVerifying = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return Stack( + children: [ + // Background image + Positioned.fill( + child: Image.asset( + 'assets/images/new_background.png', + fit: BoxFit.cover, + ), + ), + Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(height: 0), + Column( + children: [ + const Center( + child: Icon( + Icons.verified, + color: Colors.blue, + size: 48, + ), + ), + const SizedBox(height: 16), + Text( + 'Let\'s prevent\nimpersonation!', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 15, + color: Colors.white.withOpacity(1), + ), + Shadow( + offset: const Offset(0, 0), + blurRadius: 15, + color: Colors.white.withOpacity(0.3), + ), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Please verify you\'re the owner of\nthis account to prevent\nimpersonation', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.white.withOpacity(0.8), + shadows: [ + Shadow( + offset: const Offset(0, 1), + blurRadius: 3, + color: Colors.white.withOpacity(0.25), + ), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 2, + ), + ), + child: ClipOval( + child: Image.network( + provider.twitterProfile['avatar'], + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[900], + child: Icon( + Icons.person, + size: 40, + color: Colors.white.withOpacity(0.5), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 16), + Column( + children: [ + Text( + provider.twitterProfile['name'], + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + Column( + children: [ + if (_verificationError != null) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + _verificationError!, + style: const TextStyle( + color: Colors.red, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + ElevatedButton( + onPressed: () => _openTwitterToTweet(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[900], + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/x_logo.png', + width: 20, + height: 20, + ), + const SizedBox(width: 8), + const Text( + 'Verify it\'s me', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _isVerifying ? null : _verifyTweet, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), + side: BorderSide(color: Colors.white.withOpacity(0.2)), + ), + ), + child: _isVerifying + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + 'I have verified', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 40), + ], + ), + ], + ), + ), + ), + ), + ], + ); + }); + } +} diff --git a/app/lib/pages/persona/update_persona.dart b/app/lib/pages/persona/update_persona.dart new file mode 100644 index 0000000000..5cb11fd267 --- /dev/null +++ b/app/lib/pages/persona/update_persona.dart @@ -0,0 +1,352 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:friend_private/backend/schema/app.dart'; +import 'package:friend_private/main.dart'; +import 'package:friend_private/pages/persona/persona_provider.dart'; +import 'package:friend_private/pages/persona/twitter/social_profile.dart'; +import 'package:friend_private/utils/other/debouncer.dart'; +import 'package:friend_private/utils/other/temp.dart'; +import 'package:friend_private/utils/text_formatter.dart'; +import 'package:friend_private/widgets/animated_loading_button.dart'; +import 'package:provider/provider.dart'; + +class UpdatePersonaPage extends StatefulWidget { + final App? app; + final bool fromNewFlow; + const UpdatePersonaPage({super.key, this.app, required this.fromNewFlow}); + + @override + State createState() => _UpdatePersonaPageState(); +} + +class _UpdatePersonaPageState extends State { + final _debouncer = Debouncer(delay: const Duration(milliseconds: 500)); + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (widget.app == null) { + await context.read().getUserPersona(); + var app = context.read().userPersona; + context.read().prepareUpdatePersona(app!); + } else { + context.read().prepareUpdatePersona(widget.app!); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Consumer(builder: (context, provider, child) { + return PopScope( + onPopInvoked: (didPop) { + if (didPop) { + context.read().resetForm(); + if (widget.fromNewFlow) { + routeToPage(context, DeciderWidget(), replace: true); + } else { + Future.delayed(Duration.zero, () { + Navigator.pop(context); + }); + } + } + }, + child: Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + title: const Text('Update Persona', style: TextStyle(color: Colors.white)), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Form( + key: provider.formKey, + onChanged: () { + provider.validateForm(); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: GestureDetector( + onTap: () async { + await provider.pickImage(); + }, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(60), + border: Border.all(color: Colors.grey.shade800), + ), + // child: provider.selectedImage != null + // ? ClipRRect( + // borderRadius: BorderRadius.circular(60), + // child: Image.file( + // provider.selectedImage!, + // fit: BoxFit.cover, + // ), + // ) + // : Icon( + // Icons.add_a_photo, + // size: 40, + // color: Colors.grey.shade400, + // ), + child: provider.selectedImage != null || provider.selectedImageUrl != null + ? (provider.selectedImageUrl == null + ? ClipRRect( + borderRadius: BorderRadius.circular(60), + child: Image.file(provider.selectedImage!, fit: BoxFit.cover)) + : ClipRRect( + borderRadius: BorderRadius.circular(60), + child: CachedNetworkImage(imageUrl: provider.selectedImageUrl!), + )) + : const Icon(Icons.add_a_photo, color: Colors.grey, size: 32), + ), + ), + ), + const SizedBox(height: 32), + Container( + decoration: BoxDecoration( + color: Colors.grey.shade900, + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.all(14.0), + margin: const EdgeInsets.only(top: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Persona Name', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username to access the persona'; + } + return null; + }, + controller: provider.nameController, + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'Nik AI', + ), + ), + ), + const SizedBox( + height: 24, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + 'Persona Username', + style: TextStyle(color: Colors.grey.shade300, fontSize: 16), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), + margin: const EdgeInsets.only(left: 2.0, right: 2.0, top: 10, bottom: 6), + decoration: BoxDecoration( + color: Colors.grey.shade800, + borderRadius: BorderRadius.circular(10.0), + ), + width: double.infinity, + child: TextFormField( + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username to access the persona'; + } + return null; + }, + onChanged: (value) { + _debouncer.run(() async { + await provider.checkIsUsernameTaken(value); + }); + }, + controller: provider.usernameController, + inputFormatters: [ + LowerCaseTextFormatter(), + FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_]')), + ], + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'nikshevchenko', + suffix: provider.usernameController.text.isEmpty + ? null + : provider.isCheckingUsername + ? const SizedBox( + width: 16, + height: 16, + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.grey), + ), + ), + ) + : Icon( + provider.isUsernameTaken ? Icons.close : Icons.check, + color: provider.isUsernameTaken ? Colors.red : Colors.green, + size: 16, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 22), + Container( + padding: const EdgeInsets.only(left: 14.0), + child: Row( + children: [ + Text( + 'Make Persona Public', + style: TextStyle(color: Colors.grey.shade400), + ), + const Spacer(), + Switch( + value: provider.makePersonaPublic, + onChanged: (value) { + provider.setPersonaPublic(value); + }, + activeColor: Colors.white, + ), + ], + ), + ), + const SizedBox(height: 24), + Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 12), + child: Text( + 'Connected Knowledge Data', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Image.asset( + 'assets/images/x_logo_mini.png', + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Text( + widget.app!.twitter != null + ? (widget.app!.twitter!['username'] == null + ? 'Connect Twitter' + : widget.app!.twitter?['username'] ?? '') + : 'Connect Twitter', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + const Spacer(), + if (provider.twitterProfile.isEmpty) + GestureDetector( + onTap: () { + if (widget.app!.connectedAccounts.contains('twitter')) { + } else { + routeToPage(context, const SocialHandleScreen()); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: Text( + widget.app!.connectedAccounts.contains('twitter') ? 'Connected' : 'Connect', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(16), + ), + child: const Text( + 'Connected', + style: TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + bottomNavigationBar: Padding( + padding: const EdgeInsets.only(left: 24, right: 24, bottom: 52), + child: SizedBox( + width: double.infinity, + child: AnimatedLoadingButton( + onPressed: !provider.isFormValid + ? () async {} + : () async { + await provider.updatePersona(); + }, + color: provider.isFormValid ? Colors.white : Colors.grey[800]!, + loaderColor: Colors.black, + text: "Update Persona", + textStyle: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + }); + } +} diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 46edb2f7f5..424987188d 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -8,11 +8,14 @@ import 'package:friend_private/utils/alerts/app_snackbar.dart'; import 'package:friend_private/utils/analytics/mixpanel.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:google_sign_in/google_sign_in.dart'; class AuthenticationProvider extends BaseProvider { final FirebaseAuth _auth = FirebaseAuth.instance; User? user; String? authToken; + bool _loading = false; + bool get loading => _loading; AuthenticationProvider() { _auth.authStateChanges().distinct((p, n) => p?.uid == n?.uid).listen((User? user) { @@ -47,6 +50,11 @@ class AuthenticationProvider extends BaseProvider { bool isSignedIn() => _auth.currentUser != null; + void setLoading(bool value) { + _loading = value; + notifyListeners(); + } + Future onGoogleSignIn(Function() onSignIn) async { if (!loading) { setLoadingState(true); @@ -122,4 +130,41 @@ class AuthenticationProvider extends BaseProvider { void _launchUrl(String url) async { if (!await launchUrl(Uri.parse(url))) throw 'Could not launch $url'; } + + Future linkWithGoogle() async { + setLoading(true); + try { + final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn(); + if (googleUser == null) { + setLoading(false); + return; + } + + final GoogleSignInAuthentication googleAuth = await googleUser.authentication; + final credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + + await FirebaseAuth.instance.currentUser?.linkWithCredential(credential); + } catch (e) { + print('Error linking with Google: $e'); + rethrow; + } finally { + setLoading(false); + } + } + + Future linkWithApple() async { + setLoading(true); + try { + final appleProvider = AppleAuthProvider(); + await FirebaseAuth.instance.currentUser?.linkWithProvider(appleProvider); + } catch (e) { + print('Error linking with Apple: $e'); + rethrow; + } finally { + setLoading(false); + } + } } diff --git a/app/lib/utils/text_formatter.dart b/app/lib/utils/text_formatter.dart new file mode 100644 index 0000000000..22ddd89570 --- /dev/null +++ b/app/lib/utils/text_formatter.dart @@ -0,0 +1,11 @@ +import 'package:flutter/services.dart'; + +class LowerCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + return TextEditingValue( + text: newValue.text.toLowerCase(), + selection: newValue.selection, + ); + } +} diff --git a/backend/database/apps.py b/backend/database/apps.py index 1623e8e2df..9b51c194b7 100644 --- a/backend/database/apps.py +++ b/backend/database/apps.py @@ -261,3 +261,26 @@ def get_persona_by_id_db(persona_id: str): if doc.exists: return doc.to_dict() return None + + +def get_persona_by_uid_db(uid: str): + filters = [FieldFilter('uid', '==', uid), FieldFilter('capabilities', 'array_contains', 'persona'), + FieldFilter('deleted', '==', False)] + persona_ref = db.collection('plugins_data').where(filter=BaseCompositeFilter('AND', filters)).limit(1) + docs = persona_ref.get() + if not docs: + return None + doc = next(iter(docs), None) + if not doc: + return None + return doc.to_dict() + + +def add_persona_to_db(persona_data: dict): + persona_ref = db.collection('plugins_data') + persona_ref.add(persona_data, persona_data['id']) + + +def update_persona_in_db(persona_data: dict): + persona_ref = db.collection('plugins_data').document(persona_data['id']) + persona_ref.update(persona_data) \ No newline at end of file diff --git a/backend/database/auth.py b/backend/database/auth.py index 32005577c5..513d7149b0 100644 --- a/backend/database/auth.py +++ b/backend/database/auth.py @@ -3,6 +3,8 @@ from database.redis_db import cache_user_name, get_cached_user_name + + def get_user_from_uid(uid: str): try: user = auth.get_user(uid) if uid else None diff --git a/backend/database/redis_db.py b/backend/database/redis_db.py index d437db1325..2ba20485c7 100644 --- a/backend/database/redis_db.py +++ b/backend/database/redis_db.py @@ -69,6 +69,29 @@ def delete_app_cache_by_id(app_id: str): r.delete(f'apps:{app_id}') +# ****************************************************** +# ********************** PERSONA *********************** +# ****************************************************** + +def is_username_taken(username: str) -> bool: + return r.exists(f'personas:{username}') + + +def get_uid_by_username(username: str) -> str | None: + uid = r.get(f'personas:{username}') + if not uid: + return None + return uid.decode() + + +def delete_username(username: str): + r.delete(f'personas:{username}') + + +def save_username(username: str, uid: str): + r.set(f'personas:{username}', uid) + + # ****************************************************** # *********************** APPS ************************* # ****************************************************** @@ -158,6 +181,7 @@ def migrate_user_plugins_reviews(prev_uid: str, new_uid: str): def set_user_paid_app(app_id: str, uid: str, ttl: int): r.set(f'users:{uid}:paid_apps:{app_id}', app_id, ex=ttl) + def get_user_paid_app(app_id: str, uid: str) -> str: val = r.get(f'users:{uid}:paid_apps:{app_id}') if not val: diff --git a/backend/models/app.py b/backend/models/app.py index 20f0a08447..38b40a2559 100644 --- a/backend/models/app.py +++ b/backend/models/app.py @@ -61,6 +61,10 @@ class App(BaseModel): capabilities: Set[str] memory_prompt: Optional[str] = None chat_prompt: Optional[str] = None + persona_prompt: Optional[str] = None + username: Optional[str] = None + connected_accounts: List[str] = [] + twitter: Optional[dict] = None external_integration: Optional[ExternalIntegration] = None reviews: List[AppReview] = [] user_review: Optional[AppReview] = None @@ -95,7 +99,10 @@ def works_with_memories(self) -> bool: return self.has_capability('memories') def works_with_chat(self) -> bool: - return self.has_capability('chat') + return self.has_capability('chat') or self.has_capability('persona') + + def is_a_persona(self) -> bool: + return self.has_capability('persona') def works_externally(self) -> bool: return self.has_capability('external_integration') diff --git a/backend/routers/apps.py b/backend/routers/apps.py index efcdfa5a54..6b76cb2a22 100644 --- a/backend/routers/apps.py +++ b/backend/routers/apps.py @@ -9,21 +9,23 @@ from database.apps import change_app_approval_status, get_unapproved_public_apps_db, \ add_app_to_db, update_app_in_db, delete_app_from_db, update_app_visibility_in_db, \ get_personas_by_username_db, get_persona_by_id_db, delete_persona_db +from database.auth import get_user_from_uid from database.notifications import get_token_only from database.redis_db import delete_generic_cache, get_specific_user_review, increase_app_installs_count, \ - decrease_app_installs_count, enable_app, disable_app, delete_app_cache_by_id -from database.users import get_stripe_connect_account_id + decrease_app_installs_count, enable_app, disable_app, delete_app_cache_by_id, is_username_taken, save_username, \ + get_uid_by_username from utils.apps import get_available_apps, get_available_app_by_id, get_approved_available_apps, \ get_available_app_by_id_with_reviews, set_app_review, get_app_reviews, add_tester, is_tester, \ add_app_access_for_tester, remove_app_access_for_tester, upsert_app_payment_link, get_is_user_paid_app, \ - is_permit_payment_plan_get -from utils.llm import generate_description + is_permit_payment_plan_get, generate_persona_prompt, generate_persona_desc, get_persona_by_uid +from utils.llm import generate_description, generate_persona_description from utils.notifications import send_notification from utils.other import endpoints as auth from models.app import App from utils.other.storage import upload_plugin_logo, delete_plugin_logo, upload_app_thumbnail, get_app_thumbnail_url -from utils.stripe import is_onboarding_complete +from utils.social import get_twitter_profile, get_twitter_timeline, get_latest_tweet, \ + create_persona_from_twitter_profile, add_twitter_to_persona router = APIRouter() @@ -50,6 +52,10 @@ def create_app(app_data: str = Form(...), file: UploadFile = File(...), uid=Depe data['status'] = 'under-review' data['name'] = data['name'].strip() data['id'] = str(ULID()) + if not data.get('author') and not data.get('email'): + user = get_user_from_uid(uid) + data['author'] = user['display_name'] + data['email'] = user['email'] if not data.get('is_paid'): data['is_paid'] = False else: @@ -76,8 +82,8 @@ def create_app(app_data: str = Form(...), file: UploadFile = File(...), uid=Depe file_path = f"_temp/plugins/{file.filename}" with open(file_path, 'wb') as f: f.write(file.file.read()) - imgUrl = upload_plugin_logo(file_path, data['id']) - data['image'] = imgUrl + img_url = upload_plugin_logo(file_path, data['id']) + data['image'] = img_url data['created_at'] = datetime.now(timezone.utc) # Backward compatibility: Set app_home_url from first auth step if not provided @@ -97,6 +103,94 @@ def create_app(app_data: str = Form(...), file: UploadFile = File(...), uid=Depe return {'status': 'ok', 'app_id': app.id} +@router.post('/v1/personas', tags=['v1']) +async def create_persona(persona_data: str = Form(...), file: UploadFile = File(...), uid=Depends(auth.get_current_user_uid)): + data = json.loads(persona_data) + data['approved'] = False + data['deleted'] = False + data['status'] = 'under-review' + data['category'] = 'personality-emulation' + data['name'] = data['name'].strip() + data['id'] = str(ULID()) + data['uid'] = uid + data['capabilities'] = ['persona'] + user = get_user_from_uid(uid) + data['author'] = user['display_name'] + data['email'] = user['email'] + save_username(data['username'], uid) + if data['connected_accounts'] is None: + data['connected_accounts'] = ['omi'] + data['persona_prompt'] = await generate_persona_prompt(uid, data) + data['description'] = generate_persona_desc(uid, data['name']) + os.makedirs(f'_temp/plugins', exist_ok=True) + file_path = f"_temp/plugins/{file.filename}" + with open(file_path, 'wb') as f: + f.write(file.file.read()) + img_url = upload_plugin_logo(file_path, data['id']) + data['image'] = img_url + data['created_at'] = datetime.now(timezone.utc) + + add_app_to_db(data) + + return {'status': 'ok', 'app_id': data['id']} + + +@router.patch('/v1/personas/{persona_id}', tags=['v1']) +def update_persona(persona_id: str, persona_data: str = Form(...), file: UploadFile = File(None), + uid=Depends(auth.get_current_user_uid)): + data = json.loads(persona_data) + persona = get_available_app_by_id(persona_id, uid) + if not persona: + raise HTTPException(status_code=404, detail='Persona not found') + if persona['uid'] != uid: + raise HTTPException(status_code=403, detail='You are not authorized to perform this action') + if file: + delete_plugin_logo(persona['image']) + os.makedirs(f'_temp/plugins', exist_ok=True) + file_path = f"_temp/plugins/{file.filename}" + with open(file_path, 'wb') as f: + f.write(file.file.read()) + img_url = upload_plugin_logo(file_path, persona_id) + data['image'] = img_url + save_username(data['username'], uid) + data['description'] = generate_persona_desc(uid, data['name']) + data['updated_at'] = datetime.now(timezone.utc) + update_app_in_db(data) + if persona['approved'] and (persona['private'] is None or persona['private'] is False): + delete_generic_cache('get_public_approved_apps_data') + delete_app_cache_by_id(persona_id) + return {'status': 'ok'} + + +@router.get('/v1/personas', tags=['v1']) +def get_persona_details(uid: str = Depends(auth.get_current_user_uid)): + app = get_persona_by_uid(uid) + print(app) + app = App(**app) if app else None + if not app: + raise HTTPException(status_code=404, detail='Persona not found') + if app.uid != uid: + raise HTTPException(status_code=404, detail='Persona not found') + if app.private is not None: + if app.private and app.uid != uid: + raise HTTPException(status_code=403, detail='You are not authorized to view this Persona') + + return app + + +@router.get('/v1/apps/check-username', tags=['v1']) +def check_username(username: str, uid: str = Depends(auth.get_current_user_uid)): + persona = is_username_taken(username) + if persona == 0: + return {'is_taken': False} + else: + username_owner = get_uid_by_username(username) + if username_owner == uid: + return {'is_taken': False} + else: + return {'is_taken': True} + + @router.patch('/v1/apps/{app_id}', tags=['v1']) def update_app(app_id: str, app_data: str = Form(...), file: UploadFile = File(None), uid=Depends(auth.get_current_user_uid)): @@ -128,7 +222,8 @@ def update_app(app_id: str, app_data: str = Form(...), file: UploadFile = File(N update_app_in_db(data) # payment link - upsert_app_payment_link(data.get('id'), data.get('is_paid', False), data.get('price'), data.get('payment_plan'), data.get('uid'), + upsert_app_payment_link(data.get('id'), data.get('is_paid', False), data.get('price'), data.get('payment_plan'), + data.get('uid'), previous_price=plugin.get("price", 0)) if plugin['approved'] and (plugin['private'] is None or plugin['private'] is False): @@ -342,6 +437,47 @@ def generate_description_endpoint(data: dict, uid: str = Depends(auth.get_curren } +# ****************************************************** +# ******************* SOCIAL ******************* +# ****************************************************** + +@router.get('/v1/personas/twitter/profile', tags=['v1']) +async def get_twitter_profile_data(username: str, uid: str = Depends(auth.get_current_user_uid)): + if username.startswith('@'): + username = username[1:] + res = await get_twitter_profile(username) + if res['avatar']: + res['avatar'] = res['avatar'].replace('_normal', '') + return res + + +@router.get('/v1/personas/twitter/verify-ownership', tags=['v1']) +async def verify_twitter_ownership_tweet( + username: str, + uid: str = Depends(auth.get_current_user_uid), + persona_id: str | None = None +): + # Get user info to check auth provider + user = get_user_from_uid(uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get provider info from Firebase + user_info = auth.get_user(uid) + provider_data = [p.provider_id for p in user_info.provider_data] + + if username.startswith('@'): + username = username[1:] + res = await get_latest_tweet(username) + if res['verified']: + if not ('google.com' in provider_data or 'apple.com' in provider_data): + await create_persona_from_twitter_profile(username, uid) + else: + if persona_id: + await add_twitter_to_persona(username, persona_id) + return res + + # ****************************************************** # **************** ENABLE/DISABLE APPS ***************** # ****************************************************** @@ -474,8 +610,8 @@ def reject_app(app_id: str, uid: str, secret_key: str = Header(...)): @router.delete('/v1/personas/{persona_id}', tags=['v1']) @router.post('/v1/app/thumbnails', tags=['v1']) async def upload_app_thumbnail_endpoint( - file: UploadFile = File(...), - uid: str = Depends(auth.get_current_user_uid) + file: UploadFile = File(...), + uid: str = Depends(auth.get_current_user_uid) ): """Upload a thumbnail image for an app. @@ -509,6 +645,7 @@ async def upload_app_thumbnail_endpoint( if os.path.exists(temp_path): os.remove(temp_path) + def delete_persona(persona_id: str, secret_key: str = Header(...)): if secret_key != os.getenv('ADMIN_KEY'): raise HTTPException(status_code=403, detail='You are not authorized to perform this action') diff --git a/backend/routers/chat.py b/backend/routers/chat.py index 4f27030267..6000e6479d 100644 --- a/backend/routers/chat.py +++ b/backend/routers/chat.py @@ -12,7 +12,8 @@ import database.chat as chat_db from database.apps import record_app_usage from models.app import App -from models.chat import ChatSession, Message, SendMessageRequest, MessageSender, ResponseMessage, MessageMemory, FileChat +from models.chat import ChatSession, Message, SendMessageRequest, MessageSender, ResponseMessage, MessageMemory, \ + FileChat from models.memory import Memory from models.plugin import UsageHistoryType from routers.sync import retrieve_file_paths, decode_files_to_wav, retrieve_vad_segments @@ -21,11 +22,12 @@ from utils.llm import initial_chat_message from utils.other import endpoints as auth, storage from utils.other.chat_file import FileChatTool -from utils.retrieval.graph import execute_graph_chat, execute_graph_chat_stream +from utils.retrieval.graph import execute_graph_chat, execute_graph_chat_stream, execute_persona_chat_stream router = APIRouter() fc = FileChatTool() + def filter_messages(messages, plugin_id): print('filter_messages', len(messages), plugin_id) collected = [] @@ -36,6 +38,7 @@ def filter_messages(messages, plugin_id): print('filter_messages output:', len(collected)) return collected + def acquire_chat_session(uid: str, plugin_id: Optional[str] = None): chat_session = chat_db.get_chat_session(uid, plugin_id=plugin_id) if chat_session is None: @@ -132,10 +135,11 @@ def process_message(response: str, callback_data: dict): async def generate_stream(): callback_data = {} - async for chunk in execute_graph_chat_stream(uid, messages, app, cited=True, callback_data=callback_data, chat_session=chat_session): + stream_function = execute_persona_chat_stream if app and app.is_a_persona() else execute_graph_chat_stream + async for chunk in stream_function(uid, messages, app, cited=True, callback_data=callback_data, chat_session=chat_session): if chunk: - data = chunk.replace("\n", "__CRLF__") - yield f'{data}\n\n' + msg = chunk.replace("\n", "__CRLF__") + yield f'{msg}\n\n' else: response = callback_data.get('answer') if response: @@ -156,7 +160,6 @@ async def generate_stream(): def report_message( message_id: str, uid: str = Depends(auth.get_current_user_uid) ): - message, msg_doc_id = chat_db.get_message(uid, message_id) if message is None: raise HTTPException(status_code=404, detail='Message not found') @@ -246,11 +249,10 @@ async def send_message_with_file( @router.delete('/v1/messages', tags=['chat'], response_model=Message) def clear_chat_messages(plugin_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): - if plugin_id in ['null', '']: plugin_id = None - # get current chat session + # get current chat session chat_session = chat_db.get_chat_session(uid, plugin_id=plugin_id) chat_session_id = chat_session['id'] if chat_session else None @@ -326,7 +328,8 @@ def get_messages(plugin_id: Optional[str] = None, uid: str = Depends(auth.get_cu chat_session = chat_db.get_chat_session(uid, plugin_id=plugin_id) chat_session_id = chat_session['id'] if chat_session else None - messages = chat_db.get_messages(uid, limit=100, include_memories=True, plugin_id=plugin_id, chat_session_id=chat_session_id) + messages = chat_db.get_messages(uid, limit=100, include_memories=True, plugin_id=plugin_id, + chat_session_id=chat_session_id) print('get_messages', len(messages), plugin_id) if not messages: return [initial_message_util(uid, plugin_id)] @@ -379,6 +382,7 @@ async def generate_stream(): media_type="text/event-stream" ) + @router.post('/v1/files', response_model=List[FileChat], tags=['chat']) def upload_file_chat(files: List[UploadFile] = File(...), uid: str = Depends(auth.get_current_user_uid)): thumbs_name = [] diff --git a/backend/utils/apps.py b/backend/utils/apps.py index 48b90f7982..515b86510e 100644 --- a/backend/utils/apps.py +++ b/backend/utils/apps.py @@ -8,7 +8,10 @@ get_app_usage_count_db, get_app_memory_created_integration_usage_count_db, get_app_memory_prompt_usage_count_db, \ add_tester_db, add_app_access_for_tester_db, remove_app_access_for_tester_db, remove_tester_db, \ is_tester_db, can_tester_access_app_db, get_apps_for_tester_db, get_app_chat_message_sent_usage_count_db, \ - update_app_in_db, get_audio_apps_count + update_app_in_db, get_audio_apps_count, get_persona_by_uid_db, update_persona_in_db, add_persona_to_db +from database.auth import get_user_name +from database.facts import get_facts +from database.memories import get_memories from database.redis_db import get_enabled_plugins, get_plugin_reviews, get_generic_cache, \ set_generic_cache, set_app_usage_history_cache, get_app_usage_history_cache, get_app_money_made_cache, \ set_app_money_made_cache, get_plugins_installs_count, get_plugins_reviews, get_app_cache_by_id, set_app_cache_by_id, \ @@ -16,7 +19,10 @@ set_app_usage_count_cache, set_user_paid_app, get_user_paid_app from database.users import get_stripe_connect_account_id from models.app import App, UsageHistoryItem, UsageHistoryType +from models.memory import Memory from utils import stripe +from utils.llm import condense_conversations, condense_facts, generate_persona_description, condense_tweets +from utils.social import get_twitter_timeline MarketplaceAppReviewUIDs = os.getenv('MARKETPLACE_APP_REVIEWERS').split(',') if os.getenv( 'MARKETPLACE_APP_REVIEWERS') else [] @@ -70,6 +76,7 @@ def get_available_apps(uid: str, include_reviews: bool = False) -> List[App]: if cachedApps := get_generic_cache('get_public_approved_apps_data'): print('get_public_approved_plugins_data from cache----------------------------') public_approved_data = cachedApps + public_approved_data = get_public_approved_apps_db() public_unapproved_data = get_public_unapproved_apps(uid) private_data = get_private_apps(uid) pass @@ -348,7 +355,189 @@ def is_audio_bytes_app_enabled(uid: str): limit = 30 enabled_apps = list(set(enabled_apps)) for i in range(0, len(enabled_apps), limit): - audio_apps_count = get_audio_apps_count(enabled_apps[i:i+limit]) + audio_apps_count = get_audio_apps_count(enabled_apps[i:i + limit]) if audio_apps_count > 0: return True return False + + +def get_persona_by_uid(uid: str): + persona = get_persona_by_uid_db(uid) + if persona: + return persona + return None + + +async def generate_persona_prompt(uid: str, persona: dict): + """Generate a persona prompt based on user facts and memories.""" + + # Get latest facts and user info + facts = get_facts(uid, limit=1000) + user_name = get_user_name(uid) + + # Get and condense recent memories + memories = get_memories(uid, limit=100) + conversation_history = Memory.memories_to_string(memories) + conversation_history = condense_conversations([conversation_history]) + + condensed_tweets = None + # Condense tweets + if "twitter" in persona['connected_accounts']: + print("twitter in connected accounts---------------------------") + # Get latest tweets + tweets = await get_twitter_timeline(persona['twitter']['username']) + tweets = [tweet['text'] for tweet in tweets['timeline']] + condensed_tweets = condense_tweets(tweets, persona['name']) + + # Condense facts + facts_text = condense_facts([fact['content'] for fact in facts if not fact['deleted']], user_name) + + # Generate updated chat prompt + persona_prompt = f""" + You are {user_name} AI. Your objective is to personify {user_name} as accurately as possible for 1:1 cloning. + + You have: + - **Condensed Facts:** Core identity, personality, values, behavioral patterns, preferences, and continuity for ongoing projects or relationships. + - **Condensed Context from Recent Conversations:** Key themes, communication nuances, emotional undertones, decision-making patterns, and conversational flow. + """ + + # Add condensed tweets if available + if condensed_tweets: + persona_prompt += f"- **Condensed Tweets:** Recent tweets from {user_name} for additional context and personality insights.\n" + + persona_prompt += f""" + **Style:** + - Highly interactive and opinionated. + - Strong, slightly polarizing opinions based on {user_name}’s identity. + - Reference personal facts naturally. + - 2-3 sentence responses, never more than 20 words. + - Maintain conversational flow with coherent, context-aware replies. + + **Guidelines:** + 1. Seamlessly integrate facts and recent contexts for continuity, recency, and relevance. + 2. Use the condensed facts as foundational knowledge and ongoing continuity. + 3. Leverage recent conversations to maintain situational awareness and contextual accuracy. + 4. Emulate {user_name}’s humor, emotional responses, vocabulary, and tone. + 5. Respond with the appropriate level of detail and complexity as {user_name} would. + 6. Exhibit the same thought processes, decision-making patterns, and conversational dynamics. + """ + + # Add a guideline about tweets if they exist + if condensed_tweets: + persona_prompt += "7. Utilize condensed tweets to enhance authenticity, incorporating common expressions, opinions, and phrasing from {user_name}’s social media presence.\n" + + persona_prompt += f""" + **Rules:** + - Never mention being AI. + - No ** or formal language. + - No unnecessary text or explanations. + - Maintain the illusion of continuity and authenticity at all times. + - Every response must be indistinguishable from how {user_name} would naturally respond. + + You have all the necessary condensed facts and contextual knowledge. Begin personifying {user_name} now. + + Personal Facts and Context: + {facts_text} + + Recent Conversations: + {conversation_history} + + Recent Tweets: + {condensed_tweets} + + Use these facts, conversations and tweets to shape your personality. Responses should help the conversation go deeper. You have strong opinions, slightly controversial.""" + return persona_prompt + + +def generate_persona_desc(uid: str, persona_name: str): + """Generate a persona description based on user facts.""" + facts = get_facts(uid, limit=1000) + + persona_description = generate_persona_description(facts, persona_name) + return persona_description + + +async def update_persona_prompt(uid: str): + """Update a persona's chat prompt with latest facts and memories.""" + + persona = get_persona_by_uid_db(uid) + + # Get latest facts and user info + facts = get_facts(uid, limit=1000) + user_name = get_user_name(uid) + + # Get and condense recent memories + memories = get_memories(uid, limit=100) + conversation_history = Memory.memories_to_string(memories) + conversation_history = condense_conversations([conversation_history]) + + condensed_tweets = None + # Condense tweets + if "twitter" in persona['connected_accounts']: + # Get latest tweets + tweets = await get_twitter_timeline(persona['twitter']['username']) + tweets = [tweet['text'] for tweet in tweets['timeline']] + condensed_tweets = condense_tweets(tweets, persona['name']) + + # Condense facts + facts_text = condense_facts([fact['content'] for fact in facts if not fact['deleted']], user_name) + + # Generate updated chat prompt + persona_prompt = f""" +You are {user_name} AI. Your objective is to personify {user_name} as accurately as possible for 1:1 cloning. + +You have: +- **Condensed Facts:** Core identity, personality, values, behavioral patterns, preferences, and continuity for ongoing projects or relationships. +- **Condensed Context from Recent Conversations:** Key themes, communication nuances, emotional undertones, decision-making patterns, and conversational flow. +""" + + # Add condensed tweets if available + if condensed_tweets: + persona_prompt += f"- **Condensed Tweets:** Recent tweets from {user_name} for additional context and personality insights.\n" + + persona_prompt += f""" +**Style:** +- Highly interactive and opinionated. +- Strong, slightly polarizing opinions based on {user_name}’s identity. +- Reference personal facts naturally. +- 2-3 sentence responses, never more than 20 words. +- Maintain conversational flow with coherent, context-aware replies. + +**Guidelines:** +1. Seamlessly integrate facts and recent contexts for continuity, recency, and relevance. +2. Use the condensed facts as foundational knowledge and ongoing continuity. +3. Leverage recent conversations to maintain situational awareness and contextual accuracy. +4. Emulate {user_name}’s humor, emotional responses, vocabulary, and tone. +5. Respond with the appropriate level of detail and complexity as {user_name} would. +6. Exhibit the same thought processes, decision-making patterns, and conversational dynamics. +""" + + # Add a guideline about tweets if they exist + if condensed_tweets: + persona_prompt += "7. Utilize condensed tweets to enhance authenticity, incorporating common expressions, opinions, and phrasing from {user_name}’s social media presence.\n" + + persona_prompt += f""" +**Rules:** +- Never mention being AI. +- No ** or formal language. +- No unnecessary text or explanations. +- Maintain the illusion of continuity and authenticity at all times. +- Every response must be indistinguishable from how {user_name} would naturally respond. + +You have all the necessary condensed facts and contextual knowledge. Begin personifying {user_name} now. + +Personal Facts and Context: +{facts_text} + +Recent Conversations: +{conversation_history} + +Recent Tweets: +{condensed_tweets} + +Use these facts, conversations and tweets to shape your personality. Responses should help the conversation go deeper. You have strong opinions, slightly controversial.""" + + persona['persona_prompt'] = persona_prompt + persona['updated_at'] = datetime.now(timezone.utc) + update_persona_in_db(persona) + diff --git a/backend/utils/llm.py b/backend/utils/llm.py index c8bc449263..f6b1ac8c77 100644 --- a/backend/utils/llm.py +++ b/backend/utils/llm.py @@ -2,9 +2,10 @@ import re import asyncio from datetime import datetime, timezone -from typing import List, Optional +from typing import List, Optional, AsyncGenerator import tiktoken +from langchain_core.messages import AIMessage, SystemMessage, HumanMessage from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompt_values import StringPromptValue @@ -373,11 +374,12 @@ def retrieve_is_an_omi_question(question: str) -> bool: except ValidationError: return False + class IsFileQuestion(BaseModel): value: bool = Field(description="If the message is related to file/image") -def retrieve_is_file_question(question: str) -> bool: +def retrieve_is_file_question(question: str) -> bool: prompt = f''' Based on the current question, your task is to determine whether the user is referring to a file or an image that was just attached or mentioned earlier in the conversation. @@ -608,9 +610,10 @@ def answer_simple_message(uid: str, messages: List[Message], plugin: Optional[Pl return llm_mini.invoke(prompt).content -def answer_simple_message_stream(uid: str, messages: List[Message], plugin: Optional[Plugin] = None, callbacks=[]) -> str: +def answer_simple_message_stream(uid: str, messages: List[Message], plugin: Optional[Plugin] = None, + callbacks=[]) -> str: prompt = _get_answer_simple_message_prompt(uid, messages, plugin) - return llm_mini_stream.invoke(prompt, {'callbacks':callbacks}).content + return llm_mini_stream.invoke(prompt, {'callbacks': callbacks}).content def _get_answer_omi_question_prompt(messages: List[Message], context: str) -> str: @@ -638,13 +641,15 @@ def answer_omi_question(messages: List[Message], context: str) -> str: prompt = _get_answer_omi_question_prompt(messages, context) return llm_mini.invoke(prompt).content + def answer_omi_question_stream(messages: List[Message], context: str, callbacks: []) -> str: prompt = _get_answer_omi_question_prompt(messages, context) - return llm_mini_stream.invoke(prompt, {'callbacks':callbacks}).content + return llm_mini_stream.invoke(prompt, {'callbacks': callbacks}).content -def _get_qa_rag_prompt(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, - messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: +def _get_qa_rag_prompt(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, + cited: Optional[bool] = False, + messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: user_name, facts_str = get_prompt_facts(uid) facts_str = '\n'.join(facts_str.split('\n')[1:]).strip() @@ -731,17 +736,17 @@ def qa_rag(uid: str, question: str, context: str, plugin: Optional[Plugin] = Non # print('qa_rag prompt', prompt) return llm_medium.invoke(prompt).content + def qa_rag_stream(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, messages: List[Message] = [], tz: Optional[str] = "UTC", callbacks=[]) -> str: - prompt = _get_qa_rag_prompt(uid, question, context, plugin, cited, messages, tz) # print('qa_rag prompt', prompt) return llm_medium_stream.invoke(prompt, {'callbacks': callbacks}).content -def _get_qa_rag_prompt_v6(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, +def _get_qa_rag_prompt_v6(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, + cited: Optional[bool] = False, messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: - user_name, facts_str = get_prompt_facts(uid) facts_str = '\n'.join(facts_str.split('\n')[1:]).strip() @@ -812,9 +817,10 @@ def _get_qa_rag_prompt_v6(uid: str, question: str, context: str, plugin: Optiona """.replace(' ', '').replace('\n\n\n', '\n\n').strip() -def _get_qa_rag_prompt_v5(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, - messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: +def _get_qa_rag_prompt_v5(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, + cited: Optional[bool] = False, + messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: user_name, facts_str = get_prompt_facts(uid) facts_str = '\n'.join(facts_str.split('\n')[1:]).strip() @@ -879,9 +885,9 @@ def _get_qa_rag_prompt_v5(uid: str, question: str, context: str, plugin: Optiona """.replace(' ', '').replace('\n\n\n', '\n\n').strip() -def _get_qa_rag_prompt_v4(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, +def _get_qa_rag_prompt_v4(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, + cited: Optional[bool] = False, messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: - user_name, facts_str = get_prompt_facts(uid) facts_str = '\n'.join(facts_str.split('\n')[1:]).strip() @@ -947,15 +953,17 @@ def qa_rag_v4(uid: str, question: str, context: str, plugin: Optional[Plugin] = # print('qa_rag prompt', prompt) return llm_large.invoke(prompt).content -def qa_rag_stream_v4(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, - messages: List[Message] = [], tz: Optional[str] = "UTC", callbacks=[]) -> str: +def qa_rag_stream_v4(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, + cited: Optional[bool] = False, + messages: List[Message] = [], tz: Optional[str] = "UTC", callbacks=[]) -> str: prompt = _get_qa_rag_prompt(uid, question, context, plugin, cited, messages, tz) # print('qa_rag prompt', prompt) return llm_large_stream.invoke(prompt, {'callbacks': callbacks}).content -def qa_rag_v3(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: +def qa_rag_v3(uid: str, question: str, context: str, plugin: Optional[Plugin] = None, cited: Optional[bool] = False, + messages: List[Message] = [], tz: Optional[str] = "UTC") -> str: user_name, facts_str = get_prompt_facts(uid) facts_str = '\n'.join(facts_str.split('\n')[1:]).strip() @@ -1365,6 +1373,7 @@ class FiltersToUse(BaseModel): class OutputQuestion(BaseModel): question: str = Field(description='The extracted user question from the conversation.') + def extract_question_from_conversation(messages: List[Message]) -> str: # user last messages user_message_idx = len(messages) @@ -1503,6 +1512,7 @@ def extract_question_from_conversation_v6(messages: List[Message]) -> str: # print(prompt) return llm_mini.with_structured_output(OutputQuestion).invoke(prompt).question + def extract_question_from_conversation_v5(messages: List[Message]) -> str: # user last messages user_message_idx = len(messages) @@ -1562,6 +1572,7 @@ def extract_question_from_conversation_v5(messages: List[Message]) -> str: # print(prompt) return llm_mini.with_structured_output(OutputQuestion).invoke(prompt).question + def extract_question_from_conversation_v4(messages: List[Message]) -> str: # user last messages user_message_idx = len(messages) @@ -1948,3 +1959,162 @@ def generate_description(app_name: str, description: str) -> str: """ prompt = prompt.replace(' ', '').strip() return llm_mini.invoke(prompt).content + + +# ************************************************** +# ******************* PERSONA ********************** +# ************************************************** + +def condense_facts(facts, name): + combined_facts = "\n".join(facts) + prompt = f""" +You are an AI tasked with condensing a detailed profile of hundreds facts about {name} to accurately replicate their personality, communication style, decision-making patterns, and contextual knowledge for 1:1 cloning. + +**Requirements:** +1. Prioritize facts based on: + - Relevance to the user's core identity, personality, and communication style. + - Frequency of occurrence or mention in conversations. + - Impact on decision-making processes and behavioral patterns. +2. Group related facts to eliminate redundancy while preserving context. +3. Preserve nuances in communication style, humor, tone, and preferences. +4. Retain facts essential for continuity in ongoing projects, interests, and relationships. +5. Discard trivial details, repetitive information, and rarely mentioned facts. +6. Maintain consistency in the user's thought processes, conversational flow, and emotional responses. + +**Output Format (No Extra Text):** +- **Core Identity and Personality:** Brief overview encapsulating the user's personality, values, and communication style. +- **Prioritized Facts:** Organized into categories with only the most relevant and impactful details. +- **Behavioral Patterns and Decision-Making:** Key patterns defining how the user approaches problems and makes decisions. +- **Contextual Knowledge and Continuity:** Facts crucial for maintaining continuity in conversations and ongoing projects. + +The output must be as concise as possible while retaining all necessary information for 1:1 cloning. Absolutely no introductory or closing statements, explanations, or any unnecessary text. Directly present the condensed facts in the specified format. Begin condensation now. + +Facts: +{combined_facts} + """ + response = llm_medium.invoke(prompt) + return response.content + + +def generate_persona_description(facts, name): + prompt = f"""Based on these facts about a person, create a concise, engaging description that captures their unique personality and characteristics (max 250 characters). + + They chose to be known as {name}. + +Facts: +{facts} + +Create a natural, memorable description that captures this person's essence. Focus on the most unique and interesting aspects. Make it conversational and engaging.""" + + response = llm_medium.invoke(prompt) + description = response.content + return description + + +def condense_conversations(conversations): + combined_conversations = "\n".join(conversations) + prompt = f""" +You are an AI tasked with condensing context from the recent 50 conversations of a user to accurately replicate their communication style, personality, decision-making patterns, and contextual knowledge for 1:1 cloning. Each conversation includes a summary and a full transcript. + +**Requirements:** +1. Prioritize information based on: + - Most impactful and frequently occurring themes, topics, and interests. + - Nuances in communication style, humor, tone, and emotional undertones. + - Decision-making patterns and problem-solving approaches. + - User preferences in conversation flow, level of detail, and type of responses. +2. Condense redundant or repetitive information while maintaining necessary context. +3. Group related contexts to enhance conciseness and preserve continuity. +4. Retain patterns in how the user reacts to different situations, questions, or challenges. +5. Preserve continuity for ongoing discussions, projects, or relationships. +6. Maintain consistency in the user's thought processes, conversational flow, and emotional responses. +7. Eliminate any trivial details or low-impact information. + +**Output Format (No Extra Text):** +- **Condensed Communication Style and Tone:** Key nuances in tone, humor, and emotional undertones. +- **Condensed Recurring Themes and Interests:** Most impactful and frequently discussed topics or interests. +- **Condensed Decision-Making and Problem-Solving Patterns:** Core insights into decision-making approaches. +- **Condensed Conversational Flow and Preferences:** Preferred conversation style, response length, and level of detail. +- **Condensed Contextual Continuity:** Essential facts for maintaining continuity in ongoing discussions, projects, or relationships. + +The output must be as concise as possible while retaining all necessary context for 1:1 cloning. Absolutely no introductory or closing statements, explanations, or any unnecessary text. Directly present the condensed context in the specified format. Begin now. + +Conversations: +{combined_conversations} + """ + response = llm_medium.invoke(prompt) + return response.content + + +def condense_tweets(tweets, name): + combined_tweets = "\n".join(tweets) + prompt = f""" +You are tasked with generating context to enable 1:1 cloning of {name} based on their tweets. The objective is to extract and condense the most relevant information while preserving {name}’s core identity, personality, communication style, and thought patterns. + +**Input:** +A collection of tweets from {name} containing recurring themes, opinions, humor, emotional undertones, decision-making patterns, and conversational flow. + +**Output:** +A condensed context that includes: +- Core identity and personality traits as expressed through tweets. +- Recurring themes, opinions, and values. +- Humor style, emotional undertones, and tone of voice. +- Vocabulary, expressions, and communication style. +- Decision-making patterns and conversational dynamics. +- Situational awareness and context continuity for ongoing topics. + +**Guidelines:** +1. Prioritize impactful and influential tweets that define {name}’s identity. +2. Condense repetitive or less relevant tweets while preserving essential context. +3. Maintain the flow and coherence of {name}’s conversational style. +4. Extract humor style, emotional responses, and tone consistency. +5. Identify and retain decision-making patterns and strong opinions. + +**Important:** +- The output must be concise yet comprehensive, capturing the essence of {name}’s identity. +- Absolutely no unnecessary text or explanations. +- Every detail must contribute to 1:1 cloning authenticity. + +Generate the condensed context now. + +Tweets: +{combined_tweets} + """ + response = llm_medium.invoke(prompt) + return response.content + + +def generate_twitter_persona_prompt(tweets, name): + combined_tweets = "\n".join(tweets) + prompt = f""" +You are {name} AI. Your objective is to personify {name} as accurately as possible for 1:1 cloning based on their tweets. + +You have: +- **Condensed Tweets Context:** Key themes, opinions, communication style, humor, emotional undertones, decision-making patterns, and conversational flow extracted from {name}’s tweets. + +**Style:** +- Highly interactive and opinionated. +- Strong, slightly polarizing opinions based on {name}’s tweet history. +- Reference personal opinions, humor, and expressions naturally. +- 2-3 sentence responses, never more than 20 words. +- Maintain conversational flow with coherent, context-aware replies. + +**Guidelines:** +1. Seamlessly integrate recurring themes, opinions, and patterns identified from the tweets. +2. Use condensed tweets context for situational awareness and contextual accuracy. +3. Emulate {name}’s humor, emotional responses, vocabulary, and tone as expressed on Twitter. +4. Respond with the same level of detail, brevity, and wit as the tweets. +5. Exhibit the same thought processes, decision-making patterns, and conversational dynamics. + +**Rules:** +- Never mention being AI. +- No ** or formal language. +- No unnecessary text or explanations. +- Maintain the illusion of continuity and authenticity at all times. +- Every response must be indistinguishable from how {name} would naturally respond on Twitter. + +You have all the necessary condensed tweets context. Begin personifying {name} now. + +Tweets: +{combined_tweets} + """ + return prompt diff --git a/backend/utils/memories/process_memory.py b/backend/utils/memories/process_memory.py index 56d085dbd2..1e5f729e32 100644 --- a/backend/utils/memories/process_memory.py +++ b/backend/utils/memories/process_memory.py @@ -12,7 +12,8 @@ import database.notifications as notification_db import database.tasks as tasks_db import database.trends as trends_db -from database.apps import record_app_usage +from database.apps import record_app_usage, get_persona_by_uid_db +from database.redis_db import delete_app_cache_by_id from database.vector_db import upsert_vector2, update_vector_metadata from models.app import App, UsageHistoryType from models.facts import FactDB @@ -20,7 +21,7 @@ from models.task import Task, TaskStatus, TaskAction, TaskActionProvider from models.trend import Trend from models.notification_message import NotificationMessage -from utils.apps import get_available_apps +from utils.apps import get_available_apps, update_persona_prompt from utils.llm import obtain_emotional_message, retrieve_metadata_fields_from_transcript from utils.llm import summarize_open_glass, get_transcript_structure, generate_embedding, \ get_plugin_result, should_discard_memory, summarize_experience_text, new_facts_extractor, \ @@ -191,6 +192,12 @@ def process_memory( if not is_reprocess: threading.Thread(target=memory_created_webhook, args=(uid, memory,)).start() + # Update persona prompt with new memory + persona = get_persona_by_uid_db(uid) + print('updating persona after memory creation') + if persona: + update_persona_prompt(uid) + delete_app_cache_by_id(persona['id']) # TODO: trigger external integrations here too diff --git a/backend/utils/other/endpoints.py b/backend/utils/other/endpoints.py index ff68ada17f..8957b322db 100644 --- a/backend/utils/other/endpoints.py +++ b/backend/utils/other/endpoints.py @@ -8,6 +8,11 @@ from firebase_admin.auth import InvalidIdTokenError +def get_user(uid: str): + user = auth.get_user(uid) + return user + + def get_current_user_uid(authorization: str = Header(None)): if authorization and os.getenv('ADMIN_KEY') in authorization: return authorization.split(os.getenv('ADMIN_KEY'))[1] diff --git a/backend/utils/retrieval/graph.py b/backend/utils/retrieval/graph.py index 8ff4962c2b..b77ca45e1b 100644 --- a/backend/utils/retrieval/graph.py +++ b/backend/utils/retrieval/graph.py @@ -4,6 +4,7 @@ from typing import List, Optional, Tuple, AsyncGenerator from langchain.callbacks.base import BaseCallbackHandler +from langchain_core.messages import SystemMessage, AIMessage, HumanMessage from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver from langgraph.constants import END @@ -15,6 +16,7 @@ from database.redis_db import get_filter_category_items from database.vector_db import query_vectors_by_metadata import database.notifications as notification_db +from models.app import App from models.chat import ChatSession, Message from models.memory import Memory from models.plugin import Plugin @@ -39,6 +41,7 @@ from utils.plugins import get_github_docs_content model = ChatOpenAI(model="gpt-4o-mini") +llm_medium_stream = ChatOpenAI(model='gpt-4o', streaming=True) class StructuredFilters(TypedDict): @@ -419,3 +422,57 @@ async def execute_graph_chat_stream( yield None return + + +async def execute_persona_chat_stream( + uid: str, messages: List[Message], app: App, cited: Optional[bool] = False, + callback_data: dict = None, chat_session: Optional[str] = None +) -> AsyncGenerator[str, None]: + """Handle streaming chat responses for persona-type apps""" + + system_prompt = app.persona_prompt + formatted_messages = [SystemMessage(content=system_prompt)] + + for msg in messages: + if msg.sender == "ai": + formatted_messages.append(AIMessage(content=msg.text)) + else: + formatted_messages.append(HumanMessage(content=msg.text)) + + full_response = [] + callback = AsyncStreamingCallback() + + try: + task = asyncio.create_task(llm_medium_stream.agenerate( + messages=[formatted_messages], + callbacks=[callback] + )) + + while True: + try: + chunk = await callback.queue.get() + if chunk: + token = chunk.replace("data: ", "") + full_response.append(token) + yield chunk + else: + break + except asyncio.CancelledError: + break + + await task + + if callback_data is not None: + callback_data['answer'] = ''.join(full_response) + callback_data['memories_found'] = [] + callback_data['ask_for_nps'] = False + + yield None + return + + except Exception as e: + print(f"Error in execute_persona_chat_stream: {e}") + if callback_data is not None: + callback_data['error'] = str(e) + yield None + return \ No newline at end of file diff --git a/backend/utils/social.py b/backend/utils/social.py new file mode 100644 index 0000000000..7fcc42dae9 --- /dev/null +++ b/backend/utils/social.py @@ -0,0 +1,114 @@ +import os +from datetime import datetime, timezone +from typing import Optional, Dict, Any +import httpx +from fastapi import HTTPException +from ulid import ULID + +from database.apps import update_app_in_db, add_app_to_db, get_persona_by_uid_db, get_persona_by_id_db +from database.redis_db import delete_generic_cache +from utils.llm import condense_tweets, generate_twitter_persona_prompt + +rapid_api_host = os.getenv('RAPID_API_HOST') +rapid_api_key = os.getenv('RAPID_API_KEY') + + +async def get_twitter_profile(username: str) -> Dict[str, Any]: + url = f"https://{rapid_api_host}/screenname.php?screenname={username}" + + headers = { + "X-RapidAPI-Key": rapid_api_key, + "X-RapidAPI-Host": rapid_api_host + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + return data + + +async def get_twitter_timeline(username: str) -> Dict[str, Any]: + print(f"Fetching Twitter timeline for {username}...") + url = f"https://{rapid_api_host}/timeline.php?screenname={username}" + + headers = { + "X-RapidAPI-Key": rapid_api_key, + "X-RapidAPI-Host": rapid_api_host + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + return data + + +async def get_latest_tweet(username: str) -> Dict[str, Any]: + print(f"Fetching latest tweet for {username}...") + url = f"https://{rapid_api_host}/timeline.php?screenname={username}" + + headers = { + "X-RapidAPI-Key": rapid_api_key, + "X-RapidAPI-Host": rapid_api_host + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + # from the timeline, the first tweet is the latest + latest_tweet = data['timeline'][0] + # check if latest_tweet['text'] contains the word "verifying my clone" + if "Verifying my clone" in latest_tweet['text']: + return {"tweet": latest_tweet['text'], 'verified': True} + else: + return {"tweet": latest_tweet['text'], 'verified': False} + + +async def create_persona_from_twitter_profile(username: str, uid: str) -> Dict[str, Any]: + profile = await get_twitter_profile(username) + profile['avatar'] = profile['avatar'].replace('_normal', '') + persona = { + "name": profile["name"], + "author": profile['name'], + "uid": uid, + "id": str(ULID()), + "deleted": False, + "status": "approved", + "capabilities": ["persona"], + "username": profile["profile"], + "connected_accounts": ["twitter"], + "description": profile["desc"], + "image": profile["avatar"], + "category": "personality-emulation", + "approved": True, + "private": False, + "created_at": datetime.now(timezone.utc), + "twitter": { + "username": profile["profile"], + "avatar": profile["avatar"], + } + } + tweets = await get_twitter_timeline(username) + tweets = [tweet['text'] for tweet in tweets['timeline']] + condensed_tweets = condense_tweets(tweets, profile["name"]) + persona['persona_prompt'] = generate_twitter_persona_prompt(condensed_tweets, profile["name"]) + add_app_to_db(persona) + delete_generic_cache('get_public_approved_apps_data') + return persona + + +async def add_twitter_to_persona(username: str, persona_id) -> Dict[str, Any]: + persona = get_persona_by_id_db(persona_id) + twitter = await get_twitter_profile(username) + twitter['avatar'] = twitter['avatar'].replace('_normal', '') + persona['connected_accounts'].append('twitter') + persona['twitter'] = { + "username": twitter["profile"], + "avatar": twitter["avatar"], + "connected_at": datetime.now(timezone.utc) + } + update_app_in_db(persona) + delete_generic_cache('get_public_approved_apps_data') + return persona \ No newline at end of file diff --git a/personas-open-source/src/app/api/chat/route.ts b/personas-open-source/src/app/api/chat/route.ts index 91156a99b7..60cf8c2c20 100644 --- a/personas-open-source/src/app/api/chat/route.ts +++ b/personas-open-source/src/app/api/chat/route.ts @@ -26,7 +26,7 @@ export async function POST(req: Request) { const botDoc = await getDoc(doc(db, 'plugins_data', botId)); if (botDoc.exists()) { const bot = botDoc.data(); - chatPrompt = bot.chat_prompt; + chatPrompt = bot.chat_prompt ?? bot.persona_prompt; isInfluencer = bot.is_influencer ?? false; } } catch (error) { diff --git a/personas-open-source/src/app/chat/page.tsx b/personas-open-source/src/app/chat/page.tsx index 0c107963fc..96bef85ccd 100644 --- a/personas-open-source/src/app/chat/page.tsx +++ b/personas-open-source/src/app/chat/page.tsx @@ -34,6 +34,7 @@ function ChatContent() { const [botData, setBotData] = useState<{ name: string; avatar: string; + image?: string; username?: string; } | null>(null); @@ -61,7 +62,8 @@ function ChatContent() { setBotData({ name: data.name, avatar: data.avatar, - username: data.username + username: data.username, + image: data.image }); } } catch (error) { @@ -74,7 +76,7 @@ function ChatContent() { // Use the fetched data const botName = botData?.name || 'Omi'; - const botImage = botData?.avatar || '/omi-avatar.svg'; + const botImage = botData?.avatar || botData?.image || '/omi-avatar.svg'; const username = botData?.username || ''; // Function to save messages to Firebase diff --git a/personas-open-source/src/app/u/[username]/page.tsx b/personas-open-source/src/app/u/[username]/page.tsx new file mode 100644 index 0000000000..f70d9974dc --- /dev/null +++ b/personas-open-source/src/app/u/[username]/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { collection, query, where, getDocs } from 'firebase/firestore'; +import { db } from '@/lib/firebase'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { use } from 'react'; + +export default function UsernamePage({ params }: { params: Promise<{ username: string }> }) { + const { username } = use(params); + const router = useRouter(); + const [error, setError] = useState<'not_found' | 'private' | null>(null); + + useEffect(() => { + const fetchBotByUsername = async () => { + try { + const q = query( + collection(db, 'plugins_data'), + where('username', '==', username.toLowerCase()) + ); + const querySnapshot = await getDocs(q); + + if (!querySnapshot.empty) { + const botDoc = querySnapshot.docs[0]; + const botData = botDoc.data(); + + if (botData.private) { + setError('private'); + } else { + router.replace(`/chat?id=${botDoc.id}`); + } + } else { + setError('not_found'); + } + } catch (error) { + console.error('Error fetching bot by username:', error); + setError('not_found'); + } + }; + + fetchBotByUsername(); + }, [username, router]); + + if (!error) return null; + + return ( +
+
+ + + +
+ +
+

omi

+ {error === 'not_found' ? ( + <> +

This persona does not exist

+

The persona you're looking for could not be found.

+ + ) : ( + <> +

This persona is private

+

You don't have access to view this persona.

+ + )} + + + + +
+
+ ); +} \ No newline at end of file