feat(chat): Improve attachment processing and loading indicator
This commit is contained in:
@@ -1772,41 +1772,44 @@ Future<void> _sendMessageInternal(
|
|||||||
final contextFiles = _contextAttachmentsToFiles(contextAttachments);
|
final contextFiles = _contextAttachmentsToFiles(contextAttachments);
|
||||||
|
|
||||||
// Convert attachments to files format for web client compatibility
|
// Convert attachments to files format for web client compatibility
|
||||||
|
// Process in parallel for better performance (fixes #310 - loading indicator)
|
||||||
|
// while preserving original attachment order
|
||||||
final attachmentFiles = <Map<String, dynamic>>[];
|
final attachmentFiles = <Map<String, dynamic>>[];
|
||||||
if (attachments != null && !reviewerMode && api != null) {
|
if (attachments != null && !reviewerMode && api != null) {
|
||||||
for (final attachment in attachments) {
|
// Process all attachments in parallel while preserving order
|
||||||
// Data URLs are images - store inline
|
final fileInfoFutures = attachments.map((attachment) async {
|
||||||
|
// Data URLs are images - return immediately (no API call needed)
|
||||||
if (attachment.startsWith('data:image/')) {
|
if (attachment.startsWith('data:image/')) {
|
||||||
attachmentFiles.add({'type': 'image', 'url': attachment});
|
return <String, dynamic>{'type': 'image', 'url': attachment};
|
||||||
} else {
|
|
||||||
// Server file ID - fetch info and create file entry
|
|
||||||
// Match web client format: {type, id, name, url, size, collection_name}
|
|
||||||
try {
|
|
||||||
final fileInfo = await api.getFileInfo(attachment);
|
|
||||||
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'file';
|
|
||||||
final fileSize = fileInfo['size'] ?? fileInfo['meta']?['size'];
|
|
||||||
final collectionName =
|
|
||||||
fileInfo['meta']?['collection_name'] ??
|
|
||||||
fileInfo['collection_name'];
|
|
||||||
attachmentFiles.add({
|
|
||||||
'type': 'file',
|
|
||||||
'id': attachment,
|
|
||||||
'name': fileName,
|
|
||||||
'url': '/api/v1/files/$attachment',
|
|
||||||
if (fileSize != null) 'size': fileSize,
|
|
||||||
if (collectionName != null) 'collection_name': collectionName,
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
// If we can't fetch info, store minimal file entry with placeholder name
|
|
||||||
attachmentFiles.add({
|
|
||||||
'type': 'file',
|
|
||||||
'id': attachment,
|
|
||||||
'name': 'file',
|
|
||||||
'url': '/api/v1/files/$attachment',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// Server file ID - fetch info
|
||||||
|
try {
|
||||||
|
final fileInfo = await api.getFileInfo(attachment);
|
||||||
|
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'file';
|
||||||
|
final fileSize = fileInfo['size'] ?? fileInfo['meta']?['size'];
|
||||||
|
final collectionName =
|
||||||
|
fileInfo['meta']?['collection_name'] ?? fileInfo['collection_name'];
|
||||||
|
return <String, dynamic>{
|
||||||
|
'type': 'file',
|
||||||
|
'id': attachment,
|
||||||
|
'name': fileName,
|
||||||
|
'url': '/api/v1/files/$attachment',
|
||||||
|
if (fileSize != null) 'size': fileSize,
|
||||||
|
if (collectionName != null) 'collection_name': collectionName,
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
// If we can't fetch info, store minimal file entry
|
||||||
|
return <String, dynamic>{
|
||||||
|
'type': 'file',
|
||||||
|
'id': attachment,
|
||||||
|
'name': 'file',
|
||||||
|
'url': '/api/v1/files/$attachment',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Future.wait preserves order - results match input order
|
||||||
|
final results = await Future.wait(fileInfoFutures);
|
||||||
|
attachmentFiles.addAll(results);
|
||||||
} else if (attachments != null) {
|
} else if (attachments != null) {
|
||||||
// Reviewer mode or no API - only handle images (server files need API)
|
// Reviewer mode or no API - only handle images (server files need API)
|
||||||
for (final attachment in attachments) {
|
for (final attachment in attachments) {
|
||||||
@@ -1938,21 +1941,21 @@ Future<void> _sendMessageInternal(
|
|||||||
activeConversation = updated;
|
activeConversation = updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode)
|
// Add assistant placeholder immediately after user message to show typing
|
||||||
|
// indicator right away (fixes #310 - loading animation not showing)
|
||||||
|
final String assistantMessageId = const Uuid().v4();
|
||||||
|
final assistantPlaceholder = ChatMessage(
|
||||||
|
id: assistantMessageId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
model: selectedModel.id,
|
||||||
|
isStreaming: true,
|
||||||
|
);
|
||||||
|
ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder);
|
||||||
|
|
||||||
// Reviewer mode: simulate a response locally and return
|
// Reviewer mode: simulate a response locally and return
|
||||||
if (reviewerMode) {
|
if (reviewerMode) {
|
||||||
// Add assistant message placeholder
|
|
||||||
final assistantMessage = ChatMessage(
|
|
||||||
id: const Uuid().v4(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
model: selectedModel.id,
|
|
||||||
isStreaming: true,
|
|
||||||
);
|
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
|
|
||||||
|
|
||||||
// Check if there are attachments
|
// Check if there are attachments
|
||||||
String? filename;
|
String? filename;
|
||||||
if (attachments != null && attachments.isNotEmpty) {
|
if (attachments != null && attachments.isNotEmpty) {
|
||||||
@@ -2066,21 +2069,8 @@ Future<void> _sendMessageInternal(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Pre-seed assistant skeleton on server to ensure correct chain
|
// Assistant placeholder was already added above (after user message)
|
||||||
// Generate assistant message id now (must be consistent across client/server)
|
// to show typing indicator immediately. Sync conversation state to server.
|
||||||
final String assistantMessageId = const Uuid().v4();
|
|
||||||
|
|
||||||
// Add assistant placeholder locally before sending
|
|
||||||
final assistantPlaceholder = ChatMessage(
|
|
||||||
id: assistantMessageId,
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
timestamp: DateTime.now(),
|
|
||||||
model: selectedModel.id,
|
|
||||||
isStreaming: true,
|
|
||||||
);
|
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder);
|
|
||||||
|
|
||||||
// Sync conversation state to ensure WebUI can load conversation history
|
// Sync conversation state to ensure WebUI can load conversation history
|
||||||
try {
|
try {
|
||||||
final activeConvForSeed = ref.read(activeConversationProvider);
|
final activeConvForSeed = ref.read(activeConversationProvider);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import 'package:path/path.dart' as path;
|
|||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../../../core/utils/debug_logger.dart';
|
import '../../../core/utils/debug_logger.dart';
|
||||||
|
|
||||||
|
/// Size threshold for optimizing images to WebP (200KB).
|
||||||
|
/// Images larger than this will be converted to WebP for better compression.
|
||||||
|
const int _webpOptimizationThreshold = 200 * 1024;
|
||||||
|
|
||||||
/// Standard web image formats that LLMs can process directly.
|
/// Standard web image formats that LLMs can process directly.
|
||||||
const Set<String> _standardImageFormats = {
|
const Set<String> _standardImageFormats = {
|
||||||
'.jpg',
|
'.jpg',
|
||||||
@@ -17,17 +21,12 @@ const Set<String> _standardImageFormats = {
|
|||||||
'.png',
|
'.png',
|
||||||
'.gif',
|
'.gif',
|
||||||
'.webp',
|
'.webp',
|
||||||
'.bmp',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// iOS-specific formats that need conversion to JPEG before LLM submission.
|
/// Formats that should always be converted to WebP (not widely supported).
|
||||||
const Set<String> _iosImageFormats = {
|
const Set<String> _alwaysConvertFormats = {
|
||||||
'.heic',
|
'.heic',
|
||||||
'.heif',
|
'.heif',
|
||||||
};
|
|
||||||
|
|
||||||
/// RAW image formats that need conversion to JPEG before LLM submission.
|
|
||||||
const Set<String> _rawImageFormats = {
|
|
||||||
'.dng',
|
'.dng',
|
||||||
'.raw',
|
'.raw',
|
||||||
'.cr2',
|
'.cr2',
|
||||||
@@ -35,62 +34,116 @@ const Set<String> _rawImageFormats = {
|
|||||||
'.arw',
|
'.arw',
|
||||||
'.orf',
|
'.orf',
|
||||||
'.rw2',
|
'.rw2',
|
||||||
|
'.bmp',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Formats that benefit from WebP conversion when large.
|
||||||
|
const Set<String> _optimizableFormats = {
|
||||||
|
'.jpg',
|
||||||
|
'.jpeg',
|
||||||
|
'.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Formats that should never be converted (animation, already optimal).
|
||||||
|
const Set<String> _preserveFormats = {
|
||||||
|
'.gif',
|
||||||
|
'.webp',
|
||||||
};
|
};
|
||||||
|
|
||||||
/// All supported image formats (both standard and those requiring conversion).
|
/// All supported image formats (both standard and those requiring conversion).
|
||||||
const Set<String> allSupportedImageFormats = {
|
const Set<String> allSupportedImageFormats = {
|
||||||
..._standardImageFormats,
|
..._standardImageFormats,
|
||||||
..._iosImageFormats,
|
..._alwaysConvertFormats,
|
||||||
..._rawImageFormats,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Returns true if the extension requires conversion to a standard format.
|
/// Returns true if the extension always requires conversion to WebP.
|
||||||
bool _needsConversion(String extension) {
|
bool _alwaysNeedsConversion(String extension) {
|
||||||
return _iosImageFormats.contains(extension) ||
|
return _alwaysConvertFormats.contains(extension);
|
||||||
_rawImageFormats.contains(extension);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts an image file to a base64 data URL.
|
/// Returns true if the format can benefit from WebP optimization.
|
||||||
|
bool _canOptimize(String extension) {
|
||||||
|
return _optimizableFormats.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the format should be preserved as-is.
|
||||||
|
bool _shouldPreserve(String extension) {
|
||||||
|
return _preserveFormats.contains(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts an image file to a base64 data URL with smart optimization.
|
||||||
/// This is a standalone utility used by both FileAttachmentService and TaskWorker.
|
/// This is a standalone utility used by both FileAttachmentService and TaskWorker.
|
||||||
///
|
///
|
||||||
/// Handles iOS-specific formats (HEIC, HEIF) and RAW formats (DNG, CR2, etc.)
|
/// Optimization strategy:
|
||||||
/// by converting them to JPEG before encoding.
|
/// - HEIC/HEIF/RAW/BMP → Always convert to WebP
|
||||||
|
/// - Large JPEG/PNG (>200KB) → Convert to WebP for better compression
|
||||||
|
/// - Small JPEG/PNG (<200KB) → Pass through as-is
|
||||||
|
/// - GIF → Preserve (maintains animation)
|
||||||
|
/// - WebP → Preserve (already optimal)
|
||||||
///
|
///
|
||||||
/// Returns null if conversion fails.
|
/// Returns null if conversion fails for formats requiring conversion.
|
||||||
Future<String?> convertImageFileToDataUrl(File imageFile) async {
|
Future<String?> convertImageFileToDataUrl(File imageFile) async {
|
||||||
try {
|
try {
|
||||||
final ext = path.extension(imageFile.path).toLowerCase();
|
final ext = path.extension(imageFile.path).toLowerCase();
|
||||||
|
final fileSize = await imageFile.length();
|
||||||
|
|
||||||
// Check if we need to convert the image format
|
// Formats that must always be converted (HEIC, RAW, BMP, etc.)
|
||||||
if (_needsConversion(ext)) {
|
if (_alwaysNeedsConversion(ext)) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Converting image from $ext to JPEG',
|
'Converting image from $ext to WebP (required)',
|
||||||
scope: 'attachments',
|
scope: 'attachments',
|
||||||
data: {'path': imageFile.path},
|
data: {'path': imageFile.path, 'size': fileSize},
|
||||||
);
|
);
|
||||||
|
|
||||||
final convertedBytes = await _convertImageToJpeg(imageFile);
|
final convertedBytes = await _convertToWebP(imageFile);
|
||||||
if (convertedBytes != null) {
|
if (convertedBytes != null) {
|
||||||
return 'data:image/jpeg;base64,${base64Encode(convertedBytes)}';
|
return 'data:image/webp;base64,${base64Encode(convertedBytes)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversion failed - return null rather than sending unusable raw data
|
|
||||||
DebugLogger.warning(
|
DebugLogger.warning(
|
||||||
'Conversion failed for $ext format, cannot process image',
|
'Conversion failed for $ext format, cannot process image',
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard format - read directly
|
// Formats that should be preserved as-is (GIF, WebP)
|
||||||
final bytes = await imageFile.readAsBytes();
|
if (_shouldPreserve(ext)) {
|
||||||
|
final bytes = await imageFile.readAsBytes();
|
||||||
|
final mimeType = ext == '.gif' ? 'image/gif' : 'image/webp';
|
||||||
|
return 'data:$mimeType;base64,${base64Encode(bytes)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimizable formats (JPEG, PNG) - convert if large
|
||||||
|
if (_canOptimize(ext) && fileSize > _webpOptimizationThreshold) {
|
||||||
|
DebugLogger.log(
|
||||||
|
'Optimizing large image from $ext to WebP',
|
||||||
|
scope: 'attachments',
|
||||||
|
data: {'path': imageFile.path, 'size': fileSize},
|
||||||
|
);
|
||||||
|
|
||||||
|
final convertedBytes = await _convertToWebP(imageFile);
|
||||||
|
if (convertedBytes != null) {
|
||||||
|
final savings = fileSize - convertedBytes.length;
|
||||||
|
final savingsPercent = (savings / fileSize * 100).toStringAsFixed(1);
|
||||||
|
DebugLogger.log(
|
||||||
|
'WebP optimization saved $savingsPercent%',
|
||||||
|
scope: 'attachments',
|
||||||
|
data: {
|
||||||
|
'originalSize': fileSize,
|
||||||
|
'newSize': convertedBytes.length,
|
||||||
|
'saved': savings,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return 'data:image/webp;base64,${base64Encode(convertedBytes)}';
|
||||||
|
}
|
||||||
|
// Fall through to pass-through if conversion fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through as-is (small images or unknown formats)
|
||||||
|
final bytes = await imageFile.readAsBytes();
|
||||||
String mimeType = 'image/png';
|
String mimeType = 'image/png';
|
||||||
if (ext == '.jpg' || ext == '.jpeg') {
|
if (ext == '.jpg' || ext == '.jpeg') {
|
||||||
mimeType = 'image/jpeg';
|
mimeType = 'image/jpeg';
|
||||||
} else if (ext == '.gif') {
|
|
||||||
mimeType = 'image/gif';
|
|
||||||
} else if (ext == '.webp') {
|
|
||||||
mimeType = 'image/webp';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'data:$mimeType;base64,${base64Encode(bytes)}';
|
return 'data:$mimeType;base64,${base64Encode(bytes)}';
|
||||||
@@ -100,20 +153,19 @@ Future<String?> convertImageFileToDataUrl(File imageFile) async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts an image file to JPEG bytes using flutter_image_compress.
|
/// Converts an image file to WebP bytes using flutter_image_compress.
|
||||||
/// This handles iOS-specific formats (HEIC, HEIF) and RAW formats (DNG, etc.)
|
/// WebP provides better compression than JPEG while maintaining quality.
|
||||||
Future<List<int>?> _convertImageToJpeg(File imageFile) async {
|
Future<List<int>?> _convertToWebP(File imageFile) async {
|
||||||
try {
|
try {
|
||||||
// Use flutter_image_compress for native iOS/Android conversion
|
|
||||||
final result = await FlutterImageCompress.compressWithFile(
|
final result = await FlutterImageCompress.compressWithFile(
|
||||||
imageFile.absolute.path,
|
imageFile.absolute.path,
|
||||||
format: CompressFormat.jpeg,
|
format: CompressFormat.webp,
|
||||||
quality: 90,
|
quality: 85,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result != null && result.isNotEmpty) {
|
if (result != null && result.isNotEmpty) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'Image converted successfully',
|
'Image converted to WebP successfully',
|
||||||
scope: 'attachments',
|
scope: 'attachments',
|
||||||
data: {
|
data: {
|
||||||
'originalPath': imageFile.path,
|
'originalPath': imageFile.path,
|
||||||
@@ -126,7 +178,7 @@ Future<List<int>?> _convertImageToJpeg(File imageFile) async {
|
|||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'image-conversion-failed',
|
'webp-conversion-failed',
|
||||||
scope: 'attachments',
|
scope: 'attachments',
|
||||||
error: e,
|
error: e,
|
||||||
);
|
);
|
||||||
@@ -163,7 +215,7 @@ String _deriveDisplayName({
|
|||||||
String _timestampedName({required String prefix, required String extension}) {
|
String _timestampedName({required String prefix, required String extension}) {
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
String two(int value) => value.toString().padLeft(2, '0');
|
String two(int value) => value.toString().padLeft(2, '0');
|
||||||
final String ext = extension.isNotEmpty ? extension : '.jpg';
|
final String ext = extension.isNotEmpty ? extension : '.webp';
|
||||||
final String timestamp =
|
final String timestamp =
|
||||||
'${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}';
|
'${now.year}${two(now.month)}${two(now.day)}_${two(now.hour)}${two(now.minute)}${two(now.second)}';
|
||||||
return '${prefix}_$timestamp$ext';
|
return '${prefix}_$timestamp$ext';
|
||||||
@@ -295,7 +347,9 @@ class FileAttachmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress image similar to OpenWebUI's implementation
|
/// Compresses and resizes an image data URL.
|
||||||
|
/// Uses PNG format for the resize operation (dart:ui limitation),
|
||||||
|
/// then converts to WebP for optimal file size.
|
||||||
Future<String> compressImage(
|
Future<String> compressImage(
|
||||||
String imageDataUrl,
|
String imageDataUrl,
|
||||||
int? maxWidth,
|
int? maxWidth,
|
||||||
@@ -314,7 +368,7 @@ class FileAttachmentService {
|
|||||||
: imageDataUrl,
|
: imageDataUrl,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return imageDataUrl; // Return original if format is invalid
|
return imageDataUrl;
|
||||||
}
|
}
|
||||||
final data = parts[1];
|
final data = parts[1];
|
||||||
final bytes = base64Decode(data);
|
final bytes = base64Decode(data);
|
||||||
@@ -330,7 +384,7 @@ class FileAttachmentService {
|
|||||||
// Calculate new dimensions maintaining aspect ratio
|
// Calculate new dimensions maintaining aspect ratio
|
||||||
if (maxWidth != null && maxHeight != null) {
|
if (maxWidth != null && maxHeight != null) {
|
||||||
if (width <= maxWidth && height <= maxHeight) {
|
if (width <= maxWidth && height <= maxHeight) {
|
||||||
return imageDataUrl; // No compression needed
|
return imageDataUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (width / height > maxWidth / maxHeight) {
|
if (width / height > maxWidth / maxHeight) {
|
||||||
@@ -342,19 +396,19 @@ class FileAttachmentService {
|
|||||||
}
|
}
|
||||||
} else if (maxWidth != null) {
|
} else if (maxWidth != null) {
|
||||||
if (width <= maxWidth) {
|
if (width <= maxWidth) {
|
||||||
return imageDataUrl; // No compression needed
|
return imageDataUrl;
|
||||||
}
|
}
|
||||||
height = ((maxWidth * height) / width).round();
|
height = ((maxWidth * height) / width).round();
|
||||||
width = maxWidth;
|
width = maxWidth;
|
||||||
} else if (maxHeight != null) {
|
} else if (maxHeight != null) {
|
||||||
if (height <= maxHeight) {
|
if (height <= maxHeight) {
|
||||||
return imageDataUrl; // No compression needed
|
return imageDataUrl;
|
||||||
}
|
}
|
||||||
width = ((maxHeight * width) / height).round();
|
width = ((maxHeight * width) / height).round();
|
||||||
height = maxHeight;
|
height = maxHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create compressed image
|
// Create resized image (dart:ui only supports PNG output)
|
||||||
final recorder = ui.PictureRecorder();
|
final recorder = ui.PictureRecorder();
|
||||||
final canvas = Canvas(recorder);
|
final canvas = Canvas(recorder);
|
||||||
|
|
||||||
@@ -366,22 +420,28 @@ class FileAttachmentService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final picture = recorder.endRecording();
|
final picture = recorder.endRecording();
|
||||||
final compressedImage = await picture.toImage(width, height);
|
final resizedImage = await picture.toImage(width, height);
|
||||||
final byteData = await compressedImage.toByteData(
|
final byteData = await resizedImage.toByteData(
|
||||||
format: ui.ImageByteFormat.png,
|
format: ui.ImageByteFormat.png,
|
||||||
);
|
);
|
||||||
final compressedBytes = byteData!.buffer.asUint8List();
|
final pngBytes = byteData!.buffer.asUint8List();
|
||||||
|
|
||||||
// Convert back to data URL
|
// Convert PNG to WebP for better compression
|
||||||
final compressedBase64 = base64Encode(compressedBytes);
|
final webpBytes = await FlutterImageCompress.compressWithList(
|
||||||
return 'data:image/png;base64,$compressedBase64';
|
pngBytes,
|
||||||
|
format: CompressFormat.webp,
|
||||||
|
quality: 85,
|
||||||
|
);
|
||||||
|
|
||||||
|
final compressedBase64 = base64Encode(webpBytes);
|
||||||
|
return 'data:image/webp;base64,$compressedBase64';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'compress-failed',
|
'compress-failed',
|
||||||
scope: 'attachments/image',
|
scope: 'attachments/image',
|
||||||
error: e,
|
error: e,
|
||||||
);
|
);
|
||||||
return imageDataUrl; // Return original if compression fails
|
return imageDataUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -593,7 +593,6 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
Widget _buildSegmentedContent() {
|
Widget _buildSegmentedContent() {
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
bool firstToolSpacerAdded = false;
|
bool firstToolSpacerAdded = false;
|
||||||
bool hasNonTextSegment = false;
|
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
for (final seg in _segments) {
|
for (final seg in _segments) {
|
||||||
if (seg.isTool && seg.toolCall != null) {
|
if (seg.isTool && seg.toolCall != null) {
|
||||||
@@ -603,16 +602,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
firstToolSpacerAdded = true;
|
firstToolSpacerAdded = true;
|
||||||
}
|
}
|
||||||
children.add(_buildToolCallTile(seg.toolCall!));
|
children.add(_buildToolCallTile(seg.toolCall!));
|
||||||
hasNonTextSegment = true;
|
|
||||||
} else if (seg.isReasoning && seg.reasoning != null) {
|
} else if (seg.isReasoning && seg.reasoning != null) {
|
||||||
children.add(_buildReasoningTile(seg.reasoning!, idx));
|
children.add(_buildReasoningTile(seg.reasoning!, idx));
|
||||||
hasNonTextSegment = true;
|
|
||||||
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
} else if ((seg.text ?? '').trim().isNotEmpty) {
|
||||||
// Add spacing before text content if it follows non-text segments
|
// No extra spacing needed - reasoning/tool tiles have bottom padding
|
||||||
if (hasNonTextSegment) {
|
|
||||||
children.add(const SizedBox(height: Spacing.sm));
|
|
||||||
hasNonTextSegment = false;
|
|
||||||
}
|
|
||||||
children.add(_buildEnhancedMarkdownContent(seg.text!));
|
children.add(_buildEnhancedMarkdownContent(seg.text!));
|
||||||
}
|
}
|
||||||
idx++;
|
idx++;
|
||||||
@@ -704,12 +697,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasVisibleStatus = widget.message.statusHistory
|
// Check if there's a pending (not done) visible status - those have shimmer
|
||||||
|
// so we don't need the typing indicator. But if all visible statuses are
|
||||||
|
// done (e.g., "Retrieved 1 source"), show typing indicator to indicate
|
||||||
|
// the model is still working on generating a response.
|
||||||
|
final visibleStatuses = widget.message.statusHistory
|
||||||
.where((status) => status.hidden != true)
|
.where((status) => status.hidden != true)
|
||||||
.isNotEmpty;
|
.toList();
|
||||||
if (hasVisibleStatus) {
|
final hasPendingStatus = visibleStatuses.any((status) => status.done != true);
|
||||||
|
if (hasPendingStatus) {
|
||||||
|
// Pending status has shimmer effect, no need for typing indicator
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// If all statuses are done but no content yet, show typing indicator
|
||||||
|
|
||||||
final hasFollowUps = widget.message.followUps.isNotEmpty;
|
final hasFollowUps = widget.message.followUps.isNotEmpty;
|
||||||
if (hasFollowUps) {
|
if (hasFollowUps) {
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class CitationBadge extends StatelessWidget {
|
|||||||
SourceHelper.launchSourceUrl(url);
|
SourceHelper.launchSourceUrl(url);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: Spacing.sm,
|
horizontal: Spacing.sm,
|
||||||
@@ -150,33 +150,22 @@ class CitationBadge extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.surfaceContainer.withValues(alpha: 0.6),
|
color: theme.surfaceContainer.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: theme.cardBorder.withValues(alpha: 0.5),
|
color: theme.dividerColor.withValues(alpha: 0.5),
|
||||||
width: BorderWidth.thin,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
displayTitle,
|
||||||
children: [
|
style: TextStyle(
|
||||||
Icon(
|
fontSize: AppTypography.labelSmall,
|
||||||
Icons.link_rounded,
|
fontWeight: FontWeight.w500,
|
||||||
size: 10,
|
color: theme.textSecondary,
|
||||||
color: theme.textSecondary.withValues(alpha: 0.7),
|
),
|
||||||
),
|
maxLines: 1,
|
||||||
const SizedBox(width: Spacing.xxs),
|
overflow: TextOverflow.ellipsis,
|
||||||
Text(
|
|
||||||
displayTitle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: AppTypography.labelSmall,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: theme.textSecondary,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user