diff --git a/lib/features/chat/services/clipboard_attachment_service.dart b/lib/features/chat/services/clipboard_attachment_service.dart new file mode 100644 index 0000000..0bcd429 --- /dev/null +++ b/lib/features/chat/services/clipboard_attachment_service.dart @@ -0,0 +1,174 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; +import 'file_attachment_service.dart'; + +/// Service for handling clipboard paste operations for images and files. +/// +/// This service converts pasted image data into [LocalAttachment] objects +/// that integrate with the existing file attachment flow. +class ClipboardAttachmentService { + /// Supported MIME types for image paste operations. + static const Set supportedImageMimeTypes = { + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', + 'image/bmp', + }; + + /// Creates a [LocalAttachment] from pasted image data. + /// + /// The image data is saved to a temporary file with an appropriate extension + /// based on the MIME type. Returns null if the operation fails. + Future createAttachmentFromImageData({ + required Uint8List imageData, + required String mimeType, + String? suggestedFileName, + }) async { + try { + // Determine file extension from MIME type + final extension = _extensionForMimeType(mimeType); + if (extension == null) { + debugPrint( + 'ClipboardAttachmentService: Unsupported MIME type: $mimeType', + ); + return null; + } + + // Generate filename, ensuring proper extension + String fileName; + if (suggestedFileName != null && suggestedFileName.isNotEmpty) { + // If suggested filename doesn't have the correct extension, add it + final suggestedLower = suggestedFileName.toLowerCase(); + final hasImageExt = supportedImageMimeTypes.any((mime) { + final ext = _extensionForMimeType(mime); + return ext != null && suggestedLower.endsWith(ext); + }); + fileName = hasImageExt + ? suggestedFileName + : '$suggestedFileName$extension'; + } else { + fileName = _generateFileName(extension); + } + + // Get temporary directory and create file path + final tempDir = await getTemporaryDirectory(); + final filePath = path.join(tempDir.path, fileName); + + // Write image data to file + final file = File(filePath); + await file.writeAsBytes(imageData); + + return LocalAttachment(file: file, displayName: fileName); + } catch (e) { + debugPrint('ClipboardAttachmentService: Failed to create attachment: $e'); + return null; + } + } + + /// Creates [LocalAttachment]s from a list of pasted URIs. + /// + /// Used when content is pasted as file URIs rather than raw image data. + Future> createAttachmentsFromUris( + List uris, + ) async { + final attachments = []; + + for (final uri in uris) { + try { + if (uri.scheme == 'file') { + final file = File(uri.toFilePath()); + if (await file.exists()) { + final fileName = path.basename(file.path); + attachments.add(LocalAttachment(file: file, displayName: fileName)); + } + } else if (uri.scheme == 'content') { + // Android content URIs need special handling + // The file picker and image picker handle this internally + // For direct content URI paste, we'd need platform channel support + debugPrint( + 'ClipboardAttachmentService: Content URI not directly supported: ' + '$uri', + ); + } + } catch (e) { + debugPrint( + 'ClipboardAttachmentService: Failed to process URI $uri: $e', + ); + } + } + + return attachments; + } + + /// Checks if a MIME type is a supported image type. + bool isSupportedImageType(String mimeType) { + return supportedImageMimeTypes.contains(mimeType.toLowerCase()); + } + + /// Returns the file extension for a given MIME type, or null if unsupported. + String? _extensionForMimeType(String mimeType) { + switch (mimeType.toLowerCase()) { + case 'image/png': + return '.png'; + case 'image/jpeg': + case 'image/jpg': + return '.jpg'; + case 'image/gif': + return '.gif'; + case 'image/webp': + return '.webp'; + case 'image/bmp': + return '.bmp'; + default: + return null; + } + } + + /// Generates a timestamped filename for pasted images. + String _generateFileName(String extension) { + final now = DateTime.now(); + String two(int value) => value.toString().padLeft(2, '0'); + final timestamp = + '${now.year}${two(now.month)}${two(now.day)}_' + '${two(now.hour)}${two(now.minute)}${two(now.second)}'; + return 'pasted_$timestamp$extension'; + } + + /// Cleans up temporary pasted files that are older than the specified + /// duration. + /// + /// Call this periodically to prevent temp directory bloat. + Future cleanupOldPastedFiles({ + Duration olderThan = const Duration(hours: 24), + }) async { + try { + final tempDir = await getTemporaryDirectory(); + final dir = Directory(tempDir.path); + + if (!await dir.exists()) return; + + final cutoff = DateTime.now().subtract(olderThan); + + await for (final entity in dir.list()) { + if (entity is File && + path.basename(entity.path).startsWith('pasted_')) { + try { + final stat = await entity.stat(); + if (stat.modified.isBefore(cutoff)) { + await entity.delete(); + } + } catch (_) { + // Ignore errors for individual files + } + } + } + } catch (e) { + debugPrint('ClipboardAttachmentService: Cleanup failed: $e'); + } + } +} diff --git a/lib/features/chat/services/file_attachment_service.dart b/lib/features/chat/services/file_attachment_service.dart index e309985..88f811c 100644 --- a/lib/features/chat/services/file_attachment_service.dart +++ b/lib/features/chat/services/file_attachment_service.dart @@ -62,8 +62,14 @@ class LocalAttachment { return path.extension(file.path).toLowerCase(); } - bool get isImage => - {'.jpg', '.jpeg', '.png', '.gif', '.webp'}.contains(extension); + bool get isImage => { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.bmp', + }.contains(extension); } class FileAttachmentService { diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 7af8cc7..e42050a 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -602,6 +602,81 @@ class _ChatPageState extends ConsumerState { } } + /// Handles images/files pasted from clipboard into the chat input. + Future _handlePastedAttachments( + List attachments, + ) async { + if (attachments.isEmpty) return; + + DebugLogger.log( + 'Processing ${attachments.length} pasted attachment(s)', + scope: 'chat/page', + ); + + // Validate file count (default 10 files limit like OpenWebUI) + final currentFiles = ref.read(attachedFilesProvider); + if (!validateFileCount(currentFiles.length, attachments.length, 10)) { + if (!mounted) return; + return; + } + + // Validate file sizes and process each attachment + final validAttachments = []; + for (final attachment in attachments) { + try { + final fileSize = await attachment.file.length(); + DebugLogger.log( + 'Pasted file: ${attachment.displayName}, size: $fileSize bytes', + scope: 'chat/page', + ); + + // Validate file size (default 20MB limit like OpenWebUI) + if (!validateFileSize(fileSize, 20)) { + DebugLogger.log( + 'Skipping oversized pasted file: ${attachment.displayName}', + scope: 'chat/page', + ); + continue; + } + + validAttachments.add(attachment); + } catch (e) { + DebugLogger.log( + 'Error processing pasted attachment: $e', + scope: 'chat/page', + ); + } + } + + if (validAttachments.isEmpty) return; + + // Add attachments to the list + ref.read(attachedFilesProvider.notifier).addFiles(validAttachments); + + // Enqueue uploads via task queue for unified retry/progress + final activeConv = ref.read(activeConversationProvider); + for (final attachment in validAttachments) { + try { + final fileSize = await attachment.file.length(); + await ref + .read(taskQueueProvider.notifier) + .enqueueUploadMedia( + conversationId: activeConv?.id, + filePath: attachment.file.path, + fileName: attachment.displayName, + fileSize: fileSize, + ); + } catch (e) { + DebugLogger.log('Enqueue pasted upload failed: $e', scope: 'chat/page'); + } + } + + DebugLogger.log( + 'Added ${validAttachments.length} pasted attachment(s)', + scope: 'chat/page', + ); + } + /// Checks if a URL is a YouTube URL. bool _isYoutubeUrl(String url) { return url.startsWith('https://www.youtube.com') || @@ -2023,6 +2098,7 @@ class _ChatPageState extends ConsumerState { onCameraCapture: () => _handleImageAttachment(fromCamera: true), onWebAttachment: _promptAttachWebpage, + onPastedAttachments: _handlePastedAttachments, ), ), ), diff --git a/lib/features/chat/widgets/enhanced_attachment.dart b/lib/features/chat/widgets/enhanced_attachment.dart index fd9f199..c5b534f 100644 --- a/lib/features/chat/widgets/enhanced_attachment.dart +++ b/lib/features/chat/widgets/enhanced_attachment.dart @@ -86,7 +86,7 @@ class _EnhancedAttachmentState extends ConsumerState { .toString() .toLowerCase(); final ext = name.split('.').length > 1 ? name.split('.').last : ''; - return ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext); + return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].contains(ext); } Future _ensureLocalFile() async { @@ -234,14 +234,14 @@ class _EnhancedAttachmentState extends ConsumerState { ), ), child: Row( - mainAxisSize: MainAxisSize.max, + mainAxisSize: MainAxisSize.min, children: [ Text( _fileIconFor(filename), style: const TextStyle(fontSize: AppTypography.headlineLarge), ), const SizedBox(width: Spacing.sm), - Expanded( + Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/lib/features/chat/widgets/file_attachment_widget.dart b/lib/features/chat/widgets/file_attachment_widget.dart index c309fd6..c41273b 100644 --- a/lib/features/chat/widgets/file_attachment_widget.dart +++ b/lib/features/chat/widgets/file_attachment_widget.dart @@ -14,6 +14,7 @@ const Set _previewableImageExtensions = { '.png', '.gif', '.webp', + '.bmp', }; class FileAttachmentWidget extends ConsumerWidget { diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index b80dbba..d152c0c 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -12,6 +12,8 @@ import 'dart:async'; import 'dart:ui'; import 'dart:math' as math; import '../providers/chat_providers.dart'; +import '../services/clipboard_attachment_service.dart'; +import '../services/file_attachment_service.dart'; import '../providers/context_attachments_provider.dart'; import '../providers/knowledge_cache_provider.dart'; import '../../tools/providers/tools_providers.dart'; @@ -69,6 +71,9 @@ class ModernChatInput extends ConsumerStatefulWidget { final Function()? onCameraCapture; final Function()? onWebAttachment; + /// Callback invoked when images or files are pasted from clipboard. + final Future Function(List)? onPastedAttachments; + const ModernChatInput({ super.key, required this.onSendMessage, @@ -79,6 +84,7 @@ class ModernChatInput extends ConsumerStatefulWidget { this.onImageAttachment, this.onCameraCapture, this.onWebAttachment, + this.onPastedAttachments, }); @override @@ -110,6 +116,10 @@ class _ModernChatInputState extends ConsumerState TextRange? _currentPromptRange; int _promptSelectionIndex = 0; + /// Service for handling clipboard paste operations. + final ClipboardAttachmentService _clipboardService = + ClipboardAttachmentService(); + @override void initState() { super.initState(); @@ -225,6 +235,56 @@ class _ModernChatInputState extends ConsumerState } } + /// Handles content insertion from keyboard/clipboard (images, files). + /// + /// This is called when the user pastes rich content into the text field + /// on iOS and Android. + Future _handleContentInserted(KeyboardInsertedContent content) async { + if (!widget.enabled) return; + + // Check if we have a callback to handle pasted attachments + final onPasted = widget.onPastedAttachments; + if (onPasted == null) return; + + final mimeType = content.mimeType; + final data = content.data; + + // Only process image content + if (!_clipboardService.isSupportedImageType(mimeType)) { + return; + } + + // Check if we have actual data + if (data == null || data.isEmpty) { + return; + } + + PlatformUtils.lightHaptic(); + + // Create attachment from pasted image data + String? suggestedName; + final uriString = content.uri; + if (uriString.isNotEmpty) { + try { + final uri = Uri.parse(uriString); + if (uri.pathSegments.isNotEmpty) { + suggestedName = uri.pathSegments.last; + } + } catch (_) { + // Ignore URI parsing errors + } + } + final attachment = await _clipboardService.createAttachmentFromImageData( + imageData: data, + mimeType: mimeType, + suggestedFileName: suggestedName, + ); + + if (attachment != null) { + await onPasted([attachment]); + } + } + void _insertNewline() { final text = _controller.text; TextSelection sel = _controller.selection; @@ -336,7 +396,9 @@ class _ModernChatInputState extends ConsumerState if (prompts.isEmpty) return const []; final String query = _currentPromptCommand.toLowerCase().trim(); // Strip leading '/' prefix so we can match prompt commands (e.g., "help") - final String searchQuery = query.startsWith('/') ? query.substring(1) : query; + final String searchQuery = query.startsWith('/') + ? query.substring(1) + : query; final List filtered = prompts @@ -470,7 +532,8 @@ class _ModernChatInputState extends ConsumerState final items = selectedBaseId != null ? itemsMap[selectedBaseId] ?? const [] : const []; - final loading = cacheState.isLoading || + final loading = + cacheState.isLoading || (selectedBaseId != null && !itemsMap.containsKey(selectedBaseId)); @@ -525,15 +588,16 @@ class _ModernChatInputState extends ConsumerState final item = items[index]; final KnowledgeBase? selectedBase = bases.isEmpty - ? null - : bases.firstWhere( - (b) => b.id == selectedBaseId, - orElse: () => bases.first, - ); + ? null + : bases.firstWhere( + (b) => b.id == selectedBaseId, + orElse: () => bases.first, + ); return ListTile( title: Text( item.title ?? - item.metadata['name']?.toString() ?? + item.metadata['name'] + ?.toString() ?? 'Document', overflow: TextOverflow.ellipsis, ), @@ -550,14 +614,15 @@ class _ModernChatInputState extends ConsumerState .notifier, ) .addKnowledge( - displayName: item.title ?? + displayName: + item.title ?? item.metadata['name'] ?.toString() ?? 'Document', fileId: item.id, collectionName: selectedBase?.name ?? - 'Unknown', + 'Unknown', url: item.metadata['source'] ?.toString(), ); @@ -1361,6 +1426,13 @@ class _ModernChatInputState extends ConsumerState isDense: true, alignLabelWithHint: true, ), + // Enable pasting images and files from clipboard + contentInsertionConfiguration: ContentInsertionConfiguration( + allowedMimeTypes: ClipboardAttachmentService + .supportedImageMimeTypes + .toList(), + onContentInserted: _handleContentInserted, + ), onSubmitted: (_) { if (sendOnEnter) { _sendMessage();