Merge pull request #282 from cogwheel0/improve-openwebui-error-handling

improve-openwebui-error-handling
This commit is contained in:
cogwheel
2025-12-15 20:56:00 +05:30
committed by GitHub
6 changed files with 460 additions and 44 deletions

View File

@@ -31,15 +31,92 @@ sealed class ChatMessage with _$ChatMessage {
List<ChatSourceReference> sources,
Map<String, dynamic>? usage,
// Previous generated versions of this assistant message (OpenWebUI-style)
@JsonKey(includeFromJson: false, includeToJson: false)
// Parsed from sibling messages in OpenWebUI history
@JsonKey(fromJson: _versionsFromJson, toJson: _versionsToJson)
@Default(<ChatMessageVersion>[])
List<ChatMessageVersion> versions,
// Error information from OpenWebUI (stored separately from content)
@JsonKey(fromJson: _chatMessageErrorFromJson, toJson: _chatMessageErrorToJson)
ChatMessageError? error,
}) = _ChatMessage;
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
_$ChatMessageFromJson(json);
}
/// Error information for a chat message, matching OpenWebUI's error format.
/// OpenWebUI stores errors as `{ error: { content: "..." } }` on messages.
@freezed
abstract class ChatMessageError with _$ChatMessageError {
const factory ChatMessageError({
/// The error message content
@JsonKey(fromJson: _nullableString) String? content,
}) = _ChatMessageError;
factory ChatMessageError.fromJson(Map<String, dynamic> json) =>
_$ChatMessageErrorFromJson(json);
}
/// Parse ChatMessageError from various OpenWebUI formats.
ChatMessageError? _chatMessageErrorFromJson(dynamic value) {
if (value == null) return null;
// Legacy format: error === true means content IS the error
if (value == true) {
return const ChatMessageError(content: null);
}
if (value is String && value.isNotEmpty) {
return ChatMessageError(content: value);
}
if (value is Map) {
// Most common: { content: "error message" }
final content = value['content'];
if (content is String && content.isNotEmpty) {
return ChatMessageError(content: content);
}
// Alternative: { message: "error message" }
final message = value['message'];
if (message is String && message.isNotEmpty) {
return ChatMessageError(content: message);
}
// Nested error: { error: { message: "..." } }
final nestedError = value['error'];
if (nestedError is Map) {
final nestedMessage = nestedError['message'];
if (nestedMessage is String && nestedMessage.isNotEmpty) {
return ChatMessageError(content: nestedMessage);
}
}
// FastAPI detail format: { detail: "..." }
final detail = value['detail'];
if (detail is String && detail.isNotEmpty) {
return ChatMessageError(content: detail);
}
// If it's a map but we couldn't extract content, still return an error
// to indicate there was an error (matches legacy error === true behavior)
return const ChatMessageError(content: null);
}
return null;
}
/// Convert ChatMessageError to OpenWebUI format for persistence.
Map<String, dynamic>? _chatMessageErrorToJson(ChatMessageError? error) {
if (error == null) return null;
if (error.content == null) {
// Legacy format - just return true to indicate error
// But OpenWebUI expects a map, so return empty content
return const {'content': ''};
}
return {'content': error.content};
}
@freezed
abstract class ChatMessageVersion with _$ChatMessageVersion {
const factory ChatMessageVersion({
@@ -58,6 +135,9 @@ abstract class ChatMessageVersion with _$ChatMessageVersion {
@Default(<String>[]) List<String> followUps,
@Default(<ChatCodeExecution>[]) List<ChatCodeExecution> codeExecutions,
Map<String, dynamic>? usage,
// Error information preserved from the original message
@JsonKey(fromJson: _chatMessageErrorFromJson, toJson: _chatMessageErrorToJson)
ChatMessageError? error,
}) = _ChatMessageVersion;
factory ChatMessageVersion.fromJson(Map<String, dynamic> json) =>
@@ -205,6 +285,35 @@ int? _safeInt(dynamic value) {
List<String> _stringListToJson(List<String> value) =>
List<String>.from(value, growable: false);
/// Parse ChatMessageVersion list from JSON.
List<ChatMessageVersion> _versionsFromJson(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((item) {
try {
final Map<String, dynamic> versionMap = {};
item.forEach((key, v) {
versionMap[key.toString()] = v;
});
return ChatMessageVersion.fromJson(versionMap);
} catch (e) {
// Skip invalid entries
return null;
}
})
.where((item) => item != null)
.cast<ChatMessageVersion>()
.toList(growable: false);
}
return const [];
}
/// Convert ChatMessageVersion list to JSON.
List<Map<String, dynamic>> _versionsToJson(List<ChatMessageVersion> versions) {
return versions.map((v) => v.toJson()).toList(growable: false);
}
List<ChatStatusItem> _statusItemsFromJson(dynamic value) {
if (value is List) {
return value

View File

@@ -843,6 +843,8 @@ class ApiService {
'attachment_ids': List<String>.from(msg.attachmentIds!),
if (_sanitizeFilesForWebUI(msg.files) != null)
'files': _sanitizeFilesForWebUI(msg.files),
// Preserve error field for OpenWebUI compatibility
if (msg.error != null) 'error': msg.error!.toJson(),
};
// Update parent's childrenIds if there's a previous message
@@ -863,6 +865,8 @@ class ApiService {
'attachment_ids': List<String>.from(msg.attachmentIds!),
if (_sanitizeFilesForWebUI(msg.files) != null)
'files': _sanitizeFilesForWebUI(msg.files),
// Preserve error field for OpenWebUI compatibility
if (msg.error != null) 'error': msg.error!.toJson(),
});
previousId = messageId;
@@ -977,6 +981,8 @@ class ApiService {
'sources': msg.sources.map((s) => s.toJson()).toList(),
// Include usage statistics for persistence (issue #274)
if (msg.usage != null) 'usage': msg.usage,
// Preserve error field for OpenWebUI compatibility
if (msg.error != null) 'error': msg.error!.toJson(),
};
// Update parent's childrenIds
@@ -1014,6 +1020,8 @@ class ApiService {
'sources': msg.sources.map((s) => s.toJson()).toList(),
// Include usage statistics for persistence (issue #274)
if (msg.usage != null) 'usage': msg.usage,
// Preserve error field for OpenWebUI compatibility
if (msg.error != null) 'error': msg.error!.toJson(),
});
previousId = messageId;
@@ -1040,7 +1048,7 @@ class ApiService {
'modelIdx': 0,
'done': true,
if (ver.files != null) 'files': _sanitizeFilesForWebUI(ver.files),
// Mirror follow-ups, code executions, and sources for versions
// Mirror follow-ups, code executions, sources, and errors for versions
if (ver.followUps.isNotEmpty)
'followUps': List<String>.from(ver.followUps),
if (ver.codeExecutions.isNotEmpty)
@@ -1049,6 +1057,8 @@ class ApiService {
.toList(),
if (ver.sources.isNotEmpty)
'sources': ver.sources.map((s) => s.toJson()).toList(),
// Preserve error field for OpenWebUI compatibility
if (ver.error != null) 'error': ver.error!.toJson(),
};
// Link into parent (parentForVersions is always non-null here)
if (messagesMap.containsKey(parentForVersions)) {

View File

@@ -146,16 +146,24 @@ Map<String, dynamic> parseFullConversation(Map<String, dynamic> chatData) {
merged['content'] = synthesized;
}
messages.add(
_parseOpenWebUIMessageToJson(merged, historyMsg: historyMsg),
final parsed = _parseOpenWebUIMessageToJson(
merged,
historyMsg: historyMsg,
);
// Add versions from siblings
_addVersionsFromSiblings(parsed, msgData, historyMessagesMap);
messages.add(parsed);
index = j;
continue;
}
messages.add(
_parseOpenWebUIMessageToJson(msgData, historyMsg: historyMsg),
final parsed = _parseOpenWebUIMessageToJson(
msgData,
historyMsg: historyMsg,
);
// Add versions from siblings
_addVersionsFromSiblings(parsed, msgData, historyMessagesMap);
messages.add(parsed);
index++;
}
}
@@ -191,6 +199,171 @@ List<Map<String, dynamic>>? _extractToolCalls(
return null;
}
/// Add versions from sibling messages (alternative responses with same parent).
/// Siblings are stored in `_siblings` by `_buildMessagesListFromHistory`.
void _addVersionsFromSiblings(
Map<String, dynamic> parsed,
Map<String, dynamic> msgData,
Map<String, dynamic>? historyMessagesMap,
) {
final siblings = msgData['_siblings'];
if (siblings is! List || siblings.isEmpty) return;
final versions = <Map<String, dynamic>>[];
for (final siblingData in siblings) {
if (siblingData is! Map<String, dynamic>) continue;
final siblingId = siblingData['id']?.toString();
final historyMsg = historyMessagesMap != null && siblingId != null
? (historyMessagesMap[siblingId] as Map<String, dynamic>?)
: null;
// Parse the sibling as a version
final version = _parseSiblingAsVersion(siblingData, historyMsg: historyMsg);
if (version != null) {
versions.add(version);
}
}
if (versions.isNotEmpty) {
parsed['versions'] = versions;
}
}
/// Parse a sibling message as a ChatMessageVersion JSON map.
Map<String, dynamic>? _parseSiblingAsVersion(
Map<String, dynamic> msgData, {
Map<String, dynamic>? historyMsg,
}) {
// Extract content (same logic as _parseOpenWebUIMessageToJson)
dynamic content = msgData['content'];
if ((content == null || (content is String && content.isEmpty)) &&
historyMsg != null &&
historyMsg['content'] != null) {
content = historyMsg['content'];
}
var contentString = '';
if (content is List) {
final buffer = StringBuffer();
for (final entry in content) {
if (entry is Map && entry['type'] == 'text') {
final text = entry['text']?.toString();
if (text != null && text.isNotEmpty) {
buffer.write(text);
}
}
}
contentString = buffer.toString();
} else {
contentString = content?.toString() ?? '';
}
if (historyMsg != null) {
final histContent = historyMsg['content'];
if (histContent is String && histContent.length > contentString.length) {
contentString = histContent;
}
}
// Extract files
final effectiveFiles = msgData['files'] ?? historyMsg?['files'];
List<Map<String, dynamic>>? files;
if (effectiveFiles is List) {
final allFiles = <Map<String, dynamic>>[];
for (final entry in effectiveFiles) {
if (entry is! Map) continue;
if (entry['type'] != null && entry['url'] != null) {
final fileMap = <String, dynamic>{
'type': entry['type'],
'url': entry['url'],
};
if (entry['name'] != null) fileMap['name'] = entry['name'];
if (entry['size'] != null) fileMap['size'] = entry['size'];
allFiles.add(fileMap);
}
}
files = allFiles.isNotEmpty ? allFiles : null;
}
// Extract other fields
final sourcesRaw = historyMsg != null
? historyMsg['sources'] ?? historyMsg['citations']
: msgData['sources'] ?? msgData['citations'];
final followUpsRaw = historyMsg != null
? historyMsg['followUps'] ?? historyMsg['follow_ups']
: msgData['followUps'] ?? msgData['follow_ups'];
final codeExecRaw = historyMsg != null
? historyMsg['codeExecutions'] ?? historyMsg['code_executions']
: msgData['codeExecutions'] ?? msgData['code_executions'];
final rawUsage = _coerceJsonMap(historyMsg?['usage'] ?? msgData['usage']);
final errorData = _extractErrorData(msgData, historyMsg);
return <String, dynamic>{
'id': (msgData['id'] ?? _uuid.v4()).toString(),
'content': contentString,
'timestamp': _parseTimestamp(msgData['timestamp']).toIso8601String(),
if (msgData['model'] != null) 'model': msgData['model'].toString(),
if (files != null) 'files': files,
'sources': _parseSourcesField(sourcesRaw),
'followUps': _coerceStringList(followUpsRaw),
'codeExecutions': _parseCodeExecutionsField(codeExecRaw),
if (rawUsage.isNotEmpty) 'usage': rawUsage,
if (errorData != null) 'error': errorData,
};
}
/// Extract error data from OpenWebUI message format.
/// OpenWebUI stores errors in a separate 'error' field with 'content' inside.
/// Returns a map suitable for ChatMessageError.fromJson().
Map<String, dynamic>? _extractErrorData(
Map<String, dynamic> msgData,
Map<String, dynamic>? historyMsg,
) {
// Check msgData first, then historyMsg
final errorRaw = msgData['error'] ?? historyMsg?['error'];
if (errorRaw == null) return null;
// Handle different error formats from OpenWebUI
if (errorRaw is Map) {
// Most common: { error: { content: "error message" } }
final content = errorRaw['content'];
if (content is String && content.isNotEmpty) {
return {'content': content};
}
// Alternative: { error: { message: "error message" } }
final message = errorRaw['message'];
if (message is String && message.isNotEmpty) {
return {'content': message};
}
// Nested error: { error: { error: { message: "..." } } }
final nestedError = errorRaw['error'];
if (nestedError is Map) {
final nestedMessage = nestedError['message'];
if (nestedMessage is String && nestedMessage.isNotEmpty) {
return {'content': nestedMessage};
}
}
// FastAPI detail format: { detail: "..." }
final detail = errorRaw['detail'];
if (detail is String && detail.isNotEmpty) {
return {'content': detail};
}
// If it's a map but we couldn't extract content, still return an error
// to indicate there was an error (matches legacy error === true behavior)
return const {'content': null};
} else if (errorRaw is String && errorRaw.isNotEmpty) {
// Simple string error
return {'content': errorRaw};
} else if (errorRaw == true) {
// Legacy format: error === true means content IS the error message
// Return a marker so the UI knows this is an error message
return const {'content': null};
}
return null;
}
Map<String, dynamic> _parseOpenWebUIMessageToJson(
Map<String, dynamic> msgData, {
Map<String, dynamic>? historyMsg,
@@ -253,6 +426,9 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
}
}
// Extract error field from OpenWebUI - preserve it separately for round-trip
final errorData = _extractErrorData(msgData, historyMsg);
final role = _resolveRole(msgData);
final effectiveFiles = msgData['files'] ?? historyMsg?['files'];
@@ -325,6 +501,7 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
'sources': _parseSourcesField(sourcesRaw),
'usage': usage,
'versions': const <Map<String, dynamic>>[],
if (errorData != null) 'error': errorData,
};
}
@@ -338,6 +515,8 @@ String _resolveRole(Map<String, dynamic> msgData) {
return 'user';
}
/// Build the message chain from history, following parent links from currentId.
/// Also collects sibling messages (alternative versions) for each message.
List<Map<String, dynamic>> _buildMessagesListFromHistory(
Map<String, dynamic> history,
) {
@@ -347,6 +526,7 @@ List<Map<String, dynamic>> _buildMessagesListFromHistory(
return const [];
}
// Build the main chain from currentId back to root
List<Map<String, dynamic>> buildChain(String? id) {
if (id == null) return const [];
final raw = messagesMap[id];
@@ -360,7 +540,48 @@ List<Map<String, dynamic>> _buildMessagesListFromHistory(
return [msg];
}
return buildChain(currentId);
final chain = buildChain(currentId);
// For each message in the chain, find sibling versions
// Siblings are other children of the same parent
for (final msg in chain) {
final parentId = msg['parentId']?.toString();
if (parentId == null || parentId.isEmpty) continue;
final parent = messagesMap[parentId];
if (parent is! Map) continue;
final childrenIds = parent['childrenIds'];
if (childrenIds is! List || childrenIds.length <= 1) continue;
// Collect sibling messages (same role, different id)
final msgId = msg['id']?.toString();
final msgRole = msg['role']?.toString();
final siblings = <Map<String, dynamic>>[];
for (final siblingId in childrenIds) {
final sibId = siblingId?.toString();
if (sibId == null || sibId == msgId) continue;
final siblingRaw = messagesMap[sibId];
if (siblingRaw is! Map) continue;
final sibling = _coerceJsonMap(siblingRaw);
final siblingRole = sibling['role']?.toString();
// Only include siblings with the same role (e.g., alternative assistant responses)
if (siblingRole == msgRole) {
sibling['id'] = sibId;
siblings.add(sibling);
}
}
if (siblings.isNotEmpty) {
msg['_siblings'] = siblings;
}
}
return chain;
}
DateTime _parseTimestamp(dynamic timestamp) {

View File

@@ -1126,34 +1126,33 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
// Server reports an error for the current assistant message
try {
dynamic err = payload is Map ? payload['error'] : null;
String content = '';
String errorContent = '';
if (err is Map) {
final c = err['content'];
if (c is String) {
content = c;
errorContent = c;
} else if (c != null) {
content = c.toString();
errorContent = c.toString();
}
} else if (err is String) {
content = err;
errorContent = err;
} else if (payload is Map && payload['message'] is String) {
content = payload['message'];
errorContent = payload['message'];
}
if (content.isNotEmpty) {
// Replace current assistant message with a readable error
replaceLastMessageContent('⚠️ $content');
}
} catch (_) {}
// Drop search-only status rows so the error feels cleaner
// Set the error field on the message for proper OpenWebUI round-trip
// Also drop search-only status rows so the error feels cleaner
updateLastMessageWith((message) {
final filtered = message.statusHistory
.where((status) => status.action != 'knowledge_search')
.toList(growable: false);
if (filtered.length == message.statusHistory.length) {
return message;
}
return message.copyWith(statusHistory: filtered);
return message.copyWith(
error: errorContent.isNotEmpty
? ChatMessageError(content: errorContent)
: const ChatMessageError(content: null),
statusHistory: filtered,
);
});
} catch (_) {}
// Ensure UI exits streaming state
wrappedFinishStreaming();
} else if ((type == 'chat:message:delta' || type == 'message') &&

View File

@@ -644,6 +644,7 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
followUps: List<String>.from(last.followUps),
codeExecutions: List<ChatCodeExecution>.from(last.codeExecutions),
usage: last.usage == null ? null : Map<String, dynamic>.from(last.usage!),
error: last.error, // Preserve error in version snapshot
);
final updated = last.copyWith(
@@ -655,6 +656,7 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
codeExecutions: const [],
sources: const [],
usage: null,
error: null, // Clear error for new generation
versions: [...last.versions, snapshot],
);
@@ -1414,6 +1416,7 @@ Future<void> regenerateMessage(
followUps: prev.followUps,
codeExecutions: prev.codeExecutions,
usage: prev.usage,
error: prev.error, // Preserve error in version snapshot
);
ref
.read(chatMessagesProvider.notifier)
@@ -2483,16 +2486,16 @@ Future<void> _sendMessageInternal(
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'''⚠️ There was an issue with the message format. This might be because:
• The image attachment couldn't be processed
• The request format is incompatible with the selected model
• The message contains unsupported content
Please try sending the message again, or try without attachments.''',
content: '',
timestamp: DateTime.now(),
isStreaming: false,
error: const ChatMessageError(
content: 'There was an issue with the message format. This might be '
'because the image attachment couldn\'t be processed, the request '
'format is incompatible with the selected model, or the message '
'contains unsupported content. Please try sending the message '
'again, or try without attachments.',
),
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
} else if (e.toString().contains('401') || e.toString().contains('403')) {
@@ -2502,11 +2505,14 @@ Please try sending the message again, or try without attachments.''',
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'⚠️ Unable to connect to the AI model. The server returned an error (500).\n\n'
'This is typically a server-side issue. Please try again or contact your administrator.',
content: '',
timestamp: DateTime.now(),
isStreaming: false,
error: const ChatMessageError(
content: 'Unable to connect to the AI model. The server returned an '
'error (500). This is typically a server-side issue. Please try '
'again or contact your administrator.',
),
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
} else if (e.toString().contains('404')) {
@@ -2517,11 +2523,14 @@ Please try sending the message again, or try without attachments.''',
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'🤖 The selected AI model doesn\'t seem to be available.\n\n'
'Please try selecting a different model or check with your administrator.',
content: '',
timestamp: DateTime.now(),
isStreaming: false,
error: const ChatMessageError(
content: 'The selected AI model doesn\'t seem to be available. '
'Please try selecting a different model or check with your '
'administrator.',
),
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
} else {
@@ -2529,11 +2538,13 @@ Please try sending the message again, or try without attachments.''',
final errorMessage = ChatMessage(
id: const Uuid().v4(),
role: 'assistant',
content:
'❌ An unexpected error occurred while processing your request.\n\n'
'Please try again or check your connection.',
content: '',
timestamp: DateTime.now(),
isStreaming: false,
error: const ChatMessageError(
content: 'An unexpected error occurred while processing your request. '
'Please try again or check your connection.',
),
);
ref.read(chatMessagesProvider.notifier).addMessage(errorMessage);
}

View File

@@ -878,6 +878,12 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
),
),
// Display error banner if message or active version has an error
if (_getActiveError() != null) ...[
const SizedBox(height: Spacing.sm),
_buildErrorBanner(_getActiveError()!),
],
if (hasCodeExecutions) ...[
const SizedBox(height: Spacing.md),
CodeExecutionListView(
@@ -924,6 +930,62 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
);
}
/// Get the error for the currently active message or version.
ChatMessageError? _getActiveError() {
if (widget.message is! ChatMessage) return null;
final msg = widget.message as ChatMessage;
// If viewing a version, return the version's error
if (_activeVersionIndex >= 0 &&
_activeVersionIndex < msg.versions.length) {
return msg.versions[_activeVersionIndex].error;
}
// Otherwise return the main message's error
return msg.error;
}
/// Build an error banner matching OpenWebUI's error display style.
/// Shows error content in a red-tinted container with an info icon.
Widget _buildErrorBanner(ChatMessageError error) {
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
final errorContent = error.content;
// If no content, show a generic error message
final displayText = (errorContent != null && errorContent.isNotEmpty)
? errorContent
: 'An error occurred while generating this response.';
return Container(
padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration(
color: errorColor.withValues(alpha: 0.1),
border: Border.all(color: errorColor.withValues(alpha: 0.2)),
borderRadius: BorderRadius.circular(Spacing.sm),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline,
size: 20,
color: errorColor,
),
const SizedBox(width: Spacing.sm),
Expanded(
child: Text(
displayText,
style: theme.textTheme.bodyMedium?.copyWith(
color: errorColor,
),
),
),
],
),
);
}
Widget _buildEnhancedMarkdownContent(String content) {
if (content.trim().isEmpty) {
return const SizedBox.shrink();
@@ -1252,7 +1314,11 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final ttsState = ref.watch(textToSpeechControllerProvider);
final messageId = _messageId;
final hasSpeechText = _ttsPlainText.trim().isNotEmpty;
final isErrorMessage =
// Check for error using the error field (preferred) or legacy content detection
// Also check the active version's error if viewing a version
final activeError = _getActiveError();
final hasErrorField = activeError != null;
final isErrorMessage = hasErrorField ||
widget.message.content.contains('⚠️') ||
widget.message.content.contains('Error') ||
widget.message.content.contains('timeout') ||