didvan-app/lib/services/ai/conversation_service.dart

488 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<T> {
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<String> attachments;
final List<Map<String, dynamic>> sources;
const MessageBranch({
required this.text,
this.createdAt,
this.attachments = const [],
this.sources = const [],
});
factory MessageBranch.fromJson(Map<String, dynamic> 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>()
.map((e) => Map<String, dynamic>.from(e))
.toList() ??
const [],
);
}
class AiMessage {
/// uuid پیام
final String id;
/// 'user' | 'assistant' (RoleType بک‌اند)
final String role;
final int selectedBranch;
final List<MessageBranch> 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<String, dynamic> json) {
final body = (json['body'] as Map?) ?? const {};
final branchesRaw = (body['branches'] as List?) ?? const [];
final branches = branchesRaw
.whereType<Map>()
.map((e) => MessageBranch.fromJson(Map<String, dynamic>.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<String> suggestion;
final List<AiMessage> 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<String, dynamic> 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>()
.map((e) => AiMessage.fromJson(Map<String, dynamic>.from(e)))
.toList();
final suggRaw = json['suggestion'];
final suggestion = suggRaw is List
? suggRaw.map((e) => e.toString()).toList()
: <String>[];
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<ConversationItem> 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<String, dynamic> json) {
final dataRaw = json['data'];
final items = dataRaw is List
? dataRaw
.whereType<Map>()
.map((e) => ConversationItem.fromJson(Map<String, dynamic>.from(e)))
.toList()
: <ConversationItem>[];
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<String, String> _headers() {
final headers = <String, String>{
'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<ConversationServiceResult<ConversationPage>> getConversations({
int page = 1,
int limit = 20,
String? search,
List<MapEntry<String, String>>? sortBy,
}) async {
await AuthInterceptor.ensureFreshToken();
try {
final params = <String, String>{
'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<String, dynamic>) {
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<ConversationServiceResult<ConversationItem>> 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<String, dynamic>) {
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<ConversationServiceResult<ConversationItem>> createConversation({
required String assistantId,
String? title,
}) async {
await AuthInterceptor.ensureFreshToken();
try {
final payload = <String, dynamic>{
'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<String, dynamic>) {
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<ConversationServiceResult<ConversationItem>> updateConversation({
required String id,
String? title,
}) async {
await AuthInterceptor.ensureFreshToken();
try {
final payload = <String, dynamic>{
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<String, dynamic>) {
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<ConversationServiceResult<void>> 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<ConversationServiceResult<ConversationItem>> addMessage({
required String conversationId,
required Map<String, dynamic> 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<String, dynamic>) {
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<ConversationServiceResult<ConversationItem>> updateMessage({
required String conversationId,
required String messageId,
required Map<String, dynamic> 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<String, dynamic>) {
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<http.Response> _retry(Future<http.Response> 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;
}
}