feat(chat): Improve error handling and message versioning
This commit is contained in:
@@ -31,7 +31,8 @@ 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)
|
||||
@@ -134,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) =>
|
||||
@@ -281,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
|
||||
|
||||
@@ -1048,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)
|
||||
@@ -1057,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)) {
|
||||
|
||||
@@ -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,120 @@ 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().
|
||||
@@ -393,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,
|
||||
) {
|
||||
@@ -402,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];
|
||||
@@ -415,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) {
|
||||
|
||||
Reference in New Issue
Block a user