feat(transport): Improve socket transport mode selection and localization

This commit is contained in:
cogwheel0
2025-10-30 22:32:59 +05:30
parent f4561484f6
commit a00d64fc26
19 changed files with 441 additions and 50 deletions

View File

@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
/// Subset of the backend `/api/config` response the app cares about.
@immutable
class BackendConfig {
const BackendConfig({this.enableWebsocket});
/// Mirrors `features.enable_websocket` from OpenWebUI.
final bool? enableWebsocket;
/// Returns a copy with updated fields.
BackendConfig copyWith({bool? enableWebsocket}) {
return BackendConfig(
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
);
}
/// Whether the backend only allows WebSocket transport.
bool get websocketOnly => enableWebsocket == true;
/// Whether the backend only allows HTTP polling transport.
bool get pollingOnly => enableWebsocket == false;
/// Whether the backend permits choosing WebSocket-only mode.
bool get supportsWebsocketOnly => !pollingOnly;
/// Whether the backend permits choosing polling fallback.
bool get supportsPolling => !websocketOnly;
/// Returns the enforced transport mode derived from backend policy.
String? get enforcedTransportMode {
if (websocketOnly) return 'ws';
if (pollingOnly) return 'polling';
return null;
}
Map<String, dynamic> toJson() {
return <String, dynamic>{'enable_websocket': enableWebsocket};
}
static BackendConfig fromJson(Map<String, dynamic> json) {
bool? enableWebsocket;
final features = json['features'];
if (features is Map<String, dynamic>) {
final value = features['enable_websocket'];
if (value is bool) {
enableWebsocket = value;
}
}
return BackendConfig(enableWebsocket: enableWebsocket);
}
}

View File

@@ -15,6 +15,7 @@ import '../models/user.dart';
import '../models/model.dart';
import '../models/conversation.dart';
import '../models/chat_message.dart';
import '../models/backend_config.dart';
import '../models/folder.dart';
import '../models/user_settings.dart';
import '../models/file_info.dart';
@@ -172,6 +173,84 @@ final serverConnectionStateProvider = Provider<bool>((ref) {
);
});
final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
return null;
}
final server = await ref.watch(activeServerProvider.future);
if (server == null) {
return null;
}
try {
final config = await api.getBackendConfig();
if (config != null) {
final forcedMode = config.enforcedTransportMode;
if (forcedMode != null) {
final settings = ref.read(appSettingsProvider);
if (settings.socketTransportMode != forcedMode) {
Future.microtask(() {
ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode(forcedMode);
});
}
}
}
return config;
} catch (_) {
return null;
}
});
class SocketTransportAvailability {
const SocketTransportAvailability({
required this.allowPolling,
required this.allowWebsocketOnly,
});
final bool allowPolling;
final bool allowWebsocketOnly;
}
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
ref,
) {
final backendConfigAsync = ref.watch(backendConfigProvider);
final config = backendConfigAsync.maybeWhen(
data: (value) => value,
orElse: () => null,
);
if (config == null) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
}
if (config.websocketOnly) {
return const SocketTransportAvailability(
allowPolling: false,
allowWebsocketOnly: true,
);
}
if (config.pollingOnly) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: false,
);
}
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
});
// API Service provider with unified auth integration
final apiServiceProvider = Provider<ApiService?>((ref) {
// If reviewer mode is enabled, skip creating ApiService
@@ -242,6 +321,8 @@ class SocketServiceManager extends _$SocketServiceManager {
appSettingsProvider.select((settings) => settings.socketTransportMode),
);
final websocketOnly = transportMode == 'ws';
final transportAvailability = ref.watch(socketTransportOptionsProvider);
final allowWebsocketUpgrade = transportAvailability.allowWebsocketOnly;
// Don't watch authTokenProvider3 here to avoid rebuilding on token changes
// Token updates are handled via the subscription below
@@ -250,13 +331,15 @@ class SocketServiceManager extends _$SocketServiceManager {
final requiresNewService =
_service == null ||
_service!.serverConfig.id != server.id ||
_service!.websocketOnly != websocketOnly;
_service!.websocketOnly != websocketOnly ||
_service!.allowWebsocketUpgrade != allowWebsocketUpgrade;
if (requiresNewService) {
_disposeService();
_service = SocketService(
serverConfig: server,
authToken: token,
websocketOnly: websocketOnly,
allowWebsocketUpgrade: allowWebsocketUpgrade,
);
_scheduleConnect(_service!);
} else {

View File

@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
// import 'package:http_parser/http_parser.dart';
// Removed legacy websocket/socket.io imports
import 'package:uuid/uuid.dart';
import '../models/backend_config.dart';
import '../models/server_config.dart';
import '../models/user.dart';
import '../models/model.dart';
@@ -253,6 +254,44 @@ class ApiService {
return result;
}
Future<BackendConfig?> getBackendConfig() async {
try {
final response = await _dio.get('/api/config');
final data = response.data;
Map<String, dynamic>? jsonMap;
if (data is Map<String, dynamic>) {
jsonMap = data;
} else if (data is String && data.isNotEmpty) {
final decoded = json.decode(data);
if (decoded is Map<String, dynamic>) {
jsonMap = decoded;
}
}
if (jsonMap == null) {
return null;
}
return BackendConfig.fromJson(jsonMap);
} on DioException catch (e, stackTrace) {
_traceApi('Backend config request failed: $e');
DebugLogger.error(
'backend-config-error',
scope: 'api/config',
error: e,
stackTrace: stackTrace,
);
rethrow;
} catch (e, stackTrace) {
_traceApi('Backend config decode error: $e');
DebugLogger.error(
'backend-config-decode',
scope: 'api/config',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
// Authentication
Future<Map<String, dynamic>> login(String username, String password) async {
try {

View File

@@ -26,7 +26,7 @@ class SettingsService {
static const String _voiceAutoSendKey = PreferenceKeys.voiceAutoSendFinal;
// Realtime transport preference
static const String _socketTransportModeKey =
PreferenceKeys.socketTransportMode; // 'auto' or 'ws'
PreferenceKeys.socketTransportMode; // 'polling' or 'ws'
// Quick pill visibility selections (max 2)
static const String _quickPillsKey = PreferenceKeys
.quickPills; // StringList of identifiers e.g. ['web','image','tools']
@@ -256,15 +256,27 @@ class SettingsService {
return _preferencesBox().put(_voiceAutoSendKey, value);
}
/// Transport mode: 'auto' (polling+websocket) or 'ws' (websocket only)
/// Transport mode: 'polling' (HTTP polling + WebSocket upgrade) or 'ws'
static Future<String> getSocketTransportMode() {
final value = _preferencesBox().get(_socketTransportModeKey) as String?;
return Future.value(value ?? 'ws');
final raw = _preferencesBox().get(_socketTransportModeKey) as String?;
if (raw == null) {
return Future.value('ws');
}
if (raw == 'auto') {
return Future.value('polling');
}
if (raw != 'polling' && raw != 'ws') {
return Future.value('ws');
}
return Future.value(raw);
}
static Future<void> setSocketTransportMode(String mode) {
if (mode != 'auto' && mode != 'ws') {
mode = 'auto';
if (mode == 'auto') {
mode = 'polling';
}
if (mode != 'polling' && mode != 'ws') {
mode = 'polling';
}
return _preferencesBox().put(_socketTransportModeKey, mode);
}
@@ -344,7 +356,7 @@ class AppSettings {
final String? voiceLocaleId;
final bool voiceHoldToTalk;
final bool voiceAutoSendFinal;
final String socketTransportMode; // 'auto' or 'ws'
final String socketTransportMode; // 'polling' or 'ws'
final List<String> quickPills; // e.g., ['web','image']
final bool sendOnEnter;
final String? ttsVoice;
@@ -566,8 +578,17 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
}
Future<void> setSocketTransportMode(String mode) async {
state = state.copyWith(socketTransportMode: mode);
await SettingsService.setSocketTransportMode(mode);
var sanitized = mode;
if (sanitized == 'auto') {
sanitized = 'polling';
}
if (sanitized != 'polling' && sanitized != 'ws') {
sanitized = 'polling';
}
if (state.socketTransportMode != sanitized) {
state = state.copyWith(socketTransportMode: sanitized);
}
await SettingsService.setSocketTransportMode(sanitized);
}
Future<void> setQuickPills(List<String> pills) async {

View File

@@ -22,6 +22,7 @@ typedef SocketChannelEventHandler =
class SocketService with WidgetsBindingObserver {
final ServerConfig serverConfig;
final bool websocketOnly;
final bool allowWebsocketUpgrade;
io.Socket? _socket;
String? _authToken;
bool _isAppForeground = true;
@@ -34,6 +35,7 @@ class SocketService with WidgetsBindingObserver {
required this.serverConfig,
String? authToken,
this.websocketOnly = false,
this.allowWebsocketUpgrade = true,
}) : _authToken = authToken {
final binding = WidgetsBinding.instance;
final lifecycle = binding.lifecycleState;
@@ -72,11 +74,18 @@ class SocketService with WidgetsBindingObserver {
} catch (_) {}
final path = '/ws/socket.io';
final usePollingOnly = !websocketOnly && !allowWebsocketUpgrade;
final transports = websocketOnly
? const ['websocket']
: usePollingOnly
? const ['polling']
: const ['polling', 'websocket'];
final builder = io.OptionBuilder()
// Transport selection - WebSocket only, no polling fallback
.setTransports(['websocket'])
.setRememberUpgrade(false)
.setUpgrade(false)
// Transport selection switches between WebSocket-only and polling fallback
.setTransports(transports)
.setRememberUpgrade(!websocketOnly && allowWebsocketUpgrade)
.setUpgrade(!websocketOnly && allowWebsocketUpgrade)
// Tune reconnect/backoff and timeouts
.setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts
.setReconnectionDelay(1000)