feat(streaming): Simplify streaming logic and remove persistent tracking

This commit is contained in:
cogwheel0
2025-11-27 14:36:13 +05:30
parent e6f8a76f13
commit 61a3fcc83a
11 changed files with 181 additions and 1066 deletions

View File

@@ -19,9 +19,7 @@ import '../models/prompt.dart';
import '../auth/api_auth_interceptor.dart';
import '../error/api_error_interceptor.dart';
// Tool-call details are parsed in the UI layer to render collapsible blocks
import 'persistent_streaming_service.dart';
import 'connectivity_service.dart';
import 'sse_stream_parser.dart';
import '../utils/debug_logger.dart';
import 'conversation_parsing.dart';
import 'worker_manager.dart';
@@ -2596,36 +2594,12 @@ class ApiService {
// Chat streaming with conversation context
// Track cancellable streaming requests by messageId for stop parity
final Map<String, CancelToken> _streamCancelTokens = {};
final Map<String, String> _messagePersistentStreamIds = {};
/// Associates a streaming message with its persistent stream identifier.
void registerPersistentStreamForMessage(String messageId, String streamId) {
_messagePersistentStreamIds[messageId] = streamId;
}
/// Removes the persistent stream mapping for a message if it matches.
///
/// Returns the removed persistent stream identifier when one existed and
/// matched the optional [expectedStreamId].
String? clearPersistentStreamForMessage(
String messageId, {
String? expectedStreamId,
}) {
final current = _messagePersistentStreamIds[messageId];
if (current == null) {
return null;
}
if (expectedStreamId != null && current != expectedStreamId) {
return null;
}
return _messagePersistentStreamIds.remove(messageId);
}
// Send message using dual-stream approach (HTTP SSE + WebSocket events).
// Matches OpenWebUI web client behavior:
// - HTTP SSE stream provides immediate content chunks
// - WebSocket events deliver metadata, tool status, sources, follow-ups
// - Both streams run in parallel for reliability
// Send message using WebSocket-only streaming.
// Matches OpenWebUI web client behavior when session_id + chat_id + message_id are provided:
// - HTTP POST returns JSON with task_id (no SSE streaming)
// - All content and metadata delivered via WebSocket events
// - Events: chat:completion, chat:message:delta, status, source, follow_ups, etc.
// Returns a record with (stream, messageId, sessionId, socketSessionId, isBackgroundFlow)
({
Stream<String> stream,
@@ -2790,7 +2764,7 @@ class ApiService {
_traceApi('Including non-image files in request: ${allFiles.length}');
}
_traceApi('Preparing dual-stream chat request (HTTP SSE + WebSocket)');
_traceApi('Preparing WebSocket-only chat request');
_traceApi('Model: $model');
_traceApi('Message count: ${processedMessages.length}');
@@ -2830,118 +2804,85 @@ class ApiService {
);
_traceApi('Has background_tasks: ${data.containsKey('background_tasks')}');
_traceApi('Initiating dual-stream request (HTTP SSE + WebSocket)');
_traceApi('Initiating WebSocket-only chat request');
_traceApi('Posting to /api/chat/completions');
// Create a cancel token for this request
final cancelToken = CancelToken();
_streamCancelTokens[messageId] = cancelToken;
// Start HTTP SSE stream (matches web client behavior)
// The WebSocket events will run in parallel via streaming_helper.dart
// Send HTTP request to initiate chat task
// With session_id + chat_id + message_id, the server returns a task_id
// and all streaming happens via WebSocket events (not SSE)
() async {
try {
final resp = await _dio.post(
'/api/chat/completions',
data: data,
options: Options(
responseType: ResponseType.stream,
// Extended timeout for streaming responses - allow up to 10 minutes
// for long-running tool calls and reasoning
receiveTimeout: const Duration(minutes: 10),
// Shorter send timeout for the initial request
responseType: ResponseType.json,
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'text/event-stream',
// Enable HTTP keep-alive to maintain connection in background
'Connection': 'keep-alive',
// Request server to send keep-alive messages
'Cache-Control': 'no-cache',
},
),
cancelToken: cancelToken,
);
final respData = resp.data;
// Check if we got a task_id response (non-streaming)
if (respData is Map && respData['task_id'] != null) {
final taskId = respData['task_id'].toString();
_traceApi('Background task created: $taskId');
// In this case, all streaming will happen via WebSocket
// Close HTTP stream but keep WebSocket active
if (!streamController.isClosed) {
streamController.close();
}
return;
}
// We have a streaming response body
if (respData is ResponseBody) {
_traceApi('HTTP SSE stream started for message: $messageId');
// Parse SSE stream and forward chunks to controller
await for (final chunk in SSEStreamParser.parseResponseStream(
respData,
splitLargeDeltas: false,
heartbeatTimeout: const Duration(minutes: 2),
onHeartbeat: () {
// Notify persistent streaming service that connection is alive
final persistentStreamId = _messagePersistentStreamIds[messageId];
if (persistentStreamId != null) {
PersistentStreamingService().updateStreamProgress(
persistentStreamId,
chunkSequence: DateTime.now().millisecondsSinceEpoch,
);
}
},
)) {
if (respData is Map) {
if (respData['task_id'] != null) {
final taskId = respData['task_id'].toString();
_traceApi('Background task created: $taskId');
} else if (respData['status'] == true) {
_traceApi('Chat task initiated successfully');
} else if (respData['error'] != null) {
_traceApi('Server error: ${respData['error']}');
if (!streamController.isClosed) {
streamController.add(chunk);
} else {
_traceApi('Stream controller closed, stopping SSE parsing');
break;
streamController.addError(Exception(respData['error'].toString()));
}
}
_traceApi('HTTP SSE stream completed for message: $messageId');
} else {
_traceApi('Unexpected response type: ${respData.runtimeType}');
}
// Close the HTTP stream controller
// WebSocket events will continue independently via streaming_helper
// Close HTTP stream controller - WebSocket handles all content delivery
if (!streamController.isClosed) {
streamController.close();
}
} on DioException catch (e) {
if (CancelToken.isCancel(e)) {
_traceApi('HTTP stream cancelled for message: $messageId');
_traceApi('Request cancelled for message: $messageId');
} else {
_traceApi('HTTP stream error: $e');
_traceApi('Request error: $e');
if (!streamController.isClosed) {
streamController.addError(e);
streamController.close();
}
}
} catch (e) {
_traceApi('Unexpected error in HTTP stream: $e');
_traceApi('Unexpected error: $e');
if (!streamController.isClosed) {
streamController.addError(e);
streamController.close();
}
} finally {
_streamCancelTokens.remove(messageId);
}
// Note: Don't remove cancel token here - it should remain until WebSocket
// streaming finishes so Stop button can cancel the active generation.
// Token is removed by clearStreamCancelToken() when streaming completes.
}();
// Determine if this is actually a background flow based on the request payload
final bool isBackgroundFlow =
hasBackgroundTasksPayload ||
(toolIds != null && toolIds.isNotEmpty) ||
(toolServers != null && toolServers.isNotEmpty) ||
enableWebSearch ||
enableImageGeneration;
return (
stream: streamController.stream,
messageId: messageId,
sessionId: sessionId,
socketSessionId: socketSessionId,
isBackgroundFlow: true,
isBackgroundFlow: isBackgroundFlow,
);
}
@@ -2975,13 +2916,12 @@ class ApiService {
token.cancel('User cancelled');
}
} catch (_) {}
}
try {
final pid = clearPersistentStreamForMessage(messageId);
if (pid != null) {
PersistentStreamingService().unregisterStream(pid);
}
} catch (_) {}
/// Clears the cancel token for a message when streaming completes normally.
/// Called by streaming_helper when finishStreaming is invoked.
void clearStreamCancelToken(String messageId) {
_streamCancelTokens.remove(messageId);
}
// File upload for RAG