// lib/services/ai/conversation_service.dart // // سرویس فلاتر برای صحبت با ai-service جدید (`{apiBaseUrl}/conversation`). // - GET /conversation لیست paginated (chat list) // - GET /conversation/:id یک گفتگو با همه‌ی messages // - POST /conversation ساخت گفتگوی جدید // - PATCH /conversation/:id به‌روزرسانی (مثلا title) // - DELETE /conversation/:id حذف // - POST /conversation/:conversationId افزودن پیام // - PATCH /conversation/:conversationId/:messageId ویرایش پیام // // همه‌ی endpoint ها Bearer access token می‌خواهند. UI دست نمی‌خورد؛ این // سرویس فقط لایه‌ی شبکه است. سشن بعدی، ai_chat_state و history_ai_chat_state // به این سرویس وصل می‌شوند. import 'dart:async'; import 'dart:convert'; import 'dart:developer' as dev; import 'dart:io' show SocketException; // ignore: depend_on_referenced_packages import 'package:http/http.dart' as http; import 'package:didvan/config/auth_config.dart'; import 'package:didvan/services/auth/auth_interceptor.dart'; import 'package:didvan/services/auth/token_storage.dart'; // --------------------------------------------------------------------------- // Models // --------------------------------------------------------------------------- class ConversationServiceResult { final bool isSuccess; final int? statusCode; final T? data; final String? errorMessage; const ConversationServiceResult({ required this.isSuccess, this.statusCode, this.data, this.errorMessage, }); } /// یک «شاخه» از پاسخ — به دلیل branching، هر message ممکنه چند پاسخ داشته باشه. class MessageBranch { final String text; final String? createdAt; final List attachments; final List> sources; const MessageBranch({ required this.text, this.createdAt, this.attachments = const [], this.sources = const [], }); factory MessageBranch.fromJson(Map json) => MessageBranch( text: (json['text'] ?? '') as String, createdAt: json['createdAt'] as String?, attachments: (json['attachments'] as List?) ?.map((e) => e.toString()) .toList() ?? const [], sources: (json['sources'] as List?) ?.whereType() .map((e) => Map.from(e)) .toList() ?? const [], ); } class AiMessage { /// uuid پیام final String id; /// 'user' | 'assistant' (RoleType بک‌اند) final String role; final int selectedBranch; final List branches; final bool hidden; const AiMessage({ required this.id, required this.role, this.selectedBranch = 0, this.branches = const [], this.hidden = false, }); /// متنِ شاخه‌ی فعال (راحت‌ترین دسترسی برای UI قدیمی). String get text { if (branches.isEmpty) return ''; final i = (selectedBranch >= 0 && selectedBranch < branches.length) ? selectedBranch : 0; return branches[i].text; } factory AiMessage.fromJson(Map json) { final body = (json['body'] as Map?) ?? const {}; final branchesRaw = (body['branches'] as List?) ?? const []; final branches = branchesRaw .whereType() .map((e) => MessageBranch.fromJson(Map.from(e))) .toList(); return AiMessage( id: (json['id'] ?? '') as String, role: (json['role'] ?? 'assistant') as String, selectedBranch: (body['selectedBranch'] as num?)?.toInt() ?? 0, branches: branches, hidden: json['hidden'] == true, ); } } class ConversationItem { final String id; final String? title; final String assistantId; final String userId; final List suggestion; final List messages; final DateTime? createdAt; final DateTime? updatedAt; const ConversationItem({ required this.id, required this.assistantId, required this.userId, this.title, this.suggestion = const [], this.messages = const [], this.createdAt, this.updatedAt, }); factory ConversationItem.fromJson(Map json) { DateTime? parse(dynamic v) => v is String && v.isNotEmpty ? DateTime.tryParse(v) : null; final msgsRaw = (json['messages'] as List?) ?? const []; final messages = msgsRaw .whereType() .map((e) => AiMessage.fromJson(Map.from(e))) .toList(); final suggRaw = json['suggestion']; final suggestion = suggRaw is List ? suggRaw.map((e) => e.toString()).toList() : []; return ConversationItem( id: (json['id'] ?? '') as String, title: json['title'] as String?, assistantId: (json['assistantId'] ?? '') as String, userId: (json['userId'] ?? '') as String, suggestion: suggestion, messages: messages, createdAt: parse(json['createdAt']), updatedAt: parse(json['updatedAt']), ); } } /// نتیجه‌ی paginated از nestjs-paginate. class ConversationPage { final List data; final int currentPage; final int totalPages; final int totalItems; const ConversationPage({ required this.data, required this.currentPage, required this.totalPages, required this.totalItems, }); factory ConversationPage.fromJson(Map json) { final dataRaw = json['data']; final items = dataRaw is List ? dataRaw .whereType() .map((e) => ConversationItem.fromJson(Map.from(e))) .toList() : []; final meta = (json['meta'] as Map?) ?? const {}; return ConversationPage( data: items, currentPage: (meta['currentPage'] as num?)?.toInt() ?? 1, totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1, totalItems: (meta['totalItems'] as num?)?.toInt() ?? items.length, ); } bool get hasMore => currentPage < totalPages; } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- class ConversationService { ConversationService._(); static final ConversationService instance = ConversationService._(); String get _base => AuthConfig.apiBaseUrl; static const _timeout = Duration(seconds: 30); Map _headers() { final headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', }; final t = TokenStorage.accessToken; if (t != null && t.isNotEmpty) { headers['Authorization'] = 'Bearer $t'; } return headers; } /// لیست paginated گفتگوهای کاربر (chat list). Future> getConversations({ int page = 1, int limit = 20, String? search, List>? sortBy, }) async { await AuthInterceptor.ensureFreshToken(); try { final params = { 'page': page.toString(), 'limit': limit.toString(), }; if (search != null && search.isNotEmpty) params['search'] = search; if (sortBy != null) { for (final s in sortBy) { params['sortBy'] = '${s.key}:${s.value}'; } } final uri = Uri.parse('$_base/conversation').replace(queryParameters: params); final response = await _retry( () => http.get(uri, headers: _headers()).timeout(_timeout)); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { return ConversationServiceResult( isSuccess: true, statusCode: 200, data: ConversationPage.fromJson(body)); } } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('getConversations error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } /// یک گفتگو + messages. Future> getConversation( String id) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _retry(() => http .get(Uri.parse('$_base/conversation/$id'), headers: _headers()) .timeout(_timeout)); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { return ConversationServiceResult( isSuccess: true, statusCode: 200, data: ConversationItem.fromJson(body)); } } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('getConversation error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } /// ساخت یک گفتگوی جدید (نیاز به assistantId). Future> createConversation({ required String assistantId, String? title, }) async { await AuthInterceptor.ensureFreshToken(); try { final payload = { 'assistantId': assistantId, if (title != null && title.isNotEmpty) 'title': title, }; final response = await _retry(() => http .post(Uri.parse('$_base/conversation'), headers: _headers(), body: json.encode(payload)) .timeout(_timeout)); if (response.statusCode == 200 || response.statusCode == 201) { final body = json.decode(response.body); if (body is Map) { return ConversationServiceResult( isSuccess: true, statusCode: response.statusCode, data: ConversationItem.fromJson(body)); } } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('createConversation error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } /// به‌روزرسانیِ یک گفتگو (مثلاً title). Future> updateConversation({ required String id, String? title, }) async { await AuthInterceptor.ensureFreshToken(); try { final payload = { if (title != null) 'title': title, }; final response = await _retry(() => http .patch(Uri.parse('$_base/conversation/$id'), headers: _headers(), body: json.encode(payload)) .timeout(_timeout)); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { return ConversationServiceResult( isSuccess: true, statusCode: 200, data: ConversationItem.fromJson(body)); } return const ConversationServiceResult( isSuccess: true, statusCode: 200); } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('updateConversation error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } /// حذف یک گفتگو. Future> deleteConversation(String id) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _retry(() => http .delete(Uri.parse('$_base/conversation/$id'), headers: _headers()) .timeout(_timeout)); if (response.statusCode == 200 || response.statusCode == 204) { return const ConversationServiceResult( isSuccess: true, statusCode: 200); } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('deleteConversation error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } /// افزودنِ یک پیام به گفتگو. payload کامل را به‌صورتِ خام پاس می‌دهیم /// تا انعطاف داشته باشیم (متن، attachments، ...). Future> addMessage({ required String conversationId, required Map payload, }) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _retry(() => http .post(Uri.parse('$_base/conversation/$conversationId'), headers: _headers(), body: json.encode(payload)) .timeout(_timeout)); if (response.statusCode == 200 || response.statusCode == 201) { final body = json.decode(response.body); if (body is Map) { return ConversationServiceResult( isSuccess: true, statusCode: response.statusCode, data: ConversationItem.fromJson(body)); } } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('addMessage error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } /// ویرایشِ یک پیام در گفتگو. Future> updateMessage({ required String conversationId, required String messageId, required Map payload, }) async { await AuthInterceptor.ensureFreshToken(); try { final response = await _retry(() => http .patch( Uri.parse( '$_base/conversation/$conversationId/$messageId'), headers: _headers(), body: json.encode(payload)) .timeout(_timeout)); if (response.statusCode == 200) { final body = json.decode(response.body); if (body is Map) { return ConversationServiceResult( isSuccess: true, statusCode: 200, data: ConversationItem.fromJson(body)); } } return ConversationServiceResult( isSuccess: false, statusCode: response.statusCode, errorMessage: _err(response.body)); } catch (e) { dev.log('updateMessage error: $e', name: 'ConversationService'); return const ConversationServiceResult( isSuccess: false, errorMessage: 'خطا در ارتباط با سرور.'); } } // -------- Helpers -------- Future _retry(Future Function() body) async { for (var attempt = 0; attempt < 3; attempt++) { try { return await body(); } on SocketException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on http.ClientException { if (attempt == 2) rethrow; await Future.delayed(Duration(milliseconds: 250 * (attempt + 1))); } on TimeoutException { if (attempt == 2) rethrow; } } throw StateError('retry loop exited without return'); } String? _err(String body) { if (body.isEmpty) return null; try { final d = json.decode(body); if (d is Map) { final m = d['message'] ?? d['error']; if (m is String && m.isNotEmpty) return m; if (m is List && m.isNotEmpty) return m.first.toString(); } } catch (_) {} return null; } }