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>
621 lines
19 KiB
Dart
621 lines
19 KiB
Dart
/// Conversation management endpoints for CQRS API
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import '../client.dart';
|
|
import '../types.dart';
|
|
|
|
// =============================================================================
|
|
// Commands
|
|
// =============================================================================
|
|
|
|
/// Command to create a new conversation
|
|
class CreateConversationCommand implements Serializable {
|
|
final String title;
|
|
final String? summary;
|
|
|
|
const CreateConversationCommand({
|
|
required this.title,
|
|
this.summary,
|
|
});
|
|
|
|
@override
|
|
Map<String, Object?> toJson() => {
|
|
'title': title,
|
|
if (summary != null) 'summary': summary,
|
|
};
|
|
}
|
|
|
|
/// Command to send a message to an agent
|
|
class SendMessageCommand implements Serializable {
|
|
final String agentId;
|
|
final String? conversationId;
|
|
final String message;
|
|
final String? userId;
|
|
|
|
const SendMessageCommand({
|
|
required this.agentId,
|
|
this.conversationId,
|
|
required this.message,
|
|
this.userId,
|
|
});
|
|
|
|
@override
|
|
Map<String, Object?> toJson() => {
|
|
'agentId': agentId,
|
|
if (conversationId != null) 'conversationId': conversationId,
|
|
'message': message,
|
|
if (userId != null) 'userId': userId,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Queries
|
|
// =============================================================================
|
|
|
|
/// Query to get a single conversation by ID
|
|
class GetConversationQuery implements Serializable {
|
|
final String id;
|
|
|
|
const GetConversationQuery({required this.id});
|
|
|
|
@override
|
|
Map<String, Object?> toJson() => {'id': id};
|
|
}
|
|
|
|
// =============================================================================
|
|
// DTOs
|
|
// =============================================================================
|
|
|
|
/// Response when creating a conversation (returns only ID)
|
|
class CreateConversationResult {
|
|
final String id;
|
|
|
|
const CreateConversationResult({required this.id});
|
|
|
|
factory CreateConversationResult.fromJson(Map<String, Object?> json) {
|
|
return CreateConversationResult(
|
|
id: json['id'] as String,
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {'id': id};
|
|
}
|
|
|
|
/// Conversation message DTO
|
|
class ConversationMessageDto {
|
|
final String id;
|
|
final String role;
|
|
final String content;
|
|
final DateTime timestamp;
|
|
|
|
const ConversationMessageDto({
|
|
required this.id,
|
|
required this.role,
|
|
required this.content,
|
|
required this.timestamp,
|
|
});
|
|
|
|
factory ConversationMessageDto.fromJson(Map<String, Object?> json) {
|
|
return ConversationMessageDto(
|
|
id: json['id'] as String,
|
|
role: json['role'] as String,
|
|
content: json['content'] as String,
|
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'id': id,
|
|
'role': role,
|
|
'content': content,
|
|
'timestamp': timestamp.toIso8601String(),
|
|
};
|
|
}
|
|
|
|
/// Full conversation details with messages and executions
|
|
class ConversationDto {
|
|
final String id;
|
|
final String title;
|
|
final String? summary;
|
|
final DateTime startedAt;
|
|
final DateTime lastMessageAt;
|
|
final int messageCount;
|
|
final bool isActive;
|
|
final int executionCount;
|
|
final List<ConversationMessageDto> messages;
|
|
|
|
const ConversationDto({
|
|
required this.id,
|
|
required this.title,
|
|
this.summary,
|
|
required this.startedAt,
|
|
required this.lastMessageAt,
|
|
required this.messageCount,
|
|
required this.isActive,
|
|
required this.executionCount,
|
|
required this.messages,
|
|
});
|
|
|
|
factory ConversationDto.fromJson(Map<String, Object?> json) {
|
|
final List<Object?> messagesList = json['messages'] as List<Object?>? ?? [];
|
|
final List<ConversationMessageDto> messages = messagesList
|
|
.cast<Map<String, Object?>>()
|
|
.map((Map<String, Object?> m) => ConversationMessageDto.fromJson(m))
|
|
.toList();
|
|
|
|
return ConversationDto(
|
|
id: json['id'] as String,
|
|
title: json['title'] as String,
|
|
summary: json['summary'] as String?,
|
|
startedAt: DateTime.parse(json['startedAt'] as String),
|
|
lastMessageAt: DateTime.parse(json['lastMessageAt'] as String),
|
|
messageCount: json['messageCount'] as int,
|
|
isActive: json['isActive'] as bool,
|
|
executionCount: json['executionCount'] as int,
|
|
messages: messages,
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'id': id,
|
|
'title': title,
|
|
'summary': summary,
|
|
'startedAt': startedAt.toIso8601String(),
|
|
'lastMessageAt': lastMessageAt.toIso8601String(),
|
|
'messageCount': messageCount,
|
|
'isActive': isActive,
|
|
'executionCount': executionCount,
|
|
'messages':
|
|
messages.map((ConversationMessageDto m) => m.toJson()).toList(),
|
|
};
|
|
}
|
|
|
|
/// Conversation list item (lightweight version for lists)
|
|
class ConversationListItemDto {
|
|
final String id;
|
|
final String title;
|
|
final String? summary;
|
|
final DateTime startedAt;
|
|
final DateTime lastMessageAt;
|
|
final int messageCount;
|
|
final bool isActive;
|
|
final int executionCount;
|
|
|
|
const ConversationListItemDto({
|
|
required this.id,
|
|
required this.title,
|
|
this.summary,
|
|
required this.startedAt,
|
|
required this.lastMessageAt,
|
|
required this.messageCount,
|
|
required this.isActive,
|
|
required this.executionCount,
|
|
});
|
|
|
|
factory ConversationListItemDto.fromJson(Map<String, Object?> json) {
|
|
return ConversationListItemDto(
|
|
id: json['id'] as String,
|
|
title: json['title'] as String,
|
|
summary: json['summary'] as String?,
|
|
startedAt: DateTime.parse(json['startedAt'] as String),
|
|
lastMessageAt: DateTime.parse(json['lastMessageAt'] as String),
|
|
messageCount: json['messageCount'] as int,
|
|
isActive: json['isActive'] as bool,
|
|
executionCount: json['executionCount'] as int,
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'id': id,
|
|
'title': title,
|
|
'summary': summary,
|
|
'startedAt': startedAt.toIso8601String(),
|
|
'lastMessageAt': lastMessageAt.toIso8601String(),
|
|
'messageCount': messageCount,
|
|
'isActive': isActive,
|
|
'executionCount': executionCount,
|
|
};
|
|
}
|
|
|
|
/// User message details from sendMessage response
|
|
class UserMessageDto {
|
|
final String content;
|
|
final DateTime timestamp;
|
|
|
|
const UserMessageDto({
|
|
required this.content,
|
|
required this.timestamp,
|
|
});
|
|
|
|
factory UserMessageDto.fromJson(Map<String, Object?> json) {
|
|
return UserMessageDto(
|
|
content: json['content'] as String,
|
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'content': content,
|
|
'timestamp': timestamp.toIso8601String(),
|
|
};
|
|
}
|
|
|
|
/// Agent response details from sendMessage response
|
|
class AgentResponseDto {
|
|
final String content;
|
|
final DateTime timestamp;
|
|
final int? inputTokens;
|
|
final int? outputTokens;
|
|
final double? estimatedCost;
|
|
|
|
const AgentResponseDto({
|
|
required this.content,
|
|
required this.timestamp,
|
|
this.inputTokens,
|
|
this.outputTokens,
|
|
this.estimatedCost,
|
|
});
|
|
|
|
factory AgentResponseDto.fromJson(Map<String, Object?> json) {
|
|
return AgentResponseDto(
|
|
content: json['content'] as String,
|
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
|
inputTokens: json['inputTokens'] as int?,
|
|
outputTokens: json['outputTokens'] as int?,
|
|
estimatedCost: (json['estimatedCost'] as num?)?.toDouble(),
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'content': content,
|
|
'timestamp': timestamp.toIso8601String(),
|
|
if (inputTokens != null) 'inputTokens': inputTokens,
|
|
if (outputTokens != null) 'outputTokens': outputTokens,
|
|
if (estimatedCost != null) 'estimatedCost': estimatedCost,
|
|
};
|
|
}
|
|
|
|
/// Result of sending a message to an agent
|
|
class SendMessageResult {
|
|
final String conversationId;
|
|
final String messageId;
|
|
final String agentResponseId;
|
|
final UserMessageDto userMessage;
|
|
final AgentResponseDto agentResponse;
|
|
|
|
const SendMessageResult({
|
|
required this.conversationId,
|
|
required this.messageId,
|
|
required this.agentResponseId,
|
|
required this.userMessage,
|
|
required this.agentResponse,
|
|
});
|
|
|
|
factory SendMessageResult.fromJson(Map<String, Object?> json) {
|
|
return SendMessageResult(
|
|
conversationId: json['conversationId'] as String,
|
|
messageId: json['messageId'] as String,
|
|
agentResponseId: json['agentResponseId'] as String,
|
|
userMessage: UserMessageDto.fromJson(
|
|
json['userMessage'] as Map<String, Object?>),
|
|
agentResponse: AgentResponseDto.fromJson(
|
|
json['agentResponse'] as Map<String, Object?>),
|
|
);
|
|
}
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'conversationId': conversationId,
|
|
'messageId': messageId,
|
|
'agentResponseId': agentResponseId,
|
|
'userMessage': userMessage.toJson(),
|
|
'agentResponse': agentResponse.toJson(),
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Extension Methods
|
|
// =============================================================================
|
|
|
|
/// Conversation management endpoints
|
|
extension ConversationEndpoint on CqrsApiClient {
|
|
/// Create a new conversation
|
|
///
|
|
/// Returns the ID of the newly created conversation.
|
|
///
|
|
/// Example:
|
|
/// ```dart
|
|
/// final result = await client.createConversation(
|
|
/// CreateConversationCommand(
|
|
/// title: 'My First Conversation',
|
|
/// summary: 'Optional summary',
|
|
/// ),
|
|
/// );
|
|
///
|
|
/// result.when(
|
|
/// success: (created) => print('Conversation ID: ${created.id}'),
|
|
/// error: (error) => print('Error: ${error.message}'),
|
|
/// );
|
|
/// ```
|
|
Future<Result<CreateConversationResult>> createConversation(
|
|
CreateConversationCommand command,
|
|
) async {
|
|
// This is a special command that returns data (conversation ID)
|
|
// We use executeQuery pattern but with command endpoint
|
|
try {
|
|
final Uri url =
|
|
Uri.parse('${config.baseUrl}/api/command/createConversation');
|
|
final String body = jsonEncode(command.toJson());
|
|
|
|
final http.Response response = await http
|
|
.post(
|
|
url,
|
|
headers: config.defaultHeaders,
|
|
body: body,
|
|
)
|
|
.timeout(config.timeout);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
try {
|
|
final Object? json = jsonDecode(response.body);
|
|
final CreateConversationResult result =
|
|
CreateConversationResult.fromJson(json as Map<String, Object?>);
|
|
return ApiSuccess<CreateConversationResult>(result);
|
|
} catch (e) {
|
|
return ApiError<CreateConversationResult>(
|
|
ApiErrorInfo(
|
|
message: 'Failed to parse create conversation response',
|
|
statusCode: response.statusCode,
|
|
type: ApiErrorType.serialization,
|
|
details: e.toString(),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
return ApiError<CreateConversationResult>(
|
|
ApiErrorInfo(
|
|
message: 'Create conversation failed',
|
|
statusCode: response.statusCode,
|
|
type: ApiErrorType.http,
|
|
),
|
|
);
|
|
}
|
|
} on TimeoutException catch (e) {
|
|
return ApiError<CreateConversationResult>(
|
|
ApiErrorInfo(
|
|
message: 'Request timeout: ${e.message ?? "Operation took too long"}',
|
|
type: ApiErrorType.timeout,
|
|
),
|
|
);
|
|
} on SocketException catch (e) {
|
|
return ApiError<CreateConversationResult>(
|
|
ApiErrorInfo(
|
|
message: 'Network error: ${e.message}',
|
|
type: ApiErrorType.network,
|
|
details: e.osError?.message,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
return ApiError<CreateConversationResult>(
|
|
ApiErrorInfo(
|
|
message: 'Unexpected error: $e',
|
|
type: ApiErrorType.unknown,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Get a single conversation by ID with full details
|
|
///
|
|
/// Example:
|
|
/// ```dart
|
|
/// final result = await client.getConversation('conversation-uuid');
|
|
///
|
|
/// result.when(
|
|
/// success: (conversation) {
|
|
/// print('Title: ${conversation.title}');
|
|
/// print('Messages: ${conversation.messageCount}');
|
|
/// for (final message in conversation.messages) {
|
|
/// print('${message.role}: ${message.content}');
|
|
/// }
|
|
/// },
|
|
/// error: (error) => print('Error: ${error.message}'),
|
|
/// );
|
|
/// ```
|
|
Future<Result<ConversationDto>> getConversation(String id) async {
|
|
return executeQuery<ConversationDto>(
|
|
endpoint: 'getConversation',
|
|
query: GetConversationQuery(id: id),
|
|
fromJson: (Object? json) =>
|
|
ConversationDto.fromJson(json as Map<String, Object?>),
|
|
);
|
|
}
|
|
|
|
/// List all conversations
|
|
///
|
|
/// Returns a list of all conversations from the backend.
|
|
/// Backend endpoint: GET /api/conversations
|
|
///
|
|
/// Example:
|
|
/// ```dart
|
|
/// final result = await client.listConversations();
|
|
///
|
|
/// result.when(
|
|
/// success: (conversations) {
|
|
/// print('Found ${conversations.length} conversations');
|
|
/// for (final conv in conversations) {
|
|
/// print('${conv.title} - ${conv.messageCount} messages');
|
|
/// }
|
|
/// },
|
|
/// error: (error) => print('Error: ${error.message}'),
|
|
/// );
|
|
/// ```
|
|
Future<Result<List<ConversationListItemDto>>> listConversations() async {
|
|
try {
|
|
final Uri url = Uri.parse('${config.baseUrl}/api/conversations');
|
|
final http.Response response = await http
|
|
.get(url, headers: config.defaultHeaders)
|
|
.timeout(config.timeout);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final Object? jsonData = jsonDecode(response.body);
|
|
if (jsonData is! List) {
|
|
return ApiError<List<ConversationListItemDto>>(ApiErrorInfo(
|
|
message: 'Expected array response, got ${jsonData.runtimeType}',
|
|
type: ApiErrorType.serialization,
|
|
));
|
|
}
|
|
|
|
final List<ConversationListItemDto> conversations = jsonData
|
|
.map((Object? item) =>
|
|
ConversationListItemDto.fromJson(item as Map<String, Object?>))
|
|
.toList();
|
|
|
|
return ApiSuccess<List<ConversationListItemDto>>(conversations);
|
|
}
|
|
|
|
return ApiError<List<ConversationListItemDto>>(ApiErrorInfo(
|
|
message: 'Failed to load conversations',
|
|
type: ApiErrorType.http,
|
|
statusCode: response.statusCode,
|
|
));
|
|
} on TimeoutException {
|
|
return ApiError<List<ConversationListItemDto>>(ApiErrorInfo(
|
|
message: 'Request timed out',
|
|
type: ApiErrorType.timeout,
|
|
));
|
|
} on SocketException {
|
|
return ApiError<List<ConversationListItemDto>>(ApiErrorInfo(
|
|
message: 'No internet connection',
|
|
type: ApiErrorType.network,
|
|
));
|
|
} catch (e) {
|
|
return ApiError<List<ConversationListItemDto>>(ApiErrorInfo(
|
|
message: 'Unexpected error: $e',
|
|
type: ApiErrorType.unknown,
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Send a message to an AI agent
|
|
///
|
|
/// Sends a user message to the specified agent and receives an AI-generated
|
|
/// response. If conversationId is null, a new conversation is created.
|
|
/// If conversationId is provided, the message is added to the existing
|
|
/// conversation with full context awareness.
|
|
///
|
|
/// Backend endpoint: POST /api/command/sendMessage
|
|
///
|
|
/// Example (new conversation):
|
|
/// ```dart
|
|
/// final result = await client.sendMessage(
|
|
/// SendMessageCommand(
|
|
/// agentId: 'agent-uuid',
|
|
/// conversationId: null, // Creates new conversation
|
|
/// message: 'Write a hello world function in Python',
|
|
/// ),
|
|
/// );
|
|
///
|
|
/// result.when(
|
|
/// success: (response) {
|
|
/// print('Conversation ID: ${response.conversationId}');
|
|
/// print('User: ${response.userMessage.content}');
|
|
/// print('Agent: ${response.agentResponse.content}');
|
|
/// print('Tokens: ${response.agentResponse.inputTokens} in, ${response.agentResponse.outputTokens} out');
|
|
/// },
|
|
/// error: (error) => print('Error: ${error.message}'),
|
|
/// );
|
|
/// ```
|
|
///
|
|
/// Example (continue conversation):
|
|
/// ```dart
|
|
/// final result = await client.sendMessage(
|
|
/// SendMessageCommand(
|
|
/// agentId: 'agent-uuid',
|
|
/// conversationId: 'existing-conversation-uuid',
|
|
/// message: 'Now make it print in uppercase',
|
|
/// ),
|
|
/// );
|
|
/// ```
|
|
Future<Result<SendMessageResult>> sendMessage(
|
|
SendMessageCommand command,
|
|
) async {
|
|
try {
|
|
final Uri url = Uri.parse('${config.baseUrl}/api/command/sendMessage');
|
|
final String body = jsonEncode(command.toJson());
|
|
|
|
final http.Response response = await http
|
|
.post(
|
|
url,
|
|
headers: config.defaultHeaders,
|
|
body: body,
|
|
)
|
|
.timeout(config.timeout);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
try {
|
|
final Object? json = jsonDecode(response.body);
|
|
final SendMessageResult result =
|
|
SendMessageResult.fromJson(json as Map<String, Object?>);
|
|
return ApiSuccess<SendMessageResult>(result);
|
|
} catch (e) {
|
|
return ApiError<SendMessageResult>(
|
|
ApiErrorInfo(
|
|
message: 'Failed to parse send message response',
|
|
statusCode: response.statusCode,
|
|
type: ApiErrorType.serialization,
|
|
details: e.toString(),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
String errorMessage = 'Failed to send message';
|
|
try {
|
|
final Object? errorJson = jsonDecode(response.body);
|
|
if (errorJson is Map<String, Object?>) {
|
|
errorMessage =
|
|
errorJson['message'] as String? ?? errorMessage;
|
|
}
|
|
} catch (_) {
|
|
// If parsing fails, use default error message
|
|
}
|
|
|
|
return ApiError<SendMessageResult>(
|
|
ApiErrorInfo(
|
|
message: errorMessage,
|
|
statusCode: response.statusCode,
|
|
type: ApiErrorType.http,
|
|
),
|
|
);
|
|
}
|
|
} on TimeoutException catch (e) {
|
|
return ApiError<SendMessageResult>(
|
|
ApiErrorInfo(
|
|
message: 'Request timeout: ${e.message ?? "Operation took too long"}',
|
|
type: ApiErrorType.timeout,
|
|
details: 'Agent responses can take 1-5 seconds via Ollama',
|
|
),
|
|
);
|
|
} on SocketException catch (e) {
|
|
return ApiError<SendMessageResult>(
|
|
ApiErrorInfo(
|
|
message: 'Network error: ${e.message}',
|
|
type: ApiErrorType.network,
|
|
details: e.osError?.message,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
return ApiError<SendMessageResult>(
|
|
ApiErrorInfo(
|
|
message: 'Unexpected error: $e',
|
|
type: ApiErrorType.unknown,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|