Multi-agent AI laboratory with ASP.NET Core 8.0 backend and Flutter frontend. Implements CQRS architecture, OpenAPI contract-first API design. BACKEND: Agent management, conversations, executions with PostgreSQL + Ollama FRONTEND: Cross-platform UI with strict typing and Result-based error handling Co-Authored-By: Jean-Philippe Brule <jp@svrnty.io>
188 lines
6.9 KiB
Dart
188 lines
6.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:animate_do/animate_do.dart';
|
|
import 'package:iconsax/iconsax.dart';
|
|
|
|
/// Represents a single chat message bubble
|
|
class MessageBubble extends StatelessWidget {
|
|
final String message;
|
|
final bool isUser;
|
|
final String senderName;
|
|
final DateTime timestamp;
|
|
final int? inputTokens;
|
|
final int? outputTokens;
|
|
final double? estimatedCost;
|
|
|
|
const MessageBubble({
|
|
super.key,
|
|
required this.message,
|
|
required this.isUser,
|
|
required this.senderName,
|
|
required this.timestamp,
|
|
this.inputTokens,
|
|
this.outputTokens,
|
|
this.estimatedCost,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return FadeInUp(
|
|
duration: const Duration(milliseconds: 300),
|
|
child: Align(
|
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
|
child: Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
|
children: [
|
|
// Sender name and timestamp
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
senderName,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_formatTimestamp(timestamp),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Message bubble
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 10,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isUser
|
|
? colorScheme.primary.withValues(alpha: 0.15)
|
|
: colorScheme.secondary.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: const Radius.circular(16),
|
|
topRight: const Radius.circular(16),
|
|
bottomLeft: Radius.circular(isUser ? 16 : 4),
|
|
bottomRight: Radius.circular(isUser ? 4 : 16),
|
|
),
|
|
border: Border.all(
|
|
color: isUser
|
|
? colorScheme.primary.withValues(alpha: 0.3)
|
|
: colorScheme.secondary.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
message,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurface,
|
|
height: 1.4,
|
|
),
|
|
),
|
|
// Token metrics (only for agent responses)
|
|
if (!isUser && (inputTokens != null || outputTokens != null))
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Iconsax.cpu,
|
|
size: 10,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${inputTokens ?? 0} in',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontFamily: 'IBM Plex Mono',
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'•',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${outputTokens ?? 0} out',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontFamily: 'IBM Plex Mono',
|
|
),
|
|
),
|
|
if (estimatedCost != null) ...[
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'•',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'\$${estimatedCost!.toStringAsFixed(4)}',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: colorScheme.secondary,
|
|
fontFamily: 'IBM Plex Mono',
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatTimestamp(DateTime dt) {
|
|
final DateTime now = DateTime.now();
|
|
final Duration diff = now.difference(dt);
|
|
|
|
if (diff.inMinutes < 1) {
|
|
return 'Just now';
|
|
} else if (diff.inHours < 1) {
|
|
return '${diff.inMinutes}m ago';
|
|
} else if (diff.inDays < 1) {
|
|
return '${diff.inHours}h ago';
|
|
} else {
|
|
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
|
}
|
|
}
|
|
}
|