import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/models/conversation.dart'; import '../data/models/message.dart'; import 'api_client_provider.dart'; // Separate StateProvider so UI re-builds immediately when streaming starts/stops. final isStreamingProvider = StateProvider.family((ref, _) => false); final conversationsProvider = AsyncNotifierProvider>( ConversationsNotifier.new); class ConversationsNotifier extends AsyncNotifier> { @override Future> build() async { return ref.watch(chatRepositoryProvider).getConversations(); } Future create(String title) async { final conv = await ref.read(chatRepositoryProvider).createConversation(title); state = AsyncData([conv, ...state.valueOrNull ?? []]); return conv; } Future delete(int id) async { await ref.read(chatRepositoryProvider).deleteConversation(id); state = AsyncData([ for (final c in state.valueOrNull ?? []) if (c.id != id) c, ]); } // Called after a message is sent to patch the server-generated title // in-place without triggering a full reload or loading state. void patchConversation(Conversation updated) { final list = state.valueOrNull; if (list == null) return; state = AsyncData([ for (final c in list) if (c.id == updated.id) updated else c, ]); } } final messagesProvider = AsyncNotifierProvider.family, int>(MessagesNotifier.new); class MessagesNotifier extends FamilyAsyncNotifier, int> { @override Future> build(int arg) async { final (_, messages) = await ref.watch(chatRepositoryProvider).getMessages(arg); return messages; } Future sendMessage(String content) async { final convId = arg; final repo = ref.read(chatRepositoryProvider); final previousMessages = state.valueOrNull ?? []; // Optimistic UI: show the user message + assistant placeholder immediately. final userMsg = Message( conversationId: convId, role: MessageRole.user, content: content, ); final placeholder = Message( conversationId: convId, role: MessageRole.assistant, content: '', status: 'generating', ); state = AsyncData([...previousMessages, userMsg, placeholder]); ref.read(isStreamingProvider(convId).notifier).state = true; try { // ── Step 1: POST the message. If this fails the server never got it. ── await repo.sendMessage(convId, content); } catch (e) { state = AsyncData(previousMessages); ref.read(isStreamingProvider(convId).notifier).state = false; rethrow; } // ── Step 2: Stream the response (best effort — silent on failure). ── bool streamedContent = false; try { await for (final chunk in repo.streamGeneration(convId)) { streamedContent = true; final msgs = state.requireValue; if (msgs.isEmpty) continue; final updated = msgs.last.copyWith(content: msgs.last.content + chunk); state = AsyncData([...msgs.sublist(0, msgs.length - 1), updated]); } } catch (_) { // SSE failed — fall through to the polling reload below. } // ── Step 3: Poll the API until we have a completed assistant response. // // If SSE delivered content the server is done; one reload suffices. // If SSE delivered nothing, the LLM may still be generating — we retry // with short delays so the response appears as soon as it's ready. // A completed message is one whose status is not 'generating'. try { final maxAttempts = streamedContent ? 1 : 20; for (var attempt = 0; attempt < maxAttempts; attempt++) { if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); final (conv, fresh) = await repo.getMessages(convId); state = AsyncData(fresh); ref.read(conversationsProvider.notifier).patchConversation(conv); final done = fresh.any( (m) => m.role == MessageRole.assistant && m.status != 'generating', ); if (done) break; } } catch (_) { // If every reload fails, at least clear the generating placeholder. final msgs = state.valueOrNull; if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') { state = AsyncData([ ...msgs.sublist(0, msgs.length - 1), msgs.last.copyWith(status: 'complete'), ]); } } finally { ref.read(isStreamingProvider(convId).notifier).state = false; } } }