feat(task_worker): Enhance image upload with conversion and pre-caching
This commit is contained in:
@@ -1023,49 +1023,11 @@ bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) {
|
||||
return (currentCount + newFilesCount) <= maxCount;
|
||||
}
|
||||
|
||||
// Helper function to get file content as base64
|
||||
Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
|
||||
// Check if this is already a data URL (for images)
|
||||
if (fileId.startsWith('data:')) {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
try {
|
||||
// First, get file info to determine if it's an image
|
||||
final fileInfo = await api.getFileInfo(fileId);
|
||||
|
||||
// Try different fields for filename - check all possible field names
|
||||
final fileName =
|
||||
fileInfo['filename'] ??
|
||||
fileInfo['meta']?['name'] ??
|
||||
fileInfo['name'] ??
|
||||
fileInfo['file_name'] ??
|
||||
fileInfo['original_name'] ??
|
||||
fileInfo['original_filename'] ??
|
||||
'';
|
||||
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
|
||||
// Only process image files (including SVG)
|
||||
if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].contains(ext)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get file content as base64 string
|
||||
final fileContent = await api.getFileContent(fileId);
|
||||
|
||||
// The API service returns base64 string directly
|
||||
return fileContent;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Small internal helper to convert a message with attachments into the
|
||||
// OpenWebUI content payload format (text + image_url + files).
|
||||
// - Adds text first (if non-empty)
|
||||
// - Handles images as inline base64 data URLs (matching web client behavior)
|
||||
// - Includes non-image attachments in a 'files' array for server-side resolution
|
||||
// - Images (base64 or server-stored) go into content array as image_url
|
||||
// - Non-image files go into files array for RAG/server-side resolution
|
||||
Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||
required dynamic api,
|
||||
required String role,
|
||||
@@ -1078,15 +1040,14 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||
contentArray.add({'type': 'text', 'text': cleanedText});
|
||||
}
|
||||
|
||||
// Collect all files in OpenWebUI format for the files array
|
||||
// Collect non-image files for the files array
|
||||
final allFiles = <Map<String, dynamic>>[];
|
||||
|
||||
for (final attachmentId in attachmentIds) {
|
||||
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
|
||||
// Check if this is a base64 data URL (legacy or inline)
|
||||
if (attachmentId.startsWith('data:image/')) {
|
||||
// This is an inline image data URL - add directly to content array
|
||||
// Inline image data URL - add directly to content array for LLM vision
|
||||
contentArray.add({
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': attachmentId},
|
||||
@@ -1094,46 +1055,43 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||
continue;
|
||||
}
|
||||
|
||||
// For server-stored files, fetch info
|
||||
// For server-stored files, fetch info to determine type
|
||||
final fileInfo = await api.getFileInfo(attachmentId);
|
||||
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
|
||||
final fileSize = fileInfo['size'];
|
||||
final fileSize = fileInfo['size'] ?? fileInfo['meta']?['size'];
|
||||
final contentType =
|
||||
fileInfo['meta']?['content_type'] ?? fileInfo['content_type'] ?? '';
|
||||
|
||||
final base64Data = await _getFileAsBase64(api, attachmentId);
|
||||
if (base64Data != null) {
|
||||
// This is an image file from server - add to content array only
|
||||
if (base64Data.startsWith('data:')) {
|
||||
contentArray.add({
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': base64Data},
|
||||
});
|
||||
} else {
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
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';
|
||||
} else if (ext == 'svg') {
|
||||
mimeType = 'image/svg+xml';
|
||||
// Check if this is an image file
|
||||
final isImage = contentType.toString().startsWith('image/');
|
||||
|
||||
if (isImage) {
|
||||
// Images must be in content array as image_url for LLM vision
|
||||
// Fetch the image content from server and convert to base64 data URL
|
||||
try {
|
||||
final fileContent = await api.getFileContent(attachmentId);
|
||||
String dataUrl;
|
||||
if (fileContent.startsWith('data:')) {
|
||||
dataUrl = fileContent;
|
||||
} else {
|
||||
// Determine MIME type from content type or file extension
|
||||
String mimeType = contentType.isNotEmpty
|
||||
? contentType.toString()
|
||||
: _getMimeTypeFromFileName(fileName);
|
||||
dataUrl = 'data:$mimeType;base64,$fileContent';
|
||||
}
|
||||
|
||||
final dataUrl = 'data:$mimeType;base64,$base64Data';
|
||||
contentArray.add({
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': dataUrl},
|
||||
});
|
||||
} catch (_) {
|
||||
// If we can't fetch the image, skip it
|
||||
}
|
||||
|
||||
// Note: Images are handled in content array above, no need to duplicate in files array
|
||||
// This prevents duplicate display in the WebUI
|
||||
} else {
|
||||
// This is a non-image file - match web client format
|
||||
// Non-image files go to files array for RAG/server-side processing
|
||||
allFiles.add({
|
||||
'type': 'file',
|
||||
'id': attachmentId, // Required for RAG system to lookup file content
|
||||
'id': attachmentId,
|
||||
'url': '/api/v1/files/$attachmentId',
|
||||
'name': fileName,
|
||||
if (fileSize != null) 'size': fileSize,
|
||||
@@ -1154,6 +1112,19 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||
return messageMap;
|
||||
}
|
||||
|
||||
String _getMimeTypeFromFileName(String fileName) {
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
return switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
'bmp' => 'image/bmp',
|
||||
_ => 'image/png',
|
||||
};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _contextAttachmentsToFiles(
|
||||
List<ChatContextAttachment> attachments,
|
||||
) {
|
||||
@@ -1751,107 +1722,138 @@ Future<void> _sendMessageInternal(
|
||||
throw Exception('No API service or model selected');
|
||||
}
|
||||
|
||||
Map<String, dynamic>? userSettingsData;
|
||||
String? userSystemPrompt;
|
||||
if (!reviewerMode && api != null) {
|
||||
try {
|
||||
userSettingsData = await api.getUserSettings();
|
||||
userSystemPrompt = _extractSystemPromptFromSettings(userSettingsData);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Check if we need to create a new conversation first
|
||||
var activeConversation = ref.read(activeConversationProvider);
|
||||
|
||||
// Create user message first
|
||||
// Build the files array to match web client format for persistence:
|
||||
// - Images stored as {type: 'image', url: 'data:...'} (matching web client)
|
||||
// - Server files stored as {type: 'file', id: '...', name: '...', url: '...'}
|
||||
// - Context attachments (web/youtube/knowledge)
|
||||
// Get context attachments synchronously (no API calls)
|
||||
final contextAttachments = ref.read(contextAttachmentsProvider);
|
||||
final contextFiles = _contextAttachmentsToFiles(contextAttachments);
|
||||
|
||||
// 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>>[];
|
||||
if (attachments != null && !reviewerMode && api != null) {
|
||||
// Process all attachments in parallel while preserving order
|
||||
final fileInfoFutures = attachments.map((attachment) async {
|
||||
// Data URLs are images - return immediately (no API call needed)
|
||||
if (attachment.startsWith('data:image/')) {
|
||||
return <String, dynamic>{'type': 'image', 'url': 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) {
|
||||
// Reviewer mode or no API - only handle images (server files need API)
|
||||
// All attachments are now server file IDs (images uploaded like OpenWebUI)
|
||||
// Legacy base64 support kept for backwards compatibility
|
||||
final legacyBase64Images = <Map<String, dynamic>>[];
|
||||
final serverFileIds = <String>[];
|
||||
|
||||
if (attachments != null) {
|
||||
for (final attachment in attachments) {
|
||||
if (attachment.startsWith('data:image/')) {
|
||||
attachmentFiles.add({'type': 'image', 'url': attachment});
|
||||
// Legacy base64 format - keep for backwards compatibility
|
||||
legacyBase64Images.add({'type': 'image', 'url': attachment});
|
||||
} else {
|
||||
DebugLogger.log(
|
||||
'Ignoring non-image attachment in reviewer mode: $attachment',
|
||||
scope: 'chat/providers',
|
||||
);
|
||||
// Server file ID (both images and documents)
|
||||
serverFileIds.add(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine attachment files and context files
|
||||
final List<Map<String, dynamic>>? userFiles =
|
||||
(attachmentFiles.isNotEmpty || contextFiles.isNotEmpty)
|
||||
? [...attachmentFiles, ...contextFiles]
|
||||
// Build initial user files with legacy base64 and context (server files added later)
|
||||
final List<Map<String, dynamic>>? initialUserFiles =
|
||||
(legacyBase64Images.isNotEmpty || contextFiles.isNotEmpty)
|
||||
? [...legacyBase64Images, ...contextFiles]
|
||||
: null;
|
||||
|
||||
final userMessage = ChatMessage(
|
||||
id: const Uuid().v4(),
|
||||
// Create user message - files will be updated after fetching server info
|
||||
final userMessageId = const Uuid().v4();
|
||||
var userMessage = ChatMessage(
|
||||
id: userMessageId,
|
||||
role: 'user',
|
||||
content: message,
|
||||
timestamp: DateTime.now(),
|
||||
model: selectedModel.id,
|
||||
attachmentIds: attachments,
|
||||
files: userFiles,
|
||||
files: initialUserFiles,
|
||||
);
|
||||
|
||||
// Add user message to UI immediately for instant feedback
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
// Add assistant placeholder immediately to show typing indicator right away
|
||||
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);
|
||||
|
||||
// Now do async work in parallel: user settings + server file info
|
||||
String? userSystemPrompt;
|
||||
Map<String, dynamic>? userSettingsData;
|
||||
final serverFiles = <Map<String, dynamic>>[];
|
||||
|
||||
if (!reviewerMode && api != null) {
|
||||
// Fetch user settings and server file info in parallel
|
||||
final settingsFuture = api.getUserSettings().catchError((_) => null);
|
||||
final fileInfoFutures = serverFileIds.map((fileId) async {
|
||||
try {
|
||||
final fileInfo = await api.getFileInfo(fileId);
|
||||
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'file';
|
||||
final fileSize = fileInfo['size'] ?? fileInfo['meta']?['size'];
|
||||
final contentType =
|
||||
fileInfo['meta']?['content_type'] ?? fileInfo['content_type'] ?? '';
|
||||
final collectionName =
|
||||
fileInfo['meta']?['collection_name'] ?? fileInfo['collection_name'];
|
||||
|
||||
// Determine type: 'image' for image content types, 'file' for others
|
||||
// .toString() for safety against malformed API responses returning non-String
|
||||
final isImage = contentType.toString().startsWith('image/');
|
||||
return <String, dynamic>{
|
||||
'type': isImage ? 'image' : 'file',
|
||||
'id': fileId,
|
||||
'name': fileName,
|
||||
'url': '/api/v1/files/$fileId', // Full URL for conversation parsing compatibility
|
||||
if (fileSize != null) 'size': fileSize,
|
||||
if (collectionName != null) 'collection_name': collectionName,
|
||||
if (contentType.isNotEmpty) 'content_type': contentType,
|
||||
};
|
||||
} catch (_) {
|
||||
return <String, dynamic>{
|
||||
'type': 'file',
|
||||
'id': fileId,
|
||||
'name': 'file',
|
||||
'url': '/api/v1/files/$fileId',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all async work to complete in parallel
|
||||
final fileInfoResults = await Future.wait(fileInfoFutures);
|
||||
userSettingsData = await settingsFuture;
|
||||
|
||||
if (userSettingsData != null) {
|
||||
userSystemPrompt = _extractSystemPromptFromSettings(userSettingsData);
|
||||
}
|
||||
serverFiles.addAll(fileInfoResults);
|
||||
|
||||
// Update user message with server file info if needed
|
||||
if (serverFiles.isNotEmpty || legacyBase64Images.isNotEmpty) {
|
||||
final allFiles = [...legacyBase64Images, ...serverFiles, ...contextFiles];
|
||||
userMessage = userMessage.copyWith(files: allFiles);
|
||||
ref
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.updateMessageById(
|
||||
userMessageId,
|
||||
(ChatMessage m) => m.copyWith(files: allFiles),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to create a new conversation first
|
||||
var activeConversation = ref.read(activeConversationProvider);
|
||||
|
||||
if (activeConversation == null) {
|
||||
// Check if there's a pending folder ID for this new conversation
|
||||
final pendingFolderId = ref.read(pendingFolderIdProvider);
|
||||
|
||||
// Create new conversation with the first message included
|
||||
// Create new conversation with user message AND assistant placeholder
|
||||
// so the listener doesn't remove the placeholder when setting active
|
||||
final localConversation = Conversation(
|
||||
id: const Uuid().v4(),
|
||||
title: 'New Chat',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
systemPrompt: userSystemPrompt,
|
||||
messages: [userMessage], // Include the user message
|
||||
messages: [userMessage, assistantPlaceholder],
|
||||
folderId: pendingFolderId,
|
||||
);
|
||||
|
||||
@@ -1860,11 +1862,16 @@ Future<void> _sendMessageInternal(
|
||||
activeConversation = localConversation;
|
||||
|
||||
if (!reviewerMode) {
|
||||
// Try to create on server with the first message included
|
||||
// Try to create on server - use lightweight message without large
|
||||
// base64 image data to avoid timeout (images sent in chat request)
|
||||
try {
|
||||
final lightweightMessage = userMessage.copyWith(
|
||||
attachmentIds: null,
|
||||
files: null,
|
||||
);
|
||||
final serverConversation = await api.createConversation(
|
||||
title: 'New Chat',
|
||||
messages: [userMessage], // Include the first message in creation
|
||||
messages: [lightweightMessage],
|
||||
model: selectedModel.id,
|
||||
systemPrompt: userSystemPrompt,
|
||||
folderId: pendingFolderId,
|
||||
@@ -1873,21 +1880,18 @@ Future<void> _sendMessageInternal(
|
||||
// Clear the pending folder ID after successful creation
|
||||
ref.read(pendingFolderIdProvider.notifier).clear();
|
||||
|
||||
// Keep local messages (user + assistant placeholder) instead of server
|
||||
// messages, since we're in the middle of sending and streaming
|
||||
final currentMessages = ref.read(chatMessagesProvider);
|
||||
final updatedConversation = localConversation.copyWith(
|
||||
id: serverConversation.id,
|
||||
systemPrompt: serverConversation.systemPrompt ?? userSystemPrompt,
|
||||
messages: serverConversation.messages.isNotEmpty
|
||||
? serverConversation.messages
|
||||
: [userMessage],
|
||||
messages: currentMessages,
|
||||
folderId: serverConversation.folderId ?? pendingFolderId,
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).set(updatedConversation);
|
||||
activeConversation = updatedConversation;
|
||||
|
||||
// Set messages in the messages provider to keep UI in sync
|
||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
ref
|
||||
.read(conversationsProvider.notifier)
|
||||
.upsertConversation(
|
||||
@@ -1914,22 +1918,13 @@ Future<void> _sendMessageInternal(
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Still add the message locally
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
// Clear the pending folder ID on failure to prevent stale state
|
||||
ref.read(pendingFolderIdProvider.notifier).clear();
|
||||
}
|
||||
} else {
|
||||
// Add message for reviewer mode
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
// Clear the pending folder ID even in reviewer mode
|
||||
ref.read(pendingFolderIdProvider.notifier).clear();
|
||||
}
|
||||
} else {
|
||||
// Add user message to existing conversation
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
}
|
||||
|
||||
if (activeConversation != null &&
|
||||
@@ -1941,19 +1936,6 @@ Future<void> _sendMessageInternal(
|
||||
activeConversation = updated;
|
||||
}
|
||||
|
||||
// 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
|
||||
if (reviewerMode) {
|
||||
// Check if there are attachments
|
||||
|
||||
Reference in New Issue
Block a user