feat(chat): Add clipboard image paste support in chat input
This commit is contained in:
174
lib/features/chat/services/clipboard_attachment_service.dart
Normal file
174
lib/features/chat/services/clipboard_attachment_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const Set<String> _previewableImageExtensions = <String>{
|
|||||||
'.png',
|
'.png',
|
||||||
'.gif',
|
'.gif',
|
||||||
'.webp',
|
'.webp',
|
||||||
|
'.bmp',
|
||||||
};
|
};
|
||||||
|
|
||||||
class FileAttachmentWidget extends ConsumerWidget {
|
class FileAttachmentWidget extends ConsumerWidget {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
@@ -525,15 +588,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
final item = items[index];
|
final item = items[index];
|
||||||
final KnowledgeBase? selectedBase =
|
final KnowledgeBase? selectedBase =
|
||||||
bases.isEmpty
|
bases.isEmpty
|
||||||
? null
|
? null
|
||||||
: bases.firstWhere(
|
: bases.firstWhere(
|
||||||
(b) => b.id == selectedBaseId,
|
(b) => b.id == selectedBaseId,
|
||||||
orElse: () => bases.first,
|
orElse: () => bases.first,
|
||||||
);
|
);
|
||||||
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,14 +614,15 @@ 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',
|
||||||
fileId: item.id,
|
fileId: item.id,
|
||||||
collectionName:
|
collectionName:
|
||||||
selectedBase?.name ??
|
selectedBase?.name ??
|
||||||
'Unknown',
|
'Unknown',
|
||||||
url: item.metadata['source']
|
url: item.metadata['source']
|
||||||
?.toString(),
|
?.toString(),
|
||||||
);
|
);
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user