fix: image and files previews on the web

This commit is contained in:
cogwheel0
2025-09-22 23:17:23 +05:30
parent 66a28958ed
commit 7ab1ec3acf
5 changed files with 336 additions and 133 deletions

View File

@@ -788,24 +788,44 @@ class ApiService {
if (effectiveFiles != null) { if (effectiveFiles != null) {
final filesList = effectiveFiles as List; final filesList = effectiveFiles as List;
// Separate user uploads (with file_id) from generated images (with type and url) // Handle different file formats from OpenWebUI
final userAttachments = <String>[]; final userAttachments = <String>[];
final generatedFiles = <Map<String, dynamic>>[]; final allFiles = <Map<String, dynamic>>[];
for (final file in filesList) { for (final file in filesList) {
if (file is Map) { if (file is Map) {
if (file['file_id'] != null) { if (file['file_id'] != null) {
// User uploaded file // User uploaded file with file_id (legacy format)
userAttachments.add(file['file_id'] as String); userAttachments.add(file['file_id'] as String);
} else if (file['type'] == 'image' && file['url'] != null) { } else if (file['type'] != null && file['url'] != null) {
// Generated image // File with type and url (OpenWebUI format)
generatedFiles.add({'type': file['type'], 'url': file['url']}); final fileMap = <String, dynamic>{
'type': file['type'],
'url': file['url'],
};
// Add optional fields if present
if (file['name'] != null) fileMap['name'] = file['name'];
if (file['size'] != null) fileMap['size'] = file['size'];
allFiles.add(fileMap);
// If this is a user-uploaded file (URL contains file ID), also extract the ID
final url = file['url'] as String;
if (url.contains('/api/v1/files/') && url.contains('/content')) {
final fileIdMatch = RegExp(
r'/api/v1/files/([^/]+)/content',
).firstMatch(url);
if (fileIdMatch != null) {
userAttachments.add(fileIdMatch.group(1)!);
}
}
} }
} }
} }
attachmentIds = userAttachments.isNotEmpty ? userAttachments : null; attachmentIds = userAttachments.isNotEmpty ? userAttachments : null;
files = generatedFiles.isNotEmpty ? generatedFiles : null; files = allFiles.isNotEmpty ? allFiles : null;
} }
return ChatMessage( return ChatMessage(
@@ -1084,8 +1104,8 @@ class ApiService {
return _parseFullOpenWebUIChat(responseData); return _parseFullOpenWebUIChat(responseData);
} }
// Update conversation with full chat data including all messages // Sync conversation messages to ensure WebUI can load conversation history
Future<void> updateConversationWithMessages( Future<void> syncConversationMessages(
String conversationId, String conversationId,
List<ChatMessage> messages, { List<ChatMessage> messages, {
String? title, String? title,
@@ -1093,7 +1113,7 @@ class ApiService {
String? systemPrompt, String? systemPrompt,
}) async { }) async {
debugPrint( debugPrint(
'DEBUG: Updating conversation $conversationId with ${messages.length} messages', 'DEBUG: Syncing conversation $conversationId with ${messages.length} messages',
); );
// Build messages map and array in OpenWebUI format // Build messages map and array in OpenWebUI format
@@ -1105,21 +1125,11 @@ class ApiService {
for (final msg in messages) { for (final msg in messages) {
final messageId = msg.id; final messageId = msg.id;
// Build message for messages map (history.messages) // Use the properly formatted files array for WebUI display
final List<Map<String, dynamic>> combinedFilesMap = []; // The msg.files array already contains all attachments in the correct format
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) { final List<Map<String, dynamic>> combinedFilesMap = msg.files ?? [];
for (final id in msg.attachmentIds!) {
if (id.startsWith('data:') || id.startsWith('http')) {
combinedFilesMap.add({'type': 'image', 'url': id});
} else {
combinedFilesMap.add({'file_id': id});
}
}
}
if (msg.files != null && msg.files!.isNotEmpty) {
combinedFilesMap.addAll(msg.files!);
}
// Build message for messages map (history.messages)
messagesMap[messageId] = { messagesMap[messageId] = {
'id': messageId, 'id': messageId,
'parentId': previousId, 'parentId': previousId,
@@ -1141,21 +1151,10 @@ class ApiService {
(messagesMap[previousId]['childrenIds'] as List).add(messageId); (messagesMap[previousId]['childrenIds'] as List).add(messageId);
} }
// Build message for messages array // Use the same properly formatted files array for messages array
final List<Map<String, dynamic>> combinedFilesArray = []; final List<Map<String, dynamic>> combinedFilesArray = msg.files ?? [];
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
for (final id in msg.attachmentIds!) {
if (id.startsWith('data:') || id.startsWith('http')) {
combinedFilesArray.add({'type': 'image', 'url': id});
} else {
combinedFilesArray.add({'file_id': id});
}
}
}
if (msg.files != null && msg.files!.isNotEmpty) {
combinedFilesArray.addAll(msg.files!);
}
// Build message for messages array
messagesArray.add({ messagesArray.add({
'id': messageId, 'id': messageId,
'parentId': previousId, 'parentId': previousId,
@@ -1193,12 +1192,12 @@ class ApiService {
}, },
}; };
debugPrint('DEBUG: Updating chat with OpenWebUI format data using POST'); debugPrint('DEBUG: Syncing chat with OpenWebUI format data using POST');
// OpenWebUI uses POST not PUT for updating chats // OpenWebUI uses POST not PUT for updating chats
await _dio.post('/api/v1/chats/$conversationId', data: chatData); await _dio.post('/api/v1/chats/$conversationId', data: chatData);
DebugLogger.log('Update conversation response received successfully'); DebugLogger.log('Sync conversation response received successfully');
} }
Future<void> updateConversation( Future<void> updateConversation(

View File

@@ -525,9 +525,6 @@ Future<String> _preseedAssistantAndPersist(
required String modelId, required String modelId,
String? systemPrompt, String? systemPrompt,
}) async { }) async {
final api = ref.read(apiServiceProvider);
final activeConv = ref.read(activeConversationProvider);
// Choose id: reuse existing if provided, else create new // Choose id: reuse existing if provided, else create new
final String assistantMessageId = final String assistantMessageId =
(existingAssistantId != null && existingAssistantId.isNotEmpty) (existingAssistantId != null && existingAssistantId.isNotEmpty)
@@ -564,22 +561,26 @@ Future<String> _preseedAssistantAndPersist(
} catch (_) {} } catch (_) {}
} }
// Persist the skeleton to the server so the web client sees a correct chain // Sync conversation state to ensure WebUI can load conversation history
try { try {
final api = ref.read(apiServiceProvider);
final activeConv = ref.read(activeConversationProvider);
if (api != null && activeConv != null) { if (api != null && activeConv != null) {
final resolvedSystemPrompt = final resolvedSystemPrompt =
(systemPrompt != null && systemPrompt.trim().isNotEmpty) (systemPrompt != null && systemPrompt.trim().isNotEmpty)
? systemPrompt.trim() ? systemPrompt.trim()
: activeConv.systemPrompt; : activeConv.systemPrompt;
final current = ref.read(chatMessagesProvider); final current = ref.read(chatMessagesProvider);
await api.updateConversationWithMessages( await api.syncConversationMessages(
activeConv.id, activeConv.id,
current, current,
model: modelId, model: modelId,
systemPrompt: resolvedSystemPrompt, systemPrompt: resolvedSystemPrompt,
); );
} }
} catch (_) {} } catch (_) {
// Non-critical - continue if sync fails
}
return assistantMessageId; return assistantMessageId;
} }
@@ -708,6 +709,47 @@ bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) {
return (currentCount + newFilesCount) <= maxCount; return (currentCount + newFilesCount) <= maxCount;
} }
// Helper function to build files array from attachment IDs
Future<List<Map<String, dynamic>>?> _buildFilesArrayFromAttachments(
dynamic api,
List<String> attachmentIds,
) async {
final filesArray = <Map<String, dynamic>>[];
for (final attachmentId in attachmentIds) {
try {
final fileInfo = await api.getFileInfo(attachmentId);
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
final fileSize = fileInfo['size'];
// Check if it's an image
final ext = fileName.toLowerCase().split('.').last;
final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext);
// Add all files to the files array for WebUI display
// Note: This is for storage/display, not for API message sending
filesArray.add({
'type': isImage ? 'image' : 'file',
'id': attachmentId, // Required for RAG system to lookup file content
'url': '/api/v1/files/$attachmentId/content',
'name': fileName,
if (fileSize != null) 'size': fileSize,
});
} catch (_) {
// If we can't get file info, assume it's a non-image file
// Images should be handled in the content array anyway
filesArray.add({
'type': 'file',
'id': attachmentId, // Required for RAG system to lookup file content
'url': '/api/v1/files/$attachmentId/content',
'name': 'Unknown',
});
}
}
return filesArray.isNotEmpty ? filesArray : null;
}
// Helper function to get file content as base64 // Helper function to get file content as base64
Future<String?> _getFileAsBase64(dynamic api, String fileId) async { Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
// Check if this is already a data URL (for images) // Check if this is already a data URL (for images)
@@ -758,44 +800,57 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
required List<String> attachmentIds, required List<String> attachmentIds,
}) async { }) async {
final List<Map<String, dynamic>> contentArray = []; final List<Map<String, dynamic>> contentArray = [];
final List<Map<String, dynamic>> nonImageFiles = [];
if (cleanedText.isNotEmpty) { if (cleanedText.isNotEmpty) {
contentArray.add({'type': 'text', 'text': cleanedText}); contentArray.add({'type': 'text', 'text': cleanedText});
} }
// Collect all files in OpenWebUI format for the files array
final allFiles = <Map<String, dynamic>>[];
for (final attachmentId in attachmentIds) { for (final attachmentId in attachmentIds) {
try { try {
final fileInfo = await api.getFileInfo(attachmentId);
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
final fileSize = fileInfo['size'];
final base64Data = await _getFileAsBase64(api, attachmentId); final base64Data = await _getFileAsBase64(api, attachmentId);
if (base64Data != null) { if (base64Data != null) {
// This is an image file - add to content array only
if (base64Data.startsWith('data:')) { if (base64Data.startsWith('data:')) {
contentArray.add({ contentArray.add({
'type': 'image_url', 'type': 'image_url',
'image_url': {'url': base64Data}, 'image_url': {'url': base64Data},
}); });
} else { } else {
if (!attachmentId.startsWith('data:')) { final ext = fileName.toLowerCase().split('.').last;
final fileInfo = await api.getFileInfo(attachmentId); String mimeType = 'image/png';
final fileName = fileInfo['filename'] ?? ''; if (ext == 'jpg' || ext == 'jpeg') {
final ext = fileName.toLowerCase().split('.').last; mimeType = 'image/jpeg';
} else if (ext == 'gif') {
String mimeType = 'image/png'; mimeType = 'image/gif';
if (ext == 'jpg' || ext == 'jpeg') { } else if (ext == 'webp') {
mimeType = 'image/jpeg'; mimeType = 'image/webp';
} else if (ext == 'gif') {
mimeType = 'image/gif';
} else if (ext == 'webp') {
mimeType = 'image/webp';
}
contentArray.add({
'type': 'image_url',
'image_url': {'url': 'data:$mimeType;base64,$base64Data'},
});
} }
final dataUrl = 'data:$mimeType;base64,$base64Data';
contentArray.add({
'type': 'image_url',
'image_url': {'url': dataUrl},
});
} }
// Note: Images are handled in content array above, no need to duplicate in files array
// This prevents duplicate display in the WebUI
} else { } else {
nonImageFiles.add({'id': attachmentId, 'type': 'file'}); // This is a non-image file
allFiles.add({
'type': 'file',
'id': attachmentId, // Required for RAG system to lookup file content
'url': '/api/v1/files/$attachmentId/content',
'name': fileName,
if (fileSize != null) 'size': fileSize,
});
} }
} catch (_) { } catch (_) {
// Swallow and continue to keep regeneration robust // Swallow and continue to keep regeneration robust
@@ -806,8 +861,8 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
'role': role, 'role': role,
'content': contentArray.isNotEmpty ? contentArray : cleanedText, 'content': contentArray.isNotEmpty ? contentArray : cleanedText,
}; };
if (nonImageFiles.isNotEmpty) { if (allFiles.isNotEmpty) {
messageMap['files'] = nonImageFiles; messageMap['files'] = allFiles;
} }
return messageMap; return messageMap;
} }
@@ -1221,6 +1276,13 @@ Future<void> _sendMessageInternal(
var activeConversation = ref.read(activeConversationProvider); var activeConversation = ref.read(activeConversationProvider);
// Create user message first // Create user message first
List<Map<String, dynamic>>? userFiles;
if (attachments != null &&
attachments.isNotEmpty &&
!reviewerMode &&
api != null) {
userFiles = await _buildFilesArrayFromAttachments(api, attachments);
}
final userMessage = ChatMessage( final userMessage = ChatMessage(
id: const Uuid().v4(), id: const Uuid().v4(),
@@ -1229,6 +1291,7 @@ Future<void> _sendMessageInternal(
timestamp: DateTime.now(), timestamp: DateTime.now(),
model: selectedModel.id, model: selectedModel.id,
attachmentIds: attachments, attachmentIds: attachments,
files: userFiles,
); );
if (activeConversation == null) { if (activeConversation == null) {
@@ -1450,19 +1513,21 @@ Future<void> _sendMessageInternal(
); );
ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder); ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder);
// Persist skeleton chain to server so web can load correct history // Sync conversation state to ensure WebUI can load conversation history
try { try {
final activeConvForSeed = ref.read(activeConversationProvider); final activeConvForSeed = ref.read(activeConversationProvider);
if (activeConvForSeed != null) { if (activeConvForSeed != null) {
final msgsForSeed = ref.read(chatMessagesProvider); final msgsForSeed = ref.read(chatMessagesProvider);
await api.updateConversationWithMessages( await api.syncConversationMessages(
activeConvForSeed.id, activeConvForSeed.id,
msgsForSeed, msgsForSeed,
model: selectedModel.id, model: selectedModel.id,
systemPrompt: effectiveSystemPrompt, systemPrompt: effectiveSystemPrompt,
); );
} }
} catch (_) {} } catch (_) {
// Non-critical - continue if sync fails
}
// Use the model's actual supported parameters if available // Use the model's actual supported parameters if available
final supportedParams = final supportedParams =
selectedModel.supportedParameters ?? selectedModel.supportedParameters ??

View File

@@ -561,17 +561,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Display attachments (images use EnhancedImageAttachment; non-images use card) // Display attachments - prioritize files array over attachmentIds to avoid duplication
if (widget.message.attachmentIds != null &&
widget.message.attachmentIds!.isNotEmpty) ...[
_buildAttachmentItems(),
const SizedBox(height: Spacing.md),
],
// Display generated images from files property - OUTSIDE AnimatedSwitcher to prevent fade issues
if (widget.message.files != null && if (widget.message.files != null &&
widget.message.files!.isNotEmpty) ...[ widget.message.files!.isNotEmpty) ...[
_buildGeneratedImages(), _buildFilesFromArray(),
const SizedBox(height: Spacing.md),
] else if (widget.message.attachmentIds != null &&
widget.message.attachmentIds!.isNotEmpty) ...[
_buildAttachmentItems(),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.md),
], ],
@@ -767,30 +764,57 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
} }
Widget _buildGeneratedImages() { Widget _buildFilesFromArray() {
if (widget.message.files == null || widget.message.files!.isEmpty) { if (widget.message.files == null || widget.message.files!.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Filter for image files final allFiles = widget.message.files!;
final imageFiles = widget.message.files!
// Separate images and non-image files
final imageFiles = allFiles
.where((file) => file['type'] == 'image') .where((file) => file['type'] == 'image')
.toList(); .toList();
final nonImageFiles = allFiles
.where((file) => file['type'] != 'image')
.toList();
if (imageFiles.isEmpty) { final widgets = <Widget>[];
// Add images first
if (imageFiles.isNotEmpty) {
widgets.add(_buildImagesFromFiles(imageFiles));
}
// Add non-image files
if (nonImageFiles.isNotEmpty) {
if (widgets.isNotEmpty) {
widgets.add(const SizedBox(height: Spacing.sm));
}
widgets.add(_buildNonImageFiles(nonImageFiles));
}
if (widgets.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
);
}
Widget _buildImagesFromFiles(List<dynamic> imageFiles) {
final imageCount = imageFiles.length; final imageCount = imageFiles.length;
// Display generated images using EnhancedImageAttachment for consistency // Display images using EnhancedImageAttachment for consistency
// Use AnimatedSwitcher for smooth transitions // Use AnimatedSwitcher for smooth transitions
return AnimatedSwitcher( return AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut, switchInCurve: Curves.easeInOut,
child: imageCount == 1 child: imageCount == 1
? Container( ? Container(
key: ValueKey('gen_single_${imageFiles[0]['url']}'), key: ValueKey('file_single_${imageFiles[0]['url']}'),
child: Builder( child: Builder(
builder: (context) { builder: (context) {
final imageUrl = imageFiles[0]['url'] as String?; final imageUrl = imageFiles[0]['url'] as String?;
@@ -812,7 +836,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
) )
: Wrap( : Wrap(
key: ValueKey( key: ValueKey(
'gen_multi_${imageFiles.map((f) => f['url']).join('_')}', 'file_multi_${imageFiles.map((f) => f['url']).join('_')}',
), ),
spacing: Spacing.sm, spacing: Spacing.sm,
runSpacing: Spacing.sm, runSpacing: Spacing.sm,
@@ -836,6 +860,38 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
} }
Widget _buildNonImageFiles(List<dynamic> nonImageFiles) {
return Wrap(
spacing: Spacing.sm,
runSpacing: Spacing.sm,
children: nonImageFiles.map<Widget>((file) {
final fileUrl = file['url'] as String?;
if (fileUrl == null) return const SizedBox.shrink();
// Extract file ID from URL if it's in the format /api/v1/files/{id}/content
String attachmentId = fileUrl;
if (fileUrl.contains('/api/v1/files/') &&
fileUrl.contains('/content')) {
final fileIdMatch = RegExp(
r'/api/v1/files/([^/]+)/content',
).firstMatch(fileUrl);
if (fileIdMatch != null) {
attachmentId = fileIdMatch.group(1)!;
}
}
return EnhancedAttachment(
key: ValueKey('file_attachment_$attachmentId'),
attachmentId: attachmentId,
isMarkdownFormat: true,
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 100),
disableAnimation: widget.isStreaming,
);
}).toList(),
);
}
Widget _buildTypingIndicator() { Widget _buildTypingIndicator() {
return Consumer( return Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {

View File

@@ -64,7 +64,9 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
duration: AnimationDuration.messageSlide, duration: AnimationDuration.messageSlide,
vsync: this, vsync: this,
); );
_editController = TextEditingController(text: widget.message?.content ?? ''); _editController = TextEditingController(
text: widget.message?.content ?? '',
);
} }
Widget _buildUserAttachmentImages() { Widget _buildUserAttachmentImages() {
@@ -88,23 +90,50 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final imageFiles = widget.message.files! final allFiles = widget.message.files!;
// Separate images and non-image files
final imageFiles = allFiles
.where( .where(
(file) => (file) =>
file is Map && file['type'] == 'image' && file['url'] != null, file is Map && file['type'] == 'image' && file['url'] != null,
) )
.toList(); .toList();
final nonImageFiles = allFiles
.where(
(file) =>
file is Map && file['type'] != 'image' && file['url'] != null,
)
.toList();
if (imageFiles.isEmpty) { final widgets = <Widget>[];
// Add images first
if (imageFiles.isNotEmpty) {
widgets.add(
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
child: _buildFileImageLayout(imageFiles, imageFiles.length),
),
);
}
// Add non-image files
if (nonImageFiles.isNotEmpty) {
if (widgets.isNotEmpty) {
widgets.add(const SizedBox(height: Spacing.xs));
}
widgets.add(_buildUserNonImageFiles(nonImageFiles));
}
if (widgets.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final imageCount = imageFiles.length; return Column(
crossAxisAlignment: CrossAxisAlignment.end,
return AnimatedSwitcher( children: widgets,
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
child: _buildFileImageLayout(imageFiles, imageCount),
); );
} }
@@ -394,6 +423,47 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
} }
} }
Widget _buildUserNonImageFiles(List<dynamic> nonImageFiles) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Wrap(
alignment: WrapAlignment.end,
spacing: Spacing.xs,
runSpacing: Spacing.xs,
children: nonImageFiles.map<Widget>((file) {
final fileUrl = file['url'] as String?;
if (fileUrl == null) return const SizedBox.shrink();
// Extract file ID from URL if it's in the format /api/v1/files/{id}/content
String attachmentId = fileUrl;
if (fileUrl.contains('/api/v1/files/') &&
fileUrl.contains('/content')) {
final fileIdMatch = RegExp(
r'/api/v1/files/([^/]+)/content',
).firstMatch(fileUrl);
if (fileIdMatch != null) {
attachmentId = fileIdMatch.group(1)!;
}
}
return EnhancedAttachment(
key: ValueKey('user_file_attachment_$attachmentId'),
attachmentId: attachmentId,
isMarkdownFormat: false,
isUserMessage: true,
constraints: const BoxConstraints(maxWidth: 280, maxHeight: 80),
disableAnimation: widget.isStreaming,
);
}).toList(),
),
),
],
);
}
// Assistant-only helpers removed; this widget renders only user bubbles. // Assistant-only helpers removed; this widget renders only user bubbles.
@override @override
@@ -429,14 +499,14 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
widget.message.attachmentIds != null && widget.message.attachmentIds != null &&
widget.message.attachmentIds!.isNotEmpty; widget.message.attachmentIds!.isNotEmpty;
final hasText = widget.message.content.isNotEmpty; final hasText = widget.message.content.isNotEmpty;
final hasGeneratedImages = final hasFilesFromArray =
widget.message.files != null && widget.message.files != null &&
(widget.message.files as List).any( (widget.message.files as List).any((f) => f is Map && f['url'] != null);
(f) => f is Map && f['type'] == 'image' && f['url'] != null,
);
// Prefer input/textPrimary colors during inline editing to avoid low contrast // Prefer input/textPrimary colors during inline editing to avoid low contrast
final inlineEditTextColor = context.conduitTheme.textPrimary; final inlineEditTextColor = context.conduitTheme.textPrimary;
final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(alpha: 0.92); final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(
alpha: 0.92,
);
return GestureDetector( return GestureDetector(
onLongPress: () => _toggleActions(), onLongPress: () => _toggleActions(),
@@ -452,8 +522,12 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
// Display images outside and above the text bubble (iMessage style) // Display images outside and above the text bubble (iMessage style)
if (hasImages) ...[_buildUserAttachmentImages()], // Prioritize files array over attachmentIds to avoid duplication
if (hasGeneratedImages) ...[_buildUserFileImages()], if (hasFilesFromArray) ...[
_buildUserFileImages(),
] else if (hasImages) ...[
_buildUserAttachmentImages(),
],
// Display text bubble if there's text content // Display text bubble if there's text content
if (hasText) const SizedBox(height: Spacing.xs), if (hasText) const SizedBox(height: Spacing.xs),
@@ -504,9 +578,14 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: inlineEditFill, color: inlineEditFill,
borderRadius: BorderRadius.circular(AppBorderRadius.sm), borderRadius: BorderRadius.circular(
AppBorderRadius.sm,
),
border: Border.all( border: Border.all(
color: context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6), color: context
.conduitTheme
.inputBorderFocused
.withValues(alpha: 0.6),
width: BorderWidth.thin, width: BorderWidth.thin,
), ),
), ),
@@ -520,38 +599,41 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
controller: _editController, controller: _editController,
maxLines: null, maxLines: null,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
autofillHints: autofillHints: const <String>[],
const <String>[],
style: AppTypography style: AppTypography
.chatMessageStyle .chatMessageStyle
.copyWith( .copyWith(
color: inlineEditTextColor, color:
), inlineEditTextColor,
decoration: const BoxDecoration(), ),
decoration:
const BoxDecoration(),
cursorColor: context cursorColor: context
.conduitTheme.buttonPrimary, .conduitTheme
.buttonPrimary,
onSubmitted: (_) => onSubmitted: (_) =>
_saveInlineEdit(), _saveInlineEdit(),
) )
: TextField( : TextField(
controller: _editController, controller: _editController,
maxLines: null, maxLines: null,
autofillHints: autofillHints: const <String>[],
const <String>[],
style: AppTypography style: AppTypography
.chatMessageStyle .chatMessageStyle
.copyWith( .copyWith(
color: inlineEditTextColor, color:
), inlineEditTextColor,
),
decoration: decoration:
const InputDecoration( const InputDecoration(
isCollapsed: true, isCollapsed: true,
border: InputBorder.none, border: InputBorder.none,
contentPadding: contentPadding:
EdgeInsets.zero, EdgeInsets.zero,
), ),
cursorColor: context cursorColor: context
.conduitTheme.buttonPrimary, .conduitTheme
.buttonPrimary,
onSubmitted: (_) => onSubmitted: (_) =>
_saveInlineEdit(), _saveInlineEdit(),
), ),
@@ -562,18 +644,19 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
widget.message.content, widget.message.content,
style: AppTypography.chatMessageStyle style: AppTypography.chatMessageStyle
.copyWith( .copyWith(
color: context color: context
.conduitTheme.chatBubbleUserText, .conduitTheme
), .chatBubbleUserText,
),
softWrap: true, softWrap: true,
textAlign: TextAlign.left, textAlign: TextAlign.left,
textHeightBehavior: textHeightBehavior:
const TextHeightBehavior( const TextHeightBehavior(
applyHeightToFirstAscent: false, applyHeightToFirstAscent: false,
applyHeightToLastDescent: false, applyHeightToLastDescent: false,
leadingDistribution: leadingDistribution:
TextLeadingDistribution.even, TextLeadingDistribution.even,
), ),
), ),
), ),
), ),
@@ -633,8 +716,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
), ),
] else ...[ ] else ...[
_buildActionButton( _buildActionButton(
icon: icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
label: AppLocalizations.of(context)!.edit, label: AppLocalizations.of(context)!.edit,
onTap: widget.onEdit ?? _startInlineEdit, onTap: widget.onEdit ?? _startInlineEdit,
), ),
@@ -693,7 +775,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
// Enqueue edited text as a new message // Enqueue edited text as a new message
final activeConv = ref.read(activeConversationProvider); final activeConv = ref.read(activeConversationProvider);
final List<String>? attachments = (widget.message.attachmentIds != null && final List<String>? attachments =
(widget.message.attachmentIds != null &&
(widget.message.attachmentIds as List).isNotEmpty) (widget.message.attachmentIds as List).isNotEmpty)
? List<String>.from(widget.message.attachmentIds as List) ? List<String>.from(widget.message.attachmentIds as List)
: null; : null;

Submodule openwebui-src updated: 2407d9b905...6bc5d331a2