feat(models): Add safe parsing for boolean and integer values

This commit is contained in:
cogwheel0
2025-12-07 09:54:27 +05:30
parent 46d581d732
commit 649a708a68
7 changed files with 143 additions and 27 deletions

View File

@@ -69,9 +69,9 @@ abstract class ChatStatusUpdate with _$ChatStatusUpdate {
const factory ChatStatusUpdate({ const factory ChatStatusUpdate({
String? action, String? action,
String? description, String? description,
bool? done, @JsonKey(fromJson: _safeBool) bool? done,
bool? hidden, @JsonKey(fromJson: _safeBool) bool? hidden,
int? count, @JsonKey(fromJson: _safeInt) int? count,
String? query, String? query,
@JsonKey(fromJson: _safeStringList, toJson: _stringListToJson) @JsonKey(fromJson: _safeStringList, toJson: _stringListToJson)
@Default(<String>[]) @Default(<String>[])
@@ -178,6 +178,29 @@ List<String> _safeStringList(dynamic value) {
return const []; 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<String> _stringListToJson(List<String> value) => List<String> _stringListToJson(List<String> value) =>
List<String>.from(value, growable: false); List<String>.from(value, growable: false);

View File

@@ -2,6 +2,18 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'folder.freezed.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 @freezed
sealed class Folder with _$Folder { sealed class Folder with _$Folder {
const factory Folder({ const factory Folder({
@@ -72,7 +84,7 @@ sealed class Folder with _$Folder {
userId: json['user_id'] as String?, userId: json['user_id'] as String?,
createdAt: parseTimestamp(json['created_at']), createdAt: parseTimestamp(json['created_at']),
updatedAt: parseTimestamp(json['updated_at']), updatedAt: parseTimestamp(json['updated_at']),
isExpanded: json['is_expanded'] as bool? ?? false, isExpanded: _safeBool(json['is_expanded']) ?? false,
conversationIds: conversationIds, conversationIds: conversationIds,
meta: json['meta'] as Map<String, dynamic>?, meta: json['meta'] as Map<String, dynamic>?,
data: json['data'] as Map<String, dynamic>?, data: json['data'] as Map<String, dynamic>?,

View File

@@ -3,6 +3,18 @@ import 'toggle_filter.dart';
part 'model.freezed.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 @freezed
sealed class Model with _$Model { sealed class Model with _$Model {
const Model._(); const Model._();
@@ -180,7 +192,7 @@ sealed class Model with _$Model {
description: json['description'] as String?, description: json['description'] as String?,
isMultimodal: isMultimodal, isMultimodal: isMultimodal,
supportsStreaming: supportsStreaming, supportsStreaming: supportsStreaming,
supportsRAG: json['supportsRAG'] as bool? ?? false, supportsRAG: _safeBool(json['supportsRAG']) ?? false,
supportedParameters: supportedParamsList, supportedParameters: supportedParamsList,
capabilities: { capabilities: {
'architecture': architecture, 'architecture': architecture,

View File

@@ -2,6 +2,18 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'toggle_filter.freezed.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. /// Represents a toggleable filter that can be enabled/disabled per chat.
/// ///
/// These filters are created by OpenWebUI when a filter function has /// 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, name: json['name'] as String,
description: json['description'] as String?, description: json['description'] as String?,
icon: json['icon'] as String?, icon: json['icon'] as String?,
hasUserValves: json['has_user_valves'] as bool? ?? false, hasUserValves: _safeBool(json['has_user_valves']) ?? false,
); );
} }

View File

@@ -2,6 +2,18 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.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 @freezed
sealed class User with _$User { sealed class User with _$User {
const User._(); const User._();
@@ -27,7 +39,8 @@ sealed class User with _$User {
json['profile_image_url'] as String? ?? json['profile_image_url'] as String? ??
json['profileImage'] as String?, json['profileImage'] as String?,
role: json['role'] as String? ?? 'user', 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,
); );
} }

View File

@@ -913,6 +913,15 @@ class ApiService {
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
'attachment_ids': List<String>.from(msg.attachmentIds!), 'attachment_ids': List<String>.from(msg.attachmentIds!),
if (sanitizedFiles != null) 'files': sanitizedFiles, 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<String>.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 // Update parent's childrenIds
@@ -939,6 +948,15 @@ class ApiService {
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty) if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
'attachment_ids': List<String>.from(msg.attachmentIds!), 'attachment_ids': List<String>.from(msg.attachmentIds!),
if (sanitizedArrayFiles != null) 'files': sanitizedArrayFiles, 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<String>.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; previousId = messageId;
@@ -965,6 +983,15 @@ class ApiService {
'modelIdx': 0, 'modelIdx': 0,
'done': true, 'done': true,
if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files), if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files),
// Mirror follow-ups, code executions, and sources for versions
if (ver.followUps.isNotEmpty)
'followUps': List<String>.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) // Link into parent (parentForVersions is always non-null here)
if (messagesMap.containsKey(parentForVersions)) { if (messagesMap.containsKey(parentForVersions)) {

View File

@@ -18,8 +18,8 @@ Map<String, dynamic> parseConversationSummary(Map<String, dynamic> chatData) {
final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt']; final updatedAtRaw = chatData['updated_at'] ?? chatData['updatedAt'];
final createdAtRaw = chatData['created_at'] ?? chatData['createdAt']; final createdAtRaw = chatData['created_at'] ?? chatData['createdAt'];
final pinned = chatData['pinned'] as bool? ?? false; final pinned = _safeBool(chatData['pinned']) ?? false;
final archived = chatData['archived'] as bool? ?? false; final archived = _safeBool(chatData['archived']) ?? false;
final shareId = chatData['share_id']?.toString(); final shareId = chatData['share_id']?.toString();
final folderId = chatData['folder_id']?.toString(); final folderId = chatData['folder_id']?.toString();
@@ -62,8 +62,8 @@ Map<String, dynamic> parseFullConversation(Map<String, dynamic> chatData) {
final createdAt = _parseTimestamp( final createdAt = _parseTimestamp(
chatData['created_at'] ?? chatData['createdAt'], chatData['created_at'] ?? chatData['createdAt'],
); );
final pinned = chatData['pinned'] as bool? ?? false; final pinned = _safeBool(chatData['pinned']) ?? false;
final archived = chatData['archived'] as bool? ?? false; final archived = _safeBool(chatData['archived']) ?? false;
final shareId = chatData['share_id']?.toString(); final shareId = chatData['share_id']?.toString();
final folderId = chatData['folder_id']?.toString(); final folderId = chatData['folder_id']?.toString();
@@ -289,20 +289,17 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
files = allFiles.isNotEmpty ? allFiles : null; files = allFiles.isNotEmpty ? allFiles : null;
} }
final statusHistoryRaw = final statusHistoryRaw = historyMsg != null
historyMsg != null && historyMsg.containsKey('statusHistory') ? historyMsg['statusHistory'] ?? historyMsg['status_history']
? historyMsg['statusHistory'] : msgData['statusHistory'] ?? msgData['status_history'];
: msgData['statusHistory']; final followUpsRaw = historyMsg != null
final followUpsRaw = historyMsg != null && historyMsg.containsKey('followUps') ? historyMsg['followUps'] ?? historyMsg['follow_ups']
? historyMsg['followUps']
: msgData['followUps'] ?? msgData['follow_ups']; : msgData['followUps'] ?? msgData['follow_ups'];
final codeExecRaw = historyMsg != null final codeExecRaw = historyMsg != null
? historyMsg['code_executions'] ?? historyMsg['codeExecutions'] ? historyMsg['codeExecutions'] ?? historyMsg['code_executions']
: msgData['code_executions'] ?? msgData['codeExecutions']; : msgData['codeExecutions'] ?? msgData['code_executions'];
final sourcesRaw = historyMsg != null final sourcesRaw = historyMsg != null
? historyMsg.containsKey('sources') ? historyMsg['sources'] ?? historyMsg['citations']
? historyMsg['sources']
: historyMsg['citations']
: msgData['sources'] ?? msgData['citations']; : msgData['sources'] ?? msgData['citations'];
return <String, dynamic>{ return <String, dynamic>{
@@ -311,7 +308,7 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
'content': contentString, 'content': contentString,
'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(), 'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(),
'model': msgData['model']?.toString(), 'model': msgData['model']?.toString(),
'isStreaming': msgData['isStreaming'] as bool? ?? false, 'isStreaming': _safeBool(msgData['isStreaming']) ?? false,
if (attachmentIds != null) 'attachmentIds': attachmentIds, if (attachmentIds != null) 'attachmentIds': attachmentIds,
if (files != null) 'files': files, if (files != null) 'files': files,
'metadata': _coerceJsonMap(msgData['metadata']), 'metadata': _coerceJsonMap(msgData['metadata']),
@@ -384,10 +381,16 @@ DateTime _parseTimestamp(dynamic timestamp) {
List<Map<String, dynamic>> _parseStatusHistoryField(dynamic raw) { List<Map<String, dynamic>> _parseStatusHistoryField(dynamic raw) {
if (raw is List) { if (raw is List) {
return raw final results = <Map<String, dynamic>>[];
.whereType<Map>() for (final entry in raw) {
.map((entry) => _coerceJsonMap(entry)) if (entry is! Map) continue;
.toList(growable: false); try {
results.add(_coerceJsonMap(entry));
} catch (_) {
// Skip malformed status entries to prevent error boundary
}
}
return results;
} }
return const <Map<String, dynamic>>[]; return const <Map<String, dynamic>>[];
} }
@@ -508,6 +511,20 @@ String _stringOr(dynamic value, String fallback) {
return 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<Map> calls) { String _synthesizeToolDetailsFromToolCalls(List<Map> calls) {
final buffer = StringBuffer(); final buffer = StringBuffer();
for (final rawCall in calls) { for (final rawCall in calls) {