feat(chat): Add usage statistics support for message persistence
This commit is contained in:
@@ -966,7 +966,7 @@ class ApiService {
|
||||
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
||||
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
||||
if (sanitizedFiles != null) 'files': sanitizedFiles,
|
||||
// Mirror status updates, follow-ups, code executions, and sources
|
||||
// Mirror status updates, follow-ups, code executions, sources, and usage
|
||||
if (msg.statusHistory.isNotEmpty)
|
||||
'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(),
|
||||
if (msg.followUps.isNotEmpty)
|
||||
@@ -975,6 +975,8 @@ class ApiService {
|
||||
'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(),
|
||||
if (msg.sources.isNotEmpty)
|
||||
'sources': msg.sources.map((s) => s.toJson()).toList(),
|
||||
// Include usage statistics for persistence (issue #274)
|
||||
if (msg.usage != null) 'usage': msg.usage,
|
||||
};
|
||||
|
||||
// Update parent's childrenIds
|
||||
@@ -1001,7 +1003,7 @@ class ApiService {
|
||||
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
||||
'attachment_ids': List<String>.from(msg.attachmentIds!),
|
||||
if (sanitizedArrayFiles != null) 'files': sanitizedArrayFiles,
|
||||
// Mirror status updates, follow-ups, code executions, and sources
|
||||
// Mirror status updates, follow-ups, code executions, sources, and usage
|
||||
if (msg.statusHistory.isNotEmpty)
|
||||
'statusHistory': msg.statusHistory.map((s) => s.toJson()).toList(),
|
||||
if (msg.followUps.isNotEmpty)
|
||||
@@ -1010,6 +1012,8 @@ class ApiService {
|
||||
'codeExecutions': msg.codeExecutions.map((e) => e.toJson()).toList(),
|
||||
if (msg.sources.isNotEmpty)
|
||||
'sources': msg.sources.map((s) => s.toJson()).toList(),
|
||||
// Include usage statistics for persistence (issue #274)
|
||||
if (msg.usage != null) 'usage': msg.usage,
|
||||
});
|
||||
|
||||
previousId = messageId;
|
||||
@@ -1747,6 +1751,10 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Send chat completed notification
|
||||
// This persists usage data and other message metadata to the server
|
||||
/// Notify backend that chat streaming is complete.
|
||||
/// This triggers any configured filters/actions on the backend.
|
||||
/// Matches OpenWebUI's chatCompletedHandler in Chat.svelte.
|
||||
Future<void> sendChatCompleted({
|
||||
required String chatId,
|
||||
required String messageId,
|
||||
@@ -1754,61 +1762,61 @@ class ApiService {
|
||||
required String model,
|
||||
Map<String, dynamic>? modelItem,
|
||||
String? sessionId,
|
||||
List<String>? filterIds,
|
||||
}) async {
|
||||
_traceApi('Sending chat completed notification (optional endpoint)');
|
||||
|
||||
// This endpoint appears to be optional or deprecated in newer OpenWebUI versions
|
||||
// The main chat synchronization happens through /api/v1/chats/{id} updates
|
||||
// We'll still try to call it but won't fail if it doesn't work
|
||||
|
||||
// Format messages to match OpenWebUI expected structure
|
||||
// Note: Removing 'id' field as it causes 400 error
|
||||
// Format messages to match OpenWebUI expected structure exactly
|
||||
final formattedMessages = messages.map((msg) {
|
||||
final formatted = {
|
||||
// Don't include 'id' - it causes 400 error with detail: 'id'
|
||||
final formatted = <String, dynamic>{
|
||||
'id': msg['id'],
|
||||
'role': msg['role'],
|
||||
'content': msg['content'],
|
||||
'timestamp':
|
||||
msg['timestamp'] ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
};
|
||||
|
||||
// Add model info for assistant messages
|
||||
if (msg['role'] == 'assistant') {
|
||||
formatted['model'] = model;
|
||||
if (msg.containsKey('usage')) {
|
||||
formatted['usage'] = msg['usage'];
|
||||
}
|
||||
// Include info if present (OpenWebUI sends this)
|
||||
if (msg.containsKey('info') && msg['info'] != null) {
|
||||
formatted['info'] = msg['info'];
|
||||
}
|
||||
// Include usage if present (issue #274)
|
||||
if (msg.containsKey('usage') && msg['usage'] != null) {
|
||||
formatted['usage'] = msg['usage'];
|
||||
}
|
||||
// Include sources if present
|
||||
if (msg.containsKey('sources') && msg['sources'] != null) {
|
||||
formatted['sources'] = msg['sources'];
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}).toList();
|
||||
|
||||
// Include the message ID and session ID at the top level - server expects these
|
||||
final requestData = {
|
||||
'id': messageId, // The server expects the assistant message ID here
|
||||
'chat_id': chatId,
|
||||
final requestData = <String, dynamic>{
|
||||
'model': model,
|
||||
'messages': formattedMessages,
|
||||
'session_id':
|
||||
sessionId ?? const Uuid().v4().substring(0, 20), // Add session_id
|
||||
// Don't include model_item as it might not be expected
|
||||
'chat_id': chatId,
|
||||
'session_id': sessionId ?? const Uuid().v4().substring(0, 20),
|
||||
'id': messageId,
|
||||
};
|
||||
|
||||
// Include filter_ids if provided (for outlet filters)
|
||||
if (filterIds != null && filterIds.isNotEmpty) {
|
||||
requestData['filter_ids'] = filterIds;
|
||||
}
|
||||
|
||||
// Include model_item if available
|
||||
if (modelItem != null) {
|
||||
requestData['model_item'] = modelItem;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
await _dio.post(
|
||||
'/api/chat/completed',
|
||||
data: requestData,
|
||||
options: Options(
|
||||
sendTimeout: const Duration(seconds: 4),
|
||||
receiveTimeout: const Duration(seconds: 4),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
_traceApi('Chat completed response: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
// This is a non-critical endpoint - main sync happens via /api/v1/chats/{id}
|
||||
_traceApi(
|
||||
'Chat completed endpoint not available or failed (non-critical): $e',
|
||||
);
|
||||
} catch (_) {
|
||||
// Non-critical - filters/actions may not be configured
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2826,6 +2834,16 @@ class ApiService {
|
||||
data['chat_id'] = conversationId;
|
||||
}
|
||||
|
||||
// Request usage statistics if model supports it (issue #274)
|
||||
// Matches OpenWebUI: only sends stream_options when model.info.meta.capabilities.usage is true
|
||||
final supportsUsage =
|
||||
modelItem?['capabilities']?['usage'] == true ||
|
||||
(modelItem?['info'] as Map?)?['meta']?['capabilities']?['usage'] ==
|
||||
true;
|
||||
if (supportsUsage) {
|
||||
data['stream_options'] = {'include_usage': true};
|
||||
}
|
||||
|
||||
// Add feature flags via 'features' object only (not as top-level params).
|
||||
// Top-level 'web_search'/'image_generation' params are not recognized by
|
||||
// OpenAI and cause errors when forwarded. Open WebUI expects these in the
|
||||
|
||||
@@ -305,6 +305,10 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
||||
? historyMsg['sources'] ?? historyMsg['citations']
|
||||
: msgData['sources'] ?? msgData['citations'];
|
||||
|
||||
// Parse usage data - Open WebUI stores this in 'usage' field on messages
|
||||
final rawUsage = _coerceJsonMap(historyMsg?['usage'] ?? msgData['usage']);
|
||||
final Map<String, dynamic>? usage = rawUsage.isEmpty ? null : rawUsage;
|
||||
|
||||
return <String, dynamic>{
|
||||
'id': (msgData['id'] ?? _uuid.v4()).toString(),
|
||||
'role': role,
|
||||
@@ -319,7 +323,7 @@ Map<String, dynamic> _parseOpenWebUIMessageToJson(
|
||||
'followUps': _coerceStringList(followUpsRaw),
|
||||
'codeExecutions': _parseCodeExecutionsField(codeExecRaw),
|
||||
'sources': _parseSourcesField(sourcesRaw),
|
||||
'usage': _coerceJsonMap(msgData['usage']),
|
||||
'usage': usage,
|
||||
'versions': const <Map<String, dynamic>>[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -576,12 +576,15 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
||||
|
||||
setFollowUps(assistant.id, assistant.followUps);
|
||||
updateMessageById(assistant.id, (current) {
|
||||
// Preserve existing usage if server doesn't have it yet (issue #274)
|
||||
// Usage is captured from streaming but may not be persisted on server
|
||||
final effectiveUsage = assistant.usage ?? current.usage;
|
||||
return current.copyWith(
|
||||
followUps: List<String>.from(assistant.followUps),
|
||||
statusHistory: assistant.statusHistory,
|
||||
sources: assistant.sources,
|
||||
metadata: {...?current.metadata, ...?assistant.metadata},
|
||||
usage: assistant.usage,
|
||||
usage: effectiveUsage,
|
||||
);
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -638,6 +641,14 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
||||
}
|
||||
try {
|
||||
final Map<String, dynamic> j = jsonDecode(dataStr);
|
||||
|
||||
// Capture usage statistics from OpenAI-style streaming (issue #274)
|
||||
// Usage is sent in the final chunk with stream_options.include_usage
|
||||
final usageData = j['usage'];
|
||||
if (usageData is Map<String, dynamic> && usageData.isNotEmpty) {
|
||||
updateLastMessageWith((m) => m.copyWith(usage: usageData));
|
||||
}
|
||||
|
||||
final choices = j['choices'];
|
||||
if (choices is List && choices.isNotEmpty) {
|
||||
final choice = choices.first;
|
||||
@@ -746,6 +757,18 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
||||
|
||||
if (type == 'chat:completion' && payload != null) {
|
||||
if (payload is Map<String, dynamic>) {
|
||||
// Capture usage statistics whenever they appear (issue #274)
|
||||
// Usage may come in a separate payload before the done:true payload
|
||||
final usageData = payload['usage'];
|
||||
if (usageData is Map<String, dynamic> && usageData.isNotEmpty) {
|
||||
final targetId = _resolveTargetMessageId(messageId, getMessages);
|
||||
if (targetId != null) {
|
||||
updateMessageById(targetId, (current) {
|
||||
return current.copyWith(usage: usageData);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final rawSources = payload['sources'] ?? payload['citations'];
|
||||
final normalizedSources = _normalizeSourcesPayload(rawSources);
|
||||
if (normalizedSources != null && normalizedSources.isNotEmpty) {
|
||||
@@ -832,18 +855,55 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
|
||||
}
|
||||
if (payload['done'] == true) {
|
||||
try {
|
||||
// Get current messages to send with usage data (issue #274)
|
||||
final currentMessages = getMessages();
|
||||
final messagesForCompleted = currentMessages.map((m) {
|
||||
final msgMap = <String, dynamic>{
|
||||
'id': m.id,
|
||||
'role': m.role,
|
||||
'content': m.content,
|
||||
'timestamp': m.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
};
|
||||
if (m.role == 'assistant' && m.usage != null) {
|
||||
msgMap['usage'] = m.usage;
|
||||
}
|
||||
if (m.sources.isNotEmpty) {
|
||||
msgMap['sources'] = m.sources.map((s) => s.toJson()).toList();
|
||||
}
|
||||
return msgMap;
|
||||
}).toList();
|
||||
|
||||
// Send chatCompleted to run any filters/actions
|
||||
// ignore: unawaited_futures
|
||||
api.sendChatCompleted(
|
||||
chatId: activeConversationId ?? '',
|
||||
messageId: assistantMessageId,
|
||||
messages: const [],
|
||||
messages: messagesForCompleted,
|
||||
model: modelId,
|
||||
modelItem: modelItem,
|
||||
sessionId: sessionId,
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
Future.microtask(refreshConversationSnapshot);
|
||||
// Sync conversation to persist usage data (issue #274)
|
||||
// chatCompleted doesn't persist - syncConversationMessages does
|
||||
final chatId = activeConversationId;
|
||||
if (chatId != null && chatId.isNotEmpty) {
|
||||
// ignore: unawaited_futures
|
||||
api.syncConversationMessages(
|
||||
chatId,
|
||||
currentMessages,
|
||||
model: modelId,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-critical - continue if sync fails
|
||||
}
|
||||
|
||||
// Delay snapshot refresh to allow backend to persist data
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
refreshConversationSnapshot,
|
||||
);
|
||||
|
||||
final msgs = getMessages();
|
||||
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
|
||||
|
||||
Reference in New Issue
Block a user