diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index d1bf59fd3a6..72c1b64dca5 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -173,6 +173,8 @@ PODS: - GTMSessionFetcher/Core (3.5.0) - image_picker_ios (0.0.1): - Flutter + - in_app_review (2.0.0): + - Flutter - Instabug (14.3.0) - instabug_flutter (14.3.1): - Flutter @@ -250,6 +252,9 @@ PODS: - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS @@ -280,6 +285,7 @@ DEPENDENCIES: - google_sign_in_all_platforms_mobile (from `.symlinks/plugins/google_sign_in_all_platforms_mobile/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - instabug_flutter (from `.symlinks/plugins/instabug_flutter/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - intercom_flutter (from `.symlinks/plugins/intercom_flutter/ios`) @@ -298,6 +304,7 @@ DEPENDENCIES: - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: @@ -387,6 +394,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_sign_in_ios/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + in_app_review: + :path: ".symlinks/plugins/in_app_review/ios" instabug_flutter: :path: ".symlinks/plugins/instabug_flutter/ios" integration_test: @@ -423,24 +432,26 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6 + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 - audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe - awesome_notifications: dd5518ff1c80be03d4f1c40f04da9d9cc2a37af5 - awesome_notifications_core: d02eed89738fa362d56cbd372850e9adcd2c6bef - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + awesome_notifications: 0f432b28098d193920b11a44cfa9d2d9313a3888 + awesome_notifications_core: 429c28df8746780a474de177e5acde33af87da63 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_auth: 3f532201cbdc7cd6dfc3bfa89affc0c294111e20 - firebase_core: 432718558359a8c08762151b5f49bb0f093eb6e0 - firebase_messaging: 3b99522baf7480dfb4b7683d2b34e842d577c362 + firebase_auth: 83bf106e5ac670dd3a0af27a86be6cba16a85723 + firebase_core: 2d4534e7b489907dcede540c835b48981d890943 + firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64 FirebaseAppCheckInterop: a92ba81d0ee3c4cddb1a2e52c668ea51dc63c3ae FirebaseAuth: c4146bdfdc87329f9962babd24dae89373f49a32 FirebaseAuthInterop: e25b58ecb90f3285085fa2118861a3c9dfdc62ad @@ -450,13 +461,13 @@ SPEC CHECKSUMS: FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_archive: cb3e0219e555897ba4b36f166baa1eca394890b9 - flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac - flutter_blue_plus: 4837da7d00cf5d441fdd6635b3a57f936778ea96 - flutter_foreground_task: 21ef182ab0a29a3005cc72cd70e5f45cb7f7f817 - flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a - flutter_silero_vad: bcad5bcce50bd7f63b772ad3f46f9ce1995dd833 - flutter_sound: 82aba29055d6feba684d08906e0623217b87bcd3 + flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe + flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e + flutter_blue_plus: e5808fc4e5ebc58bb911635f8fdaf5e2b4da2754 + flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 + flutter_native_splash: 6cad9122ea0fad137d23137dd14b937f3e90b145 + flutter_silero_vad: 623c22e30420ae174857926385670b9f6e52e4b9 + flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c flutter_timezone: ee50ce7786b5fde27e2fe5375bbc8c9661ffc13f frame_sdk: 4d4df786d828557bf57e05f6f1856613896cc9db @@ -468,7 +479,8 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457 Instabug: 97a4e694731f46bbc02dbe49ab29cc552c5e2f41 instabug_flutter: 0a2d35be020c80b2b63bd8337a94a3f2ffe65bc0 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e @@ -481,9 +493,9 @@ SPEC CHECKSUMS: map_launcher: fe43bda6720bb73c12fcc1bdd86123ff49a4d4d6 mcumgr_flutter: 8c4a598cb1b4d10a9adbc0f13288334297185506 Mixpanel-swift: 7b26468fc0e2e521104e51d65c4bbf7cab8162f8 - mixpanel_flutter: c2bb8345c90bef15512a1b812ec800b52f8614b6 + mixpanel_flutter: a0b6b937035899cd01951735ad5f87718b2ffee5 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - nordic_dfu: 963e2e4e6afb04e515ff43c546e6f8abf3e04ed5 + nordic_dfu: e4fb6f461f4a290b28ea4b1dfb69071665cdfa3e onnxruntime-c: e87399683ec19e3b812e13c6692882609a802b86 onnxruntime-objc: 57ae8f83779a4c32731065d50d02d042af581114 opus_flutter_ios: f16ed3599997ced564ad44509e87003159a86def @@ -502,10 +514,11 @@ SPEC CHECKSUMS: SwiftCBOR: ce5354ec8b660da2d6fc754462881119dbe1f963 SwiftProtobuf: b7aa08087e2ab6d162862d143020091254095f69 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c PODFILE CHECKSUM: 0ff3dedbc65a62aff6be5119a19cb4fd9e15742d -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index 963da43bd33..3ff5296707a 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -302,14 +302,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -394,14 +390,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme index d6d589e2f65..dcdf1f4755e 100644 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme @@ -10,6 +10,7 @@ buildConfiguration = "Debug-dev" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> > getMessagesServer({ String? pluginId, + String? chatSessionId, bool dropdownSelected = false, }) async { if (pluginId == 'no_selected') pluginId = null; - // TODO: Add pagination + + var url = '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}&dropdown_selected=$dropdownSelected'; + if (chatSessionId != null) { + url += '&chat_session_id=$chatSessionId'; + } + var response = await makeApiCall( - url: '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}&dropdown_selected=$dropdownSelected', + url: url, headers: {}, method: 'GET', body: '', @@ -36,10 +43,16 @@ Future> getMessagesServer({ return []; } -Future> clearChatServer({String? pluginId}) async { +Future> clearChatServer({String? pluginId, String? chatSessionId}) async { if (pluginId == 'no_selected') pluginId = null; + + var url = '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}'; + if (chatSessionId != null) { + url += '&chat_session_id=$chatSessionId'; + } + var response = await makeApiCall( - url: '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}', + url: url, headers: {}, method: 'DELETE', body: '', @@ -76,11 +89,16 @@ ServerMessageChunk? parseMessageChunk(String line, String messageId) { return null; } -Stream sendMessageStreamServer(String text, {String? appId, List? filesId}) async* { +Stream sendMessageStreamServer(String text, {String? appId, String? chatSessionId, List? filesId}) async* { var url = '${Env.apiBaseUrl}v2/messages?plugin_id=$appId'; if (appId == null || appId.isEmpty || appId == 'null' || appId == 'no_selected') { url = '${Env.apiBaseUrl}v2/messages'; } + if (chatSessionId != null) { + // Check if URL already has query parameters + var separator = url.contains('?') ? '&' : '?'; + url += '${separator}chat_session_id=$chatSessionId'; + } try { final request = await HttpClient().postUrl(Uri.parse(url)); @@ -284,3 +302,104 @@ Future transcribeVoiceMessage(File audioFile) async { throw Exception('Error transcribing voice message: $e'); } } + + +Future> getChatSessions({String? appId}) async { + if (appId == 'no_selected') appId = null; + + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions?app_id=${appId ?? ''}', + headers: {}, + method: 'GET', + body: '', + ); + + if (response == null) return []; + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + var decodedBody = jsonDecode(body) as List; + return decodedBody.map((session) => ChatSession.fromJson(session)).toList(); + } + return []; +} + +Future createChatSession({String? appId, String? title}) async { + if (appId == 'no_selected') appId = null; + + var url = '${Env.apiBaseUrl}v2/chat-sessions?app_id=${appId ?? ''}'; + if (title != null) { + url += '&title=${Uri.encodeComponent(title)}'; + } + + var response = await makeApiCall( + url: url, + headers: {}, + method: 'POST', + body: '', + ); + + if (response == null) return null; + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + return ChatSession.fromJson(jsonDecode(body)); + } + return null; +} + +Future getChatSessionById(String sessionId) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions/$sessionId', + headers: {}, + method: 'GET', + body: '', + ); + + if (response == null) return null; + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + return ChatSession.fromJson(jsonDecode(body)); + } + return null; +} + +Future updateChatSessionTitle(String sessionId, String title) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions/$sessionId/title', + headers: {}, + method: 'PUT', + body: jsonEncode({'title': title}), + ); + + return response?.statusCode == 200; +} + +Future deleteChatSession(String sessionId) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions/$sessionId', + headers: {}, + method: 'DELETE', + body: '', + ); + + return response?.statusCode == 200; +} + +Future createInitialMessage({String? appId, String? chatSessionId}) async { + var url = '${Env.apiBaseUrl}v2/initial-message?app_id=${appId ?? ''}'; + if (chatSessionId != null) { + url += '&chat_session_id=$chatSessionId'; + } + + var response = await makeApiCall( + url: url, + headers: {}, + method: 'POST', + body: '', + ); + + if (response == null) return null; + if (response.statusCode == 200) { + return ServerMessage.fromJson(jsonDecode(response.body)); + } + return null; +} diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 38336134c3b..08e7b2dca7c 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -6,6 +6,7 @@ import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; import 'package:omi/backend/schema/person.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/services/wals.dart'; import 'package:omi/utils/platform/platform_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -152,6 +153,12 @@ class SharedPreferencesUtil { set showInstallAppConfirmation(bool value) => saveBool('showInstallAppConfirmation', value); + //-------------------------------- Sidebar ----------------------------------// + + bool get sidebarCollapsed => getBool('sidebarCollapsed') ?? false; + + set sidebarCollapsed(bool value) => saveBool('sidebarCollapsed', value); + bool get showFirmwareUpdateDialog => getBool('v2/showFirmwareUpdateDialog') ?? true; set showFirmwareUpdateDialog(bool value) => saveBool('v2/showFirmwareUpdateDialog', value); @@ -280,6 +287,44 @@ class SharedPreferencesUtil { saveStringList('cachedMessages', messages); } + List get cachedSessions { + final List sessions = getStringList('cachedSessions') ?? []; + return sessions.map((e) => ChatSession.fromJson(jsonDecode(e))).toList(); + } + + set cachedSessions(List value) { + final List sessions = value.map((e) => jsonEncode(e.toJson())).toList(); + saveStringList('cachedSessions', sessions); + } + + // Cache sessions by app ID for better performance + List getCachedSessionsForApp(String? appId) { + final key = 'cachedSessions_${appId ?? 'default'}'; + final List sessions = getStringList(key) ?? []; + return sessions.map((e) => ChatSession.fromJson(jsonDecode(e))).toList(); + } + + void setCachedSessionsForApp(String? appId, List sessions) { + final key = 'cachedSessions_${appId ?? 'default'}'; + final List sessionStrings = sessions.map((e) => jsonEncode(e.toJson())).toList(); + saveStringList(key, sessionStrings); + } + + void clearCachedSessionsForApp(String? appId) { + final key = 'cachedSessions_${appId ?? 'default'}'; + _preferences?.remove(key); + } + + Future clearAllCachedSessions() async { + // Get all keys to find session cache keys + final allKeys = _preferences?.getKeys() ?? {}; + for (final key in allKeys) { + if (key.startsWith('cachedSessions_')) { + await _preferences?.remove(key); + } + } + } + List get cachedPeople { final List people = getStringList('cachedPeople') ?? []; return people.map((e) => Person.fromJson(jsonDecode(e))).toList(); @@ -452,7 +497,11 @@ class SharedPreferencesUtil { await remove('cachedConversations'); await remove('cachedMessages'); await remove('cachedPeople'); + await remove('cachedSessions'); await remove('modifiedConversationDetails'); + + // Clear app-specific cached sessions + await clearAllCachedSessions(); // Remove app related data await remove('selectedChatAppId2'); diff --git a/app/lib/backend/schema/chat_session.dart b/app/lib/backend/schema/chat_session.dart new file mode 100644 index 00000000000..d2f66d22d0a --- /dev/null +++ b/app/lib/backend/schema/chat_session.dart @@ -0,0 +1,74 @@ +class ChatSession { + final String id; + final List messageIds; + final List fileIds; + final String? appId; + final String? pluginId; + final DateTime createdAt; + final String? title; + + ChatSession({ + required this.id, + required this.messageIds, + required this.fileIds, + this.appId, + this.pluginId, + required this.createdAt, + this.title, + }); + + factory ChatSession.fromJson(Map json) { + return ChatSession( + id: json['id'] as String, + messageIds: List.from(json['message_ids'] ?? []), + fileIds: List.from(json['file_ids'] ?? []), + appId: json['app_id'] as String?, + pluginId: json['plugin_id'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + title: json['title'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'message_ids': messageIds, + 'file_ids': fileIds, + 'app_id': appId, + 'plugin_id': pluginId, + 'created_at': createdAt.toIso8601String(), + 'title': title, + }; + } + + ChatSession copyWith({ + String? id, + List? messageIds, + List? fileIds, + String? appId, + String? pluginId, + DateTime? createdAt, + String? title, + }) { + return ChatSession( + id: id ?? this.id, + messageIds: messageIds ?? this.messageIds, + fileIds: fileIds ?? this.fileIds, + appId: appId ?? this.appId, + pluginId: pluginId ?? this.pluginId, + createdAt: createdAt ?? this.createdAt, + title: title ?? this.title, + ); + } + + String get displayTitle { + if (title == null || title!.isEmpty) { + return 'New Chat'; + } + // If title starts with "New Chat", show "New Chat" instead + if (title!.startsWith('New Chat')) { + return 'New Chat'; + } + return title!; + } +} \ No newline at end of file diff --git a/app/lib/desktop/pages/chat/desktop_chat_page.dart b/app/lib/desktop/pages/chat/desktop_chat_page.dart index 0e6d431871a..ed88bd7d2ce 100644 --- a/app/lib/desktop/pages/chat/desktop_chat_page.dart +++ b/app/lib/desktop/pages/chat/desktop_chat_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:omi/backend/http/api/messages.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/app.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; import 'package:omi/gen/assets.gen.dart'; @@ -17,6 +18,7 @@ import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/message_provider.dart'; import 'package:omi/providers/app_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/ui/atoms/omi_typing_indicator.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/other/temp.dart'; @@ -34,6 +36,8 @@ import 'package:omi/ui/atoms/omi_message_input.dart'; import 'package:omi/ui/atoms/omi_send_button.dart'; import 'package:omi/ui/atoms/omi_icon_button.dart'; import 'package:omi/ui/molecules/omi_section_header.dart'; +import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; +import 'package:omi/ui/molecules/omi_session_tile.dart'; import 'widgets/desktop_message_action_menu.dart'; @@ -130,8 +134,16 @@ class DesktopChatPageState extends State with AutomaticKeepAliv SchedulerBinding.instance.addPostFrameCallback((_) async { var provider = context.read(); + var sessionProvider = context.read(); + + // Load sessions from cache first for instant UI + sessionProvider.setSessionsFromCache(); + + // Then load sessions from server + await sessionProvider.loadSessions(); + if (provider.messages.isEmpty) { - provider.refreshMessages(); + provider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); } _fadeController.forward(); @@ -156,8 +168,8 @@ class DesktopChatPageState extends State with AutomaticKeepAliv Widget build(BuildContext context) { super.build(context); - return Consumer3( - builder: (context, provider, connectivityProvider, appProvider, child) { + return Consumer4( + builder: (context, provider, connectivityProvider, appProvider, sessionProvider, child) { return Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -172,6 +184,25 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ), child: ClipRRect( borderRadius: BorderRadius.circular(20), + child: Row( + children: [ + // Sessions sidebar + Container( + width: 300, + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.6), + border: Border( + right: BorderSide( + color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), + width: 1, + ), + ), + ), + child: _buildSessionsSidebar(sessionProvider, appProvider), + ), + + // Main chat area + Expanded( child: Stack( children: [ // Animated background pattern @@ -181,7 +212,10 @@ class DesktopChatPageState extends State with AutomaticKeepAliv Container( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.02), - borderRadius: BorderRadius.circular(20), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + ), ), child: Column( children: [ @@ -199,6 +233,9 @@ class DesktopChatPageState extends State with AutomaticKeepAliv : _buildChatContent(provider, connectivityProvider), ), _buildFloatingInputArea(provider, connectivityProvider), + ], + ), + ), ], ), ), @@ -1415,11 +1452,12 @@ class DesktopChatPageState extends State with AutomaticKeepAliv void _sendMessageUtil(String text) { var provider = context.read(); + var sessionProvider = context.read(); provider.setSendingMessage(true); provider.addMessageLocally(text); scrollToBottom(); textController.clear(); - provider.sendMessageStreamToServer(text); + provider.sendMessageStreamToServer(text, chatSessionId: sessionProvider.currentSessionId, context: context); provider.clearSelectedFiles(); provider.setSendingMessage(false); } @@ -1671,7 +1709,12 @@ class DesktopChatPageState extends State with AutomaticKeepAliv appProvider.setSelectedChatAppId(app?.id); final messageProvider = context.read(); - await messageProvider.refreshMessages(dropdownSelected: true); + final sessionProvider = context.read(); + + // Refresh sessions for the new app + await sessionProvider.refreshOnAppChange(); + + await messageProvider.refreshMessages(dropdownSelected: true, chatSessionId: sessionProvider.currentSessionId); if (messageProvider.messages.isEmpty) { messageProvider.sendInitialAppMessage(app); @@ -1699,7 +1742,8 @@ class DesktopChatPageState extends State with AutomaticKeepAliv context, () => Navigator.of(context).pop(), () { - context.read().clearChat(); + final sessionProvider = context.read(); + context.read().clearChat(chatSessionId: sessionProvider.currentSessionId); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1716,4 +1760,126 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ), ); } + + void _showDeleteSessionDialog(BuildContext context, ChatSessionProvider sessionProvider, ChatSession session) async { + final confirmed = await OmiConfirmDialog.show( + context, + title: 'Delete Session?', + message: 'Are you sure you want to delete the chat session "${session.displayTitle}"? This action cannot be undone.', + confirmLabel: 'Delete', + confirmColor: ResponsiveHelper.errorColor, + ); + + if (confirmed == true) { + await sessionProvider.deleteSession(session); + + // Refresh messages for the new current session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + scrollToBottom(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chat session "${session.displayTitle}" deleted'), + backgroundColor: ResponsiveHelper.backgroundTertiary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + } + + Widget _buildSessionsSidebar(ChatSessionProvider sessionProvider, AppProvider appProvider) { + return Column( + children: [ + // Header with new session button + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Text( + 'Chat Sessions', + style: TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + OmiIconButton( + icon: Icons.add, + onPressed: () async { + await sessionProvider.createNewSession(); + // Refresh messages for the new session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + scrollToBottom(); + }, + style: OmiIconButtonStyle.filled, + size: 32, + iconSize: 16, + ), + ], + ), + ), + + // Sessions list + Expanded( + child: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(ResponsiveHelper.purplePrimary), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: sessionProvider.sessions.length, + itemBuilder: (context, index) { + final session = sessionProvider.sessions[index]; + final isActive = session.id == sessionProvider.currentSessionId; + + return OmiSessionTile( + title: session.displayTitle, + subtitle: _formatSessionTime(session.createdAt), + isActive: isActive, + onTap: () => _handleSessionSwitch(sessionProvider, session), + onDelete: sessionProvider.sessions.length > 1 + ? () => _showDeleteSessionDialog(context, sessionProvider, session) + : null, + showDeleteButton: sessionProvider.sessions.length > 1, + ); + }, + ), + ), + ], + ); + } + + String _formatSessionTime(DateTime time) { + final now = DateTime.now(); + final difference = now.difference(time); + + if (difference.inDays > 0) { + return '${difference.inDays}d ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m ago'; + } else { + return 'Just now'; + } + } + + void _handleSessionSwitch(ChatSessionProvider sessionProvider, ChatSession session) async { + if (sessionProvider.currentSessionId == session.id) return; + + await sessionProvider.switchToSession(session); + + // Refresh messages for the new session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: session.id); + + scrollToBottom(); + } } diff --git a/app/lib/desktop/pages/desktop_home_page.dart b/app/lib/desktop/pages/desktop_home_page.dart index 5e4bd89b40f..8b8a1d8a822 100644 --- a/app/lib/desktop/pages/desktop_home_page.dart +++ b/app/lib/desktop/pages/desktop_home_page.dart @@ -92,15 +92,15 @@ class _MacWindowButtonState extends State<_MacWindowButton> { width: 12, height: 12, decoration: BoxDecoration( - color: _isHovered ? _getButtonColor() : const Color(0xFFFFFFFF).withOpacity(0.07), + color: _isHovered ? _getButtonColor() : const Color(0xFFFFFFFF).withValues(alpha: 0.07), borderRadius: BorderRadius.circular(6), border: Border.all( - color: _isHovered ? _getButtonColor().withOpacity(0.8) : const Color(0xFFD0D0D0), + color: _isHovered ? _getButtonColor().withValues(alpha: 0.8) : const Color(0xFFD0D0D0), width: 0.5, ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 1, offset: const Offset(0, 0.5), ), @@ -111,8 +111,8 @@ class _MacWindowButtonState extends State<_MacWindowButton> { _getButtonIcon(), size: 8, color: widget.type == MacWindowButtonType.close - ? Colors.white.withOpacity(0.9) - : Colors.black.withOpacity(0.7), + ? Colors.white.withValues(alpha: 0.9) + : Colors.black.withValues(alpha: 0.7), ) : null, ), @@ -138,6 +138,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb final GlobalKey _profileCardKey = GlobalKey(); bool _isRecordingMinimized = false; + bool _isSidebarCollapsed = false; // Native overlay platform channel static const _overlayChannel = MethodChannel('overlayPlatform'); @@ -178,6 +179,9 @@ class _DesktopHomePageState extends State with WidgetsBindingOb void initState() { SharedPreferencesUtil().onboardingCompleted = true; + // Initialize sidebar collapse state + _isSidebarCollapsed = SharedPreferencesUtil().sidebarCollapsed; + // Initialize animations _sidebarAnimationController = AnimationController( duration: const Duration(milliseconds: 300), @@ -456,28 +460,31 @@ class _DesktopHomePageState extends State with WidgetsBindingOb offset: Offset(-50 * (1 - _sidebarSlideAnimation.value), 0), child: Opacity( opacity: _sidebarSlideAnimation.value, - child: Container( - width: responsive.sidebarWidth(baseWidth: 280), - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.85), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(16), - bottomRight: Radius.circular(16), - ), - border: const Border( - right: BorderSide( - color: ResponsiveHelper.backgroundTertiary, - width: 1, + child: ClipRect( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + width: _isSidebarCollapsed ? 130 : responsive.sidebarWidth(baseWidth: 280), + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.85), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), ), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(2, 0), + border: const Border( + right: BorderSide( + color: ResponsiveHelper.backgroundTertiary, + width: 1, + ), ), - ], - ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(2, 0), + ), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -489,7 +496,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb // Main navigation section Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: EdgeInsets.symmetric(horizontal: _isSidebarCollapsed ? 18 : 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -596,6 +603,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ), ), ), + ), ); }, ); @@ -612,46 +620,67 @@ class _DesktopHomePageState extends State with WidgetsBindingOb margin: const EdgeInsets.symmetric(vertical: 1), child: Stack( children: [ - // Navigation item with full container - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - MixpanelManager() - .bottomNavigationTabClicked(['Conversations', 'Chat', 'Memories', 'Actions', 'Apps'][index]); - onTap(); - }, - borderRadius: BorderRadius.circular(8), + // Navigation item with full container + Material( + color: Colors.transparent, + child: Tooltip( + message: _isSidebarCollapsed ? label : '', + child: InkWell( + onTap: () { + MixpanelManager() + .bottomNavigationTabClicked(['Conversations', 'Chat', 'Memories', 'Actions', 'Apps'][index]); + onTap(); + }, + borderRadius: BorderRadius.circular(8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: EdgeInsets.symmetric( + horizontal: _isSidebarCollapsed ? 0 : 16, + vertical: 12, + ), decoration: BoxDecoration( color: isSelected ? ResponsiveHelper.backgroundTertiary.withOpacity(0.8) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), - child: Row( - children: [ - Icon( - icon, - color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, - size: 18, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + child: _isSidebarCollapsed + ? Center( + child: Icon( + icon, color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + size: 18, ), + ) + : Row( + children: [ + Icon( + icon, + color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ], ), - ), - ], - ), ), ), ), - // Selection accent line spanning full item height + ), + // Selection accent line spanning full item height if (isSelected) Positioned( right: 0, @@ -673,6 +702,8 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ); } + + void _navigateToIndex(int index, HomeProvider homeProvider) { if (homeProvider.selectedIndex == index) return; @@ -684,6 +715,13 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ); } + void _toggleSidebarCollapse() { + setState(() { + _isSidebarCollapsed = !_isSidebarCollapsed; + }); + SharedPreferencesUtil().sidebarCollapsed = _isSidebarCollapsed; + } + /// Navigate to create app page (index 5) void navigateToCreateApp() { final homeProvider = Provider.of(context, listen: false); @@ -819,12 +857,11 @@ class _DesktopHomePageState extends State with WidgetsBindingOb } } - // Legacy Flutter floating widget removed - now using native macOS overlay Widget _buildWindowControls() { return Container( height: 52, - padding: const EdgeInsets.fromLTRB(24, 16, 16, 0), + padding: const EdgeInsets.fromLTRB(18, 16, 16, 0), child: Row( children: [ // Close button @@ -886,6 +923,11 @@ class _DesktopHomePageState extends State with WidgetsBindingOb } }, ), + + const SizedBox(width: 16), + + // Collapse/Expand button + _buildWindowCollapseButton(), ], ), ); @@ -901,6 +943,27 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ); } + Widget _buildWindowCollapseButton() { + return Material( + color: Colors.transparent, + child: Tooltip( + message: _isSidebarCollapsed ? 'Expand Sidebar' : 'Collapse Sidebar', + child: InkWell( + onTap: _toggleSidebarCollapse, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.all(6), + child: Icon( + _isSidebarCollapsed ? FontAwesomeIcons.indent : FontAwesomeIcons.outdent, + color: ResponsiveHelper.textSecondary, + size: 14, + ), + ), + ), + ), + ); + } + Widget _buildProfileCard() { final userName = SharedPreferencesUtil().givenName; final userEmail = SharedPreferencesUtil().email; @@ -923,70 +986,114 @@ class _DesktopHomePageState extends State with WidgetsBindingOb width: 1, ), ), - child: Row( - children: [ - // Profile picture - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), - width: 1.5, - ), - ), - child: Center( - child: Text( - userName.isNotEmpty ? userName[0].toUpperCase() : 'U', - style: const TextStyle( - color: ResponsiveHelper.purplePrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), + child: ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final shouldShowFullLayout = constraints.maxWidth > 160; + + return shouldShowFullLayout && !_isSidebarCollapsed + ? Row( + children: [ + // Profile picture + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: Center( + child: Text( + userName.isNotEmpty ? userName[0].toUpperCase() : 'U', + style: const TextStyle( + color: ResponsiveHelper.purplePrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), - const SizedBox(width: 12), - - // User info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - userName.isNotEmpty ? userName : 'User', - style: const TextStyle( - color: ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - userEmail.isNotEmpty ? userEmail : 'No email set', - style: const TextStyle( - color: ResponsiveHelper.textTertiary, - fontSize: 11, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), + const SizedBox(width: 12), + + // User info + Expanded( + child: AnimatedOpacity( + opacity: shouldShowFullLayout && !_isSidebarCollapsed ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + userName.isNotEmpty ? userName : 'User', + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + userEmail.isNotEmpty ? userEmail : 'No email set', + style: const TextStyle( + color: ResponsiveHelper.textTertiary, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), - // Chevron icon - const Icon( - FontAwesomeIcons.chevronUp, - color: ResponsiveHelper.textSecondary, - size: 12, - ), - ], + // Chevron icon + AnimatedOpacity( + opacity: shouldShowFullLayout && !_isSidebarCollapsed ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: const Icon( + FontAwesomeIcons.chevronUp, + color: ResponsiveHelper.textSecondary, + size: 12, + ), + ), + ], + ) + : Center( + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: Center( + child: Text( + userName.isNotEmpty ? userName[0].toUpperCase() : 'U', + style: const TextStyle( + color: ResponsiveHelper.purplePrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + }, + ), ), ), ), diff --git a/app/lib/main.dart b/app/lib/main.dart index f8db3e1e009..984609123b7 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -28,6 +28,7 @@ import 'package:omi/pages/persona/persona_provider.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/providers/auth_provider.dart'; import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/developer_mode_provider.dart'; import 'package:omi/providers/mcp_provider.dart'; @@ -245,6 +246,7 @@ class _MyAppState extends State with WidgetsBindingObserver { ChangeNotifierProvider(create: (context) => PersonaProvider()), ChangeNotifierProvider(create: (context) => MemoriesProvider()), ChangeNotifierProvider(create: (context) => UserProvider()), + ChangeNotifierProvider(create: (context) => ChatSessionProvider()), ], builder: (context, child) { return WithForegroundTask( diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 7bff913adb7..60ca2cdefb4 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -12,18 +12,18 @@ import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/app.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/gen/assets.gen.dart'; import 'package:omi/pages/chat/select_text_screen.dart'; import 'package:omi/pages/chat/widgets/ai_message.dart'; -import 'package:omi/pages/chat/widgets/animated_mini_banner.dart'; import 'package:omi/pages/chat/widgets/user_message.dart'; import 'package:omi/pages/chat/widgets/voice_recorder_widget.dart'; import 'package:omi/pages/home/page.dart'; -import 'package:omi/pages/home/widgets/chat_apps_dropdown_widget.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/message_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/other/temp.dart'; @@ -96,8 +96,14 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { }); SchedulerBinding.instance.addPostFrameCallback((_) async { var provider = context.read(); + var sessionProvider = context.read(); + + // Initialize sessions + await sessionProvider.loadSessions(); + + // Refresh messages for the current session if (provider.messages.isEmpty) { - provider.refreshMessages(); + provider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); } scrollToBottom(); // Auto-focus the text field only on initial load, not on app switches @@ -120,17 +126,19 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { super.dispose(); } - @override + + + @override Widget build(BuildContext context) { super.build(context); - return Consumer2( - builder: (context, provider, connectivityProvider, child) { + return Consumer3( + builder: (context, provider, connectivityProvider, sessionProvider, child) { return Scaffold( key: scaffoldKey, backgroundColor: Theme.of(context).colorScheme.primary, appBar: _buildAppBar(context, provider), - endDrawer: _buildHistoryDrawer(context), + endDrawer: _buildSessionsDrawer(context), body: GestureDetector( onTap: () { // Hide keyboard when tapping outside textfield @@ -308,7 +316,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { // Send message area - fixed at bottom Container( margin: const EdgeInsets.only(top: 10), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Color(0xFF1f1f25), borderRadius: BorderRadius.only( topLeft: Radius.circular(22), @@ -510,7 +518,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { height: 44, width: 32, alignment: Alignment.center, - child: FaIcon( + child: const FaIcon( FontAwesomeIcons.microphone, color: Colors.white, size: 20, @@ -563,7 +571,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { ), ], ), - child: Icon( + child: const Icon( FontAwesomeIcons.arrowUp, color: Color(0xFF35343B), size: 18, @@ -590,6 +598,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { textFieldFocusNode.unfocus(); var provider = context.read(); + var sessionProvider = context.read(); provider.setSendingMessage(true); provider.addMessageLocally(text); @@ -597,7 +606,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { _scrollToAlignUserMessageToTop(); textController.clear(); - provider.sendMessageStreamToServer(text); + provider.sendMessageStreamToServer(text, chatSessionId: sessionProvider.currentSessionId, context: context); provider.clearSelectedFiles(); provider.setSendingMessage(false); } @@ -768,6 +777,8 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { icon: const Icon(Icons.history, color: Colors.white), onPressed: () { HapticFeedback.mediumImpact(); + // Dismiss keyboard before opening drawer + FocusScope.of(context).unfocus(); scaffoldKey.currentState?.openEndDrawer(); }, ), @@ -1127,248 +1138,351 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { height: 0.5, color: Colors.grey.shade700, margin: const EdgeInsets.symmetric(horizontal: 20), - ); + ); } - Widget _buildHistoryDrawer(BuildContext context) { - // Dummy chat history data - final List> chatHistory = [ - { - 'title': 'Travel Planning Discussion', - 'subtitle': 'Help me plan a trip to Japan...', - 'timestamp': 'Today 2:30 PM', - 'appName': 'Travel Assistant', - 'appColor': Colors.blue, - 'isSelected': true, - }, - { - 'title': 'Code Review Session', - 'subtitle': 'Can you review my Flutter code?', - 'timestamp': 'Today 10:45 AM', - 'appName': 'Code Helper', - 'appColor': Colors.green, - 'isSelected': false, - }, - { - 'title': 'Recipe Suggestions', - 'subtitle': 'What can I cook with chicken?', - 'timestamp': 'Yesterday 6:20 PM', - 'appName': 'Chef Bot', - 'appColor': Colors.orange, - 'isSelected': false, - }, - { - 'title': 'Learning Discussion', - 'subtitle': 'Explain machine learning basics', - 'timestamp': 'Yesterday 2:15 PM', - 'appName': 'Omi', - 'appColor': Colors.purple, - 'isSelected': false, - }, - { - 'title': 'Book Recommendations', - 'subtitle': 'Suggest some sci-fi novels', - 'timestamp': '2 days ago', - 'appName': 'Book Guru', - 'appColor': Colors.red, - 'isSelected': false, - }, - { - 'title': 'Workout Planning', - 'subtitle': 'Create a weekly exercise routine', - 'timestamp': '3 days ago', - 'appName': 'Fitness Pro', - 'appColor': Colors.teal, - 'isSelected': false, - }, - { - 'title': 'Career Advice Chat', - 'subtitle': 'Should I switch to tech?', - 'timestamp': '1 week ago', - 'appName': 'Career Coach', - 'appColor': Colors.indigo, - 'isSelected': false, - }, - { - 'title': 'Creative Writing Help', - 'subtitle': 'Help with story plot ideas', - 'timestamp': '1 week ago', - 'appName': 'Story Helper', - 'appColor': Colors.pink, - 'isSelected': false, + Widget _buildSessionsDrawer(BuildContext context) { + return Consumer( + builder: (context, sessionProvider, child) { + return Theme( + data: Theme.of(context).copyWith( + canvasColor: Colors.white.withOpacity(0.1), + ), + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.85, + child: Drawer( + backgroundColor: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + // Search bar and header + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16, + left: 12, + right: 12, + bottom: 6, + ), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + enabled: false, + readOnly: true, + focusNode: FocusNode()..canRequestFocus = false, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search chats...', + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 16, + ), + prefixIcon: Icon( + Icons.search, + color: Colors.white.withOpacity(0.6), + size: 20, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => _handleCreateNewSession(), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF151415), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.plus, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), + ), + // Content + Expanded( + child: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : sessionProvider.sessions.isEmpty + ? _buildDrawerEmptyState() + : _buildDrawerSessionsList(sessionProvider), + ), + ], + ), + ), + ), + ); }, - ]; + ); + } - return Theme( - data: Theme.of(context).copyWith( - canvasColor: Colors.white.withOpacity(0.1), + Widget _buildDrawerEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + FontAwesomeIcons.comments, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: 16), + Text( + 'No chat sessions yet', + style: TextStyle( + color: Colors.grey.shade300, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Start a new conversation to create your first session', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: () => _handleCreateNewSession(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + FaIcon(FontAwesomeIcons.plus, size: 16, color: Colors.black), + SizedBox(width: 8), + Text( + 'Start New Chat', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], ), - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.85, - child: Drawer( - backgroundColor: Theme.of(context).colorScheme.primary, - child: Column( - children: [ - // Search bar - Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16, - left: 12, - right: 12, - bottom: 6, + ); + } + + Widget _buildDrawerSessionsList(ChatSessionProvider sessionProvider) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: sessionProvider.sessions.length, + itemBuilder: (context, index) { + final session = sessionProvider.sessions[index]; + final isSelected = session.id == sessionProvider.currentSessionId; + final sessionColor = _getSessionColor(index); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleSessionTap(session), + onLongPress: sessionProvider.sessions.length > 1 + ? () => _handleDeleteSession(session) + : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2A282A) : const Color(0xFF151415), + borderRadius: BorderRadius.circular(16), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: TextField( - enabled: false, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: 'Search chats...', - hintStyle: TextStyle( - color: Colors.white.withOpacity(0.6), - fontSize: 16, - ), - prefixIcon: Icon( - Icons.search, - color: Colors.white.withOpacity(0.6), - size: 20, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: sessionColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle[0].toUpperCase() + : 'C', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), ), - ), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('New chat functionality coming soon!'), - duration: Duration(seconds: 1), + const SizedBox(width: 12), + Expanded( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle + : 'Chat Session', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ); - }, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Color(0xFF151415), - borderRadius: BorderRadius.circular(12), ), - child: const Center( - child: FaIcon( - FontAwesomeIcons.plus, - color: Colors.white, - size: 18, + if (isSelected) + const SizedBox( + width: 24, + child: Icon(Icons.check, color: Colors.white60, size: 16), ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + _formatSessionTime(session.createdAt), + style: TextStyle( + color: Colors.white.withOpacity(0.5), + fontSize: 12, ), ), ), ], ), ), - // Content - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: chatHistory.length, - itemBuilder: (context, index) { - final chat = chatHistory[index]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - // TODO: Navigate to specific chat - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Opening: ${chat['title']}'), - duration: const Duration(seconds: 1), - ), - ); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: chat['isSelected'] ? Color(0xFF2A282A) : Color(0xFF151415), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: chat['appColor'], - shape: BoxShape.circle, - ), - child: Center( - child: Text( - chat['appName'][0].toUpperCase(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - chat['title'], - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - chat['timestamp'], - style: TextStyle( - color: Colors.white.withOpacity(0.5), - fontSize: 12, - ), - ), - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ], + ), ), - ), - ), + ); + }, ); } + + void _handleSessionTap(ChatSession session) async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + // Switch to the selected session + sessionProvider.switchToSession(session); + + // Refresh messages for the selected session + messageProvider.refreshMessages(chatSessionId: session.id); + + // Close the drawer + if (mounted) { + Navigator.of(context).pop(); + } + } + + void _handleDeleteSession(ChatSession session) async { + final result = await showDialog( + context: context, + builder: (context) { + return getDialog( + context, + () => Navigator.of(context).pop(false), + () => Navigator.of(context).pop(true), + 'Delete Session?', + 'Are you sure you want to delete the chat session "${session.displayTitle}"? This action cannot be undone.', + ); + }, + ); + + if (result == true && mounted) { + final sessionProvider = context.read(); + await sessionProvider.deleteSession(session); + + // Refresh messages for the new current session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chat session "${session.displayTitle}" deleted'), + backgroundColor: Theme.of(context).colorScheme.secondary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + void _handleCreateNewSession() async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + // Close the drawer first + Navigator.of(context).pop(); + + await sessionProvider.createNewSession(); + + // Refresh messages for the new session + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + } + + String _formatSessionTime(DateTime createdAt) { + final now = DateTime.now(); + final difference = now.difference(createdAt); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; + } + } + + Color _getSessionColor(int index) { + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.indigo, + Colors.pink, + ]; + return colors[index % colors.length]; + } + } diff --git a/app/lib/pages/chat/sessions_history_page.dart b/app/lib/pages/chat/sessions_history_page.dart new file mode 100644 index 00000000000..39cbd8483a2 --- /dev/null +++ b/app/lib/pages/chat/sessions_history_page.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:omi/backend/schema/chat_session.dart'; +import 'package:omi/providers/chat_session_provider.dart'; +import 'package:omi/providers/message_provider.dart'; +import 'package:omi/providers/app_provider.dart'; +import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class MobileSessionsHistoryPage extends StatefulWidget { + const MobileSessionsHistoryPage({super.key}); + + @override + State createState() => _MobileSessionsHistoryPageState(); +} + +class _MobileSessionsHistoryPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + // Ensure sessions are loaded + context.read().loadSessions(); + }); + } + + void _handleSessionTap(ChatSession session) async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + // Switch to the selected session + sessionProvider.switchToSession(session); + + // Refresh messages for the selected session + messageProvider.refreshMessages(chatSessionId: session.id); + + // Navigate back to chat + if (mounted) { + Navigator.of(context).pop(); + } + } + + void _handleDeleteSession(ChatSession session) async { + final result = await OmiConfirmDialog.show( + context, + title: 'Delete Session?', + message: 'Are you sure you want to delete the chat session "${session.displayTitle}"? This action cannot be undone.', + confirmLabel: 'Delete', + ); + + if (result == true && mounted) { + final sessionProvider = context.read(); + await sessionProvider.deleteSession(session); + + // Refresh messages for the new current session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chat session "${session.displayTitle}" deleted'), + backgroundColor: Theme.of(context).colorScheme.secondary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + void _handleCreateNewSession() async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + Navigator.of(context).pop(); + + await sessionProvider.createNewSession(); + + // Refresh messages for the new session + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + } + + String _formatSessionTime(DateTime createdAt) { + final now = DateTime.now(); + final difference = now.difference(createdAt); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; + } + } + + Color _getSessionColor(int index) { + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.indigo, + Colors.pink, + ]; + return colors[index % colors.length]; + } + + @override + Widget build(BuildContext context) { + return Consumer3( + builder: (context, sessionProvider, appProvider, messageProvider, child) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.primary, + body: Column( + children: [ + // Search bar and header + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16, + left: 12, + right: 12, + bottom: 6, + ), + child: Row( + children: [ + // Back button + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF151415), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 18, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + enabled: false, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search chats...', + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 16, + ), + prefixIcon: Icon( + Icons.search, + color: Colors.white.withOpacity(0.6), + size: 20, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: _handleCreateNewSession, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF151415), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.plus, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), + ), + // Content + Expanded( + child: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : sessionProvider.sessions.isEmpty + ? _buildEmptyState() + : _buildSessionsList(sessionProvider), + ), + ], + ), + ); + }, + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + FontAwesomeIcons.comments, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: 16), + Text( + 'No chat sessions yet', + style: TextStyle( + color: Colors.grey.shade300, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Start a new conversation to create your first session', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: _handleCreateNewSession, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + FaIcon(FontAwesomeIcons.plus, size: 16, color: Colors.black), + SizedBox(width: 8), + Text( + 'Start New Chat', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildSessionsList(ChatSessionProvider sessionProvider) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: sessionProvider.sessions.length, + itemBuilder: (context, index) { + final session = sessionProvider.sessions[index]; + final isSelected = session.id == sessionProvider.currentSessionId; + final sessionColor = _getSessionColor(index); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleSessionTap(session), + onLongPress: sessionProvider.sessions.length > 1 + ? () => _handleDeleteSession(session) + : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2A282A) : const Color(0xFF151415), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: sessionColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle[0].toUpperCase() + : 'C', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle + : 'Chat Session', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + const SizedBox( + width: 24, + child: Icon(Icons.check, color: Colors.white60, size: 16), + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + _formatSessionTime(session.createdAt), + style: TextStyle( + color: Colors.white.withOpacity(0.5), + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 5ee829fc19f..6d674417f26 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -15,6 +15,7 @@ import 'package:omi/gen/assets.gen.dart'; import 'package:omi/main.dart'; import 'package:omi/pages/action_items/action_items_page.dart'; import 'package:omi/pages/apps/page.dart'; +import 'package:omi/pages/chat/sessions_history_page.dart'; import 'package:omi/pages/chat/page.dart'; import 'package:omi/pages/conversations/conversations_page.dart'; import 'package:omi/pages/home/widgets/chat_apps_dropdown_widget.dart'; @@ -24,6 +25,7 @@ import 'package:omi/pages/settings/page.dart'; import 'package:omi/pages/settings/settings_drawer.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/device_provider.dart'; diff --git a/app/lib/providers/chat_session_provider.dart b/app/lib/providers/chat_session_provider.dart new file mode 100644 index 00000000000..bd89335a9c0 --- /dev/null +++ b/app/lib/providers/chat_session_provider.dart @@ -0,0 +1,183 @@ +import 'package:flutter/foundation.dart'; +import 'package:omi/backend/http/api/messages.dart'; +import 'package:omi/backend/schema/chat_session.dart'; +import 'package:omi/providers/app_provider.dart'; +import 'package:omi/backend/preferences.dart'; + +class ChatSessionProvider extends ChangeNotifier { + AppProvider? appProvider; + List sessions = []; + ChatSession? currentSession; + bool isLoadingSessions = false; + bool hasCachedSessions = false; + + void updateAppProvider(AppProvider p) { + appProvider = p; + } + + String? get currentSessionId => currentSession?.id; + String? get currentAppId => appProvider?.selectedChatAppId; + + Future loadSessions() async { + isLoadingSessions = true; + notifyListeners(); + + try { + final appId = appProvider?.selectedChatAppId; + + // First, try to load cached sessions + final cachedSessions = SharedPreferencesUtil().getCachedSessionsForApp(appId); + if (cachedSessions.isNotEmpty) { + sessions = cachedSessions; + hasCachedSessions = true; + + // If no current session and we have sessions, set the first one as current + if (currentSession == null && sessions.isNotEmpty) { + currentSession = sessions.first; + } + + // Notify listeners early with cached data + isLoadingSessions = false; + notifyListeners(); + } + + // Then load from server and update cache + List serverSessions = await getChatSessions(appId: appId); + sessions = serverSessions; + + // Update cache with fresh data + if (sessions.isNotEmpty) { + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + hasCachedSessions = true; + } + + // If no current session and we have sessions, set the first one as current + if (currentSession == null && sessions.isNotEmpty) { + currentSession = sessions.first; + } + + // If no sessions exist, create a new one + if (sessions.isEmpty) { + await createNewSession(); + } + } catch (e) { + debugPrint('Error loading sessions: $e'); + // If we have cached sessions, use them even if server fails + if (hasCachedSessions && sessions.isNotEmpty) { + debugPrint('Using cached sessions due to server error'); + } + } finally { + isLoadingSessions = false; + notifyListeners(); + } + } + + Future createNewSession({String? title}) async { + try { + final appId = appProvider?.selectedChatAppId; + final newSession = await createChatSession(appId: appId); + + if (newSession != null) { + sessions.insert(0, newSession); + currentSession = newSession; + + // Update cache with new session + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + hasCachedSessions = true; + + notifyListeners(); + } + } catch (e) { + debugPrint('Error creating new session: $e'); + } + } + + Future switchToSession(ChatSession session) async { + if (currentSession?.id != session.id) { + currentSession = session; + notifyListeners(); + } + } + + Future deleteSession(ChatSession session) async { + try { + final success = await deleteChatSession(session.id); + if (success) { + sessions.removeWhere((s) => s.id == session.id); + + // If we deleted the current session, switch to another one + if (currentSession?.id == session.id) { + if (sessions.isNotEmpty) { + currentSession = sessions.first; + } else { + await createNewSession(); + } + } + + // Update cache + final appId = appProvider?.selectedChatAppId; + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + + notifyListeners(); + } + } catch (e) { + debugPrint('Error deleting session: $e'); + } + } + + Future updateSessionTitle(ChatSession session, String title) async { + try { + final success = await updateChatSessionTitle(session.id, title); + if (success) { + final index = sessions.indexWhere((s) => s.id == session.id); + if (index != -1) { + sessions[index] = session.copyWith(title: title); + if (currentSession?.id == session.id) { + currentSession = sessions[index]; + } + + // Update cache + final appId = appProvider?.selectedChatAppId; + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + + notifyListeners(); + } + } + } catch (e) { + debugPrint('Error updating session title: $e'); + } + } + + Future refreshOnAppChange() async { + // Clear current state + sessions.clear(); + currentSession = null; + hasCachedSessions = false; + + // Load sessions for the new app + await loadSessions(); + } + + void setSessionsFromCache() { + final appId = appProvider?.selectedChatAppId; + final cachedSessions = SharedPreferencesUtil().getCachedSessionsForApp(appId); + if (cachedSessions.isNotEmpty) { + sessions = cachedSessions; + hasCachedSessions = true; + + // If no current session and we have sessions, set the first one as current + if (currentSession == null && sessions.isNotEmpty) { + currentSession = sessions.first; + } + + notifyListeners(); + } + } + + void clear() { + sessions.clear(); + currentSession = null; + hasCachedSessions = false; + notifyListeners(); + } +} \ No newline at end of file diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index 141deba808d..73fdd6be947 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -18,6 +18,9 @@ import 'package:omi/utils/file.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/platform/platform_service.dart'; import 'package:uuid/uuid.dart'; +import 'package:flutter/material.dart' as flutter; +import 'package:omi/providers/chat_session_provider.dart'; +import 'package:provider/provider.dart'; class MessageProvider extends ChangeNotifier { AppProvider? appProvider; @@ -266,12 +269,12 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } - Future refreshMessages({bool dropdownSelected = false}) async { + Future refreshMessages({bool dropdownSelected = false, String? chatSessionId}) async { setLoadingMessages(true); if (SharedPreferencesUtil().cachedMessages.isNotEmpty) { setHasCachedMessages(true); } - messages = await getMessagesFromServer(dropdownSelected: dropdownSelected); + messages = await getMessagesFromServer(dropdownSelected: dropdownSelected, chatSessionId: chatSessionId); if (messages.isEmpty) { messages = SharedPreferencesUtil().cachedMessages; } else { @@ -290,7 +293,7 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } - Future> getMessagesFromServer({bool dropdownSelected = false}) async { + Future> getMessagesFromServer({bool dropdownSelected = false, String? chatSessionId}) async { if (!hasCachedMessages) { firstTimeLoadingText = 'Reading your memories...'; notifyListeners(); @@ -298,6 +301,7 @@ class MessageProvider extends ChangeNotifier { setLoadingMessages(true); var mes = await getMessagesServer( pluginId: appProvider?.selectedChatAppId, + chatSessionId: chatSessionId, dropdownSelected: dropdownSelected, ); if (!hasCachedMessages) { @@ -316,9 +320,9 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } - Future clearChat() async { + Future clearChat({String? chatSessionId}) async { setClearingChat(true); - var mes = await clearChatServer(pluginId: appProvider?.selectedChatAppId); + var mes = await clearChatServer(pluginId: appProvider?.selectedChatAppId, chatSessionId: chatSessionId); messages = mes; setClearingChat(false); notifyListeners(); @@ -431,7 +435,7 @@ class MessageProvider extends ChangeNotifier { setShowTypingIndicator(false); } - Future sendMessageStreamToServer(String text) async { + Future sendMessageStreamToServer(String text, {String? chatSessionId, flutter.BuildContext? context}) async { setShowTypingIndicator(true); var currentAppId = appProvider?.selectedChatAppId; if (currentAppId == 'no_selected') { @@ -458,8 +462,9 @@ class MessageProvider extends ChangeNotifier { List fileIds = uploadedFiles.map((e) => e.id).toList(); clearSelectedFiles(); clearUploadedFiles(); + bool messageSuccessful = false; try { - await for (var chunk in sendMessageStreamServer(text, appId: currentAppId, filesId: fileIds)) { + await for (var chunk in sendMessageStreamServer(text, appId: currentAppId, chatSessionId: chatSessionId, filesId: fileIds)) { if (chunk.type == MessageChunkType.think) { message.thinkings.add(chunk.text); notifyListeners(); @@ -479,6 +484,7 @@ class MessageProvider extends ChangeNotifier { if (chunk.type == MessageChunkType.done) { message = chunk.message!; messages[0] = message; + messageSuccessful = true; notifyListeners(); continue; } @@ -495,6 +501,18 @@ class MessageProvider extends ChangeNotifier { } setShowTypingIndicator(false); + + if (messageSuccessful && chatSessionId != null && context != null) { + List userMessages = messages.where((m) => m.sender == MessageSender.human).toList(); + + if (userMessages.length <= 2) { + Future.delayed(const Duration(milliseconds: 1500), () { + if (context.mounted) { + _refreshSessionTitleFromBackend(chatSessionId, context); + } + }); + } + } } Future sendInitialAppMessage(App? app) async { @@ -508,4 +526,26 @@ class MessageProvider extends ChangeNotifier { App? messageSenderApp(String? appId) { return appProvider?.apps.firstWhereOrNull((p) => p.id == appId); } + + Future _refreshSessionTitleFromBackend(String chatSessionId, flutter.BuildContext? context) async { + if (context == null) return; + + try { + final sessionProvider = context.read(); + final currentSession = sessionProvider.currentSession; + + if (currentSession != null && currentSession.id == chatSessionId) { + final updatedSession = await getChatSessionById(chatSessionId); + + if (updatedSession != null && updatedSession.title != null && + updatedSession.title!.isNotEmpty && !updatedSession.title!.startsWith('New Chat')) { + // Update the session in the provider with the AI-generated title + await sessionProvider.updateSessionTitle(currentSession, updatedSession.title!); + debugPrint('Refreshed session title from backend: ${updatedSession.title}'); + } + } + } catch (e) { + debugPrint('Error refreshing session title from backend: $e'); + } + } } diff --git a/app/lib/ui/molecules/omi_session_tile.dart b/app/lib/ui/molecules/omi_session_tile.dart new file mode 100644 index 00000000000..97242d2f0bb --- /dev/null +++ b/app/lib/ui/molecules/omi_session_tile.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:omi/ui/atoms/omi_icon_button.dart'; +import 'package:omi/ui/adaptive_widget.dart'; +import 'package:omi/utils/responsive/responsive_helper.dart'; + +class OmiSessionTile extends AdaptiveWidget { + final String title; + final String subtitle; + final bool isActive; + final VoidCallback onTap; + final VoidCallback? onDelete; + final bool showDeleteButton; + + const OmiSessionTile({ + super.key, + required this.title, + required this.subtitle, + required this.isActive, + required this.onTap, + this.onDelete, + this.showDeleteButton = true, + }); + + @override + Widget buildDesktop(BuildContext context) => _tile(); + + @override + Widget buildMobile(BuildContext context) => _tile(); + + Widget _tile() { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isActive + ? ResponsiveHelper.purplePrimary.withValues(alpha: 0.15) + : ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isActive + ? ResponsiveHelper.purplePrimary.withValues(alpha: 0.3) + : Colors.transparent, + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + color: ResponsiveHelper.textSecondary, + size: 16, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: isActive + ? ResponsiveHelper.purplePrimary + : ResponsiveHelper.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: ResponsiveHelper.textTertiary, + fontSize: 11, + ), + ), + ], + ), + ), + if (showDeleteButton && onDelete != null) ...[ + const SizedBox(width: 8), + // Use different sizes for mobile vs desktop + LayoutBuilder( + builder: (context, constraints) { + // Check if we're on mobile by looking at screen width + final isMobile = MediaQuery.of(context).size.width < 600; + return OmiIconButton( + icon: Icons.delete_outline, + onPressed: onDelete!, + style: OmiIconButtonStyle.neutral, + size: isMobile ? 40 : 24, + iconSize: isMobile ? 18 : 14, + borderRadius: isMobile ? 8 : 4, + ); + }, + ), + ], + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/app/pubspec.lock b/app/pubspec.lock index 2309498fd91..97d3d8acf38 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1157,6 +1157,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + in_app_review: + dependency: "direct main" + description: + name: in_app_review + sha256: "36a06771b88fb0e79985b15e7f2ac0f1142e903fe72517f3c055d78bc3bc1819" + url: "https://pub.dev" + source: hosted + version: "2.0.10" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 + url: "https://pub.dev" + source: hosted + version: "2.0.5" inject_js: dependency: transitive description: @@ -1757,6 +1773,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + pull_down_button: + dependency: "direct main" + description: + name: pull_down_button + sha256: "12cdd8ff187a3150ebdf075e5074299f085579b158d2b4e655ccbafccf95f25b" + url: "https://pub.dev" + source: hosted + version: "0.10.2" qr: dependency: transitive description: @@ -1917,6 +1941,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + siri_wave: + dependency: "direct main" + description: + name: siri_wave + sha256: "0b92945d37f46e58ae3d5c1adb69879a0db12d656ba68537925d0a3ec759937c" + url: "https://pub.dev" + source: hosted + version: "2.3.0" skeletonizer: dependency: "direct main" description: @@ -2266,6 +2298,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73" + url: "https://pub.dev" + source: hosted + version: "2.8.7" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9fedd55023249f3a02738c195c906b4e530956191febf0838e37d0dac912f953" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a + url: "https://pub.dev" + source: hosted + version: "6.4.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" visibility_detector: dependency: "direct main" description: diff --git a/backend/database/chat.py b/backend/database/chat.py index eb1346643f4..a0a1ca63b31 100644 --- a/backend/database/chat.py +++ b/backend/database/chat.py @@ -357,6 +357,59 @@ def add_files_to_chat_session(uid: str, chat_session_id: str, file_ids: List[str session_ref.update({"file_ids": firestore.ArrayUnion(file_ids)}) +def get_chat_sessions(uid: str, app_id: Optional[str] = None) -> List[dict]: + """Get all chat sessions for a user and app""" + session_ref = ( + db.collection('users') + .document(uid) + .collection('chat_sessions') + .where(filter=FieldFilter('plugin_id', '==', app_id)) + .order_by('created_at', direction=firestore.Query.DESCENDING) + ) + return [doc.to_dict() for doc in session_ref.stream()] + + +def get_chat_session_by_id(uid: str, chat_session_id: str) -> Optional[dict]: + """Get a specific chat session by ID""" + user_ref = db.collection('users').document(uid) + session_ref = user_ref.collection('chat_sessions').document(chat_session_id) + session_doc = session_ref.get() + + if session_doc.exists: + return session_doc.to_dict() + return None + + +def create_new_chat_session(uid: str, app_id: Optional[str] = None, title: Optional[str] = None) -> dict: + """Create a new chat session""" + from datetime import datetime, timezone + import uuid + + # Generate a temporary title if not provided + if not title: + session_count = len(get_chat_sessions(uid, app_id)) + 1 + title = f"New Chat {session_count}" + + session_data = { + 'id': str(uuid.uuid4()), + 'created_at': datetime.now(timezone.utc), + 'plugin_id': app_id, + 'app_id': app_id, + 'message_ids': [], + 'file_ids': [], + 'title': title + } + + return add_chat_session(uid, session_data) + + +def update_chat_session_title(uid: str, chat_session_id: str, title: str): + """Update the title of a chat session""" + user_ref = db.collection('users').document(uid) + session_ref = user_ref.collection('chat_sessions').document(chat_session_id) + session_ref.update({"title": title}) + + # ************************************** # ********* MIGRATION HELPERS ********** # ************************************** diff --git a/backend/models/chat.py b/backend/models/chat.py index 3167e9fb731..3bcfebe8a10 100644 --- a/backend/models/chat.py +++ b/backend/models/chat.py @@ -153,6 +153,7 @@ class ChatSession(BaseModel): app_id: Optional[str] = None plugin_id: Optional[str] = None created_at: datetime + title: Optional[str] = None @model_validator(mode='before') @classmethod @@ -177,3 +178,7 @@ def add_file_ids(self, new_file_ids: List[str]): def retrieve_new_file(self, file_ids) -> List: existing_files = set(self.file_ids or []) return list(set(file_ids) - existing_files) + + +class UpdateChatSessionTitleRequest(BaseModel): + title: str diff --git a/backend/routers/chat.py b/backend/routers/chat.py index 280dde1d696..dda88847755 100644 --- a/backend/routers/chat.py +++ b/backend/routers/chat.py @@ -20,6 +20,7 @@ ResponseMessage, MessageConversation, FileChat, + UpdateChatSessionTitleRequest, ) from models.conversation import Conversation from routers.sync import retrieve_file_paths, decode_files_to_wav @@ -30,7 +31,7 @@ transcribe_voice_message_segment, ) from utils.llm.persona import initial_persona_chat_message -from utils.llm.chat import initial_chat_message +from utils.llm.chat import initial_chat_message, generate_session_title 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, execute_persona_chat_stream @@ -50,7 +51,12 @@ def filter_messages(messages, app_id): return collected -def acquire_chat_session(uid: str, plugin_id: Optional[str] = None): +def acquire_chat_session(uid: str, plugin_id: Optional[str] = None, chat_session_id: Optional[str] = None): + if chat_session_id: + chat_session = chat_db.get_chat_session_by_id(uid, chat_session_id) + if chat_session: + return chat_session + chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) if chat_session is None: cs = ChatSession(id=str(uuid.uuid4()), created_at=datetime.now(timezone.utc), plugin_id=plugin_id) @@ -60,7 +66,7 @@ def acquire_chat_session(uid: str, plugin_id: Optional[str] = None): @router.post('/v2/messages', tags=['chat'], response_model=ResponseMessage) def send_message( - data: SendMessageRequest, plugin_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid) + data: SendMessageRequest, plugin_id: Optional[str] = None, chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid) ): print('send_message', data.text, plugin_id, uid) @@ -68,8 +74,12 @@ def send_message( plugin_id = None # get chat session - chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) - chat_session = ChatSession(**chat_session) if chat_session else None + if chat_session_id: + chat_session = chat_db.get_chat_session_by_id(uid, chat_session_id) + chat_session = ChatSession(**chat_session) if chat_session else None + else: + chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) + chat_session = ChatSession(**chat_session) if chat_session else None message = Message( id=str(uuid.uuid4()), @@ -99,6 +109,26 @@ def send_message( chat_db.add_message(uid, message.dict()) + # Auto-generate title for new sessions based on first user message + if (chat_session and + hasattr(chat_session, 'title') and + chat_session.title and + (chat_session.title.startswith('New Chat') or not chat_session.title.strip()) and + data.text and len(data.text.strip()) > 5): + + try: + session_messages = chat_db.get_messages(uid, limit=10, chat_session_id=chat_session.id) + user_messages = [msg for msg in session_messages if msg.get('sender') == 'human'] + + if len(user_messages) <= 1: + new_title = generate_session_title(data.text) + if new_title and new_title != "New Chat": + chat_db.update_chat_session_title(uid, chat_session.id, new_title) + print(f"Auto-generated title for session {chat_session.id}: '{new_title}'") + + except Exception as e: + print(f"Failed to generate title for session {chat_session.id}: {e}") + app = get_available_app_by_id(plugin_id, uid) app = App(**app) if app else None @@ -182,13 +212,14 @@ def report_message(message_id: str, uid: str = Depends(auth.get_current_user_uid @router.delete('/v2/messages', tags=['chat'], response_model=Message) -def clear_chat_messages(app_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): +def clear_chat_messages(app_id: Optional[str] = None, chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): if app_id in ['null', '']: app_id = None # get current chat session - chat_session = chat_db.get_chat_session(uid, app_id=app_id) - chat_session_id = chat_session['id'] if chat_session else None + if not chat_session_id: + chat_session = chat_db.get_chat_session(uid, app_id=app_id) + chat_session_id = chat_session['id'] if chat_session else None err = chat_db.clear_chat(uid, app_id=app_id, chat_session_id=chat_session_id) if err: @@ -205,11 +236,11 @@ def clear_chat_messages(app_id: Optional[str] = None, uid: str = Depends(auth.ge return initial_message_util(uid, app_id) -def initial_message_util(uid: str, app_id: Optional[str] = None): +def initial_message_util(uid: str, app_id: Optional[str] = None, chat_session_id: Optional[str] = None): print('initial_message_util', app_id) # init chat session - chat_session = acquire_chat_session(uid, plugin_id=app_id) + chat_session = acquire_chat_session(uid, plugin_id=app_id, chat_session_id=chat_session_id) prev_messages = list(reversed(chat_db.get_messages(uid, limit=5, app_id=app_id))) print('initial_message_util returned', len(prev_messages), 'prev messages for', app_id) @@ -246,24 +277,25 @@ def initial_message_util(uid: str, app_id: Optional[str] = None): @router.post('/v2/initial-message', tags=['chat'], response_model=Message) -def create_initial_message(app_id: Optional[str], uid: str = Depends(auth.get_current_user_uid)): - return initial_message_util(uid, app_id) +def create_initial_message(app_id: Optional[str], chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): + return initial_message_util(uid, app_id, chat_session_id) @router.get('/v2/messages', response_model=List[Message], tags=['chat']) -def get_messages(plugin_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): +def get_messages(plugin_id: Optional[str] = None, chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): if plugin_id in ['null', '']: plugin_id = None - chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) - chat_session_id = chat_session['id'] if chat_session else None + if not chat_session_id: + chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) + chat_session_id = chat_session['id'] if chat_session else None messages = chat_db.get_messages( uid, limit=100, include_conversations=True, app_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)] + return [initial_message_util(uid, plugin_id, chat_session_id)] return messages @@ -527,3 +559,55 @@ async def transcribe_voice_message(files: List[UploadFile] = File(...), uid: str @router.post('/v1/initial-message', tags=['chat'], response_model=Message) def create_initial_message(plugin_id: Optional[str], uid: str = Depends(auth.get_current_user_uid)): return initial_message_util(uid, plugin_id) + + +@router.get('/v2/chat-sessions', response_model=List[ChatSession], tags=['chat']) +def get_chat_sessions(app_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): + """Get all chat sessions for a user and app""" + if app_id in ['null', '']: + app_id = None + sessions = chat_db.get_chat_sessions(uid, app_id=app_id) + return [ChatSession(**session) for session in sessions] + + +@router.post('/v2/chat-sessions', response_model=ChatSession, tags=['chat']) +def create_chat_session(app_id: Optional[str] = None, title: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): + """Create a new chat session""" + if app_id in ['null', '']: + app_id = None + session = chat_db.create_new_chat_session(uid, app_id, title) + return ChatSession(**session) + + +@router.get('/v2/chat-sessions/{session_id}', response_model=ChatSession, tags=['chat']) +def get_chat_session_by_id(session_id: str, uid: str = Depends(auth.get_current_user_uid)): + """Get a specific chat session by ID""" + session = chat_db.get_chat_session_by_id(uid, session_id) + if session is None: + raise HTTPException(status_code=404, detail='Chat session not found') + return ChatSession(**session) + + +@router.put('/v2/chat-sessions/{session_id}/title', response_model=dict, tags=['chat']) +def update_chat_session_title(session_id: str, request: UpdateChatSessionTitleRequest, uid: str = Depends(auth.get_current_user_uid)): + """Update the title of a chat session""" + # Verify session exists + session = chat_db.get_chat_session_by_id(uid, session_id) + if session is None: + raise HTTPException(status_code=404, detail='Chat session not found') + + chat_db.update_chat_session_title(uid, session_id, request.title) + return {'message': 'Title updated successfully'} + + +@router.delete('/v2/chat-sessions/{session_id}', response_model=dict, tags=['chat']) +def delete_chat_session(session_id: str, uid: str = Depends(auth.get_current_user_uid)): + """Delete a chat session""" + # Verify session exists + session = chat_db.get_chat_session_by_id(uid, session_id) + if session is None: + raise HTTPException(status_code=404, detail='Chat session not found') + + # Delete the session + chat_db.delete_chat_session(uid, session_id) + return {'message': 'Chat session deleted successfully'} diff --git a/backend/utils/llm/chat.py b/backend/utils/llm/chat.py index e538b3f6916..10df3d29899 100644 --- a/backend/utils/llm/chat.py +++ b/backend/utils/llm/chat.py @@ -46,6 +46,68 @@ def initial_chat_message(uid: str, plugin: Optional[App] = None, prev_messages_s return llm_mini.invoke(prompt).content +def generate_session_title(message_text: str) -> str: + """ + Generate a concise, meaningful title for a chat session based on the first user message. + The title should be 3-4 words maximum and capture the main topic or intent. + """ + + if not message_text or not message_text.strip(): + return "New Chat" + + clean_text = message_text.strip()[:500] + + prompt = f""" + You are an expert at creating concise, meaningful titles for conversations. + + Your task is to generate a short title (3-4 words maximum) that captures the main topic or intent of this message. + The title should be: + - Very concise (3-4 words max) + - Clear and descriptive + - Focus on the main topic, not implementation details + - Use Title Case (capitalize first letter of each word) + - Avoid generic words like "Help", "Question", "Issue" unless very specific + - Avoid quotes or special characters + + Examples of good titles: + - "Python Data Analysis" (for questions about analyzing data with Python) + - "Travel Planning Europe" (for questions about planning European travel) + - "Career Change Advice" (for questions about switching careers) + - "Recipe Recommendations" (for asking about food recipes) + - "Investment Strategy" (for financial planning questions) + - "Machine Learning Setup" (for ML environment questions) + - "React Component Design" (for React development questions) + + Message: ```{clean_text}``` + + Output only the title, nothing else. + """.replace(' ', '').strip() + + try: + response = llm_mini.invoke(prompt) + title = response.content.strip() + + title = title.replace('"', '').replace("'", "").strip() + + words = title.split() + if len(words) > 4: + title = ' '.join(words[:4]) + elif len(words) == 0: + title = "New Chat" + + title = ' '.join(word.capitalize() for word in title.split()) + + return title + + except Exception as e: + print(f"Error generating session title: {e}") + words = clean_text.split()[:3] + fallback_title = ' '.join(words).title() if words else "New Chat" + + fallback_title = fallback_title.replace('?', '').replace('!', '').strip() + return fallback_title[:50] + + # ********************************************* # ************* RETRIEVAL + CHAT ************** # *********************************************