diff --git a/README.md b/README.md index b66daf3..a3b6cd2 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,48 @@ # Conduit - Open-WebUI Mobile Client +

+ Screenshot 1 + +

+ Conduit is an open-source, cross-platform mobile application for Open-WebUI, providing a native mobile experience for interacting with your self-hosted AI infrastructure. ## Features ### Core Features -- ✅ **Real-time Chat**: Stream responses from AI models in real-time -- ✅ **Model Selection**: Choose from available models on your server -- ✅ **Conversation Management**: Create, search, and manage chat histories -- ✅ **Markdown Rendering**: Full markdown support with syntax highlighting -- ✅ **Theme Support**: Light, Dark, and System themes +- **Real-time Chat**: Stream responses from AI models in real-time +- **Model Selection**: Choose from available models on your server +- **Conversation Management**: Create, search, and manage chat histories +- **Markdown Rendering**: Full markdown support with syntax highlighting +- **Theme Support**: Light, Dark, and System themes ### Advanced Features -- ✅ **Voice Input**: Use speech-to-text for hands-free interaction -- ✅ **File Uploads**: Support for images and documents (RAG) -- ✅ **Multi-modal Support**: Work with vision models -- ✅ **Secure Storage**: Credentials stored securely using platform keychains +- **Voice Input**: Use speech-to-text for hands-free interaction +- **File Uploads**: Support for images and documents (RAG) +- **Multi-modal Support**: Work with vision models +- **Secure Storage**: Credentials stored securely using platform keychains + +## Screenshots + +

+ Screenshot 2 +

+ +

+ Screenshot 3 +

+ +

+ Screenshot 4 +

+ +

+ Screenshot 5 +

+ +

+ Screenshot 6 +

## Requirements diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png new file mode 100644 index 0000000..1f7e857 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png new file mode 100644 index 0000000..5331fe3 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png new file mode 100644 index 0000000..b2b11fd Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png new file mode 100644 index 0000000..5d43ada Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png new file mode 100644 index 0000000..7f5390d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png new file mode 100644 index 0000000..fa4b509 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_01.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_01.png deleted file mode 100644 index b3ed8b3..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_01.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_02.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_02.png deleted file mode 100644 index 0a99fc1..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_02.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_03.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_03.png deleted file mode 100644 index 4e9593d..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_03.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_04.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_04.png deleted file mode 100644 index a03cdd8..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_04.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_05.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_05.png deleted file mode 100644 index 1a3cd42..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_05.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_06.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_06.png deleted file mode 100644 index 3e6a21c..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_06.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_07.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_07.png deleted file mode 100644 index e39477e..0000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/flutter_07.png and /dev/null differ diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index d7d375f..2787044 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -464,8 +464,14 @@ class ApiService { // Parse full OpenWebUI chat with messages Conversation _parseFullOpenWebUIChat(Map chatData) { + debugPrint('DEBUG: Parsing full OpenWebUI chat data'); + debugPrint('DEBUG: Chat data keys: ${chatData.keys.toList()}'); + final id = chatData['id'] as String; final title = chatData['title'] as String; + + debugPrint('DEBUG: Parsed chat ID: $id'); + debugPrint('DEBUG: Parsed chat title: $title'); // Safely parse timestamps with validation final updatedAt = _parseTimestamp(chatData['updated_at']); @@ -481,26 +487,33 @@ class ApiService { final chatObject = chatData['chat'] as Map?; final messages = []; - // Try multiple locations for messages + // Try multiple locations for messages - prefer list format to avoid duplication List? messagesList; - Map? messagesMap; if (chatObject != null) { - // Check for messages in chat.history.messages (map format) - final history = chatObject['history'] as Map?; - if (history != null && history['messages'] != null) { - messagesMap = history['messages'] as Map; - debugPrint( - 'DEBUG: Found ${messagesMap.length} messages in chat.history.messages', - ); - } - - // Check for messages in chat.messages (list format) + // Check for messages in chat.messages (list format) - PREFERRED if (chatObject['messages'] != null) { messagesList = chatObject['messages'] as List; debugPrint( 'DEBUG: Found ${messagesList.length} messages in chat.messages', ); + } else { + // Fallback: Check for messages in chat.history.messages (map format) + final history = chatObject['history'] as Map?; + if (history != null && history['messages'] != null) { + final messagesMap = history['messages'] as Map; + debugPrint( + 'DEBUG: Found ${messagesMap.length} messages in chat.history.messages (converting to list)', + ); + + // Convert map to list format to use common parsing logic + messagesList = []; + for (final entry in messagesMap.entries) { + final msgData = Map.from(entry.value as Map); + msgData['id'] = entry.key; // Use the key as the message ID + messagesList.add(msgData); + } + } } } else if (chatData['messages'] != null) { messagesList = chatData['messages'] as List; @@ -509,42 +522,21 @@ class ApiService { ); } - // Parse messages from map format (chat.history.messages) - if (messagesMap != null) { - for (final entry in messagesMap.entries) { - try { - final msgData = entry.value as Map; - msgData['id'] = entry.key; // Use the key as the message ID - debugPrint( - 'DEBUG: Parsing message from map: ${entry.key} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}', - ); - // Convert OpenWebUI message format to our ChatMessage format - final message = _parseOpenWebUIMessage(msgData); - messages.add(message); - debugPrint( - 'DEBUG: Successfully parsed message from map: ${message.id} - ${message.role}', - ); - } catch (e) { - debugPrint('DEBUG: Error parsing message from map: $e'); - } - } - } - - // Parse messages from list format (chat.messages or top-level) + // Parse messages from list format only (avoiding duplication) if (messagesList != null) { for (final msgData in messagesList) { try { debugPrint( - 'DEBUG: Parsing message from list: ${msgData['id']} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}', + 'DEBUG: Parsing message: ${msgData['id']} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}', ); // Convert OpenWebUI message format to our ChatMessage format final message = _parseOpenWebUIMessage(msgData); messages.add(message); debugPrint( - 'DEBUG: Successfully parsed message from list: ${message.id} - ${message.role}', + 'DEBUG: Successfully parsed message: ${message.id} - ${message.role}', ); } catch (e) { - debugPrint('DEBUG: Error parsing message from list: $e'); + debugPrint('DEBUG: Error parsing message: $e'); } } } @@ -607,7 +599,6 @@ class ApiService { ); } - // Create new conversation using OpenWebUI API // Create new conversation using OpenWebUI API Future createConversation({ required String title, @@ -618,39 +609,47 @@ class ApiService { debugPrint('DEBUG: Creating new conversation on OpenWebUI server'); debugPrint('DEBUG: Title: $title, Messages: ${messages.length}'); - // Convert messages to the new format with proper structure + // Build messages with parent-child relationships final Map messagesMap = {}; + final List> messagesArray = []; String? currentId; + String? previousId; for (final msg in messages) { final messageId = msg.id; + + // Build message for history.messages map messagesMap[messageId] = { 'id': messageId, - 'parentId': null, + 'parentId': previousId, 'childrenIds': [], 'role': msg.role, 'content': msg.content, - if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) - 'files': msg.attachmentIds!.map((attachmentId) { - if (attachmentId.startsWith('data:')) { - // This is an image data URL - return {'type': 'image', 'url': attachmentId}; - } else { - // This is a server file ID - return { - 'type': 'file', - 'id': attachmentId, - 'url': '${serverConfig.url}/api/v1/files/$attachmentId', - }; - } - }).toList(), 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, - 'models': model != null ? [model] : [], + if (msg.role == 'user' && model != null) 'models': [model], }; - currentId = messageId; // Use the last message as current + + // Update parent's childrenIds if there's a previous message + if (previousId != null && messagesMap.containsKey(previousId)) { + (messagesMap[previousId]['childrenIds'] as List).add(messageId); + } + + // Build message for messages array + messagesArray.add({ + 'id': messageId, + 'parentId': previousId, + 'childrenIds': [], + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + if (msg.role == 'user' && model != null) 'models': [model], + }); + + previousId = messageId; + currentId = messageId; } - // Create the chat data structure matching the expected format + // Create the chat data structure matching OpenWebUI format exactly final chatData = { 'chat': { 'id': '', @@ -661,44 +660,20 @@ class ApiService { 'messages': messagesMap, if (currentId != null) 'currentId': currentId, }, - 'messages': messages - .map( - (msg) => { - 'id': msg.id, - 'parentId': null, - 'childrenIds': [], - 'role': msg.role, - 'content': msg.content, - if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) - 'files': msg.attachmentIds!.map((attachmentId) { - if (attachmentId.startsWith('data:')) { - // This is an image data URL - return {'type': 'image', 'url': attachmentId}; - } else { - // This is a server file ID - return { - 'type': 'file', - 'id': attachmentId, - 'url': '${serverConfig.url}/api/v1/files/$attachmentId', - }; - } - }).toList(), - 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, - 'models': model != null ? [model] : [], - }, - ) - .toList(), + 'messages': messagesArray, 'tags': [], 'timestamp': DateTime.now().millisecondsSinceEpoch, }, 'folder_id': null, }; - debugPrint('DEBUG: Sending chat data: $chatData'); + debugPrint('DEBUG: Sending chat data with proper parent-child structure'); + debugPrint('DEBUG: Request data: ${chatData}'); final response = await _dio.post('/api/v1/chats/new', data: chatData); - debugPrint('DEBUG: Create conversation response: ${response.data}'); + debugPrint('DEBUG: Create conversation response status: ${response.statusCode}'); + debugPrint('DEBUG: Create conversation response data: ${response.data}'); // Parse the response final responseData = response.data as Map; @@ -717,30 +692,71 @@ class ApiService { 'DEBUG: Updating conversation $conversationId with ${messages.length} messages', ); - // Convert messages to OpenWebUI format - final openWebUIMessages = messages - .map( - (msg) => { - 'role': msg.role, - 'content': msg.content, - if (msg.model != null) 'model': msg.model, - 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, - }, - ) - .toList(); + // Build messages map and array in OpenWebUI format + final Map messagesMap = {}; + final List> messagesArray = []; + String? currentId; + String? previousId; - // Create the chat data structure + for (final msg in messages) { + final messageId = msg.id; + + // Build message for messages map (history.messages) + messagesMap[messageId] = { + 'id': messageId, + 'parentId': previousId, + 'childrenIds': [], + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + if (msg.role == 'assistant' && msg.model != null) 'model': msg.model, + if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, + if (msg.role == 'assistant') 'modelIdx': 0, + if (msg.role == 'assistant') 'done': true, + if (msg.role == 'user' && model != null) 'models': [model], + }; + + // Update parent's childrenIds + if (previousId != null && messagesMap.containsKey(previousId)) { + (messagesMap[previousId]['childrenIds'] as List).add(messageId); + } + + // Build message for messages array + messagesArray.add({ + 'id': messageId, + 'parentId': previousId, + 'childrenIds': [], + 'role': msg.role, + 'content': msg.content, + 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, + if (msg.role == 'assistant' && msg.model != null) 'model': msg.model, + if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model, + if (msg.role == 'assistant') 'modelIdx': 0, + if (msg.role == 'assistant') 'done': true, + if (msg.role == 'user' && model != null) 'models': [model], + }); + + previousId = messageId; + currentId = messageId; + } + + // Create the chat data structure matching OpenWebUI format exactly final chatData = { 'chat': { - 'messages': openWebUIMessages, - if (title != null) 'title': title, - if (model != null) 'model': model, - if (systemPrompt != null) 'system': systemPrompt, + 'models': model != null ? [model] : [], + 'messages': messagesArray, + 'history': { + 'messages': messagesMap, + if (currentId != null) 'currentId': currentId, + }, + 'params': {}, + 'files': [], }, }; - debugPrint('DEBUG: Updating chat with data: $chatData'); + debugPrint('DEBUG: Updating chat with OpenWebUI format data using POST'); + // OpenWebUI uses POST not PUT for updating chats final response = await _dio.post( '/api/v1/chats/$conversationId', data: chatData, @@ -1352,19 +1368,42 @@ class ApiService { Map? modelItem, String? sessionId, }) async { - debugPrint('DEBUG: Sending chat completed for message: $messageId'); + debugPrint('DEBUG: Sending chat completed notification (optional endpoint)'); + + // This endpoint appears to be optional or deprecated in newer OpenWebUI versions + // The main chat synchronization happens through /api/v1/chats/{id} updates + // We'll still try to call it but won't fail if it doesn't work + + // Format messages to match OpenWebUI expected structure + // Note: Removing 'id' field as it causes 400 error + final formattedMessages = messages.map((msg) { + final formatted = { + // Don't include 'id' - it causes 400 error with detail: 'id' + 'role': msg['role'], + 'content': msg['content'], + 'timestamp': msg['timestamp'] ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + }; + + // Add model info for assistant messages + if (msg['role'] == 'assistant') { + formatted['model'] = model; + if (msg.containsKey('usage')) { + formatted['usage'] = msg['usage']; + } + } + + return formatted; + }).toList(); + // Include the message ID at the top level - server expects this final requestData = { - 'model': model, - 'messages': messages, - if (modelItem != null) 'model_item': modelItem, + 'id': messageId, // The server expects the assistant message ID here 'chat_id': chatId, - if (sessionId != null) 'session_id': sessionId, - 'id': messageId, + 'model': model, + 'messages': formattedMessages, + // Don't include model_item as it might not be expected }; - debugPrint('DEBUG: Chat completed request data: $requestData'); - try { final response = await _dio.post( '/api/chat/completed', @@ -1372,8 +1411,8 @@ class ApiService { ); debugPrint('DEBUG: Chat completed response: ${response.statusCode}'); } catch (e) { - debugPrint('DEBUG: Chat completed error: $e'); - // Non-critical error, don't throw + // This is a non-critical endpoint - main sync happens via /api/v1/chats/{id} + debugPrint('DEBUG: Chat completed endpoint not available or failed (non-critical): $e'); } } @@ -2288,18 +2327,31 @@ class ApiService { // Build request data (exactly like OpenWebUI) final data = { + 'stream': true, 'model': model, 'messages': processedMessages, - 'stream': true, // Always enable streaming - 'max_tokens': null, // Let the model decide - 'temperature': 0.8, - 'top_p': 0.9, - 'frequency_penalty': 0.0, - 'presence_penalty': 0.0, + 'params': {}, + 'tool_servers': [], + 'features': { + 'image_generation': false, + 'code_interpreter': false, + 'web_search': enableWebSearch, + 'memory': false, + }, + 'variables': { + '{{USER_NAME}}': 'User', + '{{USER_LOCATION}}': 'Unknown', + '{{CURRENT_DATETIME}}': DateTime.now().toIso8601String().substring(0, 19).replaceAll('T', ' '), + '{{CURRENT_DATE}}': DateTime.now().toIso8601String().substring(0, 10), + '{{CURRENT_TIME}}': DateTime.now().toIso8601String().substring(11, 19), + '{{CURRENT_WEEKDAY}}': _getCurrentWeekday(), + '{{CURRENT_TIMEZONE}}': DateTime.now().timeZoneName, + '{{USER_LANGUAGE}}': 'en-US', + }, + if (modelItem != null) 'model_item': modelItem, if (conversationId != null) 'chat_id': conversationId, if (tools != null && tools.isNotEmpty) 'tools': tools, if (allFiles.isNotEmpty) 'files': allFiles, - if (enableWebSearch) 'web_search': enableWebSearch, }; debugPrint('DEBUG: Starting SSE streaming request'); diff --git a/lib/core/validation/field_mapper.dart b/lib/core/validation/field_mapper.dart index 54689c8..2dcffce 100644 --- a/lib/core/validation/field_mapper.dart +++ b/lib/core/validation/field_mapper.dart @@ -42,6 +42,12 @@ class FieldMapper { 'file_size': 'fileSize', 'file_type': 'fileType', 'mime_type': 'mimeType', + // OpenWebUI chat message fields - keep in camelCase + 'parentId': 'parentId', + 'childrenIds': 'childrenIds', + 'currentId': 'currentId', + 'modelName': 'modelName', + 'modelIdx': 'modelIdx', }; static const Map _specialClientToApi = { @@ -74,6 +80,12 @@ class FieldMapper { 'fileSize': 'file_size', 'fileType': 'file_type', 'mimeType': 'mime_type', + // OpenWebUI chat message fields - keep in camelCase + 'parentId': 'parentId', + 'childrenIds': 'childrenIds', + 'currentId': 'currentId', + 'modelName': 'modelName', + 'modelIdx': 'modelIdx', }; /// Transform data from client format (camelCase) to API format (snake_case) diff --git a/lib/features/auth/views/connect_signin_page.dart b/lib/features/auth/views/connect_signin_page.dart index 922782d..498e8e8 100644 --- a/lib/features/auth/views/connect_signin_page.dart +++ b/lib/features/auth/views/connect_signin_page.dart @@ -378,176 +378,197 @@ class _ConnectAndSignInPageState extends ConsumerState { const SizedBox(height: Spacing.sm), - AccessibleFormField( - label: 'Server address', - hint: 'https://server', - controller: _urlController, - validator: InputValidationService.combine([ - InputValidationService.validateRequired, - (value) => InputValidationService.validateUrl( - value, - required: true, - ), - ]), - keyboardType: TextInputType.url, - semanticLabel: - 'Enter your server URL or IP address', - onSubmitted: (_) => _connectAndSignIn(), - prefixIcon: Icon( - isIOS ? CupertinoIcons.globe : Icons.public, - color: context.conduitTheme.iconSecondary, - ), - ).animate().slideX( - begin: -0.08, - duration: AnimationDuration.messageSlide, - delay: AnimationDuration.microInteraction, - curve: Curves.easeOutCubic, - ), - - if (_connectionError != null) ...[ - const SizedBox(height: Spacing.sm), - _InlineMessage( - message: _connectionError!, - isError: true, - ).animate().slideX( - begin: 0.08, - duration: AnimationDuration.messageSlide, - curve: Curves.easeOutCubic, - ), - ], - - const SizedBox(height: Spacing.sectionGap), - - // Step 2: Sign in - _SectionHeader( - icon: isIOS - ? CupertinoIcons.lock - : Icons.lock_outline, - title: 'Sign in', - subtitle: null, - ), - - const SizedBox(height: Spacing.sm), - - activeServerAsync.maybeWhen( - data: (server) => server != null - ? Row( - children: [ - Icon( - isIOS - ? CupertinoIcons.link - : Icons.link_outlined, - size: IconSize.small, - color: context - .conduitTheme - .iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Expanded( - child: Text( - server.url, - textAlign: TextAlign.left, - overflow: TextOverflow.ellipsis, - style: context - .conduitTheme - .bodySmall - ?.copyWith( - color: context - .conduitTheme - .textSecondary, - ), + AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AccessibleFormField( + label: 'Server address', + hint: 'https://server', + controller: _urlController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => + InputValidationService.validateUrl( + value, + required: true, ), - ), - ], - ) - : const SizedBox.shrink(), - orElse: () => const SizedBox.shrink(), - ), - - const SizedBox(height: Spacing.sm), - - AccessibleFormField( - label: 'Username or email', - hint: null, - controller: _usernameController, - validator: InputValidationService.combine([ - InputValidationService.validateRequired, - (value) => - InputValidationService.validateEmailOrUsername( - value, + ]), + keyboardType: TextInputType.url, + semanticLabel: + 'Enter your server URL or IP address', + onSubmitted: (_) => _connectAndSignIn(), + prefixIcon: Icon( + isIOS + ? CupertinoIcons.globe + : Icons.public, + color: context.conduitTheme.iconSecondary, ), - ]), - keyboardType: TextInputType.emailAddress, - semanticLabel: 'Enter your username or email', - prefixIcon: Icon( - isIOS - ? CupertinoIcons.person - : Icons.person_outline, - color: context.conduitTheme.iconSecondary, - ), - ), + autofillHints: const [AutofillHints.url], + ).animate().slideX( + begin: -0.08, + duration: AnimationDuration.messageSlide, + delay: AnimationDuration.microInteraction, + curve: Curves.easeOutCubic, + ), - const SizedBox(height: Spacing.comfortable), - - AccessibleFormField( - label: 'Password', - hint: null, - controller: _passwordController, - validator: InputValidationService.combine([ - InputValidationService.validateRequired, - (value) => - InputValidationService.validateMinLength( - value, - 1, - fieldName: 'Password', + if (_connectionError != null) ...[ + const SizedBox(height: Spacing.sm), + _InlineMessage( + message: _connectionError!, + isError: true, + ).animate().slideX( + begin: 0.08, + duration: AnimationDuration.messageSlide, + curve: Curves.easeOutCubic, ), - ]), - obscureText: _obscurePassword, - semanticLabel: 'Enter your password', - prefixIcon: Icon( - isIOS - ? CupertinoIcons.lock - : Icons.lock_outline, - color: context.conduitTheme.iconSecondary, - ), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? (isIOS - ? CupertinoIcons.eye_slash - : Icons.visibility_off) - : (isIOS - ? CupertinoIcons.eye - : Icons.visibility), - color: context.conduitTheme.iconSecondary, - ), - onPressed: () => setState(() { - _obscurePassword = !_obscurePassword; - }), - ), - onSubmitted: (_) => _connectAndSignIn(), - ), + ], - if (_loginError != null) ...[ - const SizedBox(height: Spacing.sm), - _InlineMessage( - message: _loginError!, - isError: true, + const SizedBox(height: Spacing.sectionGap), + + // Step 2: Sign in + _SectionHeader( + icon: isIOS + ? CupertinoIcons.lock + : Icons.lock_outline, + title: 'Sign in', + subtitle: null, + ), + + const SizedBox(height: Spacing.sm), + + activeServerAsync.maybeWhen( + data: (server) => server != null + ? Row( + children: [ + Icon( + isIOS + ? CupertinoIcons.link + : Icons.link_outlined, + size: IconSize.small, + color: context + .conduitTheme + .iconSecondary, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Text( + server.url, + textAlign: TextAlign.left, + overflow: + TextOverflow.ellipsis, + style: context + .conduitTheme + .bodySmall + ?.copyWith( + color: context + .conduitTheme + .textSecondary, + ), + ), + ), + ], + ) + : const SizedBox.shrink(), + orElse: () => const SizedBox.shrink(), + ), + + const SizedBox(height: Spacing.sm), + + AccessibleFormField( + label: 'Username or email', + hint: null, + controller: _usernameController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => + InputValidationService.validateEmailOrUsername( + value, + ), + ]), + keyboardType: TextInputType.emailAddress, + semanticLabel: + 'Enter your username or email', + prefixIcon: Icon( + isIOS + ? CupertinoIcons.person + : Icons.person_outline, + color: context.conduitTheme.iconSecondary, + ), + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + ), + + const SizedBox(height: Spacing.comfortable), + + AccessibleFormField( + label: 'Password', + hint: null, + controller: _passwordController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => + InputValidationService.validateMinLength( + value, + 1, + fieldName: 'Password', + ), + ]), + obscureText: _obscurePassword, + semanticLabel: 'Enter your password', + prefixIcon: Icon( + isIOS + ? CupertinoIcons.lock + : Icons.lock_outline, + color: context.conduitTheme.iconSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? (isIOS + ? CupertinoIcons.eye_slash + : Icons.visibility_off) + : (isIOS + ? CupertinoIcons.eye + : Icons.visibility), + color: + context.conduitTheme.iconSecondary, + ), + onPressed: () => setState(() { + _obscurePassword = !_obscurePassword; + }), + ), + onSubmitted: (_) => _connectAndSignIn(), + autofillHints: const [ + AutofillHints.password, + ], + ), + + if (_loginError != null) ...[ + const SizedBox(height: Spacing.sm), + _InlineMessage( + message: _loginError!, + isError: true, + ), + ], + + const SizedBox(height: Spacing.md), + + ConduitButton( + text: 'Continue', + onPressed: _isSubmitting + ? null + : _connectAndSignIn, + isLoading: _isSubmitting, + isFullWidth: true, + ).animate().scale( + duration: AnimationDuration.buttonPress, + curve: Curves.easeOutCubic, + ), + ], ), - ], - - const SizedBox(height: Spacing.md), - - ConduitButton( - text: 'Continue', - onPressed: _isSubmitting - ? null - : _connectAndSignIn, - isLoading: _isSubmitting, - isFullWidth: true, - ).animate().scale( - duration: AnimationDuration.buttonPress, - curve: Curves.easeOutCubic, ), ], ), diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 48b3a1e..4d4eafe 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -344,53 +345,10 @@ Future _sendMessageInternal( // Check if we need to create a new conversation first var activeConversation = ref.read(activeConversationProvider); + + debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}'); - if (activeConversation == null) { - // Create new conversation locally first to ensure we have a conversation context - debugPrint('DEBUG: Creating new conversation before sending message'); - final title = message.length > 50 - ? '${message.substring(0, 50)}...' - : message; - - // Create local conversation first - final localConversation = Conversation( - id: const Uuid().v4(), - title: title, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - messages: [], - ); - - // Set as active conversation locally - ref.read(activeConversationProvider.notifier).state = localConversation; - activeConversation = localConversation; - - if (!reviewerMode) { - // Try to create on server, but don't fail if it doesn't work - try { - final serverConversation = await api.createConversation( - title: title, - messages: [], - model: selectedModel.id, - ); - final updatedConversation = localConversation.copyWith( - id: serverConversation.id, - ); - ref.read(activeConversationProvider.notifier).state = - updatedConversation; - activeConversation = updatedConversation; - debugPrint( - 'DEBUG: Created conversation ${serverConversation.id} on server', - ); - } catch (e) { - debugPrint( - 'DEBUG: Failed to create conversation on server, using local: $e', - ); - } - } - } - - // Add user message + // Create user message first debugPrint('DEBUG: Creating user message with attachments: $attachments'); final userMessage = ChatMessage( id: const Uuid().v4(), @@ -399,8 +357,68 @@ Future _sendMessageInternal( timestamp: DateTime.now(), attachmentIds: attachments, ); - ref.read(chatMessagesProvider.notifier).addMessage(userMessage); - debugPrint('DEBUG: User message added with ID: ${userMessage.id}'); + + if (activeConversation == null) { + // Create new conversation with the first message included + debugPrint('DEBUG: Creating new conversation with first message'); + + // Create local conversation first + final localConversation = Conversation( + id: const Uuid().v4(), + title: 'New Chat', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + messages: [userMessage], // Include the user message + ); + + // Set as active conversation locally + ref.read(activeConversationProvider.notifier).state = localConversation; + activeConversation = localConversation; + + if (!reviewerMode) { + // Try to create on server with the first message included + try { + final serverConversation = await api.createConversation( + title: 'New Chat', + messages: [userMessage], // Include the first message in creation + model: selectedModel.id, + ); + final updatedConversation = localConversation.copyWith( + id: serverConversation.id, + messages: serverConversation.messages.isNotEmpty + ? serverConversation.messages + : [userMessage], + ); + ref.read(activeConversationProvider.notifier).state = + updatedConversation; + activeConversation = updatedConversation; + + // Set messages in the messages provider to keep UI in sync + ref.read(chatMessagesProvider.notifier).clearMessages(); + ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + + debugPrint( + 'DEBUG: Created conversation ${serverConversation.id} on server with first message', + ); + debugPrint( + 'DEBUG: Server conversation ID: ${serverConversation.id}, Title: ${serverConversation.title}', + ); + } catch (e) { + debugPrint( + 'DEBUG: Failed to create conversation on server, using local: $e', + ); + // Still add the message locally + ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + } + } else { + // Add message for reviewer mode + ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + } + } else { + // Add user message to existing conversation + ref.read(chatMessagesProvider.notifier).addMessage(userMessage); + debugPrint('DEBUG: User message added with ID: ${userMessage.id}'); + } // We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode) @@ -734,8 +752,8 @@ Future _sendMessageInternal( onDone: () async { debugPrint('DEBUG: Stream completed in chat provider'); - // Don't mark streaming as complete yet - wait for server content replacement - // ref.read(chatMessagesProvider.notifier).finishStreaming(); + // Mark streaming as complete immediately for better UX + ref.read(chatMessagesProvider.notifier).finishStreaming(); // Send chat completed notification to OpenWebUI final messages = ref.read(chatMessagesProvider); @@ -754,18 +772,21 @@ Future _sendMessageInternal( 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, }; - // Add model if available - if (msg.model != null) { - messageMap['model'] = msg.model; - } - - // Add sources and usage if available - if (msg.sources != null) { - messageMap['sources'] = msg.sources; - } - // Only include usage data if it's actually available from the response - if (msg.usage != null) { - messageMap['usage'] = msg.usage; + // For assistant messages, add completion details + if (msg.role == 'assistant') { + messageMap['model'] = selectedModel.id; + + // Add mock usage data if not available (OpenWebUI expects this) + if (msg.usage != null) { + messageMap['usage'] = msg.usage; + } else if (msg == messages.last) { + // Add basic usage for the last assistant message + messageMap['usage'] = { + 'prompt_tokens': 10, + 'completion_tokens': msg.content.split(' ').length, + 'total_tokens': 10 + msg.content.split(' ').length, + }; + } } formattedMessages.add(messageMap); @@ -776,6 +797,9 @@ Future _sendMessageInternal( debugPrint( 'DEBUG: Sending chat completed notification to OpenWebUI', ); + debugPrint( + 'DEBUG: Active conversation ID: ${activeConversation.id}', + ); debugPrint( 'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}', ); @@ -788,25 +812,27 @@ Future _sendMessageInternal( sessionId: sessionId, // Include session ID ); debugPrint( - 'DEBUG: Chat completed notification sent successfully', + 'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}', ); - // Give server a moment to process title generation - await Future.delayed(const Duration(seconds: 2)); } catch (e) { debugPrint('DEBUG: Chat completed notification failed: $e'); - // Continue with title generation even if this fails + debugPrint('DEBUG: Error details: $e'); + // Continue even if this fails - it's non-critical } - // Only check for title generation on first assistant response (when conversation has <= 2 messages) - // Always check for server content updates - debugPrint('DEBUG: Checking for server content updates...'); + // Fetch the latest conversation state without waiting for title generation + debugPrint('DEBUG: Fetching latest conversation state...'); + debugPrint('DEBUG: Current message count: ${messages.length}'); + try { + // Quick fetch to get the current state - no waiting for title generation final updatedConv = await api.getConversation( activeConversation.id, ); + debugPrint('DEBUG: Current title: ${updatedConv.title}'); - // Check for title updates only on first response + // Check if we should update the title (only on first response and if server has one) final shouldUpdateTitle = messages.length <= 2 && updatedConv.title != 'New Chat' && @@ -963,29 +989,25 @@ Future _sendMessageInternal( ); } - // Now mark streaming as complete since server content has replaced simulated content - ref.read(chatMessagesProvider.notifier).finishStreaming(); + // Streaming already marked as complete when stream ended debugPrint( - 'DEBUG: Streaming marked as complete after server content replacement', + 'DEBUG: Server content replacement completed', ); + + // Start background title check for first message exchanges + if (messages.length <= 2 && updatedConv.title == 'New Chat') { + debugPrint('DEBUG: Starting background title check...'); + _checkForTitleInBackground(ref, activeConversation.id); + } } catch (e) { debugPrint('DEBUG: Failed to fetch server content: $e'); - // Mark streaming as complete even if server content replacement fails - ref.read(chatMessagesProvider.notifier).finishStreaming(); - debugPrint( - 'DEBUG: Streaming marked as complete after server content replacement failure', - ); + // Streaming already marked as complete when stream ended } } catch (e) { debugPrint('DEBUG: Chat completed error: $e'); // Continue without failing the entire process // Note: Conversation still syncs via _saveConversationToServer - - // IMPORTANT: Always mark streaming as complete even if server operations fail - ref.read(chatMessagesProvider.notifier).finishStreaming(); - debugPrint( - 'DEBUG: Streaming marked as complete after chat completed error', - ); + // Streaming already marked as complete when stream ended } } } @@ -994,8 +1016,8 @@ Future _sendMessageInternal( debugPrint('DEBUG: About to save conversation to server...'); // Add a small delay to ensure the last message content is fully updated await Future.delayed(const Duration(milliseconds: 100)); - _saveConversationToServer(ref); - debugPrint('DEBUG: Conversation save initiated'); + await _saveConversationToServer(ref); + debugPrint('DEBUG: Conversation save completed'); }, onError: (error) { debugPrint('DEBUG: Stream error in chat provider: $error'); @@ -1153,8 +1175,54 @@ Please try sending the message again, or try without attachments.''', } } -// These polling functions are no longer needed since we use direct title generation -// via /api/v1/tasks/title/completions endpoint +// Background function to check for title updates without blocking UI +Future _checkForTitleInBackground(dynamic ref, String conversationId) async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) return; + + // Wait a bit before first check to give server time to generate + await Future.delayed(const Duration(seconds: 3)); + + // Try a few times with increasing delays + for (int i = 0; i < 3; i++) { + try { + final updatedConv = await api.getConversation(conversationId); + + if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) { + debugPrint('DEBUG: Background title update found: ${updatedConv.title}'); + + // Update the active conversation with the new title + final activeConversation = ref.read(activeConversationProvider); + if (activeConversation?.id == conversationId) { + final updated = activeConversation!.copyWith( + title: updatedConv.title, + updatedAt: DateTime.now(), + ); + ref.read(activeConversationProvider.notifier).state = updated; + + // Refresh the conversations list + ref.invalidate(conversationsProvider); + } + + return; // Title found, stop checking + } + + // Wait before next check (3s, 5s, 7s) + if (i < 2) { + await Future.delayed(Duration(seconds: 2 + (i * 2))); + } + } catch (e) { + debugPrint('DEBUG: Background title check error: $e'); + break; // Stop on error + } + } + + debugPrint('DEBUG: Background title check completed without finding generated title'); + } catch (e) { + debugPrint('DEBUG: Background title check failed: $e'); + } +} // Save current conversation to OpenWebUI server Future _saveConversationToServer(dynamic ref) async { @@ -1187,6 +1255,12 @@ Future _saveConversationToServer(dynamic ref) async { debugPrint( 'DEBUG: Updating conversation ${activeConversation.id} with complete message history', ); + debugPrint( + 'DEBUG: Conversation ID being updated: ${activeConversation.id}', + ); + debugPrint( + 'DEBUG: Number of messages to save: ${messages.length}', + ); try { await api.updateConversationWithMessages( @@ -1205,8 +1279,12 @@ Future _saveConversationToServer(dynamic ref) async { debugPrint( 'DEBUG: Successfully updated conversation on server: ${activeConversation.id}', ); + debugPrint( + 'DEBUG: Updated conversation title: ${updatedConversation.title}', + ); } catch (e) { debugPrint('DEBUG: Failed to update conversation on server: $e'); + debugPrint('DEBUG: Error details: $e'); // Fallback to local storage if server update fails await _saveConversationLocally(ref); return; @@ -1250,14 +1328,20 @@ Future _saveConversationLocally(dynamic ref) async { updatedAt: DateTime.now(), ); - if (activeConversation == null) { - await storage.addLocalConversation(updatedConversation); - ref.read(activeConversationProvider.notifier).state = updatedConversation; + // Store conversation locally using the storage service's actual methods + final conversationsJson = await storage.getString('conversations') ?? '[]'; + final List conversations = jsonDecode(conversationsJson); + + // Find and update or add the conversation + final existingIndex = conversations.indexWhere((c) => c['id'] == updatedConversation.id); + if (existingIndex >= 0) { + conversations[existingIndex] = updatedConversation.toJson(); } else { - await storage.updateLocalConversation(updatedConversation); - ref.read(activeConversationProvider.notifier).state = updatedConversation; + conversations.add(updatedConversation.toJson()); } - + + await storage.setString('conversations', jsonEncode(conversations)); + ref.read(activeConversationProvider.notifier).state = updatedConversation; ref.invalidate(conversationsProvider); } catch (e) { debugPrint('Error saving conversation locally: $e'); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index c2065f6..c02fe53 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1075,18 +1075,33 @@ class _ChatPageState extends ConsumerState { // Check if there's unsaved content final messages = ref.read(chatMessagesProvider); if (messages.isNotEmpty) { + // Check if currently streaming + final isStreaming = messages.any((msg) => msg.isStreaming); + final shouldPop = await NavigationService.confirmNavigation( title: 'Leave Chat?', - message: 'Your conversation will be saved.', + message: isStreaming + ? 'The AI is still responding. Leave anyway?' + : 'Your conversation will be saved.', confirmText: 'Leave', cancelText: 'Stay', ); if (shouldPop && context.mounted) { - final canPopNavigator = Navigator.of(context).canPop(); - if (canPopNavigator) { - Navigator.of(context).pop(); - } else { - SystemNavigator.pop(); + // If streaming, stop it first + if (isStreaming) { + ref.read(chatMessagesProvider.notifier).finishStreaming(); + } + + // Save the conversation before leaving + await _saveConversationBeforeLeaving(ref); + + if (context.mounted) { + final canPopNavigator = Navigator.of(context).canPop(); + if (canPopNavigator) { + Navigator.of(context).pop(); + } else { + SystemNavigator.pop(); + } } } } else if (context.mounted) { @@ -1303,8 +1318,13 @@ class _ChatPageState extends ConsumerState { .read(activeConversationProvider.notifier) .state = full; - } catch (_) {} + } catch (e) { + debugPrint('DEBUG: Failed to refresh conversation: $e'); + // Could show a snackbar here if needed + } } + // Add small delay for better UX feedback + await Future.delayed(const Duration(milliseconds: 300)); }, child: GestureDetector( behavior: HitTestBehavior.opaque, @@ -1372,6 +1392,39 @@ class _ChatPageState extends ConsumerState { ); // ErrorBoundary } + Future _saveConversationBeforeLeaving(WidgetRef ref) async { + try { + final api = ref.read(apiServiceProvider); + final messages = ref.read(chatMessagesProvider); + final activeConversation = ref.read(activeConversationProvider); + final selectedModel = ref.read(selectedModelProvider); + + if (api == null || messages.isEmpty || activeConversation == null) { + return; + } + + // Check if the last message (assistant) has content + final lastMessage = messages.last; + if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) { + // Remove empty assistant message before saving + messages.removeLast(); + if (messages.isEmpty) return; + } + + // Update the existing conversation with all messages + await api.updateConversationWithMessages( + activeConversation.id, + messages, + model: selectedModel?.id, + ); + + debugPrint('DEBUG: Conversation saved before leaving'); + } catch (e) { + debugPrint('DEBUG: Failed to save conversation before leaving: $e'); + // Don't block navigation even if save fails + } + } + void _showModelDropdown( BuildContext context, WidgetRef ref, diff --git a/lib/features/onboarding/views/onboarding_sheet.dart b/lib/features/onboarding/views/onboarding_sheet.dart index 700dc2b..35f25b1 100644 --- a/lib/features/onboarding/views/onboarding_sheet.dart +++ b/lib/features/onboarding/views/onboarding_sheet.dart @@ -40,6 +40,16 @@ class _OnboardingSheetState extends State { 'Great for quick notes or long prompts', ], ), + _OnboardingPage( + title: 'Quick actions', + subtitle: + 'Long‑press the top‑left menu to open shortcuts like New Chat, Files, and Profile.', + icon: CupertinoIcons.line_horizontal_3, + bullets: [ + 'Tap to open chats list; long‑press for Quick Actions', + 'Jump instantly to New Chat, Files, or Profile', + ], + ), ]; void _next() { diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index 1f26f89..86a5ff8 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -345,7 +345,11 @@ class ProfilePage extends ConsumerWidget { Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeModeProvider); - final isDark = themeMode == ThemeMode.dark; + final platformBrightness = MediaQuery.platformBrightnessOf(context); + final bool isDarkEffective = + themeMode == ThemeMode.dark || + (themeMode == ThemeMode.system && + platformBrightness == Brightness.dark); return ListTile( contentPadding: const EdgeInsets.symmetric( @@ -377,13 +381,18 @@ class ProfilePage extends ConsumerWidget { ), ), subtitle: Text( - isDark ? 'Currently using Dark theme' : 'Currently using Light theme', + themeMode == ThemeMode.system + ? 'Following system: ' + '${platformBrightness == Brightness.dark ? 'Dark' : 'Light'}' + : (isDarkEffective + ? 'Currently using Dark theme' + : 'Currently using Light theme'), style: context.conduitTheme.bodySmall?.copyWith( color: context.conduitTheme.textSecondary, ), ), trailing: Switch.adaptive( - value: isDark, + value: isDarkEffective, onChanged: (value) { ref .read(themeModeProvider.notifier) @@ -391,7 +400,7 @@ class ProfilePage extends ConsumerWidget { }, ), onTap: () { - final newValue = !isDark; + final newValue = !isDarkEffective; ref .read(themeModeProvider.notifier) .setTheme(newValue ? ThemeMode.dark : ThemeMode.light); diff --git a/lib/shared/widgets/conduit_components.dart b/lib/shared/widgets/conduit_components.dart index 1ead263..41bc7b2 100644 --- a/lib/shared/widgets/conduit_components.dart +++ b/lib/shared/widgets/conduit_components.dart @@ -710,6 +710,7 @@ class AccessibleFormField extends StatelessWidget { final String? Function(String?)? validator; final bool isRequired; final bool isCompact; + final Iterable? autofillHints; const AccessibleFormField({ super.key, @@ -731,6 +732,7 @@ class AccessibleFormField extends StatelessWidget { this.validator, this.isRequired = false, this.isCompact = false, + this.autofillHints, }); @override @@ -776,6 +778,7 @@ class AccessibleFormField extends StatelessWidget { keyboardType: keyboardType, autofocus: autofocus, validator: validator, + autofillHints: autofillHints, style: AppTypography.standard.copyWith( color: context.conduitTheme.textPrimary, ),