Files
sure/mobile/lib/services/chat_service.dart
Lazy Bone f52b3fceb6 feat: implement mobile AI chat feature and fix duplicate response issue (#610)
Backend fixes:
- Fix duplicate AssistantResponseJob triggering causing duplicate AI responses
- UserMessage model already handles job triggering via after_create_commit callback
- Remove redundant job enqueue in chats_controller and messages_controller

Mobile app features:
- Implement complete AI chat interface and conversation management
- Add Chat, Message, and ToolCall data models
- Add ChatProvider for state management with polling mechanism
- Add ChatService to handle all chat-related API requests
- Add chat list screen (ChatListScreen)
- Add conversation detail screen (ChatConversationScreen)
- Refactor navigation structure with bottom navigation bar (MainNavigationScreen)
- Add settings screen (SettingsScreen)
- Optimize TransactionsProvider to support account filtering

Technical details:
- Implement message polling mechanism for real-time AI responses
- Support chat creation, deletion, retry and other operations
- Integrate Material Design 3 design language
- Improve user experience and error handling

Co-authored-by: dwvwdv <dwvwdv@protonmail.com>
2026-01-11 12:45:33 +01:00

396 lines
10 KiB
Dart

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/chat.dart';
import '../models/message.dart';
import 'api_config.dart';
class ChatService {
/// Get list of chats with pagination
Future<Map<String, dynamic>> getChats({
required String accessToken,
int page = 1,
int perPage = 25,
}) async {
try {
final url = Uri.parse(
'${ApiConfig.baseUrl}/api/v1/chats?page=$page&per_page=$perPage',
);
final response = await http.get(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final chatsList = (responseData['chats'] as List)
.map((json) => Chat.fromJson(json))
.toList();
return {
'success': true,
'chats': chatsList,
'pagination': responseData['pagination'],
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 403) {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': 'feature_disabled',
'message': responseData['message'] ?? 'AI features not enabled',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to fetch chats',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
/// Get a specific chat with messages
Future<Map<String, dynamic>> getChat({
required String accessToken,
required String chatId,
int page = 1,
int perPage = 50,
}) async {
try {
final url = Uri.parse(
'${ApiConfig.baseUrl}/api/v1/chats/$chatId?page=$page&per_page=$perPage',
);
final response = await http.get(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final chat = Chat.fromJson(responseData);
return {
'success': true,
'chat': chat,
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 404) {
return {
'success': false,
'error': 'not_found',
'message': 'Chat not found',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to fetch chat',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
/// Create a new chat with optional initial message
Future<Map<String, dynamic>> createChat({
required String accessToken,
String? title,
String? initialMessage,
String model = 'gpt-4',
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats');
final body = <String, dynamic>{
'model': model,
};
if (title != null) {
body['title'] = title;
}
if (initialMessage != null) {
body['message'] = initialMessage;
}
final response = await http.post(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode(body),
).timeout(const Duration(seconds: 30));
if (response.statusCode == 201) {
final responseData = jsonDecode(response.body);
final chat = Chat.fromJson(responseData);
return {
'success': true,
'chat': chat,
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 403) {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': 'feature_disabled',
'message': responseData['message'] ?? 'AI features not enabled',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to create chat',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
/// Send a message to a chat
Future<Map<String, dynamic>> sendMessage({
required String accessToken,
required String chatId,
required String content,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId/messages');
final response = await http.post(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'content': content,
}),
).timeout(const Duration(seconds: 30));
if (response.statusCode == 201) {
final responseData = jsonDecode(response.body);
final message = Message.fromJson(responseData);
return {
'success': true,
'message': message,
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 404) {
return {
'success': false,
'error': 'not_found',
'message': 'Chat not found',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to send message',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
/// Update chat title
Future<Map<String, dynamic>> updateChat({
required String accessToken,
required String chatId,
required String title,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId');
final response = await http.patch(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: jsonEncode({
'title': title,
}),
).timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
final chat = Chat.fromJson(responseData);
return {
'success': true,
'chat': chat,
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 404) {
return {
'success': false,
'error': 'not_found',
'message': 'Chat not found',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to update chat',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
/// Delete a chat
Future<Map<String, dynamic>> deleteChat({
required String accessToken,
required String chatId,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId');
final response = await http.delete(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 204) {
return {
'success': true,
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 404) {
return {
'success': false,
'error': 'not_found',
'message': 'Chat not found',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to delete chat',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
/// Retry the last assistant response in a chat
Future<Map<String, dynamic>> retryMessage({
required String accessToken,
required String chatId,
}) async {
try {
final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/chats/$chatId/messages/retry');
final response = await http.post(
url,
headers: {
'Authorization': 'Bearer $accessToken',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 30));
if (response.statusCode == 202) {
return {
'success': true,
};
} else if (response.statusCode == 401) {
return {
'success': false,
'error': 'unauthorized',
'message': 'Session expired. Please login again.',
};
} else if (response.statusCode == 404) {
return {
'success': false,
'error': 'not_found',
'message': 'Chat not found',
};
} else {
final responseData = jsonDecode(response.body);
return {
'success': false,
'error': responseData['error'] ?? 'Failed to retry message',
};
}
} catch (e) {
return {
'success': false,
'error': 'Network error: ${e.toString()}',
};
}
}
}