feat(transport): Improve socket transport mode selection and localization
This commit is contained in:
53
lib/core/models/backend_config.dart
Normal file
53
lib/core/models/backend_config.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user