import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/api/chat_api.dart'; import '../data/models/briefing_conversation.dart'; import '../data/models/message.dart'; import 'api_client_provider.dart'; /// Drives the loading indicator in BriefingScreen's reply area. final isBriefingStreamingProvider = NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new); class _BoolNotifier extends Notifier { @override bool build() => false; } final briefingProvider = AsyncNotifierProvider( BriefingNotifier.new); class BriefingNotifier extends AsyncNotifier { @override Future build() async { return ref.read(briefingApiProvider).getToday(); } /// Silently fetch the latest briefing and patch state without triggering /// AsyncLoading — existing content stays visible while the fetch is in flight. Future silentRefresh() async { final current = state.value; if (current == null) return; try { final fresh = await ref.read(briefingApiProvider).getToday(); final curLast = current.messages.isNotEmpty ? current.messages.last : null; final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null; if (fresh.messages.length != current.messages.length || newLast?.content != curLast?.content) { state = AsyncData(fresh); } } catch (_) { // Network hiccup — silently ignore, keep existing content } } /// Trigger a briefing slot (e.g. "compilation") then reload. Future refresh(String slot) async { await ref.read(briefingApiProvider).triggerSlot(slot); ref.invalidateSelf(); await future; } /// Re-fetch the current briefing conversation and unfreeze a stuck /// streaming state if the server-side message is already complete. /// /// Same role as MessagesNotifier.refresh() in chat_provider: when an SSE /// socket dies silently (mobile network handoff, app backgrounded mid-stream, /// reverse proxy dropping idle sockets) the send loop never observes close /// and [isBriefingStreamingProvider] stays stuck true. This is the manual /// recovery path hit by pull-to-refresh, the AppBar refresh button, and the /// lifecycle-resume hook. Future refreshMessages() async { final current = state.value; if (current == null) { ref.invalidateSelf(); return; } try { final fresh = await ref.read(briefingApiProvider).getToday(); state = AsyncData(fresh); final messages = fresh.messages; Message? lastAssistant; for (var i = messages.length - 1; i >= 0; i--) { if (messages[i].role == MessageRole.assistant) { lastAssistant = messages[i]; break; } } if (lastAssistant != null && lastAssistant.status != 'generating') { ref.read(isBriefingStreamingProvider.notifier).state = false; } } catch (_) { // Network hiccup — keep existing state; user can retry. } } /// Inject a news article as context and trigger generation. /// /// Mirrors sendReply() but calls the /discuss endpoint instead of /// /messages so the backend injects article content before generating. Future discussArticle(int convId, int itemId) async { final conv = state.value; if (conv == null) return; final chatApi = ref.read(chatApiProvider); final briefingApi = ref.read(briefingApiProvider); final previous = conv.messages; final placeholder = Message( conversationId: convId, role: MessageRole.assistant, content: '', status: 'generating', ); state = AsyncData(conv.copyWith(messages: [...previous, placeholder])); ref.read(isBriefingStreamingProvider.notifier).state = true; try { await briefingApi.discussArticle(convId, itemId); } catch (e) { state = AsyncData(conv.copyWith(messages: previous)); ref.read(isBriefingStreamingProvider.notifier).state = false; rethrow; } final streamedContent = await _consumeStream(chatApi.streamGeneration(convId)); await _pollUntilComplete(convId, streamedContent); } /// Send a reply to today's briefing conversation. /// /// Mirrors MessagesNotifier.sendMessage() in chat_provider with the same /// stall-watchdog pattern: /// 1. Optimistic UI update /// 2. POST message to chat endpoint /// 3. SSE stream with per-event timeout (stall watchdog) /// 4. Poll until complete Future sendReply(String content) async { final conv = state.value; if (conv == null) return; final convId = conv.id; final chatApi = ref.read(chatApiProvider); final previous = conv.messages; final userMsg = Message( conversationId: convId, role: MessageRole.user, content: content, ); final placeholder = Message( conversationId: convId, role: MessageRole.assistant, content: '', status: 'generating', ); state = AsyncData(conv.copyWith(messages: [...previous, userMsg, placeholder])); ref.read(isBriefingStreamingProvider.notifier).state = true; try { await chatApi.sendMessage(convId, content); } catch (e) { state = AsyncData(conv.copyWith(messages: previous)); ref.read(isBriefingStreamingProvider.notifier).state = false; rethrow; } final streamedContent = await _consumeStream(chatApi.streamGeneration(convId)); await _pollUntilComplete(convId, streamedContent); } /// Consume an SSE stream into the current briefing conversation state. /// /// Uses a StreamIterator with a per-event timeout as a stall watchdog — /// same rationale as MessagesNotifier.sendMessage() in chat_provider.dart. /// Mobile networks occasionally drop SSE sockets silently: the TCP /// connection is half-closed, Dio never sees the close, and `await for` /// hangs forever with [isBriefingStreamingProvider] stuck true. If no event /// arrives within the watchdog window we bail out and let the polling pass /// below reconcile state from the server. /// /// Returns whether any text content was actually streamed (the polling /// pass uses this to decide whether it's safe to overwrite with a possibly /// empty server-side row). Future _consumeStream(Stream stream) async { const stallTimeout = Duration(seconds: 45); bool streamedContent = false; final iter = StreamIterator(stream); try { while (await iter.moveNext().timeout(stallTimeout)) { final event = iter.current; final current = state.value; if (current == null) break; final msgs = current.messages; if (msgs.isEmpty) continue; if (event is ChatTextChunk) { streamedContent = true; final updated = msgs.last.copyWith(content: msgs.last.content + event.text); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } else if (event is ChatToolCall) { final last = msgs.last; if (last.role != MessageRole.assistant) continue; final nextCalls = [...?last.toolCalls, event.toolCall]; final updated = last.copyWith(toolCalls: nextCalls); state = AsyncData(current.copyWith( messages: [...msgs.sublist(0, msgs.length - 1), updated])); } } } on TimeoutException { // Stall watchdog — no SSE event for stallTimeout. Fall through to // polling so the UI eventually unfreezes even if the socket is dead. } catch (_) { // SSE failed — fall through to polling. } finally { await iter.cancel(); } return streamedContent; } /// Poll /api/briefing messages until the last assistant row is complete, /// same reconcile pattern as MessagesNotifier.sendMessage(). Always clears /// [isBriefingStreamingProvider] at the end so the input can't stay locked. Future _pollUntilComplete(int convId, bool streamedContent) async { final briefingApi = ref.read(briefingApiProvider); try { for (var attempt = 0; attempt < 20; attempt++) { if (attempt > 0) await Future.delayed(const Duration(seconds: 2)); final fresh = await briefingApi.getMessages(convId); final done = fresh.any( (m) => m.role == MessageRole.assistant && m.status != 'generating', ); final hasContent = fresh.any( (m) => m.role == MessageRole.assistant && m.content.isNotEmpty, ); final current = state.value; if (current != null && (!streamedContent || done || hasContent)) { state = AsyncData(current.copyWith(messages: fresh)); } if (done) break; } } catch (_) { final current = state.value; if (current != null) { final msgs = current.messages; if (msgs.isNotEmpty && msgs.last.status == 'generating') { state = AsyncData(current.copyWith(messages: [ ...msgs.sublist(0, msgs.length - 1), msgs.last.copyWith(status: 'complete'), ])); } } } finally { ref.read(isBriefingStreamingProvider.notifier).state = false; } } }