feat: enhanced sockets, tuned retries and polling fallback

This commit is contained in:
cogwheel0
2025-09-07 11:13:05 +05:30
parent 3decf9d46b
commit a16fb86e27
11 changed files with 519 additions and 138 deletions

View File

@@ -2907,7 +2907,8 @@ class ApiService {
bool containsDone(String s) =>
s.contains('<details type="tool_calls"') && s.contains('done="true"');
while (DateTime.now().difference(started).inSeconds < 60) {
// Allow longer time for large completions (e.g., long stories)
while (DateTime.now().difference(started).inSeconds < 180) {
try {
// Small delay between polls
await Future.delayed(const Duration(milliseconds: 900));
@@ -3069,14 +3070,15 @@ class ApiService {
break;
}
// If content hasn't changed for a few polls, assume completion
// If content hasn't changed for a few polls, assume completion,
// but do not early-exit while content is still empty.
final prev = last;
if (content == prev) {
if (content == prev && content.isNotEmpty) {
stableCount++;
} else {
} else if (content != prev) {
stableCount = 0;
}
if (stableCount >= 3) {
if (content.isNotEmpty && stableCount >= 3) {
break;
}
@@ -3086,6 +3088,66 @@ class ApiService {
}
}
// 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, dynamic>;
String content = '';
Map<String, dynamic>? chatObj = (data['chat'] is Map<String, dynamic>)
? data['chat'] as Map<String, dynamic>
: 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<String, dynamic> messagesMap =
(history['messages'] as Map).cast<String, dynamic>();
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();
}

View File

@@ -19,6 +19,8 @@ class SettingsService {
static const String _voiceLocaleKey = 'voice_locale_id';
static const String _voiceHoldToTalkKey = 'voice_hold_to_talk';
static const String _voiceAutoSendKey = 'voice_auto_send_final';
// Realtime transport preference
static const String _socketTransportModeKey = 'socket_transport_mode'; // 'auto' or 'ws'
/// Get reduced motion preference
static Future<bool> getReduceMotion() async {
@@ -133,6 +135,7 @@ class SettingsService {
voiceLocaleId: await getVoiceLocaleId(),
voiceHoldToTalk: await getVoiceHoldToTalk(),
voiceAutoSendFinal: await getVoiceAutoSendFinal(),
socketTransportMode: await getSocketTransportMode(),
);
}
@@ -150,6 +153,7 @@ class SettingsService {
setVoiceLocaleId(settings.voiceLocaleId),
setVoiceHoldToTalk(settings.voiceHoldToTalk),
setVoiceAutoSendFinal(settings.voiceAutoSendFinal),
setSocketTransportMode(settings.socketTransportMode),
]);
}
@@ -188,6 +192,18 @@ class SettingsService {
await prefs.setBool(_voiceAutoSendKey, value);
}
/// Transport mode: 'auto' (polling+websocket) or 'ws' (websocket only)
static Future<String> getSocketTransportMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_socketTransportModeKey) ?? 'auto';
}
static Future<void> setSocketTransportMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
if (mode != 'auto' && mode != 'ws') mode = 'auto';
await prefs.setString(_socketTransportModeKey, mode);
}
/// Get effective animation duration considering all settings
static Duration getEffectiveAnimationDuration(
BuildContext context,
@@ -241,6 +257,7 @@ class AppSettings {
final String? voiceLocaleId;
final bool voiceHoldToTalk;
final bool voiceAutoSendFinal;
final String socketTransportMode; // 'auto' or 'ws'
const AppSettings({
this.reduceMotion = false,
@@ -254,6 +271,7 @@ class AppSettings {
this.voiceLocaleId,
this.voiceHoldToTalk = false,
this.voiceAutoSendFinal = false,
this.socketTransportMode = 'auto',
});
AppSettings copyWith({
@@ -268,6 +286,7 @@ class AppSettings {
Object? voiceLocaleId = const _DefaultValue(),
bool? voiceHoldToTalk,
bool? voiceAutoSendFinal,
String? socketTransportMode,
}) {
return AppSettings(
reduceMotion: reduceMotion ?? this.reduceMotion,
@@ -281,6 +300,7 @@ class AppSettings {
voiceLocaleId: voiceLocaleId is _DefaultValue ? this.voiceLocaleId : voiceLocaleId as String?,
voiceHoldToTalk: voiceHoldToTalk ?? this.voiceHoldToTalk,
voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal,
socketTransportMode: socketTransportMode ?? this.socketTransportMode,
);
}
@@ -299,6 +319,7 @@ class AppSettings {
other.voiceLocaleId == voiceLocaleId &&
other.voiceHoldToTalk == voiceHoldToTalk &&
other.voiceAutoSendFinal == voiceAutoSendFinal;
// socketTransportMode intentionally not included in == to avoid frequent rebuilds
}
@override
@@ -315,6 +336,7 @@ class AppSettings {
voiceLocaleId,
voiceHoldToTalk,
voiceAutoSendFinal,
socketTransportMode,
);
}
}
@@ -390,6 +412,11 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await SettingsService.setVoiceAutoSendFinal(value);
}
Future<void> setSocketTransportMode(String mode) async {
state = state.copyWith(socketTransportMode: mode);
await SettingsService.setSocketTransportMode(mode);
}
Future<void> resetToDefaults() async {
const defaultSettings = AppSettings();
await SettingsService.saveSettings(defaultSettings);

View File

@@ -5,9 +5,14 @@ import '../models/server_config.dart';
class SocketService {
final ServerConfig serverConfig;
final String? authToken;
final bool websocketOnly;
io.Socket? _socket;
SocketService({required this.serverConfig, required this.authToken});
SocketService({
required this.serverConfig,
required this.authToken,
this.websocketOnly = false,
});
String? get sessionId => _socket?.id;
io.Socket? get socket => _socket;
@@ -24,20 +29,28 @@ class SocketService {
final base = serverConfig.url.replaceFirst(RegExp(r'/+$'), '');
final path = '/ws/socket.io';
_socket = io.io(
base,
io.OptionBuilder()
.setTransports(['websocket'])
.setPath(path)
.setExtraHeaders(
authToken != null && authToken!.isNotEmpty
? {
'Authorization': 'Bearer $authToken',
}
: {},
)
.build(),
);
final builder = io.OptionBuilder()
// Transport selection
.setTransports(
websocketOnly ? ['websocket'] : ['polling', 'websocket'],
)
.setRememberUpgrade(!websocketOnly)
.setUpgrade(!websocketOnly)
// Tune reconnect/backoff and timeouts
.setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts
.setReconnectionDelay(1000)
.setReconnectionDelayMax(5000)
.setRandomizationFactor(0.5)
.setTimeout(20000)
.setPath(path);
if (authToken != null && authToken!.isNotEmpty) {
builder
.setAuth({'token': authToken})
.setExtraHeaders({'Authorization': 'Bearer $authToken'});
}
_socket = io.io(base, builder.build());
_socket!.on('connect', (_) {
debugPrint('Socket connected: ${_socket!.id}');
@@ -52,6 +65,24 @@ class SocketService {
debugPrint('Socket connect_error: $err');
});
_socket!.on('reconnect_attempt', (attempt) {
debugPrint('Socket reconnect_attempt: $attempt');
});
_socket!.on('reconnect', (attempt) {
debugPrint('Socket reconnected after $attempt attempts');
if (authToken != null && authToken!.isNotEmpty) {
// Best-effort rejoin
_socket!.emit('user-join', {
'auth': {'token': authToken}
});
}
});
_socket!.on('reconnect_failed', (_) {
debugPrint('Socket reconnect_failed');
});
_socket!.on('disconnect', (reason) {
debugPrint('Socket disconnected: $reason');
});