488 lines
16 KiB
Dart
488 lines
16 KiB
Dart
// 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;
|
||
}
|
||
}
|