feat(chat): Add usage statistics support for message persistence

This commit is contained in:
cogwheel0
2025-12-15 18:42:06 +05:30
parent c21e70396d
commit 55cedc3ab8
7 changed files with 505 additions and 41 deletions

View File

@@ -1521,6 +1521,10 @@ Future<void> regenerateMessage(
'actions': <dynamic>[],
'filters': <dynamic>[],
'tags': <dynamic>[],
// Include capabilities from the actual model for usage stats support
'capabilities': selectedModel.capabilities,
// Include info/metadata for usage capability detection
'info': selectedModel.metadata?['info'],
};
// WebSocket-only streaming requires socket connection
@@ -2217,6 +2221,10 @@ Future<void> _sendMessageInternal(
'actions': <dynamic>[],
'filters': <dynamic>[],
'tags': <dynamic>[],
// Include capabilities from the actual model for usage stats support
'capabilities': selectedModel.capabilities,
// Include info/metadata for usage capability detection
'info': selectedModel.metadata?['info'],
};
// WebSocket-only streaming requires socket connection.

View File

@@ -1346,6 +1346,15 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
},
),
],
// Usage info button (like Open WebUI)
if (widget.message.usage != null &&
widget.message.usage!.isNotEmpty) ...[
_buildActionButton(
icon: Platform.isIOS ? CupertinoIcons.info : Icons.info_outline,
label: l10n.usageInfo,
onTap: () => _showUsageInfoSheet(context, widget.message.usage!),
),
],
if (isErrorMessage) ...[
_buildActionButton(
icon: Platform.isIOS
@@ -1373,6 +1382,242 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
return ChatActionButton(icon: icon, label: label, onTap: onTap);
}
/// Shows a bottom sheet with usage/performance statistics for the response.
/// Matches Open WebUI's info button behavior but adapted for mobile UX.
void _showUsageInfoSheet(BuildContext context, Map<String, dynamic> usage) {
final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet<void>(
context: context,
backgroundColor: theme.surfaceBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.dialog),
),
),
builder: (ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(Spacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Row(
children: [
Icon(
Icons.analytics_outlined,
size: IconSize.md,
color: theme.textPrimary,
),
const SizedBox(width: Spacing.sm),
Text(
l10n.usageInfoTitle,
style: TextStyle(
fontSize: AppTypography.bodyLarge,
fontWeight: FontWeight.w600,
color: theme.textPrimary,
),
),
],
),
const SizedBox(height: Spacing.lg),
// Stats grid
..._buildUsageStats(ctx, usage, l10n, theme),
],
),
),
);
},
);
}
/// Builds the list of usage stat widgets from the usage map.
List<Widget> _buildUsageStats(
BuildContext context,
Map<String, dynamic> usage,
AppLocalizations l10n,
ConduitThemeExtension theme,
) {
final stats = <Widget>[];
// Parse all possible fields
final evalCount = _parseNum(usage['eval_count']);
final evalDuration = _parseNum(usage['eval_duration']);
final promptEvalCount = _parseNum(usage['prompt_eval_count']);
final promptEvalDuration = _parseNum(usage['prompt_eval_duration']);
final completionTokens = _parseNum(usage['completion_tokens']);
final promptTokens = _parseNum(usage['prompt_tokens']);
final totalTokens = _parseNum(usage['total_tokens']);
// Time fields in seconds (Groq/OpenAI extended format)
final completionTime = _parseNum(usage['completion_time']);
final promptTime = _parseNum(usage['prompt_time']);
final totalTime = _parseNum(usage['total_time']);
final queueTime = _parseNum(usage['queue_time']);
// Time fields in nanoseconds (Ollama/llama.cpp format)
final totalDuration = _parseNum(usage['total_duration']);
final loadDuration = _parseNum(usage['load_duration']);
// Reasoning tokens (OpenAI o1/o3 models, Groq)
final completionDetails = usage['completion_tokens_details'];
final reasoningTokens = completionDetails is Map
? _parseNum(completionDetails['reasoning_tokens'])
: null;
// --- Token Generation Speed ---
// Priority: Ollama format > Groq/OpenAI extended format > token count only
if (evalCount != null && evalDuration != null && evalDuration > 0) {
// Ollama/llama.cpp: duration in nanoseconds
final tgSpeed = evalCount / (evalDuration / 1e9);
stats.add(
_UsageStatRow(
label: l10n.usageTokenGeneration,
value: l10n.usageTokensPerSecond(tgSpeed.toStringAsFixed(1)),
detail: l10n.usageTokenCount(evalCount.toInt()),
theme: theme,
),
);
} else if (completionTokens != null &&
completionTime != null &&
completionTime > 0) {
// Groq/OpenAI extended: time in seconds
final tgSpeed = completionTokens / completionTime;
stats.add(
_UsageStatRow(
label: l10n.usageTokenGeneration,
value: l10n.usageTokensPerSecond(tgSpeed.toStringAsFixed(1)),
detail: l10n.usageTokenCount(completionTokens.toInt()),
theme: theme,
),
);
} else if (completionTokens != null) {
// Basic OpenAI: token count only
stats.add(
_UsageStatRow(
label: l10n.usageTokenGeneration,
value: l10n.usageTokenCount(completionTokens.toInt()),
theme: theme,
),
);
}
// --- Prompt Processing Speed ---
if (promptEvalCount != null &&
promptEvalDuration != null &&
promptEvalDuration > 0) {
// Ollama/llama.cpp: duration in nanoseconds
final ppSpeed = promptEvalCount / (promptEvalDuration / 1e9);
stats.add(
_UsageStatRow(
label: l10n.usagePromptEval,
value: l10n.usageTokensPerSecond(ppSpeed.toStringAsFixed(1)),
detail: l10n.usageTokenCount(promptEvalCount.toInt()),
theme: theme,
),
);
} else if (promptTokens != null && promptTime != null && promptTime > 0) {
// Groq/OpenAI extended: time in seconds
final ppSpeed = promptTokens / promptTime;
stats.add(
_UsageStatRow(
label: l10n.usagePromptEval,
value: l10n.usageTokensPerSecond(ppSpeed.toStringAsFixed(1)),
detail: l10n.usageTokenCount(promptTokens.toInt()),
theme: theme,
),
);
} else if (promptTokens != null) {
// Basic OpenAI: token count only
stats.add(
_UsageStatRow(
label: l10n.usagePromptEval,
value: l10n.usageTokenCount(promptTokens.toInt()),
theme: theme,
),
);
}
// --- Reasoning Tokens (for o1/o3 models) ---
if (reasoningTokens != null && reasoningTokens > 0) {
stats.add(
_UsageStatRow(
label: l10n.usageReasoningTokens,
value: l10n.usageTokenCount(reasoningTokens.toInt()),
theme: theme,
),
);
}
// --- Total Tokens (if not already shown via completion + prompt) ---
if (totalTokens != null &&
(completionTokens == null || promptTokens == null)) {
stats.add(
_UsageStatRow(
label: l10n.usageTotalTokens,
value: l10n.usageTokenCount(totalTokens.toInt()),
theme: theme,
),
);
}
// --- Total Duration ---
if (totalDuration != null && totalDuration > 0) {
// Ollama/llama.cpp: nanoseconds
final totalSec = totalDuration / 1e9;
stats.add(
_UsageStatRow(
label: l10n.usageTotalDuration,
value: l10n.usageSecondsFormat(totalSec.toStringAsFixed(2)),
theme: theme,
),
);
} else if (totalTime != null && totalTime > 0) {
// Groq/OpenAI extended: seconds
stats.add(
_UsageStatRow(
label: l10n.usageTotalDuration,
value: l10n.usageSecondsFormat(totalTime.toStringAsFixed(2)),
theme: theme,
),
);
}
// --- Queue Time (Groq) ---
if (queueTime != null && queueTime > 0) {
stats.add(
_UsageStatRow(
label: l10n.usageQueueTime,
value: l10n.usageSecondsFormat(queueTime.toStringAsFixed(3)),
theme: theme,
),
);
}
// --- Model Load Time (Ollama) ---
if (loadDuration != null && loadDuration > 0) {
final loadSec = loadDuration / 1e9;
stats.add(
_UsageStatRow(
label: l10n.usageLoadDuration,
value: l10n.usageSecondsFormat(loadSec.toStringAsFixed(2)),
theme: theme,
),
);
}
return stats;
}
/// Safely parse a number from dynamic value.
num? _parseNum(dynamic value) {
if (value == null) return null;
if (value is num) return value;
if (value is String) return num.tryParse(value);
return null;
}
// Reasoning tile rendered inline - minimal design inspired by OpenWebUI
Widget _buildReasoningTile(ReasoningEntry rc, int index) {
final isExpanded = _expandedReasoning.contains(index);
@@ -1878,3 +2123,59 @@ Future<void> _launchUri(String url) async {
DebugLogger.log('Unable to open url $url: $err', scope: 'chat/assistant');
}
}
/// Row widget for displaying a single usage statistic.
class _UsageStatRow extends StatelessWidget {
const _UsageStatRow({
required this.label,
required this.value,
this.detail,
required this.theme,
});
final String label;
final String value;
final String? detail;
final ConduitThemeExtension theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: AppTypography.bodyMedium,
color: theme.textSecondary,
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
value,
style: TextStyle(
fontSize: AppTypography.bodyMedium,
fontWeight: FontWeight.w600,
fontFamily: AppTypography.monospaceFontFamily,
color: theme.textPrimary,
),
),
if (detail != null)
Text(
detail!,
style: TextStyle(
fontSize: AppTypography.labelSmall,
color: theme.textTertiary,
),
),
],
),
],
),
);
}
}