|
|
|
|
@ -13,6 +13,7 @@ import 'package:test_sa/controllers/api_routes/api_manager.dart';
|
|
|
|
|
import 'package:test_sa/controllers/api_routes/urls.dart';
|
|
|
|
|
|
|
|
|
|
import '../model/copilot_message_model.dart';
|
|
|
|
|
import '../utils/resilient_signalr_http_client.dart';
|
|
|
|
|
|
|
|
|
|
enum PttStatus { idle, connecting, recording, thinking, playingAudio, error }
|
|
|
|
|
|
|
|
|
|
@ -369,14 +370,45 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
|
|
|
|
|
final token = ApiManager.instance.user?.token ?? '';
|
|
|
|
|
final url = "${URLs.copilotHubUrl}?access_token=$token";
|
|
|
|
|
_pttLog('connectPushToTalkHub: building connection url=$url');
|
|
|
|
|
_pttLog('connectPushToTalkHub: building connection url=${URLs.copilotHubUrl}');
|
|
|
|
|
final httpOp = HttpConnectionOptions(
|
|
|
|
|
// Server only accepts LongPolling.
|
|
|
|
|
transport: HttpTransportType.LongPolling,
|
|
|
|
|
skipNegotiation: false,
|
|
|
|
|
logMessageContent: true,
|
|
|
|
|
// Never log message content: it contains base64 audio and is very heavy.
|
|
|
|
|
logMessageContent: false,
|
|
|
|
|
headers: MessageHeaders()..setHeaderValue('Authorization', 'Bearer $token'),
|
|
|
|
|
// Keeps the long-poll alive across poll timeouts / network blips instead
|
|
|
|
|
// of closing the whole connection (signalr_netcore bug workaround).
|
|
|
|
|
httpClient: ResilientSignalRHttpClient(),
|
|
|
|
|
);
|
|
|
|
|
_pttHub = HubConnectionBuilder().withUrl(url, options: httpOp).withAutomaticReconnect(retryDelays: <int>[2000, 5000, 10000, 20000]).build();
|
|
|
|
|
final hub = HubConnectionBuilder().withUrl(url, options: httpOp).withAutomaticReconnect(retryDelays: <int>[0, 2000, 5000, 10000, 20000]).build();
|
|
|
|
|
// More tolerant timeouts for mobile networks (defaults are 30s / 15s).
|
|
|
|
|
hub.serverTimeoutInMilliseconds = 60000;
|
|
|
|
|
hub.keepAliveIntervalInMilliseconds = 15000;
|
|
|
|
|
_pttHub = hub;
|
|
|
|
|
|
|
|
|
|
hub.onreconnecting(({Exception? error}) {
|
|
|
|
|
if (!identical(hub, _pttHub)) return;
|
|
|
|
|
_pttLog('hub reconnecting error=$error');
|
|
|
|
|
_handleConnectionInterrupted("Connection lost. Reconnecting…");
|
|
|
|
|
});
|
|
|
|
|
hub.onreconnected(({String? connectionId}) {
|
|
|
|
|
if (!identical(hub, _pttHub)) return;
|
|
|
|
|
_pttLog('hub reconnected connectionId=$connectionId');
|
|
|
|
|
// Server-side recording state is bound to the old connection id, so the
|
|
|
|
|
// user must start a new turn.
|
|
|
|
|
if (pttStatus == PttStatus.error || pttStatus == PttStatus.connecting) {
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
}
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
|
|
|
|
hub.onclose(({Exception? error}) {
|
|
|
|
|
if (!identical(hub, _pttHub)) return; // intentional stop of an old hub
|
|
|
|
|
_pttLog('hub closed error=$error');
|
|
|
|
|
_handleConnectionInterrupted("Connection lost. Hold the mic to reconnect.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
_pttHub!.on("RecordingStarted", _onRecordingStarted);
|
|
|
|
|
_pttHub!.on("RecordingStopped", _onRecordingStopped);
|
|
|
|
|
@ -420,20 +452,69 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
/// Stops mic streaming and tears down the hub. Does NOT touch [_pttOpQueue]
|
|
|
|
|
/// so it is safe to call from inside queued operations.
|
|
|
|
|
Future<void> _stopHubAndCleanup() async {
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
// Detach first so callbacks (onclose) from this hub are ignored.
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
_pttHub = null;
|
|
|
|
|
try {
|
|
|
|
|
await _abortMicStreaming();
|
|
|
|
|
if (_pttHub != null) {
|
|
|
|
|
await _pttHub!.stop().timeout(const Duration(seconds: 10), onTimeout: () {
|
|
|
|
|
if (hub != null) {
|
|
|
|
|
await hub.stop().timeout(const Duration(seconds: 10), onTimeout: () {
|
|
|
|
|
_pttLog('_stopHubAndCleanup: stop() timed out, forcing cleanup');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_stopHubAndCleanup: error=$e');
|
|
|
|
|
} finally {
|
|
|
|
|
_pttHub = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Called when the hub drops (reconnecting/closed). Stops the mic and frees
|
|
|
|
|
/// the UI so it never stays stuck in recording/thinking forever.
|
|
|
|
|
void _handleConnectionInterrupted(String message) {
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_pttButtonHeld = false;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_abortMicStreaming().catchError((_) {});
|
|
|
|
|
if (pttStatus != PttStatus.idle) {
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = message;
|
|
|
|
|
}
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-establishes the connection if it was lost (e.g. after the app was
|
|
|
|
|
/// in background). Safe to call any time.
|
|
|
|
|
Future<void> ensurePushToTalkConnected() async {
|
|
|
|
|
final state = _pttHub?.state;
|
|
|
|
|
if (state == HubConnectionState.Connected || state == HubConnectionState.Connecting || state == HubConnectionState.Reconnecting) return;
|
|
|
|
|
await connectPushToTalkHub();
|
|
|
|
|
if (_pttHub?.state == HubConnectionState.Connected && pttStatus == PttStatus.error) {
|
|
|
|
|
pttStatus = PttStatus.idle;
|
|
|
|
|
pttErrorMessage = null;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Timer? _pttResponseWatchdog;
|
|
|
|
|
static const Duration _responseTimeout = Duration(seconds: 60);
|
|
|
|
|
|
|
|
|
|
void _startResponseWatchdog() {
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_pttResponseWatchdog = Timer(_responseTimeout, () {
|
|
|
|
|
if (pttStatus != PttStatus.thinking) return;
|
|
|
|
|
_pttLog('response watchdog fired: no response from server');
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
pttErrorMessage = "No response from server. Please try again.";
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
notifyListeners();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _cancelResponseWatchdog() {
|
|
|
|
|
_pttResponseWatchdog?.cancel();
|
|
|
|
|
_pttResponseWatchdog = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void startNewPttConversation() {
|
|
|
|
|
pttSessionId = null;
|
|
|
|
|
pttMessages = [];
|
|
|
|
|
@ -458,6 +539,10 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
if (_pttHub == null || _pttHub!.state != HubConnectionState.Connected) {
|
|
|
|
|
await connectPushToTalkHub();
|
|
|
|
|
}
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
if (hub == null || hub.state != HubConnectionState.Connected) {
|
|
|
|
|
throw StateError('Push-to-talk hub is not connected');
|
|
|
|
|
}
|
|
|
|
|
final args = [
|
|
|
|
|
{
|
|
|
|
|
"sessionId": pttSessionId ?? '',
|
|
|
|
|
@ -514,6 +599,9 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
if (_pttHub != null && _pttHub!.state == HubConnectionState.Connected) {
|
|
|
|
|
_pttLog('endPushToTalk: invoking StopRecording');
|
|
|
|
|
await _pttHub!.invoke("StopRecording");
|
|
|
|
|
_startResponseWatchdog();
|
|
|
|
|
} else {
|
|
|
|
|
throw StateError('Connection lost before StopRecording');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('endPushToTalk: failed error=$e');
|
|
|
|
|
@ -526,6 +614,7 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
Future<void> cancelPushToTalk() async {
|
|
|
|
|
_pttButtonHeld = false;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
try {
|
|
|
|
|
await _abortMicStreaming();
|
|
|
|
|
if (_pttHub != null && _pttHub!.state == HubConnectionState.Connected) {
|
|
|
|
|
@ -658,8 +747,10 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _sendAudioChunk(Uint8List chunk) async {
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
if (hub == null || hub.state != HubConnectionState.Connected) return;
|
|
|
|
|
try {
|
|
|
|
|
await _pttHub?.send("SendAudioChunk", args: [base64Encode(chunk)]);
|
|
|
|
|
await hub.send("SendAudioChunk", args: [base64Encode(chunk)]);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
_pttLog('_sendAudioChunk: failed error=$e');
|
|
|
|
|
}
|
|
|
|
|
@ -707,6 +798,7 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
|
|
|
|
|
void _onResponseReceived(List<Object?>? params) {
|
|
|
|
|
_pttLog('event ResponseReceived params=$params');
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
final data = _firstArgAsMap(params);
|
|
|
|
|
final text = data?['voiceAnswer']?.toString() ?? '';
|
|
|
|
|
_pttCurrentAssistantMsg = CopilotMessage(id: UniqueKey().toString(), role: CopilotRole.assistant, content: text, timestamp: DateTime.now());
|
|
|
|
|
@ -736,6 +828,7 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
void _onRecordingError(List<Object?>? params) {
|
|
|
|
|
final msg = (params != null && params.isNotEmpty) ? params.first?.toString() : null;
|
|
|
|
|
_pttLog('event RecordingError message=$msg');
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
pttErrorMessage = msg ?? "Recording error occurred";
|
|
|
|
|
pttStatus = PttStatus.error;
|
|
|
|
|
liveSessionActive = false;
|
|
|
|
|
@ -757,11 +850,14 @@ class CopilotProvider extends ChangeNotifier {
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_streamSub?.cancel();
|
|
|
|
|
_cancelResponseWatchdog();
|
|
|
|
|
_abortMicStreaming();
|
|
|
|
|
if (_pttRecorderOpen) {
|
|
|
|
|
_pttRecorder.closeRecorder();
|
|
|
|
|
}
|
|
|
|
|
_pttHub?.stop();
|
|
|
|
|
final hub = _pttHub;
|
|
|
|
|
_pttHub = null;
|
|
|
|
|
hub?.stop();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|