feat(conversation): improve OpenWebUI error handling and parsing

This commit is contained in:
cogwheel0
2025-12-15 20:42:09 +05:30
parent 008c77612a
commit 45532bf78f
5 changed files with 211 additions and 20 deletions

View File

@@ -34,12 +34,88 @@ sealed class ChatMessage with _$ChatMessage {
@JsonKey(includeFromJson: false, includeToJson: false)
@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({

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;

View File

@@ -191,6 +191,57 @@ List<Map<String, dynamic>>? _extractToolCalls(
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> msgData, {
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 effectiveFiles = msgData['files'] ?? historyMsg?['files'];
@@ -325,6 +379,7 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
'sources': _parseSourcesField(sourcesRaw),
'usage': usage,
'versions': const <Map<String, dynamic>>[],
if (errorData != null) 'error': errorData,
};
}

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'];
}
if (content.isNotEmpty) {
// Replace current assistant message with a readable error
replaceLastMessageContent('⚠️ $content');
errorContent = payload['message'];
}
// 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 (_) {}
// 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
wrappedFinishStreaming();
} else if ((type == 'chat:message:delta' || type == 'message') &&