feat(conversation): improve OpenWebUI error handling and parsing
This commit is contained in:
@@ -34,12 +34,88 @@ sealed class ChatMessage with _$ChatMessage {
|
|||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@Default(<ChatMessageVersion>[])
|
@Default(<ChatMessageVersion>[])
|
||||||
List<ChatMessageVersion> versions,
|
List<ChatMessageVersion> versions,
|
||||||
|
// Error information from OpenWebUI (stored separately from content)
|
||||||
|
@JsonKey(fromJson: _chatMessageErrorFromJson, toJson: _chatMessageErrorToJson)
|
||||||
|
ChatMessageError? error,
|
||||||
}) = _ChatMessage;
|
}) = _ChatMessage;
|
||||||
|
|
||||||
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ChatMessageFromJson(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
|
@freezed
|
||||||
abstract class ChatMessageVersion with _$ChatMessageVersion {
|
abstract class ChatMessageVersion with _$ChatMessageVersion {
|
||||||
const factory ChatMessageVersion({
|
const factory ChatMessageVersion({
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ class ApiService {
|
|||||||
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
||||||
if (_sanitizeFilesForWebUI(msg.files) != null)
|
if (_sanitizeFilesForWebUI(msg.files) != null)
|
||||||
'files': _sanitizeFilesForWebUI(msg.files),
|
'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
|
// Update parent's childrenIds if there's a previous message
|
||||||
@@ -863,6 +865,8 @@ class ApiService {
|
|||||||
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
||||||
if (_sanitizeFilesForWebUI(msg.files) != null)
|
if (_sanitizeFilesForWebUI(msg.files) != null)
|
||||||
'files': _sanitizeFilesForWebUI(msg.files),
|
'files': _sanitizeFilesForWebUI(msg.files),
|
||||||
|
// Preserve error field for OpenWebUI compatibility
|
||||||
|
if (msg.error != null) 'error': msg.error!.toJson(),
|
||||||
});
|
});
|
||||||
|
|
||||||
previousId = messageId;
|
previousId = messageId;
|
||||||
@@ -977,6 +981,8 @@ class ApiService {
|
|||||||
'sources': msg.sources.map((s) => s.toJson()).toList(),
|
'sources': msg.sources.map((s) => s.toJson()).toList(),
|
||||||
// Include usage statistics for persistence (issue #274)
|
// Include usage statistics for persistence (issue #274)
|
||||||
if (msg.usage != null) 'usage': msg.usage,
|
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
|
// Update parent's childrenIds
|
||||||
@@ -1014,6 +1020,8 @@ class ApiService {
|
|||||||
'sources': msg.sources.map((s) => s.toJson()).toList(),
|
'sources': msg.sources.map((s) => s.toJson()).toList(),
|
||||||
// Include usage statistics for persistence (issue #274)
|
// Include usage statistics for persistence (issue #274)
|
||||||
if (msg.usage != null) 'usage': msg.usage,
|
if (msg.usage != null) 'usage': msg.usage,
|
||||||
|
// Preserve error field for OpenWebUI compatibility
|
||||||
|
if (msg.error != null) 'error': msg.error!.toJson(),
|
||||||
});
|
});
|
||||||
|
|
||||||
previousId = messageId;
|
previousId = messageId;
|
||||||
|
|||||||
@@ -191,6 +191,57 @@ List<Map<String, dynamic>>? _extractToolCalls(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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> _parseOpenWebUIMessageToJson(
|
||||||
Map<String, dynamic> msgData, {
|
Map<String, dynamic> msgData, {
|
||||||
Map<String, dynamic>? historyMsg,
|
Map<String, dynamic>? historyMsg,
|
||||||
@@ -253,6 +304,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 role = _resolveRole(msgData);
|
||||||
|
|
||||||
final effectiveFiles = msgData['files'] ?? historyMsg?['files'];
|
final effectiveFiles = msgData['files'] ?? historyMsg?['files'];
|
||||||
@@ -325,6 +379,7 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
|||||||
'sources': _parseSourcesField(sourcesRaw),
|
'sources': _parseSourcesField(sourcesRaw),
|
||||||
'usage': usage,
|
'usage': usage,
|
||||||
'versions': const <Map<String, dynamic>>[],
|
'versions': const <Map<String, dynamic>>[],
|
||||||
|
if (errorData != null) 'error': errorData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1126,34 +1126,33 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
|||||||
// Server reports an error for the current assistant message
|
// Server reports an error for the current assistant message
|
||||||
try {
|
try {
|
||||||
dynamic err = payload is Map ? payload['error'] : null;
|
dynamic err = payload is Map ? payload['error'] : null;
|
||||||
String content = '';
|
String errorContent = '';
|
||||||
if (err is Map) {
|
if (err is Map) {
|
||||||
final c = err['content'];
|
final c = err['content'];
|
||||||
if (c is String) {
|
if (c is String) {
|
||||||
content = c;
|
errorContent = c;
|
||||||
} else if (c != null) {
|
} else if (c != null) {
|
||||||
content = c.toString();
|
errorContent = c.toString();
|
||||||
}
|
}
|
||||||
} else if (err is String) {
|
} else if (err is String) {
|
||||||
content = err;
|
errorContent = err;
|
||||||
} else if (payload is Map && payload['message'] is String) {
|
} 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');
|
|
||||||
}
|
}
|
||||||
|
// 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);
|
||||||
|
return message.copyWith(
|
||||||
|
error: errorContent.isNotEmpty
|
||||||
|
? ChatMessageError(content: errorContent)
|
||||||
|
: const ChatMessageError(content: null),
|
||||||
|
statusHistory: filtered,
|
||||||
|
);
|
||||||
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
// Ensure UI exits streaming state
|
// Ensure UI exits streaming state
|
||||||
wrappedFinishStreaming();
|
wrappedFinishStreaming();
|
||||||
} else if ((type == 'chat:message:delta' || type == 'message') &&
|
} else if ((type == 'chat:message:delta' || type == 'message') &&
|
||||||
|
|||||||
@@ -878,6 +878,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Display error banner if message has an error
|
||||||
|
if (widget.message is ChatMessage &&
|
||||||
|
(widget.message as ChatMessage).error != null) ...[
|
||||||
|
const SizedBox(height: Spacing.sm),
|
||||||
|
_buildErrorBanner(
|
||||||
|
(widget.message as ChatMessage).error!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
if (hasCodeExecutions) ...[
|
if (hasCodeExecutions) ...[
|
||||||
const SizedBox(height: Spacing.md),
|
const SizedBox(height: Spacing.md),
|
||||||
CodeExecutionListView(
|
CodeExecutionListView(
|
||||||
@@ -924,6 +933,47 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
Widget _buildEnhancedMarkdownContent(String content) {
|
||||||
if (content.trim().isEmpty) {
|
if (content.trim().isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
@@ -1252,7 +1302,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
|||||||
final ttsState = ref.watch(textToSpeechControllerProvider);
|
final ttsState = ref.watch(textToSpeechControllerProvider);
|
||||||
final messageId = _messageId;
|
final messageId = _messageId;
|
||||||
final hasSpeechText = _ttsPlainText.trim().isNotEmpty;
|
final hasSpeechText = _ttsPlainText.trim().isNotEmpty;
|
||||||
final isErrorMessage =
|
// Check for error using the error field (preferred) or legacy content detection
|
||||||
|
final hasErrorField = widget.message is ChatMessage &&
|
||||||
|
(widget.message as ChatMessage).error != null;
|
||||||
|
final isErrorMessage = hasErrorField ||
|
||||||
widget.message.content.contains('⚠️') ||
|
widget.message.content.contains('⚠️') ||
|
||||||
widget.message.content.contains('Error') ||
|
widget.message.content.contains('Error') ||
|
||||||
widget.message.content.contains('timeout') ||
|
widget.message.content.contains('timeout') ||
|
||||||
|
|||||||
Reference in New Issue
Block a user