feat(task_worker): Enhance image upload with conversion and pre-caching
This commit is contained in:
@@ -47,6 +47,7 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
||||
try {
|
||||
// Data URL for images – short-circuit to image widget
|
||||
if (widget.attachmentId.startsWith('data:image/')) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_fileInfo = {'mime': 'image/inline'};
|
||||
@@ -56,6 +57,7 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
||||
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api is! ApiService) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_error = 'Service unavailable';
|
||||
@@ -64,11 +66,13 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
||||
}
|
||||
|
||||
final info = await api.getFileInfo(widget.attachmentId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fileInfo = info;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = 'Failed to load attachment';
|
||||
_isLoading = false;
|
||||
|
||||
@@ -26,6 +26,16 @@ final _globalImageBytesCache = <String, Uint8List>{};
|
||||
final _globalSvgStates = <String, bool>{};
|
||||
final _base64WhitespacePattern = RegExp(r'\s');
|
||||
|
||||
/// Pre-cache image bytes for instant display after upload.
|
||||
/// Call this with the server file ID and image bytes after successful upload.
|
||||
void preCacheImageBytes(String fileId, Uint8List bytes) {
|
||||
if (fileId.isEmpty || bytes.isEmpty) return;
|
||||
_globalImageBytesCache[fileId] = bytes;
|
||||
_globalLoadingStates[fileId] = false;
|
||||
// Detect SVG
|
||||
_globalSvgStates[fileId] = _isSvgBytes(bytes);
|
||||
}
|
||||
|
||||
Uint8List _decodeImageData(String data) {
|
||||
var payload = data;
|
||||
if (payload.startsWith('data:')) {
|
||||
@@ -147,6 +157,21 @@ class _EnhancedImageAttachmentState
|
||||
|
||||
Future<void> _loadImage() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// Check bytes cache first (populated during upload for instant display)
|
||||
final preCachedBytes = _globalImageBytesCache[widget.attachmentId];
|
||||
if (preCachedBytes != null) {
|
||||
final cachedIsSvg = _globalSvgStates[widget.attachmentId] ?? false;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cachedBytes = preCachedBytes;
|
||||
_isSvg = cachedIsSvg;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final cachedError = _globalErrorStates[widget.attachmentId];
|
||||
if (cachedError != null) {
|
||||
if (mounted) {
|
||||
@@ -241,15 +266,25 @@ class _EnhancedImageAttachmentState
|
||||
final fileInfo = await api.getFileInfo(attachmentId);
|
||||
final fileName = _extractFileName(fileInfo);
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
final contentType = (fileInfo['meta']?['content_type'] ??
|
||||
fileInfo['content_type'] ??
|
||||
'')
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
|
||||
if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) {
|
||||
// Check both extension and content_type for image detection
|
||||
final isImageByExt =
|
||||
['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].contains(ext);
|
||||
final isImageByContentType = contentType.startsWith('image/');
|
||||
|
||||
if (!isImageByExt && !isImageByContentType) {
|
||||
final error = l10n.notAnImageFile(fileName);
|
||||
_cacheError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track if this is an SVG file based on extension
|
||||
final isSvgFile = ext == 'svg';
|
||||
// Track if this is an SVG file based on extension or content type
|
||||
final isSvgFile = ext == 'svg' || contentType.contains('svg');
|
||||
|
||||
final fileContent = await api.getFileContent(attachmentId);
|
||||
|
||||
|
||||
@@ -1060,6 +1060,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
final isGenerating = ref.watch(isChatStreamingProvider);
|
||||
final stopGeneration = ref.read(stopGenerationProvider);
|
||||
|
||||
// Check if file uploads are in progress or complete
|
||||
final attachedFiles = ref.watch(attachedFilesProvider);
|
||||
final hasUploadsInProgress = attachedFiles.any(
|
||||
(f) =>
|
||||
f.status == FileUploadStatus.uploading ||
|
||||
f.status == FileUploadStatus.pending,
|
||||
);
|
||||
final allUploadsComplete = attachedFiles.isEmpty ||
|
||||
attachedFiles.every((f) => f.status == FileUploadStatus.completed);
|
||||
|
||||
final webSearchEnabled = ref.watch(webSearchEnabledProvider);
|
||||
final imageGenEnabled = ref.watch(imageGenerationEnabledProvider);
|
||||
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
|
||||
@@ -1349,6 +1359,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
isGenerating,
|
||||
stopGeneration,
|
||||
voiceAvailable,
|
||||
allUploadsComplete,
|
||||
hasUploadsInProgress,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1416,6 +1428,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
isGenerating,
|
||||
stopGeneration,
|
||||
voiceAvailable,
|
||||
allUploadsComplete,
|
||||
hasUploadsInProgress,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1825,12 +1839,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
bool isGenerating,
|
||||
void Function() stopGeneration,
|
||||
bool voiceAvailable,
|
||||
bool allUploadsComplete,
|
||||
bool hasUploadsInProgress,
|
||||
) {
|
||||
// Compact 44px touch target, circular radius, md icon size
|
||||
const double buttonSize = TouchTarget.minimum; // 44.0
|
||||
const double radius = AppBorderRadius.round; // big to ensure circle
|
||||
|
||||
final enabled = !isGenerating && hasText && widget.enabled;
|
||||
// Don't allow sending until all uploads are complete
|
||||
final enabled =
|
||||
!isGenerating && hasText && widget.enabled && allUploadsComplete;
|
||||
|
||||
// Generating -> STOP variant
|
||||
if (isGenerating) {
|
||||
@@ -1947,17 +1965,26 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
: const [],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.arrow_up
|
||||
: Icons.arrow_upward,
|
||||
size: IconSize.large,
|
||||
color: enabled
|
||||
? context.conduitTheme.buttonPrimaryText
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.disabled,
|
||||
child: hasUploadsInProgress
|
||||
? SizedBox(
|
||||
width: IconSize.large,
|
||||
height: IconSize.large,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.arrow_up
|
||||
: Icons.arrow_upward,
|
||||
size: IconSize.large,
|
||||
color: enabled
|
||||
? context.conduitTheme.buttonPrimaryText
|
||||
: context.conduitTheme.textPrimary.withValues(
|
||||
alpha: Alpha.disabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -130,7 +130,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
||||
|
||||
Widget _buildFileImageLayout(List<dynamic> imageFiles, int imageCount) {
|
||||
if (imageCount == 1) {
|
||||
final String imageUrl = imageFiles[0]['url'] as String;
|
||||
final file = imageFiles[0];
|
||||
final String imageUrl = file['url'] as String;
|
||||
return Row(
|
||||
key: ValueKey('user_file_single_$imageUrl'),
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
@@ -154,7 +155,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
||||
maxHeight: 350,
|
||||
),
|
||||
disableAnimation: widget.isStreaming,
|
||||
httpHeaders: _headersForFile(imageFiles[0]),
|
||||
httpHeaders: _headersForFile(file),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -173,7 +174,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: imageFiles.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final String imageUrl = entry.value['url'] as String;
|
||||
final file = entry.value;
|
||||
final String imageUrl = file['url'] as String;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : Spacing.xs),
|
||||
child: Container(
|
||||
@@ -196,7 +198,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
||||
maxHeight: 180,
|
||||
),
|
||||
disableAnimation: widget.isStreaming,
|
||||
httpHeaders: _headersForFile(entry.value),
|
||||
httpHeaders: _headersForFile(file),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user