You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
258 lines
8.3 KiB
Dart
258 lines
8.3 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:test_sa/controllers/api_routes/api_manager.dart';
|
|
import 'package:test_sa/controllers/api_routes/urls.dart';
|
|
|
|
import '../model/copilot_message_model.dart';
|
|
|
|
class CopilotProvider extends ChangeNotifier {
|
|
List<CopilotHistoryItem> history = [];
|
|
bool historyLoading = false;
|
|
List<CopilotMessage> messages = [];
|
|
bool conversationLoading = false;
|
|
bool isStreaming = false;
|
|
String? sessionId;
|
|
String? errorMessage;
|
|
StreamSubscription? _streamSub;
|
|
Future<void> loadHistory() async {
|
|
historyLoading = true;
|
|
errorMessage = null;
|
|
notifyListeners();
|
|
try {
|
|
final res = await ApiManager.instance.get(
|
|
URLs.copilotGetQueryHistory,
|
|
enableToastMessage: false, // "message" is just an informational success string here
|
|
);
|
|
final decoded = jsonDecode(res.body);
|
|
if (decoded is Map && decoded['isSuccess'] == true) {
|
|
final List threads = decoded['data']?['threads'] ?? [];
|
|
history = threads.map((e) => CopilotHistoryItem.fromJson(e)).toList();
|
|
} else {
|
|
errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to load history";
|
|
}
|
|
} catch (e) {
|
|
errorMessage = "Failed to load history";
|
|
} finally {
|
|
historyLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
Future<void> deleteHistory(String threadId) async {
|
|
final prevList = List<CopilotHistoryItem>.from(history);
|
|
history.removeWhere((h) => h.threadId == threadId);
|
|
notifyListeners();
|
|
|
|
try {
|
|
final res = await ApiManager.instance.delete(
|
|
URLs.copilotDeleteQueryHistory(threadId),
|
|
body: const {}, // API ignores the body for this route-bound id endpoint
|
|
);
|
|
final decoded = jsonDecode(res.body);
|
|
final success = decoded is Map && decoded['isSuccess'] == true && decoded['data'] == true;
|
|
if (!success) {
|
|
history = prevList; // rollback on failure
|
|
errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to delete";
|
|
notifyListeners();
|
|
}
|
|
} catch (e) {
|
|
history = prevList;
|
|
errorMessage = "Failed to delete";
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> loadConversation(String threadId) async {
|
|
conversationLoading = true;
|
|
errorMessage = null;
|
|
messages = [];
|
|
sessionId = threadId;
|
|
notifyListeners();
|
|
try {
|
|
final res = await ApiManager.instance.get(
|
|
URLs.copilotGetConversationByThreadId(threadId),
|
|
enableToastMessage: false,
|
|
);
|
|
final decoded = jsonDecode(res.body);
|
|
if (decoded is Map && decoded['isSuccess'] == true) {
|
|
final List rawMessages = decoded['data']?['messages'] ?? [];
|
|
messages = rawMessages.map<CopilotMessage>((e) => CopilotMessage.fromHistoryJson(e)).toList();
|
|
} else {
|
|
errorMessage = (decoded is Map ? decoded['message'] : null) ?? "Failed to load conversation";
|
|
}
|
|
} catch (e) {
|
|
errorMessage = "Failed to load conversation";
|
|
} finally {
|
|
conversationLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
void startNewConversation() {
|
|
_streamSub?.cancel();
|
|
sessionId = null;
|
|
messages = [];
|
|
errorMessage = null;
|
|
isStreaming = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> sendText(String query) async {
|
|
if (isStreaming || query.trim().isEmpty) return; // prevent invalid duplicate sends
|
|
sessionId ??= _generateSessionId();
|
|
|
|
messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, content: query, timestamp: DateTime.now()));
|
|
final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now());
|
|
messages.add(assistantMsg);
|
|
isStreaming = true;
|
|
errorMessage = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final streamedResponse = await ApiManager.instance.postFormDataStreaming(
|
|
URLs.copilotQueryStream,
|
|
body: {
|
|
"sessionId": sessionId!,
|
|
"isAudio": "false",
|
|
"query": query,
|
|
},
|
|
);
|
|
await _consumeStream(streamedResponse, assistantMsg);
|
|
} catch (e) {
|
|
_onStreamError(assistantMsg, e);
|
|
}
|
|
}
|
|
Future<void> sendAudio(File audioFile) async {
|
|
if (isStreaming) return;
|
|
sessionId ??= _generateSessionId();
|
|
|
|
messages.add(CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now()));
|
|
final assistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, isStreaming: true, timestamp: DateTime.now());
|
|
messages.add(assistantMsg);
|
|
isStreaming = true;
|
|
errorMessage = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final streamedResponse = await ApiManager.instance.postFormDataStreamingWithFile(
|
|
URLs.copilotQueryStream,
|
|
fields: {
|
|
"sessionId": sessionId!,
|
|
"isAudio": "true",
|
|
},
|
|
fileFieldName: "AudioFile",
|
|
file: audioFile,
|
|
filename: audioFile.path.split('/').last,
|
|
);
|
|
await _consumeStream(streamedResponse, assistantMsg);
|
|
} catch (e) {
|
|
_onStreamError(assistantMsg, e);
|
|
}
|
|
}
|
|
|
|
String _generateSessionId() {
|
|
final rand = Random();
|
|
final bytes = List<int>.generate(16, (_) => rand.nextInt(256));
|
|
bytes[6] = (bytes[6] & 0x0F) | 0x40; // version 4
|
|
bytes[8] = (bytes[8] & 0x3F) | 0x80; // variant 10xx
|
|
String byteHex(int i) => bytes[i].toRadixString(16).padLeft(2, '0');
|
|
final hex = List.generate(16, byteHex).join();
|
|
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}';
|
|
}
|
|
|
|
Future<void> _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg) async {
|
|
if (streamedResponse.statusCode < 200 || streamedResponse.statusCode >= 300) {
|
|
_onStreamError(assistantMsg, "Server error ${streamedResponse.statusCode}");
|
|
return;
|
|
}
|
|
|
|
String buffer = '';
|
|
_streamSub = streamedResponse.stream.transform(utf8.decoder).listen(
|
|
(rawChunk) {
|
|
buffer += rawChunk;
|
|
while (buffer.contains('\n\n')) {
|
|
final idx = buffer.indexOf('\n\n');
|
|
final rawEvent = buffer.substring(0, idx);
|
|
buffer = buffer.substring(idx + 2);
|
|
_handleSseEvent(rawEvent, assistantMsg);
|
|
}
|
|
},
|
|
onDone: () {
|
|
if (buffer.trim().isNotEmpty) {
|
|
_handleSseEvent(buffer, assistantMsg);
|
|
}
|
|
assistantMsg.isStreaming = false;
|
|
isStreaming = false;
|
|
notifyListeners();
|
|
},
|
|
onError: (e) => _onStreamError(assistantMsg, e),
|
|
cancelOnError: true,
|
|
);
|
|
}
|
|
|
|
void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg) {
|
|
final dataLines = rawEvent.split('\n').where((line) => line.startsWith('data:')).map((line) => line.substring(5).trim()).toList();
|
|
if (dataLines.isEmpty) return;
|
|
|
|
final payload = dataLines.join('\n');
|
|
|
|
if (payload == '[DONE]') {
|
|
assistantMsg.isStreaming = false;
|
|
isStreaming = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final json = jsonDecode(payload);
|
|
final event = CopilotStreamEvent.fromJson(json);
|
|
if (event.type == 'response' && event.content != null) {
|
|
assistantMsg.content = event.content!.answer ?? '';
|
|
assistantMsg.documentAnswer = event.content!.documentAnswer;
|
|
assistantMsg.websiteAnswer = event.content!.websiteAnswer;
|
|
assistantMsg.images = event.content!.images;
|
|
} else if (event.type == 'error') {
|
|
_onStreamError(assistantMsg, event.content?.answer ?? 'Something went wrong');
|
|
return;
|
|
}
|
|
notifyListeners();
|
|
} catch (_) {
|
|
}
|
|
}
|
|
|
|
void _onStreamError(CopilotMessage assistantMsg, Object e) {
|
|
assistantMsg.isStreaming = false;
|
|
assistantMsg.isError = true;
|
|
if (assistantMsg.content.isEmpty) {
|
|
assistantMsg.content = "Something went wrong. Please try again.";
|
|
}
|
|
isStreaming = false;
|
|
errorMessage = "Failed to get a response";
|
|
notifyListeners();
|
|
}
|
|
|
|
void cancelStreaming() {
|
|
_streamSub?.cancel();
|
|
isStreaming = false;
|
|
if (messages.isNotEmpty && messages.last.role == CopilotRole.assistant) {
|
|
messages.last.isStreaming = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_streamSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|