From 2b44e38a2e665d04be1e845ec664ebc2b22c53a8 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 25 Oct 2025 14:24:49 +0530 Subject: [PATCH] feat(i18n/socket): add WebSocket error messages and show connect errors --- lib/core/services/api_service.dart | 243 +----------------------- lib/core/services/socket_service.dart | 49 +++-- lib/core/services/streaming_helper.dart | 11 +- lib/l10n/app_de.arb | 4 +- lib/l10n/app_en.arb | 9 +- lib/l10n/app_es.arb | 4 +- lib/l10n/app_fr.arb | 4 +- lib/l10n/app_it.arb | 4 +- lib/l10n/app_localizations.dart | 12 ++ lib/l10n/app_localizations_de.dart | 8 + lib/l10n/app_localizations_en.dart | 8 + lib/l10n/app_localizations_fr.dart | 8 + lib/l10n/app_localizations_it.dart | 8 + lib/l10n/app_nl.arb | 4 +- lib/l10n/app_ru.arb | 4 +- lib/l10n/app_zh.arb | 4 +- 16 files changed, 116 insertions(+), 268 deletions(-) diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index eb5f590..634c6f9 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -3262,7 +3262,7 @@ class ApiService { _traceApi('Initiating background tools flow (task-based)'); _traceApi('Posting to /api/chat/completions'); - // Fire in background; poll chat for updates and stream deltas to UI + // Fire in background; all updates will come via WebSocket events () async { try { final resp = await _dio.post('/api/chat/completions', data: data); @@ -3272,25 +3272,10 @@ class ApiService { : null; _traceApi('Background task created: $taskId'); - // If no session/socket provided, fall back to polling for updates. - final pollChatId = (conversationId != null && conversationId.isNotEmpty) - ? conversationId - : null; - final requiresPolling = - sessionIdOverride == null || sessionIdOverride.isEmpty; - - if (requiresPolling && pollChatId != null) { - final chatId = pollChatId; - await _pollChatForMessageUpdates( - chatId: chatId, - messageId: messageId, - streamController: streamController, - ); - } else { - // Close the controller so listeners don't hang waiting for chunks - if (!streamController.isClosed) { - streamController.close(); - } + // Close the controller immediately - all streaming will happen via WebSocket + // No polling fallback to avoid duplication issues + if (!streamController.isClosed) { + streamController.close(); } } catch (e) { _traceApi('Background tools flow failed: $e'); @@ -3329,224 +3314,6 @@ class ApiService { } } - // Poll the server chat until the assistant message is populated with tool results, - // then stream deltas to the UI and close. - Future _pollChatForMessageUpdates({ - required String chatId, - required String messageId, - required StreamController streamController, - }) async { - String last = ''; - int stableCount = 0; - final started = DateTime.now(); - - bool containsDone(String s) => - s.contains('
; - - // Locate assistant content from multiple shapes - String content = ''; - - Map? chatObj = (data['chat'] is Map) - ? data['chat'] as Map - : null; - - // 1) Preferred: chat.messages (list) – try exact id first - if (chatObj != null && chatObj['messages'] is List) { - final List messagesList = chatObj['messages'] as List; - final target = messagesList.firstWhere( - (m) => (m is Map && (m['id']?.toString() == messageId)), - orElse: () => null, - ); - if (target != null) { - final rawContent = (target as Map)['content']; - if (rawContent is List) { - final textItem = rawContent.firstWhere( - (i) => i is Map && i['type'] == 'text', - orElse: () => null, - ); - if (textItem != null) { - content = textItem['text']?.toString() ?? ''; - } - } else if (rawContent is String) { - content = rawContent; - } - } - } - - // 2) Fallback: chat.history.messages (map) – try exact id - if (content.isEmpty && chatObj != null) { - final history = chatObj['history']; - if (history is Map && history['messages'] is Map) { - final Map messagesMap = - (history['messages'] as Map).cast(); - final msg = messagesMap[messageId]; - if (msg is Map) { - final rawContent = msg['content']; - if (rawContent is String) { - content = rawContent; - } else if (rawContent is List) { - final textItem = rawContent.firstWhere( - (i) => i is Map && i['type'] == 'text', - orElse: () => null, - ); - if (textItem != null) { - content = textItem['text']?.toString() ?? ''; - } - } - } - } - } - - // 3) Last resort: top-level messages (list) – try exact id - if (content.isEmpty && data['messages'] is List) { - final List topMessages = data['messages'] as List; - final target = topMessages.firstWhere( - (m) => (m is Map && (m['id']?.toString() == messageId)), - orElse: () => null, - ); - if (target != null) { - final rawContent = (target as Map)['content']; - if (rawContent is String) { - content = rawContent; - } else if (rawContent is List) { - final textItem = rawContent.firstWhere( - (i) => i is Map && i['type'] == 'text', - orElse: () => null, - ); - if (textItem != null) { - content = textItem['text']?.toString() ?? ''; - } - } - } - } - - // Note: We intentionally removed the fallback to "any latest assistant message" - // because it causes duplication issues in multi-turn conversations. - // If we can't find the specific message by ID, we skip this poll iteration - // and wait for the next one rather than showing content from a different message. - - if (content.isEmpty) { - continue; - } - - // Stream only the delta when content grows monotonically - if (content.startsWith(last)) { - final delta = content.substring(last.length); - if (delta.isNotEmpty && !streamController.isClosed) { - streamController.add(delta); - } - } else { - // Fallback: replace entire content by emitting a separator + full content - if (!streamController.isClosed) { - streamController.add('\n'); - streamController.add(content); - } - } - // Stop when we detect done=true on tool_calls or when content stabilizes - if (containsDone(content)) { - break; - } - - // If content hasn't changed for several polls, assume completion, - // but be more conservative to avoid cutting off long responses. - // OpenWebUI relies more on explicit done signals than stability checks. - final prev = last; - if (content == prev && content.isNotEmpty) { - stableCount++; - } else if (content != prev) { - stableCount = 0; - } - // Increased threshold from 3 to 8 polls to be more conservative - // This gives ~7-8 seconds of stability before assuming completion - if (content.isNotEmpty && stableCount >= 8) { - DebugLogger.log( - 'Content stable for $stableCount polls, assuming completion', - scope: 'api/polling', - ); - break; - } - - last = content; - } catch (e) { - // Ignore transient errors and continue polling - } - } - - // Final backfill: one last attempt to fetch the latest content - // in case the server wrote the final message after our last poll. - try { - if (!streamController.isClosed) { - final resp = await _dio.get('/api/v1/chats/$chatId'); - final data = resp.data as Map; - String content = ''; - Map? chatObj = (data['chat'] is Map) - ? data['chat'] as Map - : null; - if (chatObj != null && chatObj['messages'] is List) { - final List messagesList = chatObj['messages'] as List; - final target = messagesList.firstWhere( - (m) => (m is Map && (m['id']?.toString() == messageId)), - orElse: () => null, - ); - if (target != null) { - final rawContent = (target as Map)['content']; - if (rawContent is String) { - content = rawContent; - } else if (rawContent is List) { - final textItem = rawContent.firstWhere( - (i) => i is Map && i['type'] == 'text', - orElse: () => null, - ); - if (textItem != null) { - content = (textItem as Map)['text']?.toString() ?? ''; - } - } - } - } - if (content.isEmpty && chatObj != null) { - final history = chatObj['history']; - if (history is Map && history['messages'] is Map) { - final Map messagesMap = - (history['messages'] as Map).cast(); - final msg = messagesMap[messageId]; - if (msg is Map) { - final rawContent = msg['content']; - if (rawContent is String) { - content = rawContent; - } else if (rawContent is List) { - final textItem = rawContent.firstWhere( - (i) => i is Map && i['type'] == 'text', - orElse: () => null, - ); - if (textItem != null) { - content = (textItem as Map)['text']?.toString() ?? ''; - } - } - } - } - } - if (content.isNotEmpty && content != last) { - streamController.add('\n'); - streamController.add(content); - } - } - } catch (_) {} - - if (!streamController.isClosed) { - streamController.close(); - } - } - // Cancel an active streaming message by its messageId (client-side abort) void cancelStreamingMessage(String messageId) { try { diff --git a/lib/core/services/socket_service.dart b/lib/core/services/socket_service.dart index 22ff8e2..4ef30da 100644 --- a/lib/core/services/socket_service.dart +++ b/lib/core/services/socket_service.dart @@ -2,8 +2,10 @@ import 'package:flutter/widgets.dart'; import 'package:socket_io_client/socket_io_client.dart' as io; import '../models/server_config.dart'; -import '../utils/debug_logger.dart'; +import '../../l10n/app_localizations.dart'; +import 'navigation_service.dart'; import 'socket_tls_override.dart'; +import '../../shared/utils/ui_utils.dart'; typedef SocketChatEventHandler = void Function( @@ -71,10 +73,10 @@ class SocketService with WidgetsBindingObserver { final path = '/ws/socket.io'; final builder = io.OptionBuilder() - // Transport selection - .setTransports(websocketOnly ? ['websocket'] : ['polling', 'websocket']) - .setRememberUpgrade(!websocketOnly) - .setUpgrade(!websocketOnly) + // Transport selection - WebSocket only, no polling fallback + .setTransports(['websocket']) + .setRememberUpgrade(false) + .setUpgrade(false) // Tune reconnect/backoff and timeouts .setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts .setReconnectionDelay(1000) @@ -252,7 +254,6 @@ class SocketService with WidgetsBindingObserver { } void _handleConnect(dynamic _) { - DebugLogger.log('Socket connected: ${_socket?.id}', scope: 'socket'); if (_authToken != null && _authToken!.isNotEmpty) { _socket?.emit('user-join', { 'auth': {'token': _authToken}, @@ -261,14 +262,10 @@ class SocketService with WidgetsBindingObserver { } void _handleReconnectAttempt(dynamic attempt) { - DebugLogger.log('Socket reconnect_attempt: $attempt', scope: 'socket'); + // Silent reconnection attempt } void _handleReconnect(dynamic attempt) { - DebugLogger.log( - 'Socket reconnected after $attempt attempts', - scope: 'socket', - ); if (_authToken != null && _authToken!.isNotEmpty) { _socket?.emit('user-join', { 'auth': {'token': _authToken}, @@ -277,15 +274,39 @@ class SocketService with WidgetsBindingObserver { } void _handleConnectError(dynamic err) { - DebugLogger.log('Socket connect_error: $err', scope: 'socket'); + // Show user-facing error notification + final context = NavigationService.context; + if (context != null) { + final l10n = AppLocalizations.of(context); + if (l10n != null) { + UiUtils.showMessage( + context, + l10n.websocketConnectionError, + isError: true, + duration: const Duration(seconds: 5), + ); + } + } } void _handleReconnectFailed(dynamic _) { - DebugLogger.log('Socket reconnect_failed', scope: 'socket'); + // Show user-facing error notification + final context = NavigationService.context; + if (context != null) { + final l10n = AppLocalizations.of(context); + if (l10n != null) { + UiUtils.showMessage( + context, + l10n.websocketReconnectFailed, + isError: true, + duration: const Duration(seconds: 5), + ); + } + } } void _handleDisconnect(dynamic reason) { - DebugLogger.log('Socket disconnected: $reason', scope: 'socket'); + // Silent disconnect } void _handleChatEvent(dynamic data, [dynamic ack]) { diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index 1fc7668..54d7e36 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -56,9 +56,9 @@ class ActiveSocketStream { /// Unified streaming helper for chat send/regenerate flows. /// -/// This attaches chunked polling streams (fallback) plus WebSocket event handlers, -/// and manages background search/image-gen UI updates. It operates via callbacks to -/// avoid tight coupling with provider files for easier reuse and testing. +/// This attaches WebSocket event handlers and manages background search/image-gen +/// UI updates. It operates via callbacks to avoid tight coupling with provider files +/// for easier reuse and testing. ActiveSocketStream attachUnifiedChunkedStreaming({ required Stream stream, required bool webSearchEnabled, @@ -1140,9 +1140,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ // Unregister from persistent service persistentService.unregisterStream(streamId); - // Only finish streaming if no socket subscriptions are active - // This indicates a polling-driven flow where the stream ending means completion - // For socket flows, completion should be handled by socket events (done: true) + // Stream completion without socket subscriptions indicates a simple flow + // For WebSocket flows, completion should be handled by socket events (done: true) if (socketSubscriptions.isEmpty) { finishStreaming(); Future.microtask(refreshConversationSnapshot); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 05b70a3..31f9bb3 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -337,5 +337,7 @@ "transportModeAuto": "Auto (Polling + WebSocket)", "transportModeWs": "Nur WebSocket", "transportModeAutoInfo": "Robuster in restriktiven Netzwerken. Wechselt nach Möglichkeit zu WebSocket.", - "transportModeWsInfo": "Geringerer Overhead, kann jedoch hinter strikten Proxys/Firewalls fehlschlagen." + "transportModeWsInfo": "Geringerer Overhead, kann jedoch hinter strikten Proxys/Firewalls fehlschlagen.", + "websocketConnectionError": "Echtzeit-Verbindung konnte nicht hergestellt werden. Bitte überprüfen Sie Ihr Netzwerk und die Serverkonfiguration.", + "websocketReconnectFailed": "Echtzeit-Verbindung fehlgeschlagen. Streaming funktioniert möglicherweise nicht ordnungsgemäß." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bd447a5..24f8b03 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -700,7 +700,10 @@ "@transportModeWs": {"description": "Dropdown option label for WebSocket-only transport."}, "transportModeAutoInfo": "More robust on restrictive networks. Upgrades to WebSocket when possible.", "@transportModeAutoInfo": {"description": "Footnote text for the Auto transport mode."}, - "transportModeWsInfo": "Lower overhead, but may fail behind strict proxies/firewalls." - , - "@transportModeWsInfo": {"description": "Footnote text for the WebSocket-only transport mode."} + "transportModeWsInfo": "Lower overhead, but may fail behind strict proxies/firewalls.", + "@transportModeWsInfo": {"description": "Footnote text for the WebSocket-only transport mode."}, + "websocketConnectionError": "Unable to establish real-time connection. Please check your network and server configuration.", + "@websocketConnectionError": {"description": "Error message shown when WebSocket connection fails initially."}, + "websocketReconnectFailed": "Real-time connection failed. Streaming may not work properly.", + "@websocketReconnectFailed": {"description": "Error message shown when WebSocket reconnection attempts fail."} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index de572c6..e57e3a9 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -330,5 +330,7 @@ "transportModeAuto": "Automático (Polling + WebSocket)", "transportModeWs": "Solo WebSocket", "transportModeAutoInfo": "Más robusto en redes restrictivas. Se actualiza a WebSocket cuando es posible.", - "transportModeWsInfo": "Menor sobrecarga, pero puede fallar detrás de proxies/firewalls estrictos." + "transportModeWsInfo": "Menor sobrecarga, pero puede fallar detrás de proxies/firewalls estrictos.", + "websocketConnectionError": "No se puede establecer la conexión en tiempo real. Por favor, verifica tu red y la configuración del servidor.", + "websocketReconnectFailed": "Fallo en la conexión en tiempo real. El streaming podría no funcionar correctamente." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index de5976d..1646a2f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -337,5 +337,7 @@ "transportModeAuto": "Auto (Polling + WebSocket)", "transportModeWs": "WebSocket uniquement", "transportModeAutoInfo": "Plus robuste sur les réseaux restrictifs. Passe à WebSocket lorsque possible.", - "transportModeWsInfo": "Moins de surcharge, mais peut échouer derrière des proxys/firewalls stricts." + "transportModeWsInfo": "Moins de surcharge, mais peut échouer derrière des proxys/firewalls stricts.", + "websocketConnectionError": "Impossible d'établir une connexion en temps réel. Veuillez vérifier votre réseau et la configuration du serveur.", + "websocketReconnectFailed": "Échec de la connexion en temps réel. Le streaming pourrait ne pas fonctionner correctement." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 23ba696..72bc56e 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -337,5 +337,7 @@ "transportModeAuto": "Auto (Polling + WebSocket)", "transportModeWs": "Solo WebSocket", "transportModeAutoInfo": "Più robusto nelle reti restrittive. Passa a WebSocket quando possibile.", - "transportModeWsInfo": "Minore overhead, ma può fallire dietro proxy/firewall restrittivi." + "transportModeWsInfo": "Minore overhead, ma può fallire dietro proxy/firewall restrittivi.", + "websocketConnectionError": "Impossibile stabilire una connessione in tempo reale. Si prega di controllare la rete e la configurazione del server.", + "websocketReconnectFailed": "Connessione in tempo reale fallita. Lo streaming potrebbe non funzionare correttamente." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 74c3d54..46f432b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1849,6 +1849,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Lower overhead, but may fail behind strict proxies/firewalls.'** String get transportModeWsInfo; + + /// Error message shown when WebSocket connection fails initially. + /// + /// In en, this message translates to: + /// **'Unable to establish real-time connection. Please check your network and server configuration.'** + String get websocketConnectionError; + + /// Error message shown when WebSocket reconnection attempts fail. + /// + /// In en, this message translates to: + /// **'Real-time connection failed. Streaming may not work properly.'** + String get websocketReconnectFailed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 67140e3..5f6f17c 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -965,4 +965,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get transportModeWsInfo => 'Geringerer Overhead, kann jedoch hinter strikten Proxys/Firewalls fehlschlagen.'; + + @override + String get websocketConnectionError => + 'Echtzeit-Verbindung konnte nicht hergestellt werden. Bitte überprüfen Sie Ihr Netzwerk und die Serverkonfiguration.'; + + @override + String get websocketReconnectFailed => + 'Echtzeit-Verbindung fehlgeschlagen. Streaming funktioniert möglicherweise nicht ordnungsgemäß.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 019ce01..56308b5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -957,4 +957,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get transportModeWsInfo => 'Lower overhead, but may fail behind strict proxies/firewalls.'; + + @override + String get websocketConnectionError => + 'Unable to establish real-time connection. Please check your network and server configuration.'; + + @override + String get websocketReconnectFailed => + 'Real-time connection failed. Streaming may not work properly.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 59624b0..f5f6084 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -971,4 +971,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get transportModeWsInfo => 'Moins de surcharge, mais peut échouer derrière des proxys/firewalls stricts.'; + + @override + String get websocketConnectionError => + 'Impossible d\'établir une connexion en temps réel. Veuillez vérifier votre réseau et la configuration du serveur.'; + + @override + String get websocketReconnectFailed => + 'Échec de la connexion en temps réel. Le streaming pourrait ne pas fonctionner correctement.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 0350f30..2693b20 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -960,4 +960,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get transportModeWsInfo => 'Minore overhead, ma può fallire dietro proxy/firewall restrittivi.'; + + @override + String get websocketConnectionError => + 'Impossibile stabilire una connessione in tempo reale. Si prega di controllare la rete e la configurazione del server.'; + + @override + String get websocketReconnectFailed => + 'Connessione in tempo reale fallita. Lo streaming potrebbe non funzionare correttamente.'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 87389cb..c21b3f8 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -330,5 +330,7 @@ "transportModeAuto": "Automatisch (Polling + WebSocket)", "transportModeWs": "Alleen WebSocket", "transportModeAutoInfo": "Robuuster op beperkende netwerken. Upgrade naar WebSocket indien mogelijk.", - "transportModeWsInfo": "Lagere overhead, maar kan mislukken achter strikte proxies/firewalls." + "transportModeWsInfo": "Lagere overhead, maar kan mislukken achter strikte proxies/firewalls.", + "websocketConnectionError": "Kan geen realtime verbinding maken. Controleer uw netwerk en serverconfiguratie.", + "websocketReconnectFailed": "Realtime verbinding mislukt. Streaming werkt mogelijk niet goed." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index dec8368..b258c19 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -330,5 +330,7 @@ "transportModeAuto": "Авто (опрос + WebSocket)", "transportModeWs": "Только WebSocket", "transportModeAutoInfo": "Более надежен в ограничительных сетях. Переходит на WebSocket, когда это возможно.", - "transportModeWsInfo": "Меньше накладных расходов, но может не работать за строгими прокси/брандмауэрами." + "transportModeWsInfo": "Меньше накладных расходов, но может не работать за строгими прокси/брандмауэрами.", + "websocketConnectionError": "Не удалось установить соединение в реальном времени. Пожалуйста, проверьте сеть и конфигурацию сервера.", + "websocketReconnectFailed": "Сбой соединения в реальном времени. Потоковая передача может работать неправильно." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f52e592..c1d054d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -330,5 +330,7 @@ "transportModeAuto": "自动(轮询 + WebSocket)", "transportModeWs": "仅 WebSocket", "transportModeAutoInfo": "在限制性网络上更稳健。在可能的情况下升级到 WebSocket。", - "transportModeWsInfo": "开销较低,但可能在严格的代理/防火墙后失败。" + "transportModeWsInfo": "开销较低,但可能在严格的代理/防火墙后失败。", + "websocketConnectionError": "无法建立实时连接。请检查您的网络和服务器配置。", + "websocketReconnectFailed": "实时连接失败。流式传输可能无法正常工作。" }