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 history = []; bool historyLoading = false; List messages = []; bool conversationLoading = false; bool isStreaming = false; String? sessionId; String? errorMessage; StreamSubscription? _streamSub; Future 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 deleteHistory(String threadId) async { final prevList = List.from(history); try { final res = await ApiManager.instance.delete(URLs.copilotDeleteQueryHistory(threadId), body: const {}); final decoded = jsonDecode(res.body); final success = decoded is Map && decoded['isSuccess'] == true && decoded['data'] == true; if (success) { history.removeWhere((h) => h.threadId == threadId); notifyListeners(); } else { 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 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((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 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, null); } catch (e) { _onStreamError(assistantMsg, e); } } Future sendAudio(File audioFile) async { if (isStreaming) return; sessionId ??= _generateSessionId(); final humanAudioMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.user, localAudioPath: audioFile.path, timestamp: DateTime.now()); messages.add(humanAudioMsg); 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, humanAudioMsg); } catch (e) { _onStreamError(assistantMsg, e); } } String _generateSessionId() { final rand = Random(); final bytes = List.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 _consumeStream(dynamic streamedResponse, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) 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, humanAudioMsg); } }, onDone: () { if (buffer.trim().isNotEmpty) { _handleSseEvent(buffer, assistantMsg, humanAudioMsg); } assistantMsg.isStreaming = false; isStreaming = false; notifyListeners(); }, onError: (e) => _onStreamError(assistantMsg, e), cancelOnError: true, ); } void _handleSseEvent(String rawEvent, CopilotMessage assistantMsg, CopilotMessage? humanAudioMsg) { 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; } if (event.type == 'transcription' && event.transcriptionContent != null) { humanAudioMsg?.content = event.transcriptionContent ?? ''; humanAudioMsg?.localAudioPath = null; } 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(); } }