improvements

main_production_copilot
Sikander Saleem 8 hours ago
parent 5544587543
commit 575c70dd16

@ -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();
}
}

@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/border_beam/src/beam_style.dart';
import 'package:test_sa/border_beam/src/border_beam.dart';
import 'package:test_sa/controllers/providers/api/user_provider.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
@ -19,7 +21,7 @@ class CopilotPushToTalkPage extends StatefulWidget {
State<CopilotPushToTalkPage> createState() => _CopilotPushToTalkPageState();
}
class _CopilotPushToTalkPageState extends State<CopilotPushToTalkPage> {
class _CopilotPushToTalkPageState extends State<CopilotPushToTalkPage> with WidgetsBindingObserver {
final ScrollController _scrollController = ScrollController();
CopilotProvider? _provider;
@ -32,6 +34,7 @@ class _CopilotPushToTalkPageState extends State<CopilotPushToTalkPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<CopilotProvider>();
provider.startNewPttConversation();
@ -39,8 +42,19 @@ class _CopilotPushToTalkPageState extends State<CopilotPushToTalkPage> {
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// OS suspends sockets/mic in background; stop any in-progress turn.
_provider?.cancelPushToTalk().catchError((_) {});
} else if (state == AppLifecycleState.resumed) {
_provider?.ensurePushToTalkConnected().catchError((_) {});
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
_provider?.cancelPushToTalk().catchError((_) {});
_provider?.disconnectPushToTalkHub().catchError((_) {});
@ -147,36 +161,48 @@ class _CopilotPushToTalkPageState extends State<CopilotPushToTalkPage> {
style: AppTextStyles.bodyText2.copyWith(color: AppColor.lightTextColor(context)),
),
16.height,
GestureDetector(
onLongPressStart: isBusy
? null
: (_) {
context.read<CopilotProvider>().beginPushToTalk(context.settingProvider.language!).catchError((_) {});
Container(
height: 100,
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.all(0),
decoration: BoxDecoration(color: AppColor.background(context), shape: BoxShape.circle, border: Border.all(color: Colors.transparent)),
child: BorderBeam(
size: BeamSize.pulseOutside,
colorVariant: BeamColorVariant.colorful,
reserveGlowSpace: false,
borderRadius: 50,
strength: 0.7,
child: GestureDetector(
onLongPressStart: isBusy
? null
: (_) {
context.read<CopilotProvider>().beginPushToTalk(context.settingProvider.language!).catchError((_) {});
},
onLongPressEnd: (_) {
context.read<CopilotProvider>().endPushToTalk().catchError((_) {});
},
onLongPressCancel: () {
context.read<CopilotProvider>().endPushToTalk().catchError((_) {});
},
onLongPressEnd: (_) {
context.read<CopilotProvider>().endPushToTalk().catchError((_) {});
},
onLongPressCancel: () {
context.read<CopilotProvider>().endPushToTalk().catchError((_) {});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: isRecording ? 96 : 80,
width: isRecording ? 96 : 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isRecording ? Colors.red : AppColor.primary10,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: isRecording ? 96 : 80,
width: isRecording ? 96 : 80,
child: isBusy
? Padding(
padding: const EdgeInsets.all(12),
child: ThinkingOrb(
size: 48,
state: ThinkingOrbState.working,
theme: context.isDark ? ThinkingOrbTheme.dark : ThinkingOrbTheme.light,
),
)
: Icon(Icons.mic_rounded, color: Colors.white, size: isRecording ? 44 : 36),
),
),
// "atoms_logo".toSvgAsset().paddingAll(16),
),
child: isBusy
? Padding(
padding: const EdgeInsets.all(12),
child: ThinkingOrb(
size: 48,
state: ThinkingOrbState.working,
theme: !context.isDark ? ThinkingOrbTheme.dark : ThinkingOrbTheme.light,
),
)
: Icon(Icons.mic_rounded, color: Colors.white, size: isRecording ? 44 : 36),
),
),
24.height,

Loading…
Cancel
Save