feat: background streaming of responses

This commit is contained in:
cogwheel0
2025-08-16 20:27:44 +05:30
parent 33fc26d755
commit 9be04ef2b9
23 changed files with 2676 additions and 322 deletions

View File

@@ -9,6 +9,7 @@ import '../../../core/models/conversation.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/auth/auth_state_manager.dart';
import '../../../core/utils/stream_chunker.dart';
import '../../../core/services/persistent_streaming_service.dart';
// Chat messages for current conversation
final chatMessagesProvider =
@@ -309,6 +310,128 @@ Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
}
}
// Regenerate message function that doesn't duplicate user message
Future<void> regenerateMessage(
WidgetRef ref,
String userMessageContent,
List<String>? attachments,
) async {
debugPrint('DEBUG: regenerateMessage called with content: $userMessageContent');
final reviewerMode = ref.read(reviewerModeProvider);
final api = ref.read(apiServiceProvider);
final selectedModel = ref.read(selectedModelProvider);
if ((!reviewerMode && api == null) || selectedModel == null) {
debugPrint('DEBUG: Missing API service or model for regeneration');
throw Exception('No API service or model selected');
}
final activeConversation = ref.read(activeConversationProvider);
if (activeConversation == null) {
debugPrint('DEBUG: No active conversation for regeneration');
throw Exception('No active conversation');
}
// In reviewer mode, simulate response
if (reviewerMode) {
final assistantMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content: '[TYPING_INDICATOR]',
timestamp: DateTime.now(),
model: selectedModel.name,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
// Simulate streaming response
final demoText = 'This is a regenerated demo response.\n\nOriginal message: "$userMessageContent"';
final words = demoText.split(' ');
for (final word in words) {
await Future.delayed(const Duration(milliseconds: 40));
ref.read(chatMessagesProvider.notifier).appendToLastMessage('$word ');
}
ref.read(chatMessagesProvider.notifier).finishStreaming();
await _saveConversationLocally(ref);
return;
}
// For real API, proceed with regeneration using existing conversation messages
try {
// Get conversation history for context (excluding the removed assistant message)
final List<ChatMessage> messages = ref.read(chatMessagesProvider);
final List<Map<String, dynamic>> conversationMessages = <Map<String, dynamic>>[];
for (final msg in messages) {
if (msg.role.isNotEmpty && msg.content.isNotEmpty && !msg.isStreaming) {
// Handle messages with attachments
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
final List<Map<String, dynamic>> contentArray = [];
// Add text content first
if (msg.content.isNotEmpty) {
contentArray.add({'type': 'text', 'text': msg.content});
}
conversationMessages.add({
'role': msg.role,
'content': contentArray.isNotEmpty ? contentArray : msg.content,
});
} else {
// Regular text message
conversationMessages.add({
'role': msg.role,
'content': msg.content,
});
}
}
}
// Stream response using SSE
final response = await api!.sendMessage(
messages: conversationMessages,
model: selectedModel.id,
conversationId: activeConversation.id,
);
final stream = response.stream;
final assistantMessageId = response.messageId;
// Add assistant message placeholder
final assistantMessage = ChatMessage(
id: assistantMessageId,
role: 'assistant',
content: '[TYPING_INDICATOR]',
timestamp: DateTime.now(),
model: selectedModel.name,
isStreaming: true,
);
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
// Handle streaming response
final chunkedStream = StreamChunker.chunkStream(
stream,
enableChunking: true,
minChunkSize: 5,
maxChunkLength: 3,
delayBetweenChunks: const Duration(milliseconds: 15),
);
await for (final chunk in chunkedStream) {
ref.read(chatMessagesProvider.notifier).appendToLastMessage(chunk);
}
ref.read(chatMessagesProvider.notifier).finishStreaming();
await _saveConversationLocally(ref);
} catch (e) {
debugPrint('DEBUG: Error during message regeneration: $e');
rethrow;
}
}
// Send message function for widgets
Future<void> sendMessage(
WidgetRef ref,
@@ -744,13 +867,45 @@ Future<void> _sendMessageInternal(
delayBetweenChunks: const Duration(milliseconds: 15),
);
final streamSubscription = chunkedStream.listen(
// Create a stream controller for persistent handling
final persistentController = StreamController<String>.broadcast();
// Register stream with persistent service for app lifecycle handling
final persistentService = PersistentStreamingService();
final streamId = persistentService.registerStream(
subscription: chunkedStream.listen(
(chunk) {
persistentController.add(chunk);
},
onDone: () {
persistentController.close();
},
onError: (error) {
persistentController.addError(error);
},
),
controller: persistentController,
recoveryCallback: () async {
// Recovery callback to restart streaming if interrupted
debugPrint('DEBUG: Attempting to recover interrupted stream');
// TODO: Implement stream recovery logic
},
metadata: {
'conversationId': activeConversation?.id,
'messageId': assistantMessageId,
'modelId': selectedModel.id,
},
);
final streamSubscription = persistentController.stream.listen(
(chunk) {
debugPrint('DEBUG: Received stream chunk: "$chunk"');
ref.read(chatMessagesProvider.notifier).appendToLastMessage(chunk);
},
onDone: () async {
// Unregister from persistent service
persistentService.unregisterStream(streamId);
debugPrint('DEBUG: Stream completed in chat provider');
// Mark streaming as complete immediately for better UX
ref.read(chatMessagesProvider.notifier).finishStreaming();
@@ -1059,13 +1214,19 @@ Future<void> _sendMessageInternal(
id: const Uuid().v4(),
role: 'assistant',
content:
'''⚠️ There was an issue with the message format. This might be because:
'''⚠️ **Message Format Error**
• The image attachment couldn't be processed
The request format is incompatible with the selected model
The message contains unsupported content
This might be because:
Image attachment couldn't be processed
Request format incompatible with selected model
• Message contains unsupported content
Please try sending the message again, or try without attachments.''',
**💡 Solutions:**
• Long press this message and select "Retry"
• Try removing attachments and resending
• Switch to a different model and retry
*Long press this message to access retry options.*''',
timestamp: DateTime.now(),
isStreaming: false,
);
@@ -1081,11 +1242,20 @@ Please try sending the message again, or try without attachments.''',
id: const Uuid().v4(),
role: 'assistant',
content:
'⚠️ I\'m sorry, but there was a server error. This usually means:\n\n'
'• The OpenWebUI server is experiencing issues\n'
'• The selected model might be unavailable\n'
'• There could be a temporary connection problem\n\n'
'Please try again in a moment, or check with your server administrator if the problem persists.',
'''⚠️ **Server Error**
This usually means:
• OpenWebUI server is experiencing issues
• Selected model might be unavailable
• Temporary connection problem
**💡 Solutions:**
• Long press this message and select "Retry"
• Wait a moment and try again
• Switch to a different model
• Check with your server administrator
*Long press this message to access retry options.*''',
timestamp: DateTime.now(),
isStreaming: false,
);
@@ -1097,11 +1267,20 @@ Please try sending the message again, or try without attachments.''',
id: const Uuid().v4(),
role: 'assistant',
content:
'⏱️ The request timed out. This might be because:\n\n'
'• The server is taking too long to respond\n'
'• Your internet connection is slow\n'
'• The model is processing a complex request\n\n'
'Please try again with a shorter message or check your connection.',
'''⏱️ **Request Timeout**
This might be because:
• Server taking too long to respond
• Internet connection is slow
• Model processing a complex request
**💡 Solutions:**
• Long press this message and select "Retry"
• Try a shorter message
• Check your internet connection
• Switch to a faster model
*Long press this message to access retry options.*''',
timestamp: DateTime.now(),
isStreaming: false,
);

View File

@@ -2,6 +2,7 @@ import 'dart:io';
import 'dart:convert';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' as foundation;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
@@ -138,7 +139,7 @@ class FileAttachmentService {
final compressedBase64 = base64Encode(compressedBytes);
return 'data:image/png;base64,$compressedBase64';
} catch (e) {
debugPrint('DEBUG: Image compression failed: $e');
foundation.debugPrint('DEBUG: Image compression failed: $e');
return imageDataUrl; // Return original if compression fails
}
}
@@ -151,7 +152,7 @@ class FileAttachmentService {
int? maxHeight,
}) async {
try {
debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}');
foundation.debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}');
// Read the file as bytes
final bytes = await imageFile.readAsBytes();
@@ -177,24 +178,24 @@ class FileAttachmentService {
dataUrl = await compressImage(dataUrl, maxWidth, maxHeight);
}
debugPrint(
foundation.debugPrint(
'DEBUG: Image converted to data URL with MIME type: $mimeType',
);
return dataUrl;
} catch (e) {
debugPrint('DEBUG: Failed to convert image to data URL: $e');
foundation.debugPrint('DEBUG: Failed to convert image to data URL: $e');
return null;
}
}
// Upload file with progress tracking
Stream<FileUploadState> uploadFile(File file) async* {
debugPrint('DEBUG: Starting file upload for: ${file.path}');
foundation.debugPrint('DEBUG: Starting file upload for: ${file.path}');
try {
final fileName = path.basename(file.path);
final fileSize = await file.length();
debugPrint(
foundation.debugPrint(
'DEBUG: File details - Name: $fileName, Size: $fileSize bytes',
);
@@ -217,7 +218,7 @@ class FileAttachmentService {
].contains(ext.substring(1));
if (isImage) {
debugPrint(
foundation.debugPrint(
'DEBUG: Image file detected, converting to data URL instead of uploading',
);
@@ -237,10 +238,10 @@ class FileAttachmentService {
throw Exception('Failed to convert image to data URL');
}
} else {
debugPrint('DEBUG: Non-image file, uploading to server...');
foundation.debugPrint('DEBUG: Non-image file, uploading to server...');
// Upload file using the API service
final fileId = await _apiService.uploadFile(file.path, fileName);
debugPrint('DEBUG: File uploaded successfully with ID: $fileId');
foundation.debugPrint('DEBUG: File uploaded successfully with ID: $fileId');
yield FileUploadState(
file: file,
@@ -252,7 +253,7 @@ class FileAttachmentService {
);
}
} catch (e) {
debugPrint('DEBUG: File upload failed: $e');
foundation.debugPrint('DEBUG: File upload failed: $e');
final fileName = path.basename(file.path);
final fileSize = await file.length();

View File

@@ -157,8 +157,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Message failed to send. Please try again.'),
content: const Text('Message failed to send. Check your connection and try again.'),
backgroundColor: context.conduitTheme.error,
action: SnackBarAction(
label: 'Retry',
textColor: Colors.white,
onPressed: () => _handleMessageSend(text, selectedModel),
),
duration: const Duration(seconds: 6),
),
);
}
@@ -856,9 +862,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Remove the assistant message we want to regenerate
ref.read(chatMessagesProvider.notifier).removeLastMessage();
// Resend the previous user message to get a new response
// Regenerate response for the previous user message (without duplicating it)
final userMessage = messages[messageIndex - 1];
await sendMessage(ref, userMessage.content, null);
await regenerateMessage(ref, userMessage.content, userMessage.attachmentIds);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -872,8 +878,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to regenerate message: $e'),
content: Text('Failed to regenerate message. Try again or check your connection.'),
backgroundColor: context.conduitTheme.error,
action: SnackBarAction(
label: 'Retry',
textColor: Colors.white,
onPressed: () => _regenerateMessage(message),
),
duration: const Duration(seconds: 6),
),
);
}

View File

@@ -177,14 +177,25 @@ class _DocumentationMessageWidgetState
width: BorderWidth.regular,
),
),
child: Text(
widget.message.content,
style: TextStyle(
color: context.conduitTheme.chatBubbleUserText,
fontSize: AppTypography.bodyLarge,
height: 1.5,
letterSpacing: 0.1,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.message.content,
style: TextStyle(
color: context.conduitTheme.chatBubbleUserText,
fontSize: AppTypography.bodyMedium,
height: 1.5,
letterSpacing: 0.1,
),
),
// Action buttons for user messages
if (_showActions) ...[
const SizedBox(height: 12),
_buildUserActionButtons(),
],
],
),
),
),
@@ -391,13 +402,13 @@ class _DocumentationMessageWidgetState
if (match.start > lastIndex) {
final textSegment = content.substring(lastIndex, match.start);
widgets.add(
GptMarkdown(
textSegment,
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
height: 1.6,
letterSpacing: 0.1,
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)),
child: GptMarkdown(
textSegment,
style: AppTypography.chatMessageStyle.copyWith(
color: context.conduitTheme.textPrimary,
),
),
),
);
@@ -414,13 +425,13 @@ class _DocumentationMessageWidgetState
if (lastIndex < content.length) {
final tail = content.substring(lastIndex);
widgets.add(
GptMarkdown(
tail,
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.bodyLarge,
height: 1.6,
letterSpacing: 0.1,
MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.0)),
child: GptMarkdown(
tail,
style: AppTypography.chatMessageStyle.copyWith(
color: context.conduitTheme.textPrimary,
),
),
),
);
@@ -611,6 +622,11 @@ class _DocumentationMessageWidgetState
}
Widget _buildActionButtons() {
final isErrorMessage = widget.message.content.contains('⚠️') ||
widget.message.content.contains('Error') ||
widget.message.content.contains('timeout') ||
widget.message.content.contains('retry options');
return Wrap(
spacing: 8,
runSpacing: 8,
@@ -622,25 +638,33 @@ class _DocumentationMessageWidgetState
label: 'Copy',
onTap: widget.onCopy,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsup
: Icons.thumb_up_outlined,
label: 'Like',
onTap: widget.onLike,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsdown
: Icons.thumb_down_outlined,
label: 'Dislike',
onTap: widget.onDislike,
),
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
label: 'Regenerate',
onTap: widget.onRegenerate,
),
if (isErrorMessage) ...[
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.arrow_clockwise : Icons.refresh,
label: 'Retry',
onTap: widget.onRegenerate,
),
] else ...[
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsup
: Icons.thumb_up_outlined,
label: 'Like',
onTap: widget.onLike,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsdown
: Icons.thumb_down_outlined,
label: 'Dislike',
onTap: widget.onDislike,
),
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
label: 'Regenerate',
onTap: widget.onRegenerate,
),
],
],
);
}
@@ -685,4 +709,25 @@ class _DocumentationMessageWidgetState
),
);
}
Widget _buildUserActionButtons() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
label: 'Edit',
onTap: widget.onEdit,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.doc_on_clipboard
: Icons.content_copy,
label: 'Copy',
onTap: widget.onCopy,
),
],
);
}
}

View File

@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import '../../../shared/theme/theme_extensions.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'dart:io' show Platform;
import 'dart:async';

View File

@@ -129,26 +129,32 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
),
boxShadow: ConduitShadows.high,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Display images if any
if (widget.message.attachmentIds != null &&
widget.message.attachmentIds!.isNotEmpty)
_buildAttachmentImages(),
// Display text content if any
if (widget.message.content.isNotEmpty) ...[
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Display images if any
if (widget.message.attachmentIds != null &&
widget.message.attachmentIds!.isNotEmpty)
const SizedBox(height: Spacing.sm),
_buildCustomText(
widget.message.content,
context.conduitTheme.chatBubbleUserText,
),
_buildAttachmentImages(),
// Display text content if any
if (widget.message.content.isNotEmpty) ...[
if (widget.message.attachmentIds != null &&
widget.message.attachmentIds!.isNotEmpty)
const SizedBox(height: Spacing.sm),
_buildCustomText(
widget.message.content,
context.conduitTheme.chatBubbleUserText,
),
],
// Action buttons for user messages
if (_showActions) ...[
const SizedBox(height: Spacing.md),
_buildUserActionButtons(),
],
],
],
),
),
),
),
),
@@ -701,15 +707,15 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
}
Widget _buildActionButtons() {
final isErrorMessage = widget.message.content.contains('⚠️') ||
widget.message.content.contains('Error') ||
widget.message.content.contains('timeout') ||
widget.message.content.contains('retry options');
return Wrap(
spacing: Spacing.sm,
runSpacing: Spacing.sm,
children: [
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
label: 'Edit',
onTap: widget.onEdit,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.doc_on_clipboard
@@ -717,32 +723,45 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
label: 'Copy',
onTap: widget.onCopy,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.speaker_1
: Icons.volume_up_outlined,
label: 'Read',
onTap: () => _handleTextToSpeech(context),
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsup
: Icons.thumb_up_outlined,
label: 'Like',
onTap: widget.onLike,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsdown
: Icons.thumb_down_outlined,
label: 'Dislike',
onTap: widget.onDislike,
),
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
label: 'Regenerate',
onTap: widget.onRegenerate,
),
if (isErrorMessage) ...[
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.arrow_clockwise : Icons.refresh,
label: 'Retry',
onTap: widget.onRegenerate,
),
] else ...[
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
label: 'Edit',
onTap: widget.onEdit,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.speaker_1
: Icons.volume_up_outlined,
label: 'Read',
onTap: () => _handleTextToSpeech(context),
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsup
: Icons.thumb_up_outlined,
label: 'Like',
onTap: widget.onLike,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.hand_thumbsdown
: Icons.thumb_down_outlined,
label: 'Dislike',
onTap: widget.onDislike,
),
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
label: 'Regenerate',
onTap: widget.onRegenerate,
),
],
],
);
}
@@ -795,6 +814,34 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
);
}
Widget _buildUserActionButtons() {
return Wrap(
spacing: Spacing.sm,
runSpacing: Spacing.sm,
children: [
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
label: 'Edit',
onTap: widget.onEdit,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.doc_on_clipboard
: Icons.content_copy,
label: 'Copy',
onTap: widget.onCopy,
),
_buildActionButton(
icon: Platform.isIOS
? CupertinoIcons.speaker_1
: Icons.volume_up_outlined,
label: 'Read',
onTap: () => _handleTextToSpeech(context),
),
],
);
}
void _handleTextToSpeech(BuildContext context) {
// Implementation for text-to-speech functionality
ScaffoldMessenger.of(context).showSnackBar(