feat: enhanced sockets, tuned retries and polling fallback
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user