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>
445 lines
13 KiB
Dart
445 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:iconsax/iconsax.dart';
|
|
import 'package:animate_do/animate_do.dart';
|
|
import '../api/api.dart';
|
|
import 'message_bubble.dart';
|
|
|
|
/// A chat window component for interacting with a single AI agent
|
|
class AgentChatWindow extends StatefulWidget {
|
|
final String title;
|
|
final List<AgentDto> availableAgents;
|
|
|
|
const AgentChatWindow({
|
|
super.key,
|
|
required this.title,
|
|
required this.availableAgents,
|
|
});
|
|
|
|
@override
|
|
State<AgentChatWindow> createState() => _AgentChatWindowState();
|
|
}
|
|
|
|
class _AgentChatWindowState extends State<AgentChatWindow> {
|
|
AgentDto? _selectedAgent;
|
|
final TextEditingController _messageController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final List<_ChatMessage> _messages = [];
|
|
bool _isSending = false;
|
|
String? _conversationId; // Track current conversation
|
|
final CqrsApiClient _apiClient = CqrsApiClient(
|
|
config: ApiClientConfig.development,
|
|
);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
if (widget.availableAgents.isNotEmpty) {
|
|
_selectedAgent = widget.availableAgents.first;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_messageController.dispose();
|
|
_scrollController.dispose();
|
|
_apiClient.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _sendMessage() async {
|
|
if (_messageController.text.trim().isEmpty || _selectedAgent == null) {
|
|
return;
|
|
}
|
|
|
|
final String messageText = _messageController.text.trim();
|
|
|
|
setState(() {
|
|
_messages.add(_ChatMessage(
|
|
message: messageText,
|
|
isUser: true,
|
|
senderName: 'You',
|
|
timestamp: DateTime.now(),
|
|
));
|
|
_isSending = true;
|
|
});
|
|
|
|
_messageController.clear();
|
|
_scrollToBottom();
|
|
|
|
// Send message to agent via API
|
|
final Result<SendMessageResult> result = await _apiClient.sendMessage(
|
|
SendMessageCommand(
|
|
agentId: _selectedAgent!.id,
|
|
conversationId: _conversationId, // null for first message
|
|
message: messageText,
|
|
),
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
result.when(
|
|
success: (SendMessageResult response) {
|
|
setState(() {
|
|
// Store conversation ID for subsequent messages
|
|
_conversationId = response.conversationId;
|
|
|
|
// Add agent's response to chat
|
|
_messages.add(_ChatMessage(
|
|
message: response.agentResponse.content,
|
|
isUser: false,
|
|
senderName: _selectedAgent!.name,
|
|
timestamp: response.agentResponse.timestamp,
|
|
inputTokens: response.agentResponse.inputTokens,
|
|
outputTokens: response.agentResponse.outputTokens,
|
|
estimatedCost: response.agentResponse.estimatedCost,
|
|
));
|
|
_isSending = false;
|
|
});
|
|
_scrollToBottom();
|
|
},
|
|
error: (ApiErrorInfo error) {
|
|
setState(() {
|
|
_isSending = false;
|
|
});
|
|
|
|
// Show error message to user
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
const Icon(Iconsax.danger, color: Colors.white, size: 18),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
error.type == ApiErrorType.timeout
|
|
? 'Request timed out. Agent may be processing...'
|
|
: 'Failed to send message: ${error.message}',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
duration: const Duration(seconds: 4),
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
if (_scrollController.hasClients) {
|
|
_scrollController.animateTo(
|
|
_scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return FadeInUp(
|
|
duration: const Duration(milliseconds: 400),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerLow,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: colorScheme.outline.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Header with agent selector
|
|
_buildHeader(colorScheme),
|
|
|
|
// Divider
|
|
Divider(
|
|
height: 1,
|
|
color: colorScheme.outline.withValues(alpha: 0.2),
|
|
),
|
|
|
|
// Messages area
|
|
Expanded(
|
|
child: _buildMessagesArea(colorScheme),
|
|
),
|
|
|
|
// Divider
|
|
Divider(
|
|
height: 1,
|
|
color: colorScheme.outline.withValues(alpha: 0.2),
|
|
),
|
|
|
|
// Input area
|
|
_buildInputArea(colorScheme),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader(ColorScheme colorScheme) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
// Title
|
|
Icon(
|
|
Iconsax.messages_3,
|
|
color: colorScheme.primary,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
widget.title,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Agent Selector Dropdown
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: DropdownButton<AgentDto>(
|
|
value: _selectedAgent,
|
|
isExpanded: true,
|
|
underline: const SizedBox(),
|
|
icon: Icon(
|
|
Iconsax.arrow_down_1,
|
|
size: 16,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: colorScheme.onSurface,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
dropdownColor: colorScheme.surfaceContainerHigh,
|
|
items: widget.availableAgents.isEmpty
|
|
? [
|
|
DropdownMenuItem<AgentDto>(
|
|
value: null,
|
|
child: Text(
|
|
'No agents available',
|
|
style: TextStyle(
|
|
color: colorScheme.onSurfaceVariant,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
),
|
|
]
|
|
: widget.availableAgents.map((AgentDto agent) {
|
|
return DropdownMenuItem<AgentDto>(
|
|
value: agent,
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Iconsax.cpu,
|
|
size: 14,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
agent.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: widget.availableAgents.isEmpty
|
|
? null
|
|
: (AgentDto? newAgent) {
|
|
setState(() {
|
|
_selectedAgent = newAgent;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessagesArea(ColorScheme colorScheme) {
|
|
if (_messages.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Iconsax.message_text,
|
|
size: 48,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No messages yet',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_selectedAgent != null
|
|
? 'Start chatting with ${_selectedAgent!.name}'
|
|
: 'Select an agent to begin',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: _messages.length,
|
|
itemBuilder: (BuildContext context, int index) {
|
|
final _ChatMessage msg = _messages[index];
|
|
return MessageBubble(
|
|
message: msg.message,
|
|
isUser: msg.isUser,
|
|
senderName: msg.senderName,
|
|
timestamp: msg.timestamp,
|
|
inputTokens: msg.inputTokens,
|
|
outputTokens: msg.outputTokens,
|
|
estimatedCost: msg.estimatedCost,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildInputArea(ColorScheme colorScheme) {
|
|
final bool canSend =
|
|
_selectedAgent != null && !_isSending && _messageController.text.isNotEmpty;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
// Text input
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _messageController,
|
|
enabled: _selectedAgent != null && !_isSending,
|
|
maxLines: null,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: InputDecoration(
|
|
hintText: _selectedAgent != null
|
|
? 'Type your message...'
|
|
: 'Select an agent first',
|
|
hintStyle: TextStyle(
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
|
fontSize: 13,
|
|
),
|
|
filled: true,
|
|
fillColor: colorScheme.surfaceContainerHighest,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
onChanged: (_) => setState(() {}),
|
|
onSubmitted: (_) => canSend ? _sendMessage() : null,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Send button
|
|
Material(
|
|
color: canSend
|
|
? colorScheme.primary
|
|
: colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
onTap: canSend ? _sendMessage : null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(12),
|
|
child: _isSending
|
|
? SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
Colors.white,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
Iconsax.send_1,
|
|
color: canSend
|
|
? Colors.white
|
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
|
size: 20,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Internal message model for chat display
|
|
class _ChatMessage {
|
|
final String message;
|
|
final bool isUser;
|
|
final String senderName;
|
|
final DateTime timestamp;
|
|
final int? inputTokens;
|
|
final int? outputTokens;
|
|
final double? estimatedCost;
|
|
|
|
_ChatMessage({
|
|
required this.message,
|
|
required this.isUser,
|
|
required this.senderName,
|
|
required this.timestamp,
|
|
this.inputTokens,
|
|
this.outputTokens,
|
|
this.estimatedCost,
|
|
});
|
|
}
|