fix: chats syncing to server

This commit is contained in:
cogwheel0
2025-08-12 13:07:10 +05:30
parent 4c67a20c06
commit 0bb56908b4
22 changed files with 669 additions and 398 deletions

View File

@@ -1,21 +1,48 @@
# Conduit - Open-WebUI Mobile Client # Conduit - Open-WebUI Mobile Client
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" alt="Screenshot 1" width="250" />
</p>
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. 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 ## Features
### Core Features ### Core Features
- **Real-time Chat**: Stream responses from AI models in real-time - **Real-time Chat**: Stream responses from AI models in real-time
- **Model Selection**: Choose from available models on your server - **Model Selection**: Choose from available models on your server
- **Conversation Management**: Create, search, and manage chat histories - **Conversation Management**: Create, search, and manage chat histories
- **Markdown Rendering**: Full markdown support with syntax highlighting - **Markdown Rendering**: Full markdown support with syntax highlighting
- **Theme Support**: Light, Dark, and System themes - **Theme Support**: Light, Dark, and System themes
### Advanced Features ### Advanced Features
- **Voice Input**: Use speech-to-text for hands-free interaction - **Voice Input**: Use speech-to-text for hands-free interaction
- **File Uploads**: Support for images and documents (RAG) - **File Uploads**: Support for images and documents (RAG)
- **Multi-modal Support**: Work with vision models - **Multi-modal Support**: Work with vision models
- **Secure Storage**: Credentials stored securely using platform keychains - **Secure Storage**: Credentials stored securely using platform keychains
## Screenshots
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" alt="Screenshot 2" width="200" />
</p>
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" alt="Screenshot 3" width="200" />
</p>
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" alt="Screenshot 4" width="200" />
</p>
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" alt="Screenshot 5" width="200" />
</p>
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" alt="Screenshot 6" width="200" />
</p>
## Requirements ## Requirements

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -464,9 +464,15 @@ class ApiService {
// Parse full OpenWebUI chat with messages // Parse full OpenWebUI chat with messages
Conversation _parseFullOpenWebUIChat(Map<String, dynamic> chatData) { Conversation _parseFullOpenWebUIChat(Map<String, dynamic> chatData) {
debugPrint('DEBUG: Parsing full OpenWebUI chat data');
debugPrint('DEBUG: Chat data keys: ${chatData.keys.toList()}');
final id = chatData['id'] as String; final id = chatData['id'] as String;
final title = chatData['title'] 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 // Safely parse timestamps with validation
final updatedAt = _parseTimestamp(chatData['updated_at']); final updatedAt = _parseTimestamp(chatData['updated_at']);
final createdAt = _parseTimestamp(chatData['created_at']); final createdAt = _parseTimestamp(chatData['created_at']);
@@ -481,26 +487,33 @@ class ApiService {
final chatObject = chatData['chat'] as Map<String, dynamic>?; final chatObject = chatData['chat'] as Map<String, dynamic>?;
final messages = <ChatMessage>[]; final messages = <ChatMessage>[];
// Try multiple locations for messages // Try multiple locations for messages - prefer list format to avoid duplication
List? messagesList; List? messagesList;
Map<String, dynamic>? messagesMap;
if (chatObject != null) { if (chatObject != null) {
// Check for messages in chat.history.messages (map format) // Check for messages in chat.messages (list format) - PREFERRED
final history = chatObject['history'] as Map<String, dynamic>?;
if (history != null && history['messages'] != null) {
messagesMap = history['messages'] as Map<String, dynamic>;
debugPrint(
'DEBUG: Found ${messagesMap.length} messages in chat.history.messages',
);
}
// Check for messages in chat.messages (list format)
if (chatObject['messages'] != null) { if (chatObject['messages'] != null) {
messagesList = chatObject['messages'] as List; messagesList = chatObject['messages'] as List;
debugPrint( debugPrint(
'DEBUG: Found ${messagesList.length} messages in chat.messages', '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<String, dynamic>?;
if (history != null && history['messages'] != null) {
final messagesMap = history['messages'] as Map<String, dynamic>;
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<String, dynamic>.from(entry.value as Map<String, dynamic>);
msgData['id'] = entry.key; // Use the key as the message ID
messagesList.add(msgData);
}
}
} }
} else if (chatData['messages'] != null) { } else if (chatData['messages'] != null) {
messagesList = chatData['messages'] as List; messagesList = chatData['messages'] as List;
@@ -509,42 +522,21 @@ class ApiService {
); );
} }
// Parse messages from map format (chat.history.messages) // Parse messages from list format only (avoiding duplication)
if (messagesMap != null) {
for (final entry in messagesMap.entries) {
try {
final msgData = entry.value as Map<String, dynamic>;
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)
if (messagesList != null) { if (messagesList != null) {
for (final msgData in messagesList) { for (final msgData in messagesList) {
try { try {
debugPrint( 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 // Convert OpenWebUI message format to our ChatMessage format
final message = _parseOpenWebUIMessage(msgData); final message = _parseOpenWebUIMessage(msgData);
messages.add(message); messages.add(message);
debugPrint( debugPrint(
'DEBUG: Successfully parsed message from list: ${message.id} - ${message.role}', 'DEBUG: Successfully parsed message: ${message.id} - ${message.role}',
); );
} catch (e) { } 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 // Create new conversation using OpenWebUI API
Future<Conversation> createConversation({ Future<Conversation> createConversation({
required String title, required String title,
@@ -618,39 +609,47 @@ class ApiService {
debugPrint('DEBUG: Creating new conversation on OpenWebUI server'); debugPrint('DEBUG: Creating new conversation on OpenWebUI server');
debugPrint('DEBUG: Title: $title, Messages: ${messages.length}'); debugPrint('DEBUG: Title: $title, Messages: ${messages.length}');
// Convert messages to the new format with proper structure // Build messages with parent-child relationships
final Map<String, dynamic> messagesMap = {}; final Map<String, dynamic> messagesMap = {};
final List<Map<String, dynamic>> messagesArray = [];
String? currentId; String? currentId;
String? previousId;
for (final msg in messages) { for (final msg in messages) {
final messageId = msg.id; final messageId = msg.id;
// Build message for history.messages map
messagesMap[messageId] = { messagesMap[messageId] = {
'id': messageId, 'id': messageId,
'parentId': null, 'parentId': previousId,
'childrenIds': [], 'childrenIds': [],
'role': msg.role, 'role': msg.role,
'content': msg.content, '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, '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);
} }
// Create the chat data structure matching the expected format // 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 OpenWebUI format exactly
final chatData = { final chatData = {
'chat': { 'chat': {
'id': '', 'id': '',
@@ -661,44 +660,20 @@ class ApiService {
'messages': messagesMap, 'messages': messagesMap,
if (currentId != null) 'currentId': currentId, if (currentId != null) 'currentId': currentId,
}, },
'messages': messages 'messages': messagesArray,
.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(),
'tags': [], 'tags': [],
'timestamp': DateTime.now().millisecondsSinceEpoch, 'timestamp': DateTime.now().millisecondsSinceEpoch,
}, },
'folder_id': null, '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); 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 // Parse the response
final responseData = response.data as Map<String, dynamic>; final responseData = response.data as Map<String, dynamic>;
@@ -717,30 +692,71 @@ class ApiService {
'DEBUG: Updating conversation $conversationId with ${messages.length} messages', 'DEBUG: Updating conversation $conversationId with ${messages.length} messages',
); );
// Convert messages to OpenWebUI format // Build messages map and array in OpenWebUI format
final openWebUIMessages = messages final Map<String, dynamic> messagesMap = {};
.map( final List<Map<String, dynamic>> messagesArray = [];
(msg) => { String? currentId;
String? previousId;
for (final msg in messages) {
final messageId = msg.id;
// Build message for messages map (history.messages)
messagesMap[messageId] = {
'id': messageId,
'parentId': previousId,
'childrenIds': <String>[],
'role': msg.role, 'role': msg.role,
'content': msg.content, 'content': msg.content,
if (msg.model != null) 'model': msg.model,
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, '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,
.toList(); if (msg.role == 'assistant') 'modelIdx': 0,
if (msg.role == 'assistant') 'done': true,
if (msg.role == 'user' && model != null) 'models': [model],
};
// Create the chat data structure // 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 = { final chatData = {
'chat': { 'chat': {
'messages': openWebUIMessages, 'models': model != null ? [model] : [],
if (title != null) 'title': title, 'messages': messagesArray,
if (model != null) 'model': model, 'history': {
if (systemPrompt != null) 'system': systemPrompt, '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( final response = await _dio.post(
'/api/v1/chats/$conversationId', '/api/v1/chats/$conversationId',
data: chatData, data: chatData,
@@ -1352,18 +1368,41 @@ class ApiService {
Map<String, dynamic>? modelItem, Map<String, dynamic>? modelItem,
String? sessionId, String? sessionId,
}) async { }) async {
debugPrint('DEBUG: Sending chat completed for message: $messageId'); debugPrint('DEBUG: Sending chat completed notification (optional endpoint)');
final requestData = { // This endpoint appears to be optional or deprecated in newer OpenWebUI versions
'model': model, // The main chat synchronization happens through /api/v1/chats/{id} updates
'messages': messages, // We'll still try to call it but won't fail if it doesn't work
if (modelItem != null) 'model_item': modelItem,
'chat_id': chatId, // Format messages to match OpenWebUI expected structure
if (sessionId != null) 'session_id': sessionId, // Note: Removing 'id' field as it causes 400 error
'id': messageId, 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,
}; };
debugPrint('DEBUG: Chat completed request data: $requestData'); // 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 = {
'id': messageId, // The server expects the assistant message ID here
'chat_id': chatId,
'model': model,
'messages': formattedMessages,
// Don't include model_item as it might not be expected
};
try { try {
final response = await _dio.post( final response = await _dio.post(
@@ -1372,8 +1411,8 @@ class ApiService {
); );
debugPrint('DEBUG: Chat completed response: ${response.statusCode}'); debugPrint('DEBUG: Chat completed response: ${response.statusCode}');
} catch (e) { } catch (e) {
debugPrint('DEBUG: Chat completed error: $e'); // This is a non-critical endpoint - main sync happens via /api/v1/chats/{id}
// Non-critical error, don't throw debugPrint('DEBUG: Chat completed endpoint not available or failed (non-critical): $e');
} }
} }
@@ -2288,18 +2327,31 @@ class ApiService {
// Build request data (exactly like OpenWebUI) // Build request data (exactly like OpenWebUI)
final data = { final data = {
'stream': true,
'model': model, 'model': model,
'messages': processedMessages, 'messages': processedMessages,
'stream': true, // Always enable streaming 'params': {},
'max_tokens': null, // Let the model decide 'tool_servers': [],
'temperature': 0.8, 'features': {
'top_p': 0.9, 'image_generation': false,
'frequency_penalty': 0.0, 'code_interpreter': false,
'presence_penalty': 0.0, '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 (conversationId != null) 'chat_id': conversationId,
if (tools != null && tools.isNotEmpty) 'tools': tools, if (tools != null && tools.isNotEmpty) 'tools': tools,
if (allFiles.isNotEmpty) 'files': allFiles, if (allFiles.isNotEmpty) 'files': allFiles,
if (enableWebSearch) 'web_search': enableWebSearch,
}; };
debugPrint('DEBUG: Starting SSE streaming request'); debugPrint('DEBUG: Starting SSE streaming request');

View File

@@ -42,6 +42,12 @@ class FieldMapper {
'file_size': 'fileSize', 'file_size': 'fileSize',
'file_type': 'fileType', 'file_type': 'fileType',
'mime_type': 'mimeType', 'mime_type': 'mimeType',
// OpenWebUI chat message fields - keep in camelCase
'parentId': 'parentId',
'childrenIds': 'childrenIds',
'currentId': 'currentId',
'modelName': 'modelName',
'modelIdx': 'modelIdx',
}; };
static const Map<String, String> _specialClientToApi = { static const Map<String, String> _specialClientToApi = {
@@ -74,6 +80,12 @@ class FieldMapper {
'fileSize': 'file_size', 'fileSize': 'file_size',
'fileType': 'file_type', 'fileType': 'file_type',
'mimeType': 'mime_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) /// Transform data from client format (camelCase) to API format (snake_case)

View File

@@ -378,13 +378,18 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AccessibleFormField( AccessibleFormField(
label: 'Server address', label: 'Server address',
hint: 'https://server', hint: 'https://server',
controller: _urlController, controller: _urlController,
validator: InputValidationService.combine([ validator: InputValidationService.combine([
InputValidationService.validateRequired, InputValidationService.validateRequired,
(value) => InputValidationService.validateUrl( (value) =>
InputValidationService.validateUrl(
value, value,
required: true, required: true,
), ),
@@ -394,9 +399,12 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
'Enter your server URL or IP address', 'Enter your server URL or IP address',
onSubmitted: (_) => _connectAndSignIn(), onSubmitted: (_) => _connectAndSignIn(),
prefixIcon: Icon( prefixIcon: Icon(
isIOS ? CupertinoIcons.globe : Icons.public, isIOS
? CupertinoIcons.globe
: Icons.public,
color: context.conduitTheme.iconSecondary, color: context.conduitTheme.iconSecondary,
), ),
autofillHints: const [AutofillHints.url],
).animate().slideX( ).animate().slideX(
begin: -0.08, begin: -0.08,
duration: AnimationDuration.messageSlide, duration: AnimationDuration.messageSlide,
@@ -447,7 +455,8 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
child: Text( child: Text(
server.url, server.url,
textAlign: TextAlign.left, textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis, overflow:
TextOverflow.ellipsis,
style: context style: context
.conduitTheme .conduitTheme
.bodySmall .bodySmall
@@ -478,13 +487,18 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
), ),
]), ]),
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
semanticLabel: 'Enter your username or email', semanticLabel:
'Enter your username or email',
prefixIcon: Icon( prefixIcon: Icon(
isIOS isIOS
? CupertinoIcons.person ? CupertinoIcons.person
: Icons.person_outline, : Icons.person_outline,
color: context.conduitTheme.iconSecondary, color: context.conduitTheme.iconSecondary,
), ),
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
), ),
const SizedBox(height: Spacing.comfortable), const SizedBox(height: Spacing.comfortable),
@@ -519,13 +533,17 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
: (isIOS : (isIOS
? CupertinoIcons.eye ? CupertinoIcons.eye
: Icons.visibility), : Icons.visibility),
color: context.conduitTheme.iconSecondary, color:
context.conduitTheme.iconSecondary,
), ),
onPressed: () => setState(() { onPressed: () => setState(() {
_obscurePassword = !_obscurePassword; _obscurePassword = !_obscurePassword;
}), }),
), ),
onSubmitted: (_) => _connectAndSignIn(), onSubmitted: (_) => _connectAndSignIn(),
autofillHints: const [
AutofillHints.password,
],
), ),
if (_loginError != null) ...[ if (_loginError != null) ...[
@@ -555,6 +573,9 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
], ],
), ),
), ),
],
),
),
), ),
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -345,52 +346,9 @@ Future<void> _sendMessageInternal(
// Check if we need to create a new conversation first // Check if we need to create a new conversation first
var activeConversation = ref.read(activeConversationProvider); var activeConversation = ref.read(activeConversationProvider);
if (activeConversation == null) { debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}');
// 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 // Create user message 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: <ChatMessage>[],
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
debugPrint('DEBUG: Creating user message with attachments: $attachments'); debugPrint('DEBUG: Creating user message with attachments: $attachments');
final userMessage = ChatMessage( final userMessage = ChatMessage(
id: const Uuid().v4(), id: const Uuid().v4(),
@@ -399,8 +357,68 @@ Future<void> _sendMessageInternal(
timestamp: DateTime.now(), timestamp: DateTime.now(),
attachmentIds: attachments, attachmentIds: attachments,
); );
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); ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
debugPrint('DEBUG: User message added with ID: ${userMessage.id}'); 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) // 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<void> _sendMessageInternal(
onDone: () async { onDone: () async {
debugPrint('DEBUG: Stream completed in chat provider'); debugPrint('DEBUG: Stream completed in chat provider');
// Don't mark streaming as complete yet - wait for server content replacement // Mark streaming as complete immediately for better UX
// ref.read(chatMessagesProvider.notifier).finishStreaming(); ref.read(chatMessagesProvider.notifier).finishStreaming();
// Send chat completed notification to OpenWebUI // Send chat completed notification to OpenWebUI
final messages = ref.read(chatMessagesProvider); final messages = ref.read(chatMessagesProvider);
@@ -754,18 +772,21 @@ Future<void> _sendMessageInternal(
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000, 'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
}; };
// Add model if available // For assistant messages, add completion details
if (msg.model != null) { if (msg.role == 'assistant') {
messageMap['model'] = msg.model; messageMap['model'] = selectedModel.id;
}
// Add sources and usage if available // Add mock usage data if not available (OpenWebUI expects this)
if (msg.sources != null) {
messageMap['sources'] = msg.sources;
}
// Only include usage data if it's actually available from the response
if (msg.usage != null) { if (msg.usage != null) {
messageMap['usage'] = msg.usage; 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); formattedMessages.add(messageMap);
@@ -776,6 +797,9 @@ Future<void> _sendMessageInternal(
debugPrint( debugPrint(
'DEBUG: Sending chat completed notification to OpenWebUI', 'DEBUG: Sending chat completed notification to OpenWebUI',
); );
debugPrint(
'DEBUG: Active conversation ID: ${activeConversation.id}',
);
debugPrint( debugPrint(
'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}', 'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}',
); );
@@ -788,25 +812,27 @@ Future<void> _sendMessageInternal(
sessionId: sessionId, // Include session ID sessionId: sessionId, // Include session ID
); );
debugPrint( 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) { } catch (e) {
debugPrint('DEBUG: Chat completed notification failed: $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) // Fetch the latest conversation state without waiting for title generation
// Always check for server content updates debugPrint('DEBUG: Fetching latest conversation state...');
debugPrint('DEBUG: Checking for server content updates...'); debugPrint('DEBUG: Current message count: ${messages.length}');
try { try {
// Quick fetch to get the current state - no waiting for title generation
final updatedConv = await api.getConversation( final updatedConv = await api.getConversation(
activeConversation.id, 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 = final shouldUpdateTitle =
messages.length <= 2 && messages.length <= 2 &&
updatedConv.title != 'New Chat' && updatedConv.title != 'New Chat' &&
@@ -963,29 +989,25 @@ Future<void> _sendMessageInternal(
); );
} }
// Now mark streaming as complete since server content has replaced simulated content // Streaming already marked as complete when stream ended
ref.read(chatMessagesProvider.notifier).finishStreaming();
debugPrint( 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) { } catch (e) {
debugPrint('DEBUG: Failed to fetch server content: $e'); debugPrint('DEBUG: Failed to fetch server content: $e');
// Mark streaming as complete even if server content replacement fails // Streaming already marked as complete when stream ended
ref.read(chatMessagesProvider.notifier).finishStreaming();
debugPrint(
'DEBUG: Streaming marked as complete after server content replacement failure',
);
} }
} catch (e) { } catch (e) {
debugPrint('DEBUG: Chat completed error: $e'); debugPrint('DEBUG: Chat completed error: $e');
// Continue without failing the entire process // Continue without failing the entire process
// Note: Conversation still syncs via _saveConversationToServer // Note: Conversation still syncs via _saveConversationToServer
// Streaming already marked as complete when stream ended
// 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',
);
} }
} }
} }
@@ -994,8 +1016,8 @@ Future<void> _sendMessageInternal(
debugPrint('DEBUG: About to save conversation to server...'); debugPrint('DEBUG: About to save conversation to server...');
// Add a small delay to ensure the last message content is fully updated // Add a small delay to ensure the last message content is fully updated
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
_saveConversationToServer(ref); await _saveConversationToServer(ref);
debugPrint('DEBUG: Conversation save initiated'); debugPrint('DEBUG: Conversation save completed');
}, },
onError: (error) { onError: (error) {
debugPrint('DEBUG: Stream error in chat provider: $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 // Background function to check for title updates without blocking UI
// via /api/v1/tasks/title/completions endpoint Future<void> _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 // Save current conversation to OpenWebUI server
Future<void> _saveConversationToServer(dynamic ref) async { Future<void> _saveConversationToServer(dynamic ref) async {
@@ -1187,6 +1255,12 @@ Future<void> _saveConversationToServer(dynamic ref) async {
debugPrint( debugPrint(
'DEBUG: Updating conversation ${activeConversation.id} with complete message history', '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 { try {
await api.updateConversationWithMessages( await api.updateConversationWithMessages(
@@ -1205,8 +1279,12 @@ Future<void> _saveConversationToServer(dynamic ref) async {
debugPrint( debugPrint(
'DEBUG: Successfully updated conversation on server: ${activeConversation.id}', 'DEBUG: Successfully updated conversation on server: ${activeConversation.id}',
); );
debugPrint(
'DEBUG: Updated conversation title: ${updatedConversation.title}',
);
} catch (e) { } catch (e) {
debugPrint('DEBUG: Failed to update conversation on server: $e'); debugPrint('DEBUG: Failed to update conversation on server: $e');
debugPrint('DEBUG: Error details: $e');
// Fallback to local storage if server update fails // Fallback to local storage if server update fails
await _saveConversationLocally(ref); await _saveConversationLocally(ref);
return; return;
@@ -1250,14 +1328,20 @@ Future<void> _saveConversationLocally(dynamic ref) async {
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
); );
if (activeConversation == null) { // Store conversation locally using the storage service's actual methods
await storage.addLocalConversation(updatedConversation); final conversationsJson = await storage.getString('conversations') ?? '[]';
ref.read(activeConversationProvider.notifier).state = updatedConversation; final List<dynamic> 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 { } else {
await storage.updateLocalConversation(updatedConversation); conversations.add(updatedConversation.toJson());
ref.read(activeConversationProvider.notifier).state = updatedConversation;
} }
await storage.setString('conversations', jsonEncode(conversations));
ref.read(activeConversationProvider.notifier).state = updatedConversation;
ref.invalidate(conversationsProvider); ref.invalidate(conversationsProvider);
} catch (e) { } catch (e) {
debugPrint('Error saving conversation locally: $e'); debugPrint('Error saving conversation locally: $e');

View File

@@ -1075,13 +1075,27 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Check if there's unsaved content // Check if there's unsaved content
final messages = ref.read(chatMessagesProvider); final messages = ref.read(chatMessagesProvider);
if (messages.isNotEmpty) { if (messages.isNotEmpty) {
// Check if currently streaming
final isStreaming = messages.any((msg) => msg.isStreaming);
final shouldPop = await NavigationService.confirmNavigation( final shouldPop = await NavigationService.confirmNavigation(
title: 'Leave Chat?', 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', confirmText: 'Leave',
cancelText: 'Stay', cancelText: 'Stay',
); );
if (shouldPop && context.mounted) { if (shouldPop && context.mounted) {
// 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(); final canPopNavigator = Navigator.of(context).canPop();
if (canPopNavigator) { if (canPopNavigator) {
Navigator.of(context).pop(); Navigator.of(context).pop();
@@ -1089,6 +1103,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
SystemNavigator.pop(); SystemNavigator.pop();
} }
} }
}
} else if (context.mounted) { } else if (context.mounted) {
final canPopNavigator = Navigator.of(context).canPop(); final canPopNavigator = Navigator.of(context).canPop();
if (canPopNavigator) { if (canPopNavigator) {
@@ -1303,8 +1318,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
.read(activeConversationProvider.notifier) .read(activeConversationProvider.notifier)
.state = .state =
full; 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( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@@ -1372,6 +1392,39 @@ class _ChatPageState extends ConsumerState<ChatPage> {
); // ErrorBoundary ); // ErrorBoundary
} }
Future<void> _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( void _showModelDropdown(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,

View File

@@ -40,6 +40,16 @@ class _OnboardingSheetState extends State<OnboardingSheet> {
'Great for quick notes or long prompts', 'Great for quick notes or long prompts',
], ],
), ),
_OnboardingPage(
title: 'Quick actions',
subtitle:
'Longpress the topleft menu to open shortcuts like New Chat, Files, and Profile.',
icon: CupertinoIcons.line_horizontal_3,
bullets: [
'Tap to open chats list; longpress for Quick Actions',
'Jump instantly to New Chat, Files, or Profile',
],
),
]; ];
void _next() { void _next() {

View File

@@ -345,7 +345,11 @@ class ProfilePage extends ConsumerWidget {
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) { Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider); 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( return ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -377,13 +381,18 @@ class ProfilePage extends ConsumerWidget {
), ),
), ),
subtitle: Text( 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( style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary, color: context.conduitTheme.textSecondary,
), ),
), ),
trailing: Switch.adaptive( trailing: Switch.adaptive(
value: isDark, value: isDarkEffective,
onChanged: (value) { onChanged: (value) {
ref ref
.read(themeModeProvider.notifier) .read(themeModeProvider.notifier)
@@ -391,7 +400,7 @@ class ProfilePage extends ConsumerWidget {
}, },
), ),
onTap: () { onTap: () {
final newValue = !isDark; final newValue = !isDarkEffective;
ref ref
.read(themeModeProvider.notifier) .read(themeModeProvider.notifier)
.setTheme(newValue ? ThemeMode.dark : ThemeMode.light); .setTheme(newValue ? ThemeMode.dark : ThemeMode.light);

View File

@@ -710,6 +710,7 @@ class AccessibleFormField extends StatelessWidget {
final String? Function(String?)? validator; final String? Function(String?)? validator;
final bool isRequired; final bool isRequired;
final bool isCompact; final bool isCompact;
final Iterable<String>? autofillHints;
const AccessibleFormField({ const AccessibleFormField({
super.key, super.key,
@@ -731,6 +732,7 @@ class AccessibleFormField extends StatelessWidget {
this.validator, this.validator,
this.isRequired = false, this.isRequired = false,
this.isCompact = false, this.isCompact = false,
this.autofillHints,
}); });
@override @override
@@ -776,6 +778,7 @@ class AccessibleFormField extends StatelessWidget {
keyboardType: keyboardType, keyboardType: keyboardType,
autofocus: autofocus, autofocus: autofocus,
validator: validator, validator: validator,
autofillHints: autofillHints,
style: AppTypography.standard.copyWith( style: AppTypography.standard.copyWith(
color: context.conduitTheme.textPrimary, color: context.conduitTheme.textPrimary,
), ),