diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart index c84b028..8111302 100644 --- a/lib/core/models/chat_message.dart +++ b/lib/core/models/chat_message.dart @@ -69,9 +69,9 @@ abstract class ChatStatusUpdate with _$ChatStatusUpdate { const factory ChatStatusUpdate({ String? action, String? description, - bool? done, - bool? hidden, - int? count, + @JsonKey(fromJson: _safeBool) bool? done, + @JsonKey(fromJson: _safeBool) bool? hidden, + @JsonKey(fromJson: _safeInt) int? count, String? query, @JsonKey(fromJson: _safeStringList, toJson: _stringListToJson) @Default([]) @@ -178,6 +178,29 @@ List _safeStringList(dynamic value) { return const []; } +/// Safely parse a boolean from various formats (bool, String, int). +bool? _safeBool(dynamic value) { + if (value == null) return null; + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') return true; + if (lower == 'false' || lower == '0') return false; + return null; + } + if (value is num) return value != 0; + return null; +} + +/// Safely parse an integer from various formats (int, double, String). +int? _safeInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + List _stringListToJson(List value) => List.from(value, growable: false); diff --git a/lib/core/models/folder.dart b/lib/core/models/folder.dart index c4ac6f6..afb6d0a 100644 --- a/lib/core/models/folder.dart +++ b/lib/core/models/folder.dart @@ -2,6 +2,18 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'folder.freezed.dart'; +bool? _safeBool(dynamic value) { + if (value == null) return null; + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') return true; + if (lower == 'false' || lower == '0') return false; + } + if (value is num) return value != 0; + return null; +} + @freezed sealed class Folder with _$Folder { const factory Folder({ @@ -72,7 +84,7 @@ sealed class Folder with _$Folder { userId: json['user_id'] as String?, createdAt: parseTimestamp(json['created_at']), updatedAt: parseTimestamp(json['updated_at']), - isExpanded: json['is_expanded'] as bool? ?? false, + isExpanded: _safeBool(json['is_expanded']) ?? false, conversationIds: conversationIds, meta: json['meta'] as Map?, data: json['data'] as Map?, diff --git a/lib/core/models/model.dart b/lib/core/models/model.dart index 84fd177..fdc4860 100644 --- a/lib/core/models/model.dart +++ b/lib/core/models/model.dart @@ -3,6 +3,18 @@ import 'toggle_filter.dart'; part 'model.freezed.dart'; +bool? _safeBool(dynamic value) { + if (value == null) return null; + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') return true; + if (lower == 'false' || lower == '0') return false; + } + if (value is num) return value != 0; + return null; +} + @freezed sealed class Model with _$Model { const Model._(); @@ -180,7 +192,7 @@ sealed class Model with _$Model { description: json['description'] as String?, isMultimodal: isMultimodal, supportsStreaming: supportsStreaming, - supportsRAG: json['supportsRAG'] as bool? ?? false, + supportsRAG: _safeBool(json['supportsRAG']) ?? false, supportedParameters: supportedParamsList, capabilities: { 'architecture': architecture, diff --git a/lib/core/models/toggle_filter.dart b/lib/core/models/toggle_filter.dart index 3d2526a..fb5365e 100644 --- a/lib/core/models/toggle_filter.dart +++ b/lib/core/models/toggle_filter.dart @@ -2,6 +2,18 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'toggle_filter.freezed.dart'; +bool? _safeBool(dynamic value) { + if (value == null) return null; + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') return true; + if (lower == 'false' || lower == '0') return false; + } + if (value is num) return value != 0; + return null; +} + /// Represents a toggleable filter that can be enabled/disabled per chat. /// /// These filters are created by OpenWebUI when a filter function has @@ -34,7 +46,7 @@ sealed class ToggleFilter with _$ToggleFilter { name: json['name'] as String, description: json['description'] as String?, icon: json['icon'] as String?, - hasUserValves: json['has_user_valves'] as bool? ?? false, + hasUserValves: _safeBool(json['has_user_valves']) ?? false, ); } diff --git a/lib/core/models/user.dart b/lib/core/models/user.dart index df91e9a..37a4544 100644 --- a/lib/core/models/user.dart +++ b/lib/core/models/user.dart @@ -2,6 +2,18 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'user.freezed.dart'; +bool? _safeBool(dynamic value) { + if (value == null) return null; + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') return true; + if (lower == 'false' || lower == '0') return false; + } + if (value is num) return value != 0; + return null; +} + @freezed sealed class User with _$User { const User._(); @@ -27,7 +39,8 @@ sealed class User with _$User { json['profile_image_url'] as String? ?? json['profileImage'] as String?, role: json['role'] as String? ?? 'user', - isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true, + isActive: + _safeBool(json['is_active']) ?? _safeBool(json['isActive']) ?? true, ); } diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index a260d94..61799bc 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -913,6 +913,15 @@ class ApiService { if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), if (sanitizedFiles != null) 'files': sanitizedFiles, + // Mirror status updates, follow-ups, code executions, and sources + if (msg.statusHistory.isNotEmpty) + 'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(), + if (msg.followUps.isNotEmpty) + 'followUps': List.from(msg.followUps), + if (msg.codeExecutions.isNotEmpty) + 'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(), + if (msg.sources.isNotEmpty) + 'sources': msg.sources.map((s) => s.toJson()).toList(), }; // Update parent's childrenIds @@ -939,6 +948,15 @@ class ApiService { if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) 'attachment_ids': List.from(msg.attachmentIds!), if (sanitizedArrayFiles != null) 'files': sanitizedArrayFiles, + // Mirror status updates, follow-ups, code executions, and sources + if (msg.statusHistory.isNotEmpty) + 'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(), + if (msg.followUps.isNotEmpty) + 'followUps': List.from(msg.followUps), + if (msg.codeExecutions.isNotEmpty) + 'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(), + if (msg.sources.isNotEmpty) + 'sources': msg.sources.map((s) => s.toJson()).toList(), }); previousId = messageId; @@ -965,6 +983,15 @@ class ApiService { 'modelIdx': 0, 'done': true, if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files), + // Mirror follow-ups, code executions, and sources for versions + if (ver.followUps.isNotEmpty) + 'followUps': List.from(ver.followUps), + if (ver.codeExecutions.isNotEmpty) + 'codeExecutions': ver.codeExecutions + .map((e) => e.toJson()) + .toList(), + if (ver.sources.isNotEmpty) + 'sources': ver.sources.map((s) => s.toJson()).toList(), }; // Link into parent (parentForVersions is always non-null here) if (messagesMap.containsKey(parentForVersions)) { diff --git a/lib/core/services/conversation_parsing.dart b/lib/core/services/conversation_parsing.dart index c36d6ce..d97f1f3 100644 --- a/lib/core/services/conversation_parsing.dart +++ b/lib/core/services/conversation_parsing.dart @@ -18,8 +18,8 @@ Map parseConversationSummary(Map chatData) { final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; - final pinned = chatData['pinned'] as bool? ?? false; - final archived = chatData['archived'] as bool? ?? false; + final pinned = _safeBool(chatData['pinned']) ?? false; + final archived = _safeBool(chatData['archived']) ?? false; final shareId = chatData['share_id']?.toString(); final folderId = chatData['folder_id']?.toString(); @@ -62,8 +62,8 @@ Map parseFullConversation(Map chatData) { final createdAt = _parseTimestamp( chatData['created_at'] ?? chatData['createdAt'], ); - final pinned = chatData['pinned'] as bool? ?? false; - final archived = chatData['archived'] as bool? ?? false; + final pinned = _safeBool(chatData['pinned']) ?? false; + final archived = _safeBool(chatData['archived']) ?? false; final shareId = chatData['share_id']?.toString(); final folderId = chatData['folder_id']?.toString(); @@ -289,20 +289,17 @@ Map _parseOpenWebUIMessageToJson( files = allFiles.isNotEmpty ? allFiles : null; } - final statusHistoryRaw = - historyMsg != null && historyMsg.containsKey('statusHistory') - ? historyMsg['statusHistory'] - : msgData['statusHistory']; - final followUpsRaw = historyMsg != null && historyMsg.containsKey('followUps') - ? historyMsg['followUps'] + final statusHistoryRaw = historyMsg != null + ? historyMsg['statusHistory'] ?? historyMsg['status_history'] + : msgData['statusHistory'] ?? msgData['status_history']; + final followUpsRaw = historyMsg != null + ? historyMsg['followUps'] ?? historyMsg['follow_ups'] : msgData['followUps'] ?? msgData['follow_ups']; final codeExecRaw = historyMsg != null - ? historyMsg['code_executions'] ?? historyMsg['codeExecutions'] - : msgData['code_executions'] ?? msgData['codeExecutions']; + ? historyMsg['codeExecutions'] ?? historyMsg['code_executions'] + : msgData['codeExecutions'] ?? msgData['code_executions']; final sourcesRaw = historyMsg != null - ? historyMsg.containsKey('sources') - ? historyMsg['sources'] - : historyMsg['citations'] + ? historyMsg['sources'] ?? historyMsg['citations'] : msgData['sources'] ?? msgData['citations']; return { @@ -311,7 +308,7 @@ Map _parseOpenWebUIMessageToJson( 'content': contentString, 'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(), 'model': msgData['model']?.toString(), - 'isStreaming': msgData['isStreaming'] as bool? ?? false, + 'isStreaming': _safeBool(msgData['isStreaming']) ?? false, if (attachmentIds != null) 'attachmentIds': attachmentIds, if (files != null) 'files': files, 'metadata': _coerceJsonMap(msgData['metadata']), @@ -384,10 +381,16 @@ DateTime _parseTimestamp(dynamic timestamp) { List> _parseStatusHistoryField(dynamic raw) { if (raw is List) { - return raw - .whereType() - .map((entry) => _coerceJsonMap(entry)) - .toList(growable: false); + final results = >[]; + for (final entry in raw) { + if (entry is! Map) continue; + try { + results.add(_coerceJsonMap(entry)); + } catch (_) { + // Skip malformed status entries to prevent error boundary + } + } + return results; } return const >[]; } @@ -508,6 +511,20 @@ String _stringOr(dynamic value, String fallback) { return fallback; } +/// Safely parse a boolean from various formats (bool, String, int). +bool? _safeBool(dynamic value) { + if (value == null) return null; + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true' || lower == '1') return true; + if (lower == 'false' || lower == '0') return false; + return null; + } + if (value is num) return value != 0; + return null; +} + String _synthesizeToolDetailsFromToolCalls(List calls) { final buffer = StringBuffer(); for (final rawCall in calls) {