fix: image and files previews on the web
This commit is contained in:
@@ -788,24 +788,44 @@ class ApiService {
|
|||||||
if (effectiveFiles != null) {
|
if (effectiveFiles != null) {
|
||||||
final filesList = effectiveFiles as List;
|
final filesList = effectiveFiles as List;
|
||||||
|
|
||||||
// Separate user uploads (with file_id) from generated images (with type and url)
|
// Handle different file formats from OpenWebUI
|
||||||
final userAttachments = <String>[];
|
final userAttachments = <String>[];
|
||||||
final generatedFiles = <Map<String, dynamic>>[];
|
final allFiles = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
for (final file in filesList) {
|
for (final file in filesList) {
|
||||||
if (file is Map) {
|
if (file is Map) {
|
||||||
if (file['file_id'] != null) {
|
if (file['file_id'] != null) {
|
||||||
// User uploaded file
|
// User uploaded file with file_id (legacy format)
|
||||||
userAttachments.add(file['file_id'] as String);
|
userAttachments.add(file['file_id'] as String);
|
||||||
} else if (file['type'] == 'image' && file['url'] != null) {
|
} else if (file['type'] != null && file['url'] != null) {
|
||||||
// Generated image
|
// File with type and url (OpenWebUI format)
|
||||||
generatedFiles.add({'type': file['type'], 'url': file['url']});
|
final fileMap = <String, dynamic>{
|
||||||
|
'type': file['type'],
|
||||||
|
'url': file['url'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add optional fields if present
|
||||||
|
if (file['name'] != null) fileMap['name'] = file['name'];
|
||||||
|
if (file['size'] != null) fileMap['size'] = file['size'];
|
||||||
|
|
||||||
|
allFiles.add(fileMap);
|
||||||
|
|
||||||
|
// If this is a user-uploaded file (URL contains file ID), also extract the ID
|
||||||
|
final url = file['url'] as String;
|
||||||
|
if (url.contains('/api/v1/files/') && url.contains('/content')) {
|
||||||
|
final fileIdMatch = RegExp(
|
||||||
|
r'/api/v1/files/([^/]+)/content',
|
||||||
|
).firstMatch(url);
|
||||||
|
if (fileIdMatch != null) {
|
||||||
|
userAttachments.add(fileIdMatch.group(1)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attachmentIds = userAttachments.isNotEmpty ? userAttachments : null;
|
attachmentIds = userAttachments.isNotEmpty ? userAttachments : null;
|
||||||
files = generatedFiles.isNotEmpty ? generatedFiles : null;
|
files = allFiles.isNotEmpty ? allFiles : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
@@ -1084,8 +1104,8 @@ class ApiService {
|
|||||||
return _parseFullOpenWebUIChat(responseData);
|
return _parseFullOpenWebUIChat(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update conversation with full chat data including all messages
|
// Sync conversation messages to ensure WebUI can load conversation history
|
||||||
Future<void> updateConversationWithMessages(
|
Future<void> syncConversationMessages(
|
||||||
String conversationId,
|
String conversationId,
|
||||||
List<ChatMessage> messages, {
|
List<ChatMessage> messages, {
|
||||||
String? title,
|
String? title,
|
||||||
@@ -1093,7 +1113,7 @@ class ApiService {
|
|||||||
String? systemPrompt,
|
String? systemPrompt,
|
||||||
}) async {
|
}) async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'DEBUG: Updating conversation $conversationId with ${messages.length} messages',
|
'DEBUG: Syncing conversation $conversationId with ${messages.length} messages',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build messages map and array in OpenWebUI format
|
// Build messages map and array in OpenWebUI format
|
||||||
@@ -1105,21 +1125,11 @@ class ApiService {
|
|||||||
for (final msg in messages) {
|
for (final msg in messages) {
|
||||||
final messageId = msg.id;
|
final messageId = msg.id;
|
||||||
|
|
||||||
// Build message for messages map (history.messages)
|
// Use the properly formatted files array for WebUI display
|
||||||
final List<Map<String, dynamic>> combinedFilesMap = [];
|
// The msg.files array already contains all attachments in the correct format
|
||||||
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
|
final List<Map<String, dynamic>> combinedFilesMap = msg.files ?? [];
|
||||||
for (final id in msg.attachmentIds!) {
|
|
||||||
if (id.startsWith('data:') || id.startsWith('http')) {
|
|
||||||
combinedFilesMap.add({'type': 'image', 'url': id});
|
|
||||||
} else {
|
|
||||||
combinedFilesMap.add({'file_id': id});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.files != null && msg.files!.isNotEmpty) {
|
|
||||||
combinedFilesMap.addAll(msg.files!);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Build message for messages map (history.messages)
|
||||||
messagesMap[messageId] = {
|
messagesMap[messageId] = {
|
||||||
'id': messageId,
|
'id': messageId,
|
||||||
'parentId': previousId,
|
'parentId': previousId,
|
||||||
@@ -1141,21 +1151,10 @@ class ApiService {
|
|||||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build message for messages array
|
// Use the same properly formatted files array for messages array
|
||||||
final List<Map<String, dynamic>> combinedFilesArray = [];
|
final List<Map<String, dynamic>> combinedFilesArray = msg.files ?? [];
|
||||||
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) {
|
|
||||||
for (final id in msg.attachmentIds!) {
|
|
||||||
if (id.startsWith('data:') || id.startsWith('http')) {
|
|
||||||
combinedFilesArray.add({'type': 'image', 'url': id});
|
|
||||||
} else {
|
|
||||||
combinedFilesArray.add({'file_id': id});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.files != null && msg.files!.isNotEmpty) {
|
|
||||||
combinedFilesArray.addAll(msg.files!);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Build message for messages array
|
||||||
messagesArray.add({
|
messagesArray.add({
|
||||||
'id': messageId,
|
'id': messageId,
|
||||||
'parentId': previousId,
|
'parentId': previousId,
|
||||||
@@ -1193,12 +1192,12 @@ class ApiService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
debugPrint('DEBUG: Updating chat with OpenWebUI format data using POST');
|
debugPrint('DEBUG: Syncing chat with OpenWebUI format data using POST');
|
||||||
|
|
||||||
// OpenWebUI uses POST not PUT for updating chats
|
// OpenWebUI uses POST not PUT for updating chats
|
||||||
await _dio.post('/api/v1/chats/$conversationId', data: chatData);
|
await _dio.post('/api/v1/chats/$conversationId', data: chatData);
|
||||||
|
|
||||||
DebugLogger.log('Update conversation response received successfully');
|
DebugLogger.log('Sync conversation response received successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateConversation(
|
Future<void> updateConversation(
|
||||||
|
|||||||
@@ -525,9 +525,6 @@ Future<String> _preseedAssistantAndPersist(
|
|||||||
required String modelId,
|
required String modelId,
|
||||||
String? systemPrompt,
|
String? systemPrompt,
|
||||||
}) async {
|
}) async {
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
final activeConv = ref.read(activeConversationProvider);
|
|
||||||
|
|
||||||
// Choose id: reuse existing if provided, else create new
|
// Choose id: reuse existing if provided, else create new
|
||||||
final String assistantMessageId =
|
final String assistantMessageId =
|
||||||
(existingAssistantId != null && existingAssistantId.isNotEmpty)
|
(existingAssistantId != null && existingAssistantId.isNotEmpty)
|
||||||
@@ -564,22 +561,26 @@ Future<String> _preseedAssistantAndPersist(
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the skeleton to the server so the web client sees a correct chain
|
// Sync conversation state to ensure WebUI can load conversation history
|
||||||
try {
|
try {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
final activeConv = ref.read(activeConversationProvider);
|
||||||
if (api != null && activeConv != null) {
|
if (api != null && activeConv != null) {
|
||||||
final resolvedSystemPrompt =
|
final resolvedSystemPrompt =
|
||||||
(systemPrompt != null && systemPrompt.trim().isNotEmpty)
|
(systemPrompt != null && systemPrompt.trim().isNotEmpty)
|
||||||
? systemPrompt.trim()
|
? systemPrompt.trim()
|
||||||
: activeConv.systemPrompt;
|
: activeConv.systemPrompt;
|
||||||
final current = ref.read(chatMessagesProvider);
|
final current = ref.read(chatMessagesProvider);
|
||||||
await api.updateConversationWithMessages(
|
await api.syncConversationMessages(
|
||||||
activeConv.id,
|
activeConv.id,
|
||||||
current,
|
current,
|
||||||
model: modelId,
|
model: modelId,
|
||||||
systemPrompt: resolvedSystemPrompt,
|
systemPrompt: resolvedSystemPrompt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
// Non-critical - continue if sync fails
|
||||||
|
}
|
||||||
|
|
||||||
return assistantMessageId;
|
return assistantMessageId;
|
||||||
}
|
}
|
||||||
@@ -708,6 +709,47 @@ bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) {
|
|||||||
return (currentCount + newFilesCount) <= maxCount;
|
return (currentCount + newFilesCount) <= maxCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to build files array from attachment IDs
|
||||||
|
Future<List<Map<String, dynamic>>?> _buildFilesArrayFromAttachments(
|
||||||
|
dynamic api,
|
||||||
|
List<String> attachmentIds,
|
||||||
|
) async {
|
||||||
|
final filesArray = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
for (final attachmentId in attachmentIds) {
|
||||||
|
try {
|
||||||
|
final fileInfo = await api.getFileInfo(attachmentId);
|
||||||
|
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
|
||||||
|
final fileSize = fileInfo['size'];
|
||||||
|
|
||||||
|
// Check if it's an image
|
||||||
|
final ext = fileName.toLowerCase().split('.').last;
|
||||||
|
final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext);
|
||||||
|
|
||||||
|
// Add all files to the files array for WebUI display
|
||||||
|
// Note: This is for storage/display, not for API message sending
|
||||||
|
filesArray.add({
|
||||||
|
'type': isImage ? 'image' : 'file',
|
||||||
|
'id': attachmentId, // Required for RAG system to lookup file content
|
||||||
|
'url': '/api/v1/files/$attachmentId/content',
|
||||||
|
'name': fileName,
|
||||||
|
if (fileSize != null) 'size': fileSize,
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// If we can't get file info, assume it's a non-image file
|
||||||
|
// Images should be handled in the content array anyway
|
||||||
|
filesArray.add({
|
||||||
|
'type': 'file',
|
||||||
|
'id': attachmentId, // Required for RAG system to lookup file content
|
||||||
|
'url': '/api/v1/files/$attachmentId/content',
|
||||||
|
'name': 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesArray.isNotEmpty ? filesArray : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to get file content as base64
|
// Helper function to get file content as base64
|
||||||
Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
|
Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
|
||||||
// Check if this is already a data URL (for images)
|
// Check if this is already a data URL (for images)
|
||||||
@@ -758,44 +800,57 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
required List<String> attachmentIds,
|
required List<String> attachmentIds,
|
||||||
}) async {
|
}) async {
|
||||||
final List<Map<String, dynamic>> contentArray = [];
|
final List<Map<String, dynamic>> contentArray = [];
|
||||||
final List<Map<String, dynamic>> nonImageFiles = [];
|
|
||||||
|
|
||||||
if (cleanedText.isNotEmpty) {
|
if (cleanedText.isNotEmpty) {
|
||||||
contentArray.add({'type': 'text', 'text': cleanedText});
|
contentArray.add({'type': 'text', 'text': cleanedText});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect all files in OpenWebUI format for the files array
|
||||||
|
final allFiles = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
for (final attachmentId in attachmentIds) {
|
for (final attachmentId in attachmentIds) {
|
||||||
try {
|
try {
|
||||||
|
final fileInfo = await api.getFileInfo(attachmentId);
|
||||||
|
final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown';
|
||||||
|
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
|
||||||
if (base64Data.startsWith('data:')) {
|
if (base64Data.startsWith('data:')) {
|
||||||
contentArray.add({
|
contentArray.add({
|
||||||
'type': 'image_url',
|
'type': 'image_url',
|
||||||
'image_url': {'url': base64Data},
|
'image_url': {'url': base64Data},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!attachmentId.startsWith('data:')) {
|
final ext = fileName.toLowerCase().split('.').last;
|
||||||
final fileInfo = await api.getFileInfo(attachmentId);
|
String mimeType = 'image/png';
|
||||||
final fileName = fileInfo['filename'] ?? '';
|
if (ext == 'jpg' || ext == 'jpeg') {
|
||||||
final ext = fileName.toLowerCase().split('.').last;
|
mimeType = 'image/jpeg';
|
||||||
|
} else if (ext == 'gif') {
|
||||||
String mimeType = 'image/png';
|
mimeType = 'image/gif';
|
||||||
if (ext == 'jpg' || ext == 'jpeg') {
|
} else if (ext == 'webp') {
|
||||||
mimeType = 'image/jpeg';
|
mimeType = 'image/webp';
|
||||||
} else if (ext == 'gif') {
|
|
||||||
mimeType = 'image/gif';
|
|
||||||
} else if (ext == 'webp') {
|
|
||||||
mimeType = 'image/webp';
|
|
||||||
}
|
|
||||||
|
|
||||||
contentArray.add({
|
|
||||||
'type': 'image_url',
|
|
||||||
'image_url': {'url': 'data:$mimeType;base64,$base64Data'},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final dataUrl = 'data:$mimeType;base64,$base64Data';
|
||||||
|
contentArray.add({
|
||||||
|
'type': 'image_url',
|
||||||
|
'image_url': {'url': dataUrl},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Images are handled in content array above, no need to duplicate in files array
|
||||||
|
// This prevents duplicate display in the WebUI
|
||||||
} else {
|
} else {
|
||||||
nonImageFiles.add({'id': attachmentId, 'type': 'file'});
|
// This is a non-image file
|
||||||
|
allFiles.add({
|
||||||
|
'type': 'file',
|
||||||
|
'id': attachmentId, // Required for RAG system to lookup file content
|
||||||
|
'url': '/api/v1/files/$attachmentId/content',
|
||||||
|
'name': fileName,
|
||||||
|
if (fileSize != null) 'size': fileSize,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Swallow and continue to keep regeneration robust
|
// Swallow and continue to keep regeneration robust
|
||||||
@@ -806,8 +861,8 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
|||||||
'role': role,
|
'role': role,
|
||||||
'content': contentArray.isNotEmpty ? contentArray : cleanedText,
|
'content': contentArray.isNotEmpty ? contentArray : cleanedText,
|
||||||
};
|
};
|
||||||
if (nonImageFiles.isNotEmpty) {
|
if (allFiles.isNotEmpty) {
|
||||||
messageMap['files'] = nonImageFiles;
|
messageMap['files'] = allFiles;
|
||||||
}
|
}
|
||||||
return messageMap;
|
return messageMap;
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1276,13 @@ Future<void> _sendMessageInternal(
|
|||||||
var activeConversation = ref.read(activeConversationProvider);
|
var activeConversation = ref.read(activeConversationProvider);
|
||||||
|
|
||||||
// Create user message first
|
// Create user message first
|
||||||
|
List<Map<String, dynamic>>? userFiles;
|
||||||
|
if (attachments != null &&
|
||||||
|
attachments.isNotEmpty &&
|
||||||
|
!reviewerMode &&
|
||||||
|
api != null) {
|
||||||
|
userFiles = await _buildFilesArrayFromAttachments(api, attachments);
|
||||||
|
}
|
||||||
|
|
||||||
final userMessage = ChatMessage(
|
final userMessage = ChatMessage(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
@@ -1229,6 +1291,7 @@ Future<void> _sendMessageInternal(
|
|||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
model: selectedModel.id,
|
model: selectedModel.id,
|
||||||
attachmentIds: attachments,
|
attachmentIds: attachments,
|
||||||
|
files: userFiles,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (activeConversation == null) {
|
if (activeConversation == null) {
|
||||||
@@ -1450,19 +1513,21 @@ Future<void> _sendMessageInternal(
|
|||||||
);
|
);
|
||||||
ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder);
|
ref.read(chatMessagesProvider.notifier).addMessage(assistantPlaceholder);
|
||||||
|
|
||||||
// Persist skeleton chain to server so web can load correct history
|
// Sync conversation state to ensure WebUI can load conversation history
|
||||||
try {
|
try {
|
||||||
final activeConvForSeed = ref.read(activeConversationProvider);
|
final activeConvForSeed = ref.read(activeConversationProvider);
|
||||||
if (activeConvForSeed != null) {
|
if (activeConvForSeed != null) {
|
||||||
final msgsForSeed = ref.read(chatMessagesProvider);
|
final msgsForSeed = ref.read(chatMessagesProvider);
|
||||||
await api.updateConversationWithMessages(
|
await api.syncConversationMessages(
|
||||||
activeConvForSeed.id,
|
activeConvForSeed.id,
|
||||||
msgsForSeed,
|
msgsForSeed,
|
||||||
model: selectedModel.id,
|
model: selectedModel.id,
|
||||||
systemPrompt: effectiveSystemPrompt,
|
systemPrompt: effectiveSystemPrompt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
// Non-critical - continue if sync fails
|
||||||
|
}
|
||||||
// Use the model's actual supported parameters if available
|
// Use the model's actual supported parameters if available
|
||||||
final supportedParams =
|
final supportedParams =
|
||||||
selectedModel.supportedParameters ??
|
selectedModel.supportedParameters ??
|
||||||
|
|||||||
@@ -561,17 +561,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Display attachments (images use EnhancedImageAttachment; non-images use card)
|
// Display attachments - prioritize files array over attachmentIds to avoid duplication
|
||||||
if (widget.message.attachmentIds != null &&
|
|
||||||
widget.message.attachmentIds!.isNotEmpty) ...[
|
|
||||||
_buildAttachmentItems(),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Display generated images from files property - OUTSIDE AnimatedSwitcher to prevent fade issues
|
|
||||||
if (widget.message.files != null &&
|
if (widget.message.files != null &&
|
||||||
widget.message.files!.isNotEmpty) ...[
|
widget.message.files!.isNotEmpty) ...[
|
||||||
_buildGeneratedImages(),
|
_buildFilesFromArray(),
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
] else if (widget.message.attachmentIds != null &&
|
||||||
|
widget.message.attachmentIds!.isNotEmpty) ...[
|
||||||
|
_buildAttachmentItems(),
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -767,30 +764,57 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGeneratedImages() {
|
Widget _buildFilesFromArray() {
|
||||||
if (widget.message.files == null || widget.message.files!.isEmpty) {
|
if (widget.message.files == null || widget.message.files!.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter for image files
|
final allFiles = widget.message.files!;
|
||||||
final imageFiles = widget.message.files!
|
|
||||||
|
// Separate images and non-image files
|
||||||
|
final imageFiles = allFiles
|
||||||
.where((file) => file['type'] == 'image')
|
.where((file) => file['type'] == 'image')
|
||||||
.toList();
|
.toList();
|
||||||
|
final nonImageFiles = allFiles
|
||||||
|
.where((file) => file['type'] != 'image')
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (imageFiles.isEmpty) {
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
// Add images first
|
||||||
|
if (imageFiles.isNotEmpty) {
|
||||||
|
widgets.add(_buildImagesFromFiles(imageFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add non-image files
|
||||||
|
if (nonImageFiles.isNotEmpty) {
|
||||||
|
if (widgets.isNotEmpty) {
|
||||||
|
widgets.add(const SizedBox(height: Spacing.sm));
|
||||||
|
}
|
||||||
|
widgets.add(_buildNonImageFiles(nonImageFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widgets.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: widgets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagesFromFiles(List<dynamic> imageFiles) {
|
||||||
final imageCount = imageFiles.length;
|
final imageCount = imageFiles.length;
|
||||||
|
|
||||||
// Display generated images using EnhancedImageAttachment for consistency
|
// Display images using EnhancedImageAttachment for consistency
|
||||||
// Use AnimatedSwitcher for smooth transitions
|
// Use AnimatedSwitcher for smooth transitions
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
switchInCurve: Curves.easeInOut,
|
switchInCurve: Curves.easeInOut,
|
||||||
child: imageCount == 1
|
child: imageCount == 1
|
||||||
? Container(
|
? Container(
|
||||||
key: ValueKey('gen_single_${imageFiles[0]['url']}'),
|
key: ValueKey('file_single_${imageFiles[0]['url']}'),
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final imageUrl = imageFiles[0]['url'] as String?;
|
final imageUrl = imageFiles[0]['url'] as String?;
|
||||||
@@ -812,7 +836,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
)
|
)
|
||||||
: Wrap(
|
: Wrap(
|
||||||
key: ValueKey(
|
key: ValueKey(
|
||||||
'gen_multi_${imageFiles.map((f) => f['url']).join('_')}',
|
'file_multi_${imageFiles.map((f) => f['url']).join('_')}',
|
||||||
),
|
),
|
||||||
spacing: Spacing.sm,
|
spacing: Spacing.sm,
|
||||||
runSpacing: Spacing.sm,
|
runSpacing: Spacing.sm,
|
||||||
@@ -836,6 +860,38 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNonImageFiles(List<dynamic> nonImageFiles) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: Spacing.sm,
|
||||||
|
runSpacing: Spacing.sm,
|
||||||
|
children: nonImageFiles.map<Widget>((file) {
|
||||||
|
final fileUrl = file['url'] as String?;
|
||||||
|
|
||||||
|
if (fileUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Extract file ID from URL if it's in the format /api/v1/files/{id}/content
|
||||||
|
String attachmentId = fileUrl;
|
||||||
|
if (fileUrl.contains('/api/v1/files/') &&
|
||||||
|
fileUrl.contains('/content')) {
|
||||||
|
final fileIdMatch = RegExp(
|
||||||
|
r'/api/v1/files/([^/]+)/content',
|
||||||
|
).firstMatch(fileUrl);
|
||||||
|
if (fileIdMatch != null) {
|
||||||
|
attachmentId = fileIdMatch.group(1)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnhancedAttachment(
|
||||||
|
key: ValueKey('file_attachment_$attachmentId'),
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
isMarkdownFormat: true,
|
||||||
|
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 100),
|
||||||
|
disableAnimation: widget.isStreaming,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTypingIndicator() {
|
Widget _buildTypingIndicator() {
|
||||||
return Consumer(
|
return Consumer(
|
||||||
builder: (context, ref, child) {
|
builder: (context, ref, child) {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
duration: AnimationDuration.messageSlide,
|
duration: AnimationDuration.messageSlide,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_editController = TextEditingController(text: widget.message?.content ?? '');
|
_editController = TextEditingController(
|
||||||
|
text: widget.message?.content ?? '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUserAttachmentImages() {
|
Widget _buildUserAttachmentImages() {
|
||||||
@@ -88,23 +90,50 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageFiles = widget.message.files!
|
final allFiles = widget.message.files!;
|
||||||
|
|
||||||
|
// Separate images and non-image files
|
||||||
|
final imageFiles = allFiles
|
||||||
.where(
|
.where(
|
||||||
(file) =>
|
(file) =>
|
||||||
file is Map && file['type'] == 'image' && file['url'] != null,
|
file is Map && file['type'] == 'image' && file['url'] != null,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
final nonImageFiles = allFiles
|
||||||
|
.where(
|
||||||
|
(file) =>
|
||||||
|
file is Map && file['type'] != 'image' && file['url'] != null,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (imageFiles.isEmpty) {
|
final widgets = <Widget>[];
|
||||||
|
|
||||||
|
// Add images first
|
||||||
|
if (imageFiles.isNotEmpty) {
|
||||||
|
widgets.add(
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
switchInCurve: Curves.easeInOut,
|
||||||
|
child: _buildFileImageLayout(imageFiles, imageFiles.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add non-image files
|
||||||
|
if (nonImageFiles.isNotEmpty) {
|
||||||
|
if (widgets.isNotEmpty) {
|
||||||
|
widgets.add(const SizedBox(height: Spacing.xs));
|
||||||
|
}
|
||||||
|
widgets.add(_buildUserNonImageFiles(nonImageFiles));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widgets.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageCount = imageFiles.length;
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
return AnimatedSwitcher(
|
children: widgets,
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
switchInCurve: Curves.easeInOut,
|
|
||||||
child: _buildFileImageLayout(imageFiles, imageCount),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +423,47 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildUserNonImageFiles(List<dynamic> nonImageFiles) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
spacing: Spacing.xs,
|
||||||
|
runSpacing: Spacing.xs,
|
||||||
|
children: nonImageFiles.map<Widget>((file) {
|
||||||
|
final fileUrl = file['url'] as String?;
|
||||||
|
|
||||||
|
if (fileUrl == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
// Extract file ID from URL if it's in the format /api/v1/files/{id}/content
|
||||||
|
String attachmentId = fileUrl;
|
||||||
|
if (fileUrl.contains('/api/v1/files/') &&
|
||||||
|
fileUrl.contains('/content')) {
|
||||||
|
final fileIdMatch = RegExp(
|
||||||
|
r'/api/v1/files/([^/]+)/content',
|
||||||
|
).firstMatch(fileUrl);
|
||||||
|
if (fileIdMatch != null) {
|
||||||
|
attachmentId = fileIdMatch.group(1)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnhancedAttachment(
|
||||||
|
key: ValueKey('user_file_attachment_$attachmentId'),
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
isMarkdownFormat: false,
|
||||||
|
isUserMessage: true,
|
||||||
|
constraints: const BoxConstraints(maxWidth: 280, maxHeight: 80),
|
||||||
|
disableAnimation: widget.isStreaming,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Assistant-only helpers removed; this widget renders only user bubbles.
|
// Assistant-only helpers removed; this widget renders only user bubbles.
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -429,14 +499,14 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
widget.message.attachmentIds != null &&
|
widget.message.attachmentIds != null &&
|
||||||
widget.message.attachmentIds!.isNotEmpty;
|
widget.message.attachmentIds!.isNotEmpty;
|
||||||
final hasText = widget.message.content.isNotEmpty;
|
final hasText = widget.message.content.isNotEmpty;
|
||||||
final hasGeneratedImages =
|
final hasFilesFromArray =
|
||||||
widget.message.files != null &&
|
widget.message.files != null &&
|
||||||
(widget.message.files as List).any(
|
(widget.message.files as List).any((f) => f is Map && f['url'] != null);
|
||||||
(f) => f is Map && f['type'] == 'image' && f['url'] != null,
|
|
||||||
);
|
|
||||||
// Prefer input/textPrimary colors during inline editing to avoid low contrast
|
// Prefer input/textPrimary colors during inline editing to avoid low contrast
|
||||||
final inlineEditTextColor = context.conduitTheme.textPrimary;
|
final inlineEditTextColor = context.conduitTheme.textPrimary;
|
||||||
final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(alpha: 0.92);
|
final inlineEditFill = context.conduitTheme.surfaceContainer.withValues(
|
||||||
|
alpha: 0.92,
|
||||||
|
);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: () => _toggleActions(),
|
onLongPress: () => _toggleActions(),
|
||||||
@@ -452,8 +522,12 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
// Display images outside and above the text bubble (iMessage style)
|
// Display images outside and above the text bubble (iMessage style)
|
||||||
if (hasImages) ...[_buildUserAttachmentImages()],
|
// Prioritize files array over attachmentIds to avoid duplication
|
||||||
if (hasGeneratedImages) ...[_buildUserFileImages()],
|
if (hasFilesFromArray) ...[
|
||||||
|
_buildUserFileImages(),
|
||||||
|
] else if (hasImages) ...[
|
||||||
|
_buildUserAttachmentImages(),
|
||||||
|
],
|
||||||
|
|
||||||
// Display text bubble if there's text content
|
// Display text bubble if there's text content
|
||||||
if (hasText) const SizedBox(height: Spacing.xs),
|
if (hasText) const SizedBox(height: Spacing.xs),
|
||||||
@@ -504,9 +578,14 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: inlineEditFill,
|
color: inlineEditFill,
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppBorderRadius.sm,
|
||||||
|
),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: context.conduitTheme.inputBorderFocused.withValues(alpha: 0.6),
|
color: context
|
||||||
|
.conduitTheme
|
||||||
|
.inputBorderFocused
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
width: BorderWidth.thin,
|
width: BorderWidth.thin,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -520,38 +599,41 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
controller: _editController,
|
controller: _editController,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
autofillHints:
|
autofillHints: const <String>[],
|
||||||
const <String>[],
|
|
||||||
style: AppTypography
|
style: AppTypography
|
||||||
.chatMessageStyle
|
.chatMessageStyle
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: inlineEditTextColor,
|
color:
|
||||||
),
|
inlineEditTextColor,
|
||||||
decoration: const BoxDecoration(),
|
),
|
||||||
|
decoration:
|
||||||
|
const BoxDecoration(),
|
||||||
cursorColor: context
|
cursorColor: context
|
||||||
.conduitTheme.buttonPrimary,
|
.conduitTheme
|
||||||
|
.buttonPrimary,
|
||||||
onSubmitted: (_) =>
|
onSubmitted: (_) =>
|
||||||
_saveInlineEdit(),
|
_saveInlineEdit(),
|
||||||
)
|
)
|
||||||
: TextField(
|
: TextField(
|
||||||
controller: _editController,
|
controller: _editController,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
autofillHints:
|
autofillHints: const <String>[],
|
||||||
const <String>[],
|
|
||||||
style: AppTypography
|
style: AppTypography
|
||||||
.chatMessageStyle
|
.chatMessageStyle
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: inlineEditTextColor,
|
color:
|
||||||
),
|
inlineEditTextColor,
|
||||||
|
),
|
||||||
decoration:
|
decoration:
|
||||||
const InputDecoration(
|
const InputDecoration(
|
||||||
isCollapsed: true,
|
isCollapsed: true,
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
EdgeInsets.zero,
|
EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
cursorColor: context
|
cursorColor: context
|
||||||
.conduitTheme.buttonPrimary,
|
.conduitTheme
|
||||||
|
.buttonPrimary,
|
||||||
onSubmitted: (_) =>
|
onSubmitted: (_) =>
|
||||||
_saveInlineEdit(),
|
_saveInlineEdit(),
|
||||||
),
|
),
|
||||||
@@ -562,18 +644,19 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
widget.message.content,
|
widget.message.content,
|
||||||
style: AppTypography.chatMessageStyle
|
style: AppTypography.chatMessageStyle
|
||||||
.copyWith(
|
.copyWith(
|
||||||
color: context
|
color: context
|
||||||
.conduitTheme.chatBubbleUserText,
|
.conduitTheme
|
||||||
),
|
.chatBubbleUserText,
|
||||||
|
),
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
textAlign: TextAlign.left,
|
textAlign: TextAlign.left,
|
||||||
textHeightBehavior:
|
textHeightBehavior:
|
||||||
const TextHeightBehavior(
|
const TextHeightBehavior(
|
||||||
applyHeightToFirstAscent: false,
|
applyHeightToFirstAscent: false,
|
||||||
applyHeightToLastDescent: false,
|
applyHeightToLastDescent: false,
|
||||||
leadingDistribution:
|
leadingDistribution:
|
||||||
TextLeadingDistribution.even,
|
TextLeadingDistribution.even,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -633,8 +716,7 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
icon:
|
icon: Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
|
||||||
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_outlined,
|
|
||||||
label: AppLocalizations.of(context)!.edit,
|
label: AppLocalizations.of(context)!.edit,
|
||||||
onTap: widget.onEdit ?? _startInlineEdit,
|
onTap: widget.onEdit ?? _startInlineEdit,
|
||||||
),
|
),
|
||||||
@@ -693,7 +775,8 @@ class _UserMessageBubbleState extends ConsumerState<UserMessageBubble>
|
|||||||
|
|
||||||
// Enqueue edited text as a new message
|
// Enqueue edited text as a new message
|
||||||
final activeConv = ref.read(activeConversationProvider);
|
final activeConv = ref.read(activeConversationProvider);
|
||||||
final List<String>? attachments = (widget.message.attachmentIds != null &&
|
final List<String>? attachments =
|
||||||
|
(widget.message.attachmentIds != null &&
|
||||||
(widget.message.attachmentIds as List).isNotEmpty)
|
(widget.message.attachmentIds as List).isNotEmpty)
|
||||||
? List<String>.from(widget.message.attachmentIds as List)
|
? List<String>.from(widget.message.attachmentIds as List)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
Submodule openwebui-src updated: 2407d9b905...6bc5d331a2
Reference in New Issue
Block a user