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>
576 lines
20 KiB
Dart
576 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:iconsax/iconsax.dart';
|
|
import '../api/api.dart';
|
|
|
|
/// Dialog for creating a new AI agent
|
|
///
|
|
/// Provides a comprehensive form with all required fields for agent creation.
|
|
/// Includes validation and integrates with the CQRS API.
|
|
class CreateAgentDialog extends StatefulWidget {
|
|
final Function(CreateAgentCommand) onCreateAgent;
|
|
|
|
const CreateAgentDialog({
|
|
super.key,
|
|
required this.onCreateAgent,
|
|
});
|
|
|
|
@override
|
|
State<CreateAgentDialog> createState() => _CreateAgentDialogState();
|
|
}
|
|
|
|
class _CreateAgentDialogState extends State<CreateAgentDialog> {
|
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
|
|
// Form fields
|
|
final TextEditingController _nameController = TextEditingController();
|
|
final TextEditingController _descriptionController = TextEditingController();
|
|
final TextEditingController _modelProviderController = TextEditingController();
|
|
final TextEditingController _modelNameController = TextEditingController();
|
|
final TextEditingController _endpointController = TextEditingController();
|
|
final TextEditingController _apiKeyController = TextEditingController();
|
|
final TextEditingController _systemPromptController = TextEditingController();
|
|
final TextEditingController _maxTokensController = TextEditingController(text: '4000');
|
|
|
|
AgentType _selectedType = AgentType.codeGenerator;
|
|
ModelProviderType _selectedProviderType = ModelProviderType.localEndpoint;
|
|
double _temperature = 0.7;
|
|
bool _enableMemory = true;
|
|
int _conversationWindowSize = 10;
|
|
bool _isCreating = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_descriptionController.dispose();
|
|
_modelProviderController.dispose();
|
|
_modelNameController.dispose();
|
|
_endpointController.dispose();
|
|
_apiKeyController.dispose();
|
|
_systemPromptController.dispose();
|
|
_maxTokensController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleCreate() {
|
|
if (_formKey.currentState!.validate()) {
|
|
setState(() => _isCreating = true);
|
|
|
|
final CreateAgentCommand command = CreateAgentCommand(
|
|
name: _nameController.text.trim(),
|
|
description: _descriptionController.text.trim(),
|
|
type: _selectedType,
|
|
modelProvider: _modelProviderController.text.trim(),
|
|
modelName: _modelNameController.text.trim(),
|
|
providerType: _selectedProviderType,
|
|
modelEndpoint: _endpointController.text.trim().isEmpty
|
|
? null
|
|
: _endpointController.text.trim(),
|
|
apiKey: _apiKeyController.text.trim().isEmpty
|
|
? null
|
|
: _apiKeyController.text.trim(),
|
|
temperature: _temperature,
|
|
maxTokens: int.parse(_maxTokensController.text),
|
|
systemPrompt: _systemPromptController.text.trim(),
|
|
enableMemory: _enableMemory,
|
|
conversationWindowSize: _conversationWindowSize,
|
|
);
|
|
|
|
widget.onCreateAgent(command);
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Dialog(
|
|
backgroundColor: colorScheme.surface,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Container(
|
|
width: 700,
|
|
constraints: const BoxConstraints(maxHeight: 800),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Header
|
|
Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(20),
|
|
topRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Icon(
|
|
Iconsax.cpu,
|
|
color: Colors.white,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Create New Agent',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Configure your AI agent settings',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Iconsax.close_circle,
|
|
color: colorScheme.onPrimaryContainer,
|
|
),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Form Content
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Basic Information Section
|
|
_buildSectionHeader('Basic Information', Iconsax.information),
|
|
const SizedBox(height: 16),
|
|
_buildTextField(
|
|
controller: _nameController,
|
|
label: 'Agent Name',
|
|
hint: 'e.g., Code Generator',
|
|
icon: Iconsax.edit,
|
|
validator: (String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Name is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildTextField(
|
|
controller: _descriptionController,
|
|
label: 'Description',
|
|
hint: 'Describe what this agent does',
|
|
icon: Iconsax.document_text,
|
|
maxLines: 3,
|
|
validator: (String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Description is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildDropdown<AgentType>(
|
|
label: 'Agent Type',
|
|
value: _selectedType,
|
|
items: AgentType.values,
|
|
itemLabel: (AgentType type) => type.value,
|
|
onChanged: (AgentType? value) {
|
|
if (value != null) {
|
|
setState(() => _selectedType = value);
|
|
}
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Model Configuration Section
|
|
_buildSectionHeader('Model Configuration', Iconsax.cpu),
|
|
const SizedBox(height: 16),
|
|
_buildDropdown<ModelProviderType>(
|
|
label: 'Provider Type',
|
|
value: _selectedProviderType,
|
|
items: ModelProviderType.values,
|
|
itemLabel: (ModelProviderType type) => type.value,
|
|
onChanged: (ModelProviderType? value) {
|
|
if (value != null) {
|
|
setState(() => _selectedProviderType = value);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildTextField(
|
|
controller: _modelProviderController,
|
|
label: 'Model Provider',
|
|
hint: 'e.g., ollama, openai, anthropic',
|
|
icon: Iconsax.cloud,
|
|
validator: (String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Model provider is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildTextField(
|
|
controller: _modelNameController,
|
|
label: 'Model Name',
|
|
hint: 'e.g., phi, gpt-4o, claude-3.5-sonnet',
|
|
icon: Iconsax.cpu,
|
|
validator: (String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Model name is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_selectedProviderType == ModelProviderType.localEndpoint)
|
|
_buildTextField(
|
|
controller: _endpointController,
|
|
label: 'Model Endpoint',
|
|
hint: 'http://localhost:11434',
|
|
icon: Iconsax.link,
|
|
),
|
|
if (_selectedProviderType == ModelProviderType.cloudApi)
|
|
_buildTextField(
|
|
controller: _apiKeyController,
|
|
label: 'API Key',
|
|
hint: 'sk-...',
|
|
icon: Iconsax.key,
|
|
obscureText: true,
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Generation Parameters Section
|
|
_buildSectionHeader('Generation Parameters', Iconsax.setting_2),
|
|
const SizedBox(height: 16),
|
|
_buildSlider(
|
|
label: 'Temperature',
|
|
value: _temperature,
|
|
min: 0.0,
|
|
max: 2.0,
|
|
divisions: 20,
|
|
onChanged: (double value) {
|
|
setState(() => _temperature = value);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildTextField(
|
|
controller: _maxTokensController,
|
|
label: 'Max Tokens',
|
|
hint: '4000',
|
|
icon: Iconsax.maximize,
|
|
keyboardType: TextInputType.number,
|
|
validator: (String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Max tokens is required';
|
|
}
|
|
final int? tokens = int.tryParse(value);
|
|
if (tokens == null || tokens <= 0) {
|
|
return 'Must be a positive number';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildTextField(
|
|
controller: _systemPromptController,
|
|
label: 'System Prompt',
|
|
hint: 'You are a helpful AI assistant...',
|
|
icon: Iconsax.message_text,
|
|
maxLines: 4,
|
|
validator: (String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'System prompt is required';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
|
|
// Memory Settings Section
|
|
_buildSectionHeader('Memory Settings', Iconsax.archive),
|
|
const SizedBox(height: 16),
|
|
_buildSwitch(
|
|
label: 'Enable Memory',
|
|
value: _enableMemory,
|
|
onChanged: (bool value) {
|
|
setState(() => _enableMemory = value);
|
|
},
|
|
),
|
|
if (_enableMemory) ...[
|
|
const SizedBox(height: 16),
|
|
_buildSlider(
|
|
label: 'Conversation Window Size',
|
|
value: _conversationWindowSize.toDouble(),
|
|
min: 1,
|
|
max: 100,
|
|
divisions: 99,
|
|
onChanged: (double value) {
|
|
setState(() => _conversationWindowSize = value.toInt());
|
|
},
|
|
displayValue: _conversationWindowSize.toString(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Footer Actions
|
|
Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(
|
|
color: colorScheme.outline.withValues(alpha: 0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: _isCreating ? null : () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton.icon(
|
|
onPressed: _isCreating ? null : _handleCreate,
|
|
icon: _isCreating
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
)
|
|
: const Icon(Iconsax.add),
|
|
label: Text(_isCreating ? 'Creating...' : 'Create Agent'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: colorScheme.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 16,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionHeader(String title, IconData icon) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Row(
|
|
children: [
|
|
Icon(icon, size: 20, color: colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField({
|
|
required TextEditingController controller,
|
|
required String label,
|
|
required String hint,
|
|
required IconData icon,
|
|
String? Function(String?)? validator,
|
|
int maxLines = 1,
|
|
bool obscureText = false,
|
|
TextInputType? keyboardType,
|
|
}) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return TextFormField(
|
|
controller: controller,
|
|
validator: validator,
|
|
maxLines: maxLines,
|
|
obscureText: obscureText,
|
|
keyboardType: keyboardType,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
hintText: hint,
|
|
prefixIcon: Icon(icon, color: colorScheme.primary),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: colorScheme.primary,
|
|
width: 2,
|
|
),
|
|
),
|
|
filled: true,
|
|
fillColor: colorScheme.surfaceContainerLow,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDropdown<T>({
|
|
required String label,
|
|
required T value,
|
|
required List<T> items,
|
|
required String Function(T) itemLabel,
|
|
required void Function(T?) onChanged,
|
|
}) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return DropdownButtonFormField<T>(
|
|
initialValue: value,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: colorScheme.outline.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(
|
|
color: colorScheme.primary,
|
|
width: 2,
|
|
),
|
|
),
|
|
filled: true,
|
|
fillColor: colorScheme.surfaceContainerLow,
|
|
),
|
|
items: items.map((T item) {
|
|
return DropdownMenuItem<T>(
|
|
value: item,
|
|
child: Text(itemLabel(item)),
|
|
);
|
|
}).toList(),
|
|
onChanged: onChanged,
|
|
);
|
|
}
|
|
|
|
Widget _buildSlider({
|
|
required String label,
|
|
required double value,
|
|
required double min,
|
|
required double max,
|
|
required int divisions,
|
|
required void Function(double) onChanged,
|
|
String? displayValue,
|
|
}) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
displayValue ?? value.toStringAsFixed(2),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Slider(
|
|
value: value,
|
|
min: min,
|
|
max: max,
|
|
divisions: divisions,
|
|
label: displayValue ?? value.toStringAsFixed(2),
|
|
onChanged: onChanged,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSwitch({
|
|
required String label,
|
|
required bool value,
|
|
required void Function(bool) onChanged,
|
|
}) {
|
|
final ColorScheme colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: colorScheme.onSurface,
|
|
),
|
|
),
|
|
Switch(
|
|
value: value,
|
|
onChanged: onChanged,
|
|
activeTrackColor: colorScheme.primary,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|