feat(task_worker): Enhance image upload with conversion and pre-caching

This commit is contained in:
cogwheel
2025-12-25 20:29:38 +05:30
parent 1447ddd93c
commit f594982d6a
7 changed files with 433 additions and 286 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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,
),
),
),
),
),

View File

@@ -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),
),
),
),