feat: background streaming of responses
This commit is contained in:
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user