/// 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 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 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 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 json) { return CreateConversationResult( id: json['id'] as String, ); } Map 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 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 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 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 json) { final List messagesList = json['messages'] as List? ?? []; final List messages = messagesList .cast>() .map((Map 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 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 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 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 json) { return UserMessageDto( content: json['content'] as String, timestamp: DateTime.parse(json['timestamp'] as String), ); } Map 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 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 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 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), agentResponse: AgentResponseDto.fromJson( json['agentResponse'] as Map), ); } Map 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> 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); return ApiSuccess(result); } catch (e) { return ApiError( ApiErrorInfo( message: 'Failed to parse create conversation response', statusCode: response.statusCode, type: ApiErrorType.serialization, details: e.toString(), ), ); } } else { return ApiError( ApiErrorInfo( message: 'Create conversation failed', statusCode: response.statusCode, type: ApiErrorType.http, ), ); } } on TimeoutException catch (e) { return ApiError( ApiErrorInfo( message: 'Request timeout: ${e.message ?? "Operation took too long"}', type: ApiErrorType.timeout, ), ); } on SocketException catch (e) { return ApiError( ApiErrorInfo( message: 'Network error: ${e.message}', type: ApiErrorType.network, details: e.osError?.message, ), ); } catch (e) { return ApiError( 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> getConversation(String id) async { return executeQuery( endpoint: 'getConversation', query: GetConversationQuery(id: id), fromJson: (Object? json) => ConversationDto.fromJson(json as Map), ); } /// 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>> 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>(ApiErrorInfo( message: 'Expected array response, got ${jsonData.runtimeType}', type: ApiErrorType.serialization, )); } final List conversations = jsonData .map((Object? item) => ConversationListItemDto.fromJson(item as Map)) .toList(); return ApiSuccess>(conversations); } return ApiError>(ApiErrorInfo( message: 'Failed to load conversations', type: ApiErrorType.http, statusCode: response.statusCode, )); } on TimeoutException { return ApiError>(ApiErrorInfo( message: 'Request timed out', type: ApiErrorType.timeout, )); } on SocketException { return ApiError>(ApiErrorInfo( message: 'No internet connection', type: ApiErrorType.network, )); } catch (e) { return ApiError>(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> 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); return ApiSuccess(result); } catch (e) { return ApiError( 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) { errorMessage = errorJson['message'] as String? ?? errorMessage; } } catch (_) { // If parsing fails, use default error message } return ApiError( ApiErrorInfo( message: errorMessage, statusCode: response.statusCode, type: ApiErrorType.http, ), ); } } on TimeoutException catch (e) { return ApiError( 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( ApiErrorInfo( message: 'Network error: ${e.message}', type: ApiErrorType.network, details: e.osError?.message, ), ); } catch (e) { return ApiError( ApiErrorInfo( message: 'Unexpected error: $e', type: ApiErrorType.unknown, ), ); } } }