feat(chat): Add clipboard image paste support in chat input

This commit is contained in:
cogwheel0
2025-12-02 21:10:59 +05:30
parent db136b257c
commit ecad71dcf6
6 changed files with 344 additions and 15 deletions

View File

@@ -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<String> 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<LocalAttachment?> 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<List<LocalAttachment>> createAttachmentsFromUris(
List<Uri> uris,
) async {
final attachments = <LocalAttachment>[];
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<void> 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');
}
}
}

View File

@@ -62,8 +62,14 @@ class LocalAttachment {
return path.extension(file.path).toLowerCase(); return path.extension(file.path).toLowerCase();
} }
bool get isImage => bool get isImage => <String>{
<String>{'.jpg', '.jpeg', '.png', '.gif', '.webp'}.contains(extension); '.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.bmp',
}.contains(extension);
} }
class FileAttachmentService { class FileAttachmentService {

View File

@@ -602,6 +602,81 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} }
} }
/// Handles images/files pasted from clipboard into the chat input.
Future<void> _handlePastedAttachments(
List<LocalAttachment> 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 = <LocalAttachment>[];
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. /// Checks if a URL is a YouTube URL.
bool _isYoutubeUrl(String url) { bool _isYoutubeUrl(String url) {
return url.startsWith('https://www.youtube.com') || return url.startsWith('https://www.youtube.com') ||
@@ -2023,6 +2098,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
onCameraCapture: () => onCameraCapture: () =>
_handleImageAttachment(fromCamera: true), _handleImageAttachment(fromCamera: true),
onWebAttachment: _promptAttachWebpage, onWebAttachment: _promptAttachWebpage,
onPastedAttachments: _handlePastedAttachments,
), ),
), ),
), ),

View File

@@ -86,7 +86,7 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
.toString() .toString()
.toLowerCase(); .toLowerCase();
final ext = name.split('.').length > 1 ? name.split('.').last : ''; 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<String?> _ensureLocalFile() async { Future<String?> _ensureLocalFile() async {
@@ -234,14 +234,14 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
), ),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
_fileIconFor(filename), _fileIconFor(filename),
style: const TextStyle(fontSize: AppTypography.headlineLarge), style: const TextStyle(fontSize: AppTypography.headlineLarge),
), ),
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.sm),
Expanded( Flexible(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -14,6 +14,7 @@ const Set<String> _previewableImageExtensions = <String>{
'.png', '.png',
'.gif', '.gif',
'.webp', '.webp',
'.bmp',
}; };
class FileAttachmentWidget extends ConsumerWidget { class FileAttachmentWidget extends ConsumerWidget {

View File

@@ -12,6 +12,8 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'dart:math' as math; import 'dart:math' as math;
import '../providers/chat_providers.dart'; 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/context_attachments_provider.dart';
import '../providers/knowledge_cache_provider.dart'; import '../providers/knowledge_cache_provider.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
@@ -69,6 +71,9 @@ class ModernChatInput extends ConsumerStatefulWidget {
final Function()? onCameraCapture; final Function()? onCameraCapture;
final Function()? onWebAttachment; final Function()? onWebAttachment;
/// Callback invoked when images or files are pasted from clipboard.
final Future<void> Function(List<LocalAttachment>)? onPastedAttachments;
const ModernChatInput({ const ModernChatInput({
super.key, super.key,
required this.onSendMessage, required this.onSendMessage,
@@ -79,6 +84,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
this.onImageAttachment, this.onImageAttachment,
this.onCameraCapture, this.onCameraCapture,
this.onWebAttachment, this.onWebAttachment,
this.onPastedAttachments,
}); });
@override @override
@@ -110,6 +116,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
TextRange? _currentPromptRange; TextRange? _currentPromptRange;
int _promptSelectionIndex = 0; int _promptSelectionIndex = 0;
/// Service for handling clipboard paste operations.
final ClipboardAttachmentService _clipboardService =
ClipboardAttachmentService();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -225,6 +235,56 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
} }
/// 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<void> _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() { void _insertNewline() {
final text = _controller.text; final text = _controller.text;
TextSelection sel = _controller.selection; TextSelection sel = _controller.selection;
@@ -336,7 +396,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (prompts.isEmpty) return const <Prompt>[]; if (prompts.isEmpty) return const <Prompt>[];
final String query = _currentPromptCommand.toLowerCase().trim(); final String query = _currentPromptCommand.toLowerCase().trim();
// Strip leading '/' prefix so we can match prompt commands (e.g., "help") // 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<Prompt> filtered = final List<Prompt> filtered =
prompts prompts
@@ -470,7 +532,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final items = selectedBaseId != null final items = selectedBaseId != null
? itemsMap[selectedBaseId] ?? const <KnowledgeBaseItem>[] ? itemsMap[selectedBaseId] ?? const <KnowledgeBaseItem>[]
: const <KnowledgeBaseItem>[]; : const <KnowledgeBaseItem>[];
final loading = cacheState.isLoading || final loading =
cacheState.isLoading ||
(selectedBaseId != null && (selectedBaseId != null &&
!itemsMap.containsKey(selectedBaseId)); !itemsMap.containsKey(selectedBaseId));
@@ -533,7 +596,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
return ListTile( return ListTile(
title: Text( title: Text(
item.title ?? item.title ??
item.metadata['name']?.toString() ?? item.metadata['name']
?.toString() ??
'Document', 'Document',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -550,7 +614,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
.notifier, .notifier,
) )
.addKnowledge( .addKnowledge(
displayName: item.title ?? displayName:
item.title ??
item.metadata['name'] item.metadata['name']
?.toString() ?? ?.toString() ??
'Document', 'Document',
@@ -1361,6 +1426,13 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
isDense: true, isDense: true,
alignLabelWithHint: true, alignLabelWithHint: true,
), ),
// Enable pasting images and files from clipboard
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: ClipboardAttachmentService
.supportedImageMimeTypes
.toList(),
onContentInserted: _handleContentInserted,
),
onSubmitted: (_) { onSubmitted: (_) {
if (sendOnEnter) { if (sendOnEnter) {
_sendMessage(); _sendMessage();