feat(attachments): Optimize file ID extraction and image conversion
This commit is contained in:
@@ -2709,15 +2709,20 @@ class ApiService {
|
|||||||
final processedMessages = messages.map((message) {
|
final processedMessages = messages.map((message) {
|
||||||
final role = message['role'] as String;
|
final role = message['role'] as String;
|
||||||
final content = message['content'];
|
final content = message['content'];
|
||||||
final files = message['files'] as List<Map<String, dynamic>>?;
|
// Safely cast files list - may be List<dynamic> from spread operations
|
||||||
|
final rawFiles = message['files'];
|
||||||
|
final files = rawFiles is List
|
||||||
|
? rawFiles.whereType<Map<String, dynamic>>().toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
|
||||||
final isContentArray = content is List;
|
final isContentArray = content is List;
|
||||||
final hasImages = files?.any((file) => file['type'] == 'image') ?? false;
|
final hasImages =
|
||||||
|
files.isNotEmpty && files.any((file) => file['type'] == 'image');
|
||||||
|
|
||||||
if (isContentArray) {
|
if (isContentArray) {
|
||||||
return {'role': role, 'content': content};
|
return {'role': role, 'content': content};
|
||||||
} else if (hasImages && role == 'user') {
|
} else if (hasImages && role == 'user') {
|
||||||
final imageFiles = files!
|
final imageFiles = files
|
||||||
.where((file) => file['type'] == 'image')
|
.where((file) => file['type'] == 'image')
|
||||||
.toList();
|
.toList();
|
||||||
final contentText = content is String ? content : '';
|
final contentText = content is String ? content : '';
|
||||||
@@ -2741,8 +2746,10 @@ class ApiService {
|
|||||||
// Separate files from messages
|
// Separate files from messages
|
||||||
final allFiles = <Map<String, dynamic>>[];
|
final allFiles = <Map<String, dynamic>>[];
|
||||||
for (final message in messages) {
|
for (final message in messages) {
|
||||||
final files = message['files'] as List<Map<String, dynamic>>?;
|
// Safely cast files list - may be List<dynamic> from spread operations
|
||||||
if (files != null) {
|
final rawFiles = message['files'];
|
||||||
|
if (rawFiles is List) {
|
||||||
|
final files = rawFiles.whereType<Map<String, dynamic>>().toList();
|
||||||
final nonImageFiles = files
|
final nonImageFiles = files
|
||||||
.where((file) => file['type'] != 'image')
|
.where((file) => file['type'] != 'image')
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -279,7 +279,10 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
|||||||
allFiles.add(fileMap);
|
allFiles.add(fileMap);
|
||||||
|
|
||||||
final url = entry['url'].toString();
|
final url = entry['url'].toString();
|
||||||
final match = RegExp(r'/api/v1/files/([^/]+)/content').firstMatch(url);
|
// Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content
|
||||||
|
final match = RegExp(
|
||||||
|
r'/api/v1/files/([^/]+)(?:/content)?$',
|
||||||
|
).firstMatch(url);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
attachments.add(match.group(1)!);
|
attachments.add(match.group(1)!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1116,7 +1116,7 @@ Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
|
|||||||
// Small internal helper to convert a message with attachments into the
|
// Small internal helper to convert a message with attachments into the
|
||||||
// OpenWebUI content payload format (text + image_url + files).
|
// OpenWebUI content payload format (text + image_url + files).
|
||||||
// - Adds text first (if non-empty)
|
// - Adds text first (if non-empty)
|
||||||
// - Converts image attachments to image_url with data URLs (resolving MIME type when needed)
|
// - Handles images as inline base64 data URLs (matching web client behavior)
|
||||||
// - Includes non-image attachments in a 'files' array for server-side resolution
|
// - Includes non-image attachments in a 'files' array for server-side resolution
|
||||||
Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||||
required dynamic api,
|
required dynamic api,
|
||||||
@@ -1135,13 +1135,25 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
|
|
||||||
for (final attachmentId in attachmentIds) {
|
for (final attachmentId in attachmentIds) {
|
||||||
try {
|
try {
|
||||||
|
// Check if this is an image data URL (stored locally, matching web client)
|
||||||
|
// Web client stores images as base64 data URLs, not server file IDs
|
||||||
|
if (attachmentId.startsWith('data:image/')) {
|
||||||
|
// This is an inline image data URL - add directly to content array
|
||||||
|
contentArray.add({
|
||||||
|
'type': 'image_url',
|
||||||
|
'image_url': {'url': attachmentId},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For server-stored files, fetch info
|
||||||
final fileInfo = await api.getFileInfo(attachmentId);
|
final fileInfo = await api.getFileInfo(attachmentId);
|
||||||
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
|
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
|
||||||
final fileSize = fileInfo['size'];
|
final fileSize = fileInfo['size'];
|
||||||
|
|
||||||
final base64Data = await _getFileAsBase64(api, attachmentId);
|
final base64Data = await _getFileAsBase64(api, attachmentId);
|
||||||
if (base64Data != null) {
|
if (base64Data != null) {
|
||||||
// This is an image file - add to content array only
|
// This is an image file from server - add to content array only
|
||||||
if (base64Data.startsWith('data:')) {
|
if (base64Data.startsWith('data:')) {
|
||||||
contentArray.add({
|
contentArray.add({
|
||||||
'type': 'image_url',
|
'type': 'image_url',
|
||||||
@@ -1170,11 +1182,11 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
// Note: Images are handled in content array above, no need to duplicate in files array
|
// Note: Images are handled in content array above, no need to duplicate in files array
|
||||||
// This prevents duplicate display in the WebUI
|
// This prevents duplicate display in the WebUI
|
||||||
} else {
|
} else {
|
||||||
// This is a non-image file
|
// This is a non-image file - match web client format
|
||||||
allFiles.add({
|
allFiles.add({
|
||||||
'type': 'file',
|
'type': 'file',
|
||||||
'id': attachmentId, // Required for RAG system to lookup file content
|
'id': attachmentId, // Required for RAG system to lookup file content
|
||||||
'url': '/api/v1/files/$attachmentId/content',
|
'url': '/api/v1/files/$attachmentId',
|
||||||
'name': fileName,
|
'name': fileName,
|
||||||
if (fileSize != null) 'size': fileSize,
|
if (fileSize != null) 'size': fileSize,
|
||||||
});
|
});
|
||||||
@@ -1799,14 +1811,67 @@ Future<void> _sendMessageInternal(
|
|||||||
var activeConversation = ref.read(activeConversationProvider);
|
var activeConversation = ref.read(activeConversationProvider);
|
||||||
|
|
||||||
// Create user message first
|
// Create user message first
|
||||||
// Note: We only store context attachments (web/youtube/knowledge) in msg.files.
|
// Build the files array to match web client format for persistence:
|
||||||
// Uploaded files are tracked via attachmentIds and will be rebuilt by
|
// - Images stored as {type: 'image', url: 'data:...'} (matching web client)
|
||||||
// _buildMessagePayloadWithAttachments when constructing the API payload.
|
// - Server files stored as {type: 'file', id: '...', name: '...', url: '...'}
|
||||||
// This prevents uploaded files from being duplicated in the final message.
|
// - Context attachments (web/youtube/knowledge)
|
||||||
final contextAttachments = ref.read(contextAttachmentsProvider);
|
final contextAttachments = ref.read(contextAttachmentsProvider);
|
||||||
final contextFiles = _contextAttachmentsToFiles(contextAttachments);
|
final contextFiles = _contextAttachmentsToFiles(contextAttachments);
|
||||||
final List<Map<String, dynamic>>? userFiles = contextFiles.isNotEmpty
|
|
||||||
? contextFiles
|
// Convert attachments to files format for web client compatibility
|
||||||
|
final attachmentFiles = <Map<String, dynamic>>[];
|
||||||
|
if (attachments != null && !reviewerMode && api != null) {
|
||||||
|
for (final attachment in attachments) {
|
||||||
|
// Data URLs are images - store inline
|
||||||
|
if (attachment.startsWith('data:image/')) {
|
||||||
|
attachmentFiles.add({'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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (attachments != null) {
|
||||||
|
// Reviewer mode or no API - only handle images (server files need API)
|
||||||
|
for (final attachment in attachments) {
|
||||||
|
if (attachment.startsWith('data:image/')) {
|
||||||
|
attachmentFiles.add({'type': 'image', 'url': attachment});
|
||||||
|
} else {
|
||||||
|
DebugLogger.log(
|
||||||
|
'Ignoring non-image attachment in reviewer mode: $attachment',
|
||||||
|
scope: 'chat/providers',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine attachment files and context files
|
||||||
|
final List<Map<String, dynamic>>? userFiles =
|
||||||
|
(attachmentFiles.isNotEmpty || contextFiles.isNotEmpty)
|
||||||
|
? [...attachmentFiles, ...contextFiles]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final userMessage = ChatMessage(
|
final userMessage = ChatMessage(
|
||||||
@@ -1969,8 +2034,13 @@ Future<void> _sendMessageInternal(
|
|||||||
attachmentIds: ids,
|
attachmentIds: ids,
|
||||||
);
|
);
|
||||||
if (msg.files != null && msg.files!.isNotEmpty) {
|
if (msg.files != null && msg.files!.isNotEmpty) {
|
||||||
messageMap['files'] = [
|
// Safe cast - messageMap['files'] may be List<dynamic> after storage
|
||||||
...?messageMap['files'] as List<dynamic>?,
|
final rawFiles = messageMap['files'];
|
||||||
|
final existingFiles = rawFiles is List
|
||||||
|
? rawFiles.whereType<Map<String, dynamic>>().toList()
|
||||||
|
: <Map<String, dynamic>>[];
|
||||||
|
messageMap['files'] = <Map<String, dynamic>>[
|
||||||
|
...existingFiles,
|
||||||
...msg.files!,
|
...msg.files!,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import '../../../core/services/api_service.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../../../core/utils/debug_logger.dart';
|
import '../../../core/utils/debug_logger.dart';
|
||||||
|
|
||||||
|
/// Converts an image file to a base64 data URL.
|
||||||
|
/// This is a standalone utility used by both FileAttachmentService and TaskWorker.
|
||||||
|
/// Returns null if conversion fails.
|
||||||
|
Future<String?> convertImageFileToDataUrl(File imageFile) async {
|
||||||
|
try {
|
||||||
|
final bytes = await imageFile.readAsBytes();
|
||||||
|
final ext = path.extension(imageFile.path).toLowerCase();
|
||||||
|
|
||||||
|
String mimeType = 'image/png';
|
||||||
|
if (ext == '.jpg' || ext == '.jpeg') {
|
||||||
|
mimeType = 'image/jpeg';
|
||||||
|
} else if (ext == '.gif') {
|
||||||
|
mimeType = 'image/gif';
|
||||||
|
} else if (ext == '.webp') {
|
||||||
|
mimeType = 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'data:$mimeType;base64,${base64Encode(bytes)}';
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger.error('convert-image-failed', scope: 'attachments', error: e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _deriveDisplayName({
|
String _deriveDisplayName({
|
||||||
required String? preferredName,
|
required String? preferredName,
|
||||||
required String filePath,
|
required String filePath,
|
||||||
@@ -73,10 +96,9 @@ class LocalAttachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FileAttachmentService {
|
class FileAttachmentService {
|
||||||
final ApiService _apiService;
|
|
||||||
final ImagePicker _imagePicker = ImagePicker();
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
|
|
||||||
FileAttachmentService(this._apiService);
|
FileAttachmentService();
|
||||||
|
|
||||||
// Pick files from device
|
// Pick files from device
|
||||||
Future<List<LocalAttachment>> pickFiles({
|
Future<List<LocalAttachment>> pickFiles({
|
||||||
@@ -269,139 +291,23 @@ class FileAttachmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert image file to base64 data URL with compression
|
// Convert image file to base64 data URL with optional compression
|
||||||
Future<String?> convertImageToDataUrl(
|
Future<String?> convertImageToDataUrl(
|
||||||
File imageFile, {
|
File imageFile, {
|
||||||
bool enableCompression = false,
|
bool enableCompression = false,
|
||||||
int? maxWidth,
|
int? maxWidth,
|
||||||
int? maxHeight,
|
int? maxHeight,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
// Use the shared utility for basic conversion
|
||||||
DebugLogger.log(
|
String? dataUrl = await convertImageFileToDataUrl(imageFile);
|
||||||
'convert-start',
|
if (dataUrl == null) return null;
|
||||||
scope: 'attachments/image',
|
|
||||||
data: {'path': imageFile.path},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Read the file as bytes
|
|
||||||
final bytes = await imageFile.readAsBytes();
|
|
||||||
|
|
||||||
// Determine MIME type based on file extension
|
|
||||||
final ext = path.extension(imageFile.path).toLowerCase();
|
|
||||||
String mimeType = 'image/png'; // default
|
|
||||||
|
|
||||||
if (ext == '.jpg' || ext == '.jpeg') {
|
|
||||||
mimeType = 'image/jpeg';
|
|
||||||
} else if (ext == '.gif') {
|
|
||||||
mimeType = 'image/gif';
|
|
||||||
} else if (ext == '.webp') {
|
|
||||||
mimeType = 'image/webp';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to base64
|
|
||||||
final base64String = base64Encode(bytes);
|
|
||||||
String dataUrl = 'data:$mimeType;base64,$base64String';
|
|
||||||
|
|
||||||
// Apply compression if enabled
|
// Apply compression if enabled
|
||||||
if (enableCompression && (maxWidth != null || maxHeight != null)) {
|
if (enableCompression && (maxWidth != null || maxHeight != null)) {
|
||||||
dataUrl = await compressImage(dataUrl, maxWidth, maxHeight);
|
dataUrl = await compressImage(dataUrl, maxWidth, maxHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugLogger.log(
|
|
||||||
'convert-done',
|
|
||||||
scope: 'attachments/image',
|
|
||||||
data: {'mime': mimeType},
|
|
||||||
);
|
|
||||||
return dataUrl;
|
return dataUrl;
|
||||||
} catch (e) {
|
|
||||||
DebugLogger.error('convert-failed', scope: 'attachments/image', error: e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload file with progress tracking
|
|
||||||
Stream<FileUploadState> uploadFile(LocalAttachment attachment) async* {
|
|
||||||
DebugLogger.log(
|
|
||||||
'upload-start',
|
|
||||||
scope: 'attachments/file',
|
|
||||||
data: {
|
|
||||||
'path': attachment.file.path,
|
|
||||||
'displayName': attachment.displayName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
final file = attachment.file;
|
|
||||||
final fileName = attachment.displayName;
|
|
||||||
final fileSize = await file.length();
|
|
||||||
final ext = path.extension(fileName).toLowerCase();
|
|
||||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
|
||||||
|
|
||||||
DebugLogger.log(
|
|
||||||
'file-details',
|
|
||||||
scope: 'attachments/file',
|
|
||||||
data: {'name': fileName, 'bytes': fileSize},
|
|
||||||
);
|
|
||||||
|
|
||||||
yield FileUploadState(
|
|
||||||
file: file,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
progress: 0.0,
|
|
||||||
status: FileUploadStatus.uploading,
|
|
||||||
isImage: isImage,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Upload ALL files (including images) to server for consistency with web client
|
|
||||||
DebugLogger.log('upload-progress', scope: 'attachments/file');
|
|
||||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
|
||||||
DebugLogger.log(
|
|
||||||
'upload-complete',
|
|
||||||
scope: 'attachments/file',
|
|
||||||
data: {'fileId': fileId},
|
|
||||||
);
|
|
||||||
|
|
||||||
yield FileUploadState(
|
|
||||||
file: file,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
progress: 1.0,
|
|
||||||
status: FileUploadStatus.completed,
|
|
||||||
fileId: fileId,
|
|
||||||
isImage: isImage,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
DebugLogger.error('upload-failed', scope: 'attachments/file', error: e);
|
|
||||||
final file = attachment.file;
|
|
||||||
final fileName = attachment.displayName;
|
|
||||||
final fileSize = await file.length();
|
|
||||||
final ext = path.extension(fileName).toLowerCase();
|
|
||||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
|
||||||
|
|
||||||
yield FileUploadState(
|
|
||||||
file: file,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
progress: 0.0,
|
|
||||||
status: FileUploadStatus.failed,
|
|
||||||
error: e.toString(),
|
|
||||||
isImage: isImage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload multiple files
|
|
||||||
Stream<List<FileUploadState>> uploadMultipleFiles(
|
|
||||||
List<LocalAttachment> attachments,
|
|
||||||
) async* {
|
|
||||||
final states = <String, FileUploadState>{};
|
|
||||||
|
|
||||||
for (final attachment in attachments) {
|
|
||||||
final uploadStream = uploadFile(attachment);
|
|
||||||
await for (final state in uploadStream) {
|
|
||||||
states[attachment.file.path] = state;
|
|
||||||
yield states.values.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format file size for display
|
// Format file size for display
|
||||||
@@ -452,7 +358,11 @@ class FileUploadState {
|
|||||||
final FileUploadStatus status;
|
final FileUploadStatus status;
|
||||||
final String? fileId;
|
final String? fileId;
|
||||||
final String? error;
|
final String? error;
|
||||||
final bool? isImage; // Added for image files
|
final bool? isImage;
|
||||||
|
|
||||||
|
/// For images: stores the base64 data URL (e.g., "data:image/png;base64,...")
|
||||||
|
/// This matches web client behavior where images are not uploaded to server.
|
||||||
|
final String? base64DataUrl;
|
||||||
|
|
||||||
FileUploadState({
|
FileUploadState({
|
||||||
required this.file,
|
required this.file,
|
||||||
@@ -462,7 +372,8 @@ class FileUploadState {
|
|||||||
required this.status,
|
required this.status,
|
||||||
this.fileId,
|
this.fileId,
|
||||||
this.error,
|
this.error,
|
||||||
this.isImage, // Added for image files
|
this.isImage,
|
||||||
|
this.base64DataUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get formattedSize {
|
String get formattedSize {
|
||||||
@@ -578,78 +489,6 @@ class MockFileAttachmentService {
|
|||||||
throw Exception('Failed to take photo: $e');
|
throw Exception('Failed to take photo: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock upload file with progress tracking
|
|
||||||
Stream<FileUploadState> uploadFile(LocalAttachment attachment) async* {
|
|
||||||
DebugLogger.log(
|
|
||||||
'mock-upload',
|
|
||||||
scope: 'attachments/mock',
|
|
||||||
data: {
|
|
||||||
'path': attachment.file.path,
|
|
||||||
'displayName': attachment.displayName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final file = attachment.file;
|
|
||||||
final fileName = attachment.displayName;
|
|
||||||
final fileSize = await file.length();
|
|
||||||
|
|
||||||
// Yield initial state
|
|
||||||
yield FileUploadState(
|
|
||||||
file: file,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
progress: 0.0,
|
|
||||||
status: FileUploadStatus.uploading,
|
|
||||||
isImage: attachment.isImage,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate upload progress
|
|
||||||
for (int i = 1; i <= 10; i++) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
yield FileUploadState(
|
|
||||||
file: file,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
progress: i / 10,
|
|
||||||
status: FileUploadStatus.uploading,
|
|
||||||
isImage: attachment.isImage,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield completed state with mock file ID
|
|
||||||
yield FileUploadState(
|
|
||||||
file: file,
|
|
||||||
fileName: fileName,
|
|
||||||
fileSize: fileSize,
|
|
||||||
progress: 1.0,
|
|
||||||
status: FileUploadStatus.completed,
|
|
||||||
fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
isImage: attachment.isImage,
|
|
||||||
);
|
|
||||||
|
|
||||||
DebugLogger.log('mock-complete', scope: 'attachments/mock');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> uploadFiles(
|
|
||||||
List<LocalAttachment> attachments, {
|
|
||||||
Function(int, int)? onProgress,
|
|
||||||
required String conversationId,
|
|
||||||
}) async {
|
|
||||||
final uploadIds = <String>[];
|
|
||||||
|
|
||||||
for (int i = 0; i < attachments.length; i++) {
|
|
||||||
if (onProgress != null) {
|
|
||||||
for (int j = 0; j <= 100; j += 10) {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
|
||||||
onProgress(i, j);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i');
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploadIds;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
@@ -660,9 +499,11 @@ final fileAttachmentServiceProvider = Provider<dynamic>((ref) {
|
|||||||
return MockFileAttachmentService();
|
return MockFileAttachmentService();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard: only provide service when user is logged in
|
||||||
final apiService = ref.watch(apiServiceProvider);
|
final apiService = ref.watch(apiServiceProvider);
|
||||||
if (apiService == null) return null;
|
if (apiService == null) return null;
|
||||||
return FileAttachmentService(apiService);
|
|
||||||
|
return FileAttachmentService();
|
||||||
});
|
});
|
||||||
|
|
||||||
// State notifier for managing attached files
|
// State notifier for managing attached files
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ import 'streaming_status_widget.dart';
|
|||||||
|
|
||||||
// Pre-compiled regex patterns for image processing (performance optimization)
|
// Pre-compiled regex patterns for image processing (performance optimization)
|
||||||
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
|
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
|
||||||
final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)/content');
|
// Handle both URL formats: /api/v1/files/{id} and /api/v1/files/{id}/content
|
||||||
|
final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)(?:/content)?$');
|
||||||
|
|
||||||
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
class AssistantMessageWidget extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
@@ -1116,10 +1117,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
|
|
||||||
if (fileUrl == null) return const SizedBox.shrink();
|
if (fileUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
// Extract file ID from URL if it's in the format /api/v1/files/{id}/content
|
// Extract file ID from URL - handle both formats:
|
||||||
|
// /api/v1/files/{id} and /api/v1/files/{id}/content
|
||||||
String attachmentId = fileUrl;
|
String attachmentId = fileUrl;
|
||||||
if (fileUrl.contains('/api/v1/files/') &&
|
if (fileUrl.contains('/api/v1/files/')) {
|
||||||
fileUrl.contains('/content')) {
|
|
||||||
final fileIdMatch = _fileIdPattern.firstMatch(fileUrl);
|
final fileIdMatch = _fileIdPattern.firstMatch(fileUrl);
|
||||||
if (fileIdMatch != null) {
|
if (fileIdMatch != null) {
|
||||||
attachmentId = fileIdMatch.group(1)!;
|
attachmentId = fileIdMatch.group(1)!;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import '../../../shared/services/tasks/task_queue.dart';
|
|||||||
import '../../../shared/utils/conversation_context_menu.dart';
|
import '../../../shared/utils/conversation_context_menu.dart';
|
||||||
import '../../tools/providers/tools_providers.dart';
|
import '../../tools/providers/tools_providers.dart';
|
||||||
|
|
||||||
|
// Pre-compiled regex for extracting file IDs from URLs (performance optimization)
|
||||||
|
// Handles both /api/v1/files/{id} and /api/v1/files/{id}/content formats
|
||||||
|
final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)(?:/content)?$');
|
||||||
|
|
||||||
class UserMessageBubble extends ConsumerStatefulWidget {
|
class UserMessageBubble extends ConsumerStatefulWidget {
|
||||||
final dynamic message;
|
final dynamic message;
|
||||||
final bool isUser;
|
final bool isUser;
|
||||||
@@ -377,13 +381,11 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble> {
|
|||||||
|
|
||||||
if (fileUrl == null) return const SizedBox.shrink();
|
if (fileUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
// Extract file ID from URL if it's in the format /api/v1/files/{id}/content
|
// Extract file ID from URL - handle both formats:
|
||||||
|
// /api/v1/files/{id} and /api/v1/files/{id}/content
|
||||||
String attachmentId = fileUrl;
|
String attachmentId = fileUrl;
|
||||||
if (fileUrl.contains('/api/v1/files/') &&
|
if (fileUrl.contains('/api/v1/files/')) {
|
||||||
fileUrl.contains('/content')) {
|
final fileIdMatch = _fileIdPattern.firstMatch(fileUrl);
|
||||||
final fileIdMatch = RegExp(
|
|
||||||
r'/api/v1/files/([^/]+)/content',
|
|
||||||
).firstMatch(fileUrl);
|
|
||||||
if (fileIdMatch != null) {
|
if (fileIdMatch != null) {
|
||||||
attachmentId = fileIdMatch.group(1)!;
|
attachmentId = fileIdMatch.group(1)!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
@@ -74,8 +74,19 @@ class TaskWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performUploadMedia(UploadMediaTask task) async {
|
Future<void> _performUploadMedia(UploadMediaTask task) async {
|
||||||
|
const imageExts = <String>{'.jpg', '.jpeg', '.png', '.gif', '.webp'};
|
||||||
|
final lowerName = task.fileName.toLowerCase();
|
||||||
|
final bool isImage = imageExts.any(lowerName.endsWith);
|
||||||
|
|
||||||
|
// For images: read as base64 locally (matching web client behavior)
|
||||||
|
// Web client never uploads images to /api/v1/files/
|
||||||
|
if (isImage) {
|
||||||
|
await _handleImageAsBase64(task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-images: upload to server
|
||||||
final uploader = AttachmentUploadQueue();
|
final uploader = AttachmentUploadQueue();
|
||||||
// Ensure queue initialized with API upload callback
|
|
||||||
try {
|
try {
|
||||||
final api = _ref.read(apiServiceProvider);
|
final api = _ref.read(apiServiceProvider);
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
@@ -83,7 +94,6 @@ class TaskWorker {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// Enqueue and then wait until the item reaches a terminal state for basic parity
|
|
||||||
final id = await uploader.enqueue(
|
final id = await uploader.enqueue(
|
||||||
filePath: task.filePath,
|
filePath: task.filePath,
|
||||||
fileName: task.fileName,
|
fileName: task.fileName,
|
||||||
@@ -103,7 +113,6 @@ class TaskWorker {
|
|||||||
}
|
}
|
||||||
if (entry == null) return;
|
if (entry == null) return;
|
||||||
|
|
||||||
// Reflect progress into UI attachment state if that file is present
|
|
||||||
try {
|
try {
|
||||||
final current = _ref.read(attachedFilesProvider);
|
final current = _ref.read(attachedFilesProvider);
|
||||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||||
@@ -116,10 +125,6 @@ class TaskWorker {
|
|||||||
QueuedAttachmentStatus.failed => FileUploadStatus.failed,
|
QueuedAttachmentStatus.failed => FileUploadStatus.failed,
|
||||||
QueuedAttachmentStatus.cancelled => FileUploadStatus.failed,
|
QueuedAttachmentStatus.cancelled => FileUploadStatus.failed,
|
||||||
};
|
};
|
||||||
const imageExts = <String>{'.jpg', '.jpeg', '.png', '.gif', '.webp'};
|
|
||||||
final lowerName = task.fileName.toLowerCase();
|
|
||||||
final bool isImage =
|
|
||||||
existing.isImage ?? imageExts.any(lowerName.endsWith);
|
|
||||||
final newState = FileUploadState(
|
final newState = FileUploadState(
|
||||||
file: File(task.filePath),
|
file: File(task.filePath),
|
||||||
fileName: task.fileName,
|
fileName: task.fileName,
|
||||||
@@ -130,7 +135,7 @@ class TaskWorker {
|
|||||||
status: status,
|
status: status,
|
||||||
fileId: entry.fileId ?? existing.fileId,
|
fileId: entry.fileId ?? existing.fileId,
|
||||||
error: entry.lastError,
|
error: entry.lastError,
|
||||||
isImage: isImage,
|
isImage: false,
|
||||||
);
|
);
|
||||||
_ref
|
_ref
|
||||||
.read(attachedFilesProvider.notifier)
|
.read(attachedFilesProvider.notifier)
|
||||||
@@ -149,7 +154,6 @@ class TaskWorker {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fire a process tick
|
|
||||||
unawaited(uploader.processQueue());
|
unawaited(uploader.processQueue());
|
||||||
await completer.future.timeout(
|
await completer.future.timeout(
|
||||||
const Duration(minutes: 2),
|
const Duration(minutes: 2),
|
||||||
@@ -163,6 +167,69 @@ class TaskWorker {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles image files by reading as base64 locally (matching web client)
|
||||||
|
Future<void> _handleImageAsBase64(UploadMediaTask task) async {
|
||||||
|
try {
|
||||||
|
final file = File(task.filePath);
|
||||||
|
final base64DataUrl = await convertImageFileToDataUrl(file);
|
||||||
|
|
||||||
|
if (base64DataUrl == null) {
|
||||||
|
throw Exception('Failed to convert image to base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update attachment state with base64 data URL
|
||||||
|
final current = _ref.read(attachedFilesProvider);
|
||||||
|
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||||
|
if (idx != -1) {
|
||||||
|
final existing = current[idx];
|
||||||
|
final newState = FileUploadState(
|
||||||
|
file: file,
|
||||||
|
fileName: task.fileName,
|
||||||
|
fileSize: task.fileSize ?? existing.fileSize,
|
||||||
|
progress: 1.0,
|
||||||
|
status: FileUploadStatus.completed,
|
||||||
|
fileId: base64DataUrl,
|
||||||
|
isImage: true,
|
||||||
|
base64DataUrl: base64DataUrl,
|
||||||
|
);
|
||||||
|
_ref
|
||||||
|
.read(attachedFilesProvider.notifier)
|
||||||
|
.updateFileState(task.filePath, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLogger.log(
|
||||||
|
'image-base64-complete',
|
||||||
|
scope: 'tasks/upload',
|
||||||
|
data: {
|
||||||
|
'fileName': task.fileName,
|
||||||
|
'dataUrlLength': base64DataUrl.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
DebugLogger.error('image-base64-failed', scope: 'tasks/upload', error: e);
|
||||||
|
// Update state to failed
|
||||||
|
try {
|
||||||
|
final current = _ref.read(attachedFilesProvider);
|
||||||
|
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||||
|
if (idx != -1) {
|
||||||
|
final existing = current[idx];
|
||||||
|
final newState = FileUploadState(
|
||||||
|
file: File(task.filePath),
|
||||||
|
fileName: task.fileName,
|
||||||
|
fileSize: task.fileSize ?? existing.fileSize,
|
||||||
|
progress: 0.0,
|
||||||
|
status: FileUploadStatus.failed,
|
||||||
|
error: e.toString(),
|
||||||
|
isImage: true,
|
||||||
|
);
|
||||||
|
_ref
|
||||||
|
.read(attachedFilesProvider.notifier)
|
||||||
|
.updateFileState(task.filePath, newState);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _performExecuteToolCall(ExecuteToolCallTask task) async {
|
Future<void> _performExecuteToolCall(ExecuteToolCallTask task) async {
|
||||||
// Resolve API + selected model
|
// Resolve API + selected model
|
||||||
final api = _ref.read(apiServiceProvider);
|
final api = _ref.read(apiServiceProvider);
|
||||||
@@ -253,102 +320,69 @@ class TaskWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performImageToDataUrl(ImageToDataUrlTask task) async {
|
Future<void> _performImageToDataUrl(ImageToDataUrlTask task) async {
|
||||||
// Upload images to server instead of converting to data URLs
|
// Convert image to base64 data URL locally (matching web client behavior)
|
||||||
final uploader = AttachmentUploadQueue();
|
|
||||||
try {
|
try {
|
||||||
final api = _ref.read(apiServiceProvider);
|
final file = File(task.filePath);
|
||||||
if (api != null) {
|
final base64DataUrl = await convertImageFileToDataUrl(file);
|
||||||
await uploader.initialize(onUpload: (p, n) => api.uploadFile(p, n));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
try {
|
if (base64DataUrl == null) {
|
||||||
|
throw Exception('Failed to convert image to base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update attachment state with base64 data URL
|
||||||
final current = _ref.read(attachedFilesProvider);
|
final current = _ref.read(attachedFilesProvider);
|
||||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||||
if (idx != -1) {
|
if (idx != -1) {
|
||||||
final existing = current[idx];
|
final existing = current[idx];
|
||||||
final uploading = FileUploadState(
|
final newState = FileUploadState(
|
||||||
file: existing.file,
|
file: file,
|
||||||
fileName: task.fileName,
|
fileName: task.fileName,
|
||||||
fileSize: existing.fileSize,
|
fileSize: existing.fileSize,
|
||||||
progress: 0.0,
|
progress: 1.0,
|
||||||
status: FileUploadStatus.uploading,
|
status: FileUploadStatus.completed,
|
||||||
fileId: existing.fileId,
|
fileId: base64DataUrl,
|
||||||
isImage: existing.isImage ?? true,
|
isImage: true,
|
||||||
|
base64DataUrl: base64DataUrl,
|
||||||
);
|
);
|
||||||
_ref
|
_ref
|
||||||
.read(attachedFilesProvider.notifier)
|
.read(attachedFilesProvider.notifier)
|
||||||
.updateFileState(task.filePath, uploading);
|
.updateFileState(task.filePath, newState);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
final id = await uploader.enqueue(
|
DebugLogger.log(
|
||||||
filePath: task.filePath,
|
'image-to-dataurl-complete',
|
||||||
fileName: task.fileName,
|
scope: 'tasks/image',
|
||||||
fileSize: File(task.filePath).lengthSync(),
|
data: {
|
||||||
|
'fileName': task.fileName,
|
||||||
|
'dataUrlLength': base64DataUrl.length,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
final completer = Completer<void>();
|
DebugLogger.error(
|
||||||
late final StreamSubscription<List<QueuedAttachment>> sub;
|
'image-to-dataurl-failed',
|
||||||
sub = uploader.queueStream.listen((items) {
|
scope: 'tasks/image',
|
||||||
QueuedAttachment? entry;
|
error: e,
|
||||||
try {
|
);
|
||||||
entry = items.firstWhere((e) => e.id == id);
|
// Update state to failed
|
||||||
} catch (_) {
|
|
||||||
entry = null;
|
|
||||||
}
|
|
||||||
if (entry == null) return;
|
|
||||||
try {
|
try {
|
||||||
final current = _ref.read(attachedFilesProvider);
|
final current = _ref.read(attachedFilesProvider);
|
||||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||||
if (idx != -1) {
|
if (idx != -1) {
|
||||||
final existing = current[idx];
|
final existing = current[idx];
|
||||||
final status = switch (entry.status) {
|
|
||||||
QueuedAttachmentStatus.pending => FileUploadStatus.uploading,
|
|
||||||
QueuedAttachmentStatus.uploading => FileUploadStatus.uploading,
|
|
||||||
QueuedAttachmentStatus.completed => FileUploadStatus.completed,
|
|
||||||
QueuedAttachmentStatus.failed => FileUploadStatus.failed,
|
|
||||||
QueuedAttachmentStatus.cancelled => FileUploadStatus.failed,
|
|
||||||
};
|
|
||||||
final newState = FileUploadState(
|
final newState = FileUploadState(
|
||||||
file: File(task.filePath),
|
file: File(task.filePath),
|
||||||
fileName: task.fileName,
|
fileName: task.fileName,
|
||||||
fileSize: existing.fileSize,
|
fileSize: existing.fileSize,
|
||||||
progress: status == FileUploadStatus.completed
|
progress: 0.0,
|
||||||
? 1.0
|
status: FileUploadStatus.failed,
|
||||||
: existing.progress,
|
error: e.toString(),
|
||||||
status: status,
|
|
||||||
fileId: entry.fileId ?? existing.fileId,
|
|
||||||
isImage: true,
|
isImage: true,
|
||||||
error: entry.lastError,
|
|
||||||
);
|
);
|
||||||
_ref
|
_ref
|
||||||
.read(attachedFilesProvider.notifier)
|
.read(attachedFilesProvider.notifier)
|
||||||
.updateFileState(task.filePath, newState);
|
.updateFileState(task.filePath, newState);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
switch (entry.status) {
|
}
|
||||||
case QueuedAttachmentStatus.completed:
|
|
||||||
case QueuedAttachmentStatus.failed:
|
|
||||||
case QueuedAttachmentStatus.cancelled:
|
|
||||||
sub.cancel();
|
|
||||||
completer.complete();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
unawaited(uploader.processQueue());
|
|
||||||
await completer.future.timeout(
|
|
||||||
const Duration(minutes: 2),
|
|
||||||
onTimeout: () {
|
|
||||||
try {
|
|
||||||
sub.cancel();
|
|
||||||
} catch (_) {}
|
|
||||||
DebugLogger.warning('Image upload timed out: ${task.fileName}');
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user