98ad9a2557
Root cause: when SSE delivered any chunk, maxAttempts was set to 1 with no delay. That single immediate poll hit the server before it finished writing to the DB, received status='generating' with empty content, then overwrote the SSE-streamed text with the stale empty row and exited. isStreaming went false and the UI was stuck showing '...'. Two fixes: 1. Always poll up to 20 times regardless of SSE success. SSE closing does not mean the server has committed the final row to the DB. 2. When SSE delivered content, only replace state with polled data if the polled assistant message is complete or has content — this prevents clobbering streamed text with a stale empty DB row while the server is still writing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
5.1 KiB
Dart
147 lines
5.1 KiB
Dart
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<bool, int>((ref, _) => false);
|
|
|
|
final conversationsProvider =
|
|
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
|
ConversationsNotifier.new);
|
|
|
|
class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
|
@override
|
|
Future<List<Conversation>> build() async {
|
|
return ref.watch(chatRepositoryProvider).getConversations();
|
|
}
|
|
|
|
Future<Conversation> create(String title) async {
|
|
final conv =
|
|
await ref.read(chatRepositoryProvider).createConversation(title);
|
|
state = AsyncData([conv, ...state.valueOrNull ?? []]);
|
|
return conv;
|
|
}
|
|
|
|
Future<void> 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<MessagesNotifier,
|
|
List<Message>, int>(MessagesNotifier.new);
|
|
|
|
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
|
@override
|
|
Future<List<Message>> build(int arg) async {
|
|
final (_, messages) =
|
|
await ref.watch(chatRepositoryProvider).getMessages(arg);
|
|
return messages;
|
|
}
|
|
|
|
Future<void> 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.
|
|
//
|
|
// We always retry up to 20 times regardless of whether SSE streamed
|
|
// content. SSE ending does NOT mean the server has finished writing to
|
|
// the DB — there can be a brief gap between the stream closing and the
|
|
// row being marked complete.
|
|
//
|
|
// To avoid clobbering SSE-streamed text with a stale empty DB row, we
|
|
// only replace state when the polled data is complete OR has content.
|
|
try {
|
|
for (var attempt = 0; attempt < 20; attempt++) {
|
|
if (attempt > 0) await Future.delayed(const Duration(seconds: 2));
|
|
final (conv, fresh) = await repo.getMessages(convId);
|
|
ref.read(conversationsProvider.notifier).patchConversation(conv);
|
|
|
|
final done = fresh.any(
|
|
(m) => m.role == MessageRole.assistant && m.status != 'generating',
|
|
);
|
|
final polledHasContent = fresh.any(
|
|
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
|
);
|
|
|
|
// Update UI state when:
|
|
// • SSE never delivered anything (polling is the only data source), or
|
|
// • the polled response is complete or has content to show.
|
|
if (!streamedContent || done || polledHasContent) {
|
|
state = AsyncData(fresh);
|
|
}
|
|
|
|
if (done) break;
|
|
}
|
|
} catch (_) {
|
|
// Polling failed entirely — clear the generating placeholder so the UI
|
|
// doesn't spin forever.
|
|
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;
|
|
}
|
|
}
|
|
}
|