CODEX_ADK/FRONTEND/lib/api/endpoints/conversation_endpoint.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

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