feat(image): Improve image attachment loading and error handling
This commit is contained in:
@@ -280,6 +280,9 @@ Map<String, dynamic>? _parseSiblingAsVersion(
|
|||||||
};
|
};
|
||||||
if (entry['name'] != null) fileMap['name'] = entry['name'];
|
if (entry['name'] != null) fileMap['name'] = entry['name'];
|
||||||
if (entry['size'] != null) fileMap['size'] = entry['size'];
|
if (entry['size'] != null) fileMap['size'] = entry['size'];
|
||||||
|
if (entry['content_type'] != null) {
|
||||||
|
fileMap['content_type'] = entry['content_type'];
|
||||||
|
}
|
||||||
allFiles.add(fileMap);
|
allFiles.add(fileMap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,6 +451,9 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
|||||||
};
|
};
|
||||||
if (entry['name'] != null) fileMap['name'] = entry['name'];
|
if (entry['name'] != null) fileMap['name'] = entry['name'];
|
||||||
if (entry['size'] != null) fileMap['size'] = entry['size'];
|
if (entry['size'] != null) fileMap['size'] = entry['size'];
|
||||||
|
if (entry['content_type'] != null) {
|
||||||
|
fileMap['content_type'] = entry['content_type'];
|
||||||
|
}
|
||||||
final headers = _coerceStringMap(entry['headers']);
|
final headers = _coerceStringMap(entry['headers']);
|
||||||
if (headers != null && headers.isNotEmpty) {
|
if (headers != null && headers.isNotEmpty) {
|
||||||
fileMap['headers'] = headers;
|
fileMap['headers'] = headers;
|
||||||
@@ -455,12 +461,22 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
|||||||
allFiles.add(fileMap);
|
allFiles.add(fileMap);
|
||||||
|
|
||||||
final url = entry['url'].toString();
|
final url = entry['url'].toString();
|
||||||
// Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content
|
// Handle all URL formats:
|
||||||
|
// 1. /api/v1/files/{id} and /api/v1/files/{id}/content (old format)
|
||||||
|
// 2. Just a file ID like "abc-123-def" (new OpenWebUI format)
|
||||||
final match = RegExp(
|
final match = RegExp(
|
||||||
r'/api/v1/files/([^/]+)(?:/content)?$',
|
r'/api/v1/files/([^/]+)(?:/content)?$',
|
||||||
).firstMatch(url);
|
).firstMatch(url);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
attachments.add(match.group(1)!);
|
attachments.add(match.group(1)!);
|
||||||
|
} else if (!url.startsWith('data:') &&
|
||||||
|
!url.startsWith('http') &&
|
||||||
|
!url.startsWith('/')) {
|
||||||
|
// New format: URL is just a bare file ID (UUID-like)
|
||||||
|
// Validate it looks like a reasonable ID (not an empty string)
|
||||||
|
if (url.isNotEmpty) {
|
||||||
|
attachments.add(url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1092,7 +1092,8 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
allFiles.add({
|
allFiles.add({
|
||||||
'type': 'file',
|
'type': 'file',
|
||||||
'id': attachmentId,
|
'id': attachmentId,
|
||||||
'url': '/api/v1/files/$attachmentId',
|
// OpenWebUI now stores just the file ID, not the full URL path
|
||||||
|
'url': attachmentId,
|
||||||
'name': fileName,
|
'name': fileName,
|
||||||
if (fileSize != null) 'size': fileSize,
|
if (fileSize != null) 'size': fileSize,
|
||||||
});
|
});
|
||||||
@@ -1801,7 +1802,9 @@ Future<void> _sendMessageInternal(
|
|||||||
'type': isImage ? 'image' : 'file',
|
'type': isImage ? 'image' : 'file',
|
||||||
'id': fileId,
|
'id': fileId,
|
||||||
'name': fileName,
|
'name': fileName,
|
||||||
'url': '/api/v1/files/$fileId', // Full URL for conversation parsing compatibility
|
// OpenWebUI now stores just the file ID, not the full URL path
|
||||||
|
// The frontend resolves it when displaying
|
||||||
|
'url': fileId,
|
||||||
if (fileSize != null) 'size': fileSize,
|
if (fileSize != null) 'size': fileSize,
|
||||||
if (collectionName != null) 'collection_name': collectionName,
|
if (collectionName != null) 'collection_name': collectionName,
|
||||||
if (contentType.isNotEmpty) 'content_type': contentType,
|
if (contentType.isNotEmpty) 'content_type': contentType,
|
||||||
@@ -1811,7 +1814,7 @@ Future<void> _sendMessageInternal(
|
|||||||
'type': 'file',
|
'type': 'file',
|
||||||
'id': fileId,
|
'id': fileId,
|
||||||
'name': 'file',
|
'name': 'file',
|
||||||
'url': '/api/v1/files/$fileId',
|
'url': fileId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
22
lib/features/chat/utils/file_utils.dart
Normal file
22
lib/features/chat/utils/file_utils.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Utility functions for handling file data in chat messages.
|
||||||
|
// Used by both user and assistant message widgets.
|
||||||
|
|
||||||
|
/// Checks if a file map represents an image.
|
||||||
|
/// Matches OpenWebUI behavior: type === 'image' OR content_type starts with 'image/'
|
||||||
|
bool isImageFile(dynamic file) {
|
||||||
|
if (file is! Map) return false;
|
||||||
|
if (file['type'] == 'image') return true;
|
||||||
|
final contentType = file['content_type']?.toString() ?? '';
|
||||||
|
return contentType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the file URL or ID from a file map.
|
||||||
|
/// OpenWebUI stores either a full URL, data URL, or just the file ID.
|
||||||
|
///
|
||||||
|
/// Returns the URL/ID string, or null if the file has no valid URL.
|
||||||
|
String? getFileUrl(dynamic file) {
|
||||||
|
if (file is! Map) return null;
|
||||||
|
final url = file['url'];
|
||||||
|
if (url == null) return null;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import 'sources/openwebui_sources.dart';
|
|||||||
import '../providers/assistant_response_builder_provider.dart';
|
import '../providers/assistant_response_builder_provider.dart';
|
||||||
import '../../../core/services/worker_manager.dart';
|
import '../../../core/services/worker_manager.dart';
|
||||||
import 'streaming_status_widget.dart';
|
import 'streaming_status_widget.dart';
|
||||||
|
import '../utils/file_utils.dart';
|
||||||
|
|
||||||
// Pre-compiled regex patterns for image processing (performance optimization)
|
// Pre-compiled regex patterns for image processing (performance optimization)
|
||||||
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
|
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
|
||||||
@@ -1119,12 +1120,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
final allFiles = filesArray;
|
final allFiles = filesArray;
|
||||||
|
|
||||||
// Separate images and non-image files
|
// Separate images and non-image files
|
||||||
final imageFiles = allFiles
|
// Match OpenWebUI: type === 'image' OR content_type starts with 'image/'
|
||||||
.where((file) => file['type'] == 'image')
|
final imageFiles = allFiles.where(isImageFile).toList();
|
||||||
.toList();
|
final nonImageFiles = allFiles.where((file) => !isImageFile(file)).toList();
|
||||||
final nonImageFiles = allFiles
|
|
||||||
.where((file) => file['type'] != 'image')
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
final widgets = <Widget>[];
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
@@ -1164,7 +1162,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
key: ValueKey('file_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 = getFileUrl(imageFiles[0]);
|
||||||
if (imageUrl == null) return const SizedBox.shrink();
|
if (imageUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
return EnhancedImageAttachment(
|
return EnhancedImageAttachment(
|
||||||
@@ -1189,7 +1187,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
spacing: Spacing.sm,
|
spacing: Spacing.sm,
|
||||||
runSpacing: Spacing.sm,
|
runSpacing: Spacing.sm,
|
||||||
children: imageFiles.map<Widget>((file) {
|
children: imageFiles.map<Widget>((file) {
|
||||||
final imageUrl = file['url'] as String?;
|
final imageUrl = getFileUrl(file);
|
||||||
if (imageUrl == null) return const SizedBox.shrink();
|
if (imageUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
return EnhancedImageAttachment(
|
return EnhancedImageAttachment(
|
||||||
@@ -1232,12 +1230,13 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
spacing: Spacing.sm,
|
spacing: Spacing.sm,
|
||||||
runSpacing: Spacing.sm,
|
runSpacing: Spacing.sm,
|
||||||
children: nonImageFiles.map<Widget>((file) {
|
children: nonImageFiles.map<Widget>((file) {
|
||||||
final fileUrl = file['url'] as String?;
|
final fileUrl = getFileUrl(file);
|
||||||
|
|
||||||
if (fileUrl == null) return const SizedBox.shrink();
|
if (fileUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
// Extract file ID from URL - handle both formats:
|
// Extract file ID from URL - handle formats:
|
||||||
// /api/v1/files/{id} and /api/v1/files/{id}/content
|
// - Bare file ID (new OpenWebUI format): "abc-123-def"
|
||||||
|
// - /api/v1/files/{id} (legacy format)
|
||||||
|
// - /api/v1/files/{id}/content (legacy format)
|
||||||
String attachmentId = fileUrl;
|
String attachmentId = fileUrl;
|
||||||
if (fileUrl.contains('/api/v1/files/')) {
|
if (fileUrl.contains('/api/v1/files/')) {
|
||||||
final fileIdMatch = _fileIdPattern.firstMatch(fileUrl);
|
final fileIdMatch = _fileIdPattern.firstMatch(fileUrl);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ final _base64WhitespacePattern = RegExp(r'\s');
|
|||||||
/// Call this with the server file ID and image bytes after successful upload.
|
/// Call this with the server file ID and image bytes after successful upload.
|
||||||
void preCacheImageBytes(String fileId, Uint8List bytes) {
|
void preCacheImageBytes(String fileId, Uint8List bytes) {
|
||||||
if (fileId.isEmpty || bytes.isEmpty) return;
|
if (fileId.isEmpty || bytes.isEmpty) return;
|
||||||
|
// Clear any previous error state for this file
|
||||||
|
_globalErrorStates.remove(fileId);
|
||||||
_globalImageBytesCache[fileId] = bytes;
|
_globalImageBytesCache[fileId] = bytes;
|
||||||
_globalLoadingStates[fileId] = false;
|
_globalLoadingStates[fileId] = false;
|
||||||
// Detect SVG
|
// Detect SVG
|
||||||
@@ -132,8 +134,8 @@ class _EnhancedImageAttachmentState
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _isDecoding = false;
|
bool _isDecoding = false;
|
||||||
bool _isSvg = false;
|
bool _isSvg = false;
|
||||||
late final String _heroTag;
|
late String _heroTag;
|
||||||
// Removed unused animation and state flags
|
bool _hasAttemptedLoad = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
@@ -150,12 +152,40 @@ class _EnhancedImageAttachmentState
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant EnhancedImageAttachment oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// If the attachment ID changed, reload the image
|
||||||
|
if (oldWidget.attachmentId != widget.attachmentId) {
|
||||||
|
_heroTag = 'image_${widget.attachmentId}_${identityHashCode(this)}';
|
||||||
|
// Reset local state with setState for immediate visual feedback
|
||||||
|
setState(() {
|
||||||
|
_cachedImageData = null;
|
||||||
|
_cachedBytes = null;
|
||||||
|
_hasAttemptedLoad = false;
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
_isDecoding = false;
|
||||||
|
_isSvg = false;
|
||||||
|
});
|
||||||
|
// Load the new image
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadImage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadImage() async {
|
Future<void> _loadImage() async {
|
||||||
|
// Prevent duplicate loads
|
||||||
|
if (_hasAttemptedLoad) return;
|
||||||
|
_hasAttemptedLoad = true;
|
||||||
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
// Check bytes cache first (populated during upload for instant display)
|
// Check bytes cache first (populated during upload for instant display)
|
||||||
@@ -172,6 +202,8 @@ class _EnhancedImageAttachmentState
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for cached errors - if image previously failed, show error immediately
|
||||||
|
// Note: preCacheImageBytes() clears errors when upload completes successfully
|
||||||
final cachedError = _globalErrorStates[widget.attachmentId];
|
final cachedError = _globalErrorStates[widget.attachmentId];
|
||||||
if (cachedError != null) {
|
if (cachedError != null) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -407,8 +439,16 @@ class _EnhancedImageAttachmentState
|
|||||||
return _buildErrorState();
|
return _buildErrorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_cachedImageData == null) {
|
if (_cachedImageData == null && _cachedBytes == null) {
|
||||||
return const SizedBox.shrink();
|
// No data available - this shouldn't happen in normal flow since
|
||||||
|
// _loadImage always sets either data, bytes, or error before completing.
|
||||||
|
// Show error state rather than attempting reload from build().
|
||||||
|
return _buildErrorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have bytes but no cached data string, use bytes directly
|
||||||
|
if (_cachedImageData == null && _cachedBytes != null) {
|
||||||
|
return _isSvg ? _buildBase64Svg() : _buildBase64Image();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle different image data formats
|
// Handle different image data formats
|
||||||
@@ -692,11 +732,15 @@ class _EnhancedImageAttachmentState
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showFullScreenImage(BuildContext context) {
|
void _showFullScreenImage(BuildContext context) {
|
||||||
|
// Handle both data URL string and raw bytes cases
|
||||||
|
if (_cachedImageData == null && _cachedBytes == null) return;
|
||||||
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
builder: (context) => FullScreenImageViewer(
|
builder: (context) => FullScreenImageViewer(
|
||||||
imageData: _cachedImageData!,
|
imageData: _cachedImageData,
|
||||||
|
imageBytes: _cachedBytes,
|
||||||
tag: _heroTag,
|
tag: _heroTag,
|
||||||
isSvg: _isSvg,
|
isSvg: _isSvg,
|
||||||
customHeaders: widget.httpHeaders,
|
customHeaders: widget.httpHeaders,
|
||||||
@@ -707,31 +751,56 @@ class _EnhancedImageAttachmentState
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FullScreenImageViewer extends ConsumerWidget {
|
class FullScreenImageViewer extends ConsumerWidget {
|
||||||
final String imageData;
|
/// Image data as a URL (http://) or data URL (data:image/...) or base64 string.
|
||||||
|
/// Either this or [imageBytes] must be provided.
|
||||||
|
final String? imageData;
|
||||||
|
|
||||||
|
/// Raw image bytes. Used when [imageData] is null.
|
||||||
|
final Uint8List? imageBytes;
|
||||||
|
|
||||||
final String tag;
|
final String tag;
|
||||||
final bool isSvg;
|
final bool isSvg;
|
||||||
final Map<String, String>? customHeaders;
|
final Map<String, String>? customHeaders;
|
||||||
|
|
||||||
const FullScreenImageViewer({
|
const FullScreenImageViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageData,
|
this.imageData,
|
||||||
|
this.imageBytes,
|
||||||
required this.tag,
|
required this.tag,
|
||||||
this.isSvg = false,
|
this.isSvg = false,
|
||||||
this.customHeaders,
|
this.customHeaders,
|
||||||
});
|
}) : assert(imageData != null || imageBytes != null,
|
||||||
|
'Either imageData or imageBytes must be provided');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
Widget imageWidget;
|
Widget imageWidget;
|
||||||
|
|
||||||
if (imageData.startsWith('http')) {
|
// If we have raw bytes, use them directly
|
||||||
|
if (imageData == null && imageBytes != null) {
|
||||||
|
if (isSvg || _isSvgBytes(imageBytes!)) {
|
||||||
|
imageWidget = SvgPicture.memory(
|
||||||
|
imageBytes!,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) => Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
imageWidget = Image.memory(imageBytes!, fit: BoxFit.contain);
|
||||||
|
}
|
||||||
|
} else if (imageData != null && imageData!.startsWith('http')) {
|
||||||
// Get authentication headers if available
|
// Get authentication headers if available
|
||||||
final defaultHeaders = buildImageHeadersFromWidgetRef(ref);
|
final defaultHeaders = buildImageHeadersFromWidgetRef(ref);
|
||||||
final headers = _mergeHeaders(defaultHeaders, customHeaders);
|
final headers = _mergeHeaders(defaultHeaders, customHeaders);
|
||||||
|
|
||||||
if (isSvg || _isSvgUrl(imageData)) {
|
if (isSvg || _isSvgUrl(imageData!)) {
|
||||||
imageWidget = SvgPicture.network(
|
imageWidget = SvgPicture.network(
|
||||||
imageData,
|
imageData!,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
placeholderBuilder: (context) => Center(
|
placeholderBuilder: (context) => Center(
|
||||||
@@ -750,7 +819,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
final cacheManager = ref.watch(selfSignedImageCacheManagerProvider);
|
||||||
imageWidget = CachedNetworkImage(
|
imageWidget = CachedNetworkImage(
|
||||||
imageUrl: imageData,
|
imageUrl: imageData!,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
cacheManager: cacheManager,
|
cacheManager: cacheManager,
|
||||||
httpHeaders: headers,
|
httpHeaders: headers,
|
||||||
@@ -768,24 +837,24 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (imageData != null) {
|
||||||
try {
|
try {
|
||||||
String actualBase64;
|
String actualBase64;
|
||||||
if (imageData.startsWith('data:')) {
|
if (imageData!.startsWith('data:')) {
|
||||||
final commaIndex = imageData.indexOf(',');
|
final commaIndex = imageData!.indexOf(',');
|
||||||
if (commaIndex == -1) {
|
if (commaIndex == -1) {
|
||||||
throw const FormatException('Invalid data URI');
|
throw const FormatException('Invalid data URI');
|
||||||
}
|
}
|
||||||
actualBase64 = imageData.substring(commaIndex + 1);
|
actualBase64 = imageData!.substring(commaIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
actualBase64 = imageData;
|
actualBase64 = imageData!;
|
||||||
}
|
}
|
||||||
final imageBytes = base64.decode(actualBase64);
|
final decodedBytes = base64.decode(actualBase64);
|
||||||
|
|
||||||
// Check if SVG content
|
// Check if SVG content
|
||||||
if (isSvg || _isSvgDataUrl(imageData) || _isSvgBytes(imageBytes)) {
|
if (isSvg || _isSvgDataUrl(imageData!) || _isSvgBytes(decodedBytes)) {
|
||||||
imageWidget = SvgPicture.memory(
|
imageWidget = SvgPicture.memory(
|
||||||
imageBytes,
|
decodedBytes,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (context, error, stackTrace) => Center(
|
errorBuilder: (context, error, stackTrace) => Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -796,7 +865,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
imageWidget = Image.memory(imageBytes, fit: BoxFit.contain);
|
imageWidget = Image.memory(decodedBytes, fit: BoxFit.contain);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
imageWidget = Center(
|
imageWidget = Center(
|
||||||
@@ -807,6 +876,15 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No image data available - show error
|
||||||
|
imageWidget = Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final tokens = context.colorTokens;
|
final tokens = context.colorTokens;
|
||||||
@@ -860,7 +938,11 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
Uint8List bytes;
|
Uint8List bytes;
|
||||||
String? fileExtension;
|
String? fileExtension;
|
||||||
|
|
||||||
if (imageData.startsWith('http')) {
|
// If we have raw bytes, use them directly
|
||||||
|
if (imageData == null && imageBytes != null) {
|
||||||
|
bytes = imageBytes!;
|
||||||
|
fileExtension = isSvg ? 'svg' : 'png';
|
||||||
|
} else if (imageData!.startsWith('http')) {
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
final authToken = ref.read(authTokenProvider3);
|
final authToken = ref.read(authTokenProvider3);
|
||||||
final headers = <String, String>{};
|
final headers = <String, String>{};
|
||||||
@@ -878,7 +960,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
|
|
||||||
final client = api?.dio ?? dio.Dio();
|
final client = api?.dio ?? dio.Dio();
|
||||||
final response = await client.get<List<int>>(
|
final response = await client.get<List<int>>(
|
||||||
imageData,
|
imageData!,
|
||||||
options: dio.Options(
|
options: dio.Options(
|
||||||
responseType: dio.ResponseType.bytes,
|
responseType: dio.ResponseType.bytes,
|
||||||
headers: mergedHeaders,
|
headers: mergedHeaders,
|
||||||
@@ -895,7 +977,7 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
fileExtension = contentType.split('/').last;
|
fileExtension = contentType.split('/').last;
|
||||||
if (fileExtension == 'jpeg') fileExtension = 'jpg';
|
if (fileExtension == 'jpeg') fileExtension = 'jpg';
|
||||||
} else {
|
} else {
|
||||||
final uri = Uri.tryParse(imageData);
|
final uri = Uri.tryParse(imageData!);
|
||||||
final lastSegment = uri?.pathSegments.isNotEmpty == true
|
final lastSegment = uri?.pathSegments.isNotEmpty == true
|
||||||
? uri!.pathSegments.last
|
? uri!.pathSegments.last
|
||||||
: '';
|
: '';
|
||||||
@@ -907,20 +989,23 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (imageData != null) {
|
||||||
String actualBase64 = imageData;
|
String actualBase64 = imageData!;
|
||||||
if (imageData.startsWith('data:')) {
|
if (imageData!.startsWith('data:')) {
|
||||||
final commaIndex = imageData.indexOf(',');
|
final commaIndex = imageData!.indexOf(',');
|
||||||
final meta = imageData.substring(5, commaIndex); // image/png;base64
|
final meta = imageData!.substring(5, commaIndex); // image/png;base64
|
||||||
final slashIdx = meta.indexOf('/');
|
final slashIdx = meta.indexOf('/');
|
||||||
final semicolonIdx = meta.indexOf(';');
|
final semicolonIdx = meta.indexOf(';');
|
||||||
if (slashIdx != -1 && semicolonIdx != -1 && slashIdx < semicolonIdx) {
|
if (slashIdx != -1 && semicolonIdx != -1 && slashIdx < semicolonIdx) {
|
||||||
final subtype = meta.substring(slashIdx + 1, semicolonIdx);
|
final subtype = meta.substring(slashIdx + 1, semicolonIdx);
|
||||||
fileExtension = subtype == 'jpeg' ? 'jpg' : subtype;
|
fileExtension = subtype == 'jpeg' ? 'jpg' : subtype;
|
||||||
}
|
}
|
||||||
actualBase64 = imageData.substring(commaIndex + 1);
|
actualBase64 = imageData!.substring(commaIndex + 1);
|
||||||
}
|
}
|
||||||
bytes = base64.decode(actualBase64);
|
bytes = base64.decode(actualBase64);
|
||||||
|
} else {
|
||||||
|
// No image data available
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fileExtension ??= 'png';
|
fileExtension ??= 'png';
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../providers/chat_providers.dart';
|
|||||||
import '../../../shared/services/tasks/task_queue.dart';
|
import '../../../shared/services/tasks/task_queue.dart';
|
||||||
import '../../../shared/utils/conversation_context_menu.dart';
|
import '../../../shared/utils/conversation_context_menu.dart';
|
||||||
import '../../tools/providers/tools_providers.dart';
|
import '../../tools/providers/tools_providers.dart';
|
||||||
|
import '../utils/file_utils.dart';
|
||||||
|
|
||||||
// Pre-compiled regex for extracting file IDs from URLs (performance optimization)
|
// Pre-compiled regex for extracting file IDs from URLs (performance optimization)
|
||||||
// Handles both /api/v1/files/{id} and /api/v1/files/{id}/content formats
|
// Handles both /api/v1/files/{id} and /api/v1/files/{id}/content formats
|
||||||
@@ -84,16 +85,21 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
final allFiles = widget.message.files!;
|
final allFiles = widget.message.files!;
|
||||||
|
|
||||||
// Separate images and non-image files
|
// Separate images and non-image files
|
||||||
|
// Match OpenWebUI: type === 'image' OR content_type starts with 'image/'
|
||||||
final imageFiles = allFiles
|
final imageFiles = allFiles
|
||||||
.where(
|
.where(
|
||||||
(file) =>
|
(file) =>
|
||||||
file is Map && file['type'] == 'image' && file['url'] != null,
|
file is Map &&
|
||||||
|
isImageFile(file) &&
|
||||||
|
getFileUrl(file) != null,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final nonImageFiles = allFiles
|
final nonImageFiles = allFiles
|
||||||
.where(
|
.where(
|
||||||
(file) =>
|
(file) =>
|
||||||
file is Map && file['type'] != 'image' && file['url'] != null,
|
file is Map &&
|
||||||
|
!isImageFile(file) &&
|
||||||
|
getFileUrl(file) != null,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -131,7 +137,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
Widget _buildFileImageLayout(List<dynamic> imageFiles, int imageCount) {
|
Widget _buildFileImageLayout(List<dynamic> imageFiles, int imageCount) {
|
||||||
if (imageCount == 1) {
|
if (imageCount == 1) {
|
||||||
final file = imageFiles[0];
|
final file = imageFiles[0];
|
||||||
final String imageUrl = file['url'] as String;
|
final imageUrl = getFileUrl(file);
|
||||||
|
if (imageUrl == null) return const SizedBox.shrink();
|
||||||
return Row(
|
return Row(
|
||||||
key: ValueKey('user_file_single_$imageUrl'),
|
key: ValueKey('user_file_single_$imageUrl'),
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
@@ -175,7 +182,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
children: imageFiles.asMap().entries.map((entry) {
|
children: imageFiles.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final file = entry.value;
|
final file = entry.value;
|
||||||
final String imageUrl = file['url'] as String;
|
final imageUrl = getFileUrl(file);
|
||||||
|
if (imageUrl == null) return const SizedBox.shrink();
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs),
|
padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -223,7 +231,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
spacing: Spacing.xs,
|
spacing: Spacing.xs,
|
||||||
runSpacing: Spacing.xs,
|
runSpacing: Spacing.xs,
|
||||||
children: imageFiles.map((file) {
|
children: imageFiles.map((file) {
|
||||||
final String imageUrl = file['url'] as String;
|
final imageUrl = getFileUrl(file);
|
||||||
|
if (imageUrl == null) return const SizedBox.shrink();
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
|||||||
Reference in New Issue
Block a user