CODEX_ADK/FRONTEND/lib/components/agent_chat_window.dart
Svrnty 229a0698a3 Initial commit: CODEX_ADK monorepo
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>
2025-10-26 23:12:32 -04:00

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,
});
}