feat: inactivity watchdog for sockets
This commit is contained in:
@@ -333,7 +333,9 @@ class PersistentStreamingService with WidgetsBindingObserver {
|
|||||||
final lastUpdate = metadata['lastUpdate'] as DateTime?;
|
final lastUpdate = metadata['lastUpdate'] as DateTime?;
|
||||||
if (lastUpdate != null) {
|
if (lastUpdate != null) {
|
||||||
final timeSinceUpdate = DateTime.now().difference(lastUpdate);
|
final timeSinceUpdate = DateTime.now().difference(lastUpdate);
|
||||||
return timeSinceUpdate > const Duration(minutes: 1);
|
// Align with app-side watchdogs: be less aggressive than UI guard
|
||||||
|
// but still attempt recovery before server timeouts become likely.
|
||||||
|
return timeSinceUpdate > const Duration(minutes: 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
83
lib/core/utils/inactivity_watchdog.dart
Normal file
83
lib/core/utils/inactivity_watchdog.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// A simple activity-based watchdog.
|
||||||
|
///
|
||||||
|
/// Call [ping] whenever activity occurs. If no activity happens
|
||||||
|
/// within [window], [onTimeout] fires. Optionally, an [absoluteCap]
|
||||||
|
/// enforces a maximum total duration regardless of activity.
|
||||||
|
class InactivityWatchdog {
|
||||||
|
InactivityWatchdog({
|
||||||
|
required Duration window,
|
||||||
|
required this.onTimeout,
|
||||||
|
Duration? absoluteCap,
|
||||||
|
}) : _window = window,
|
||||||
|
_absoluteCap = absoluteCap;
|
||||||
|
|
||||||
|
final void Function() onTimeout;
|
||||||
|
|
||||||
|
Duration _window;
|
||||||
|
Duration? _absoluteCap;
|
||||||
|
Timer? _timer;
|
||||||
|
Timer? _absoluteTimer;
|
||||||
|
bool _started = false;
|
||||||
|
|
||||||
|
Duration get window => _window;
|
||||||
|
|
||||||
|
void setWindow(Duration newWindow) {
|
||||||
|
_window = newWindow;
|
||||||
|
if (_started) {
|
||||||
|
// Restart timer with new window
|
||||||
|
_restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAbsoluteCap(Duration? cap) {
|
||||||
|
_absoluteCap = cap;
|
||||||
|
if (_started) {
|
||||||
|
_absoluteTimer?.cancel();
|
||||||
|
if (_absoluteCap != null) {
|
||||||
|
_absoluteTimer = Timer(_absoluteCap!, _fire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
_restart();
|
||||||
|
if (_absoluteCap != null) {
|
||||||
|
_absoluteTimer = Timer(_absoluteCap!, _fire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ping() {
|
||||||
|
if (!_started) {
|
||||||
|
start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
_absoluteTimer?.cancel();
|
||||||
|
_absoluteTimer = null;
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => stop();
|
||||||
|
|
||||||
|
void _restart() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = Timer(_window, _fire);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fire() {
|
||||||
|
stop();
|
||||||
|
try {
|
||||||
|
onTimeout();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ import '../../../core/auth/auth_state_manager.dart';
|
|||||||
import '../../../core/utils/stream_chunker.dart';
|
import '../../../core/utils/stream_chunker.dart';
|
||||||
import '../../../core/services/persistent_streaming_service.dart';
|
import '../../../core/services/persistent_streaming_service.dart';
|
||||||
import '../../../core/utils/debug_logger.dart';
|
import '../../../core/utils/debug_logger.dart';
|
||||||
|
import '../../../core/utils/inactivity_watchdog.dart';
|
||||||
import '../services/reviewer_mode_service.dart';
|
import '../services/reviewer_mode_service.dart';
|
||||||
import '../../../shared/services/tasks/task_queue.dart';
|
import '../../../shared/services/tasks/task_queue.dart';
|
||||||
import '../../tools/providers/tools_providers.dart';
|
import '../../tools/providers/tools_providers.dart';
|
||||||
@@ -40,8 +41,8 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
StreamSubscription? _messageStream;
|
StreamSubscription? _messageStream;
|
||||||
ProviderSubscription? _conversationListener;
|
ProviderSubscription? _conversationListener;
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
// Inactivity watchdog to prevent stuck typing indicator
|
// Activity-based watchdog to prevent stuck typing indicator
|
||||||
Timer? _typingStuckGuard;
|
InactivityWatchdog? _typingWatchdog;
|
||||||
|
|
||||||
ChatMessagesNotifier(this._ref) : super([]) {
|
ChatMessagesNotifier(this._ref) : super([]) {
|
||||||
// Load messages when conversation changes with proper cleanup
|
// Load messages when conversation changes with proper cleanup
|
||||||
@@ -115,15 +116,16 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _cancelTypingGuard() {
|
void _cancelTypingGuard() {
|
||||||
_typingStuckGuard?.cancel();
|
_typingWatchdog?.stop();
|
||||||
_typingStuckGuard = null;
|
_typingWatchdog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleTypingGuard({Duration? timeout}) {
|
void _scheduleTypingGuard({Duration? timeout}) {
|
||||||
// Default timeout tuned to balance long tool gaps and UX
|
// Default timeout tuned to balance long tool gaps and UX
|
||||||
final effectiveTimeout = timeout ?? const Duration(seconds: 25);
|
final effectiveTimeout = timeout ?? const Duration(seconds: 25);
|
||||||
_typingStuckGuard?.cancel();
|
_typingWatchdog ??= InactivityWatchdog(
|
||||||
_typingStuckGuard = Timer(effectiveTimeout, () async {
|
window: effectiveTimeout,
|
||||||
|
onTimeout: () async {
|
||||||
try {
|
try {
|
||||||
if (state.isEmpty) return;
|
if (state.isEmpty) return;
|
||||||
final last = state.last;
|
final last = state.last;
|
||||||
@@ -167,7 +169,8 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
final history = chatObj['history'];
|
final history = chatObj['history'];
|
||||||
if (history is Map && history['messages'] is Map) {
|
if (history is Map && history['messages'] is Map) {
|
||||||
final Map<String, dynamic> messagesMap =
|
final Map<String, dynamic> messagesMap =
|
||||||
(history['messages'] as Map).cast<String, dynamic>();
|
(history['messages'] as Map)
|
||||||
|
.cast<String, dynamic>();
|
||||||
final msg = messagesMap[msgId];
|
final msg = messagesMap[msgId];
|
||||||
if (msg is Map) {
|
if (msg is Map) {
|
||||||
final rawContent = msg['content'];
|
final rawContent = msg['content'];
|
||||||
@@ -199,7 +202,10 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
} finally {
|
} finally {
|
||||||
_cancelTypingGuard();
|
_cancelTypingGuard();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
_typingWatchdog!.setWindow(effectiveTimeout);
|
||||||
|
_typingWatchdog!.ping();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _touchStreamingActivity() {
|
void _touchStreamingActivity() {
|
||||||
@@ -222,20 +228,17 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
|||||||
final webSearchAvailable = _ref.read(webSearchAvailableProvider);
|
final webSearchAvailable = _ref.read(webSearchAvailableProvider);
|
||||||
final globalImageGen = _ref.read(imageGenerationEnabledProvider);
|
final globalImageGen = _ref.read(imageGenerationEnabledProvider);
|
||||||
|
|
||||||
|
// Extend guard windows to tolerate long reasoning/tools (> 1 min)
|
||||||
if (isWebSearchFlow || (globalWebSearch && webSearchAvailable)) {
|
if (isWebSearchFlow || (globalWebSearch && webSearchAvailable)) {
|
||||||
timeout = Duration(
|
if (timeout.inSeconds < 60) timeout = const Duration(seconds: 60);
|
||||||
milliseconds: timeout.inMilliseconds.clamp(0, 45000),
|
|
||||||
);
|
|
||||||
// If current < 45s, bump to 45s
|
|
||||||
if (timeout.inSeconds < 45) timeout = const Duration(seconds: 45);
|
|
||||||
}
|
}
|
||||||
if (isBgFlow) {
|
if (isBgFlow) {
|
||||||
// Background tools/dynamic channel can be longer
|
// Background tools/dynamic channel can be much longer
|
||||||
if (timeout.inSeconds < 60) timeout = const Duration(seconds: 60);
|
if (timeout.inSeconds < 120) timeout = const Duration(seconds: 120);
|
||||||
}
|
}
|
||||||
if (isImageGenFlow || globalImageGen) {
|
if (isImageGenFlow || globalImageGen) {
|
||||||
// Image generation tends to be the longest
|
// Image generation tends to be the longest
|
||||||
if (timeout.inSeconds < 90) timeout = const Duration(seconds: 90);
|
if (timeout.inSeconds < 180) timeout = const Duration(seconds: 180);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
@@ -762,7 +765,7 @@ Future<void> regenerateMessage(
|
|||||||
final bool isLastUser = (i == messages.length - 1) && msg.role == 'user';
|
final bool isLastUser = (i == messages.length - 1) && msg.role == 'user';
|
||||||
final List<String> messageAttachments =
|
final List<String> messageAttachments =
|
||||||
(isLastUser && (attachments != null && attachments.isNotEmpty))
|
(isLastUser && (attachments != null && attachments.isNotEmpty))
|
||||||
? List<String>.from(attachments!)
|
? List<String>.from(attachments)
|
||||||
: (msg.attachmentIds ?? const <String>[]);
|
: (msg.attachmentIds ?? const <String>[]);
|
||||||
|
|
||||||
if (messageAttachments.isNotEmpty) {
|
if (messageAttachments.isNotEmpty) {
|
||||||
@@ -1468,6 +1471,26 @@ Future<void> _sendMessageInternal(
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (socketService != null) {
|
if (socketService != null) {
|
||||||
|
// Activity-based watchdog for chat/channel events (resets on activity)
|
||||||
|
final _chatWatchdog = InactivityWatchdog(
|
||||||
|
window: const Duration(minutes: 5),
|
||||||
|
onTimeout: () {
|
||||||
|
try {
|
||||||
|
socketService.offChatEvents();
|
||||||
|
socketService.offChannelEvents();
|
||||||
|
} catch (_) {}
|
||||||
|
// As a final safeguard, if we're still in streaming state, finish it
|
||||||
|
try {
|
||||||
|
final msgs = ref.read(chatMessagesProvider);
|
||||||
|
if (msgs.isNotEmpty &&
|
||||||
|
msgs.last.role == 'assistant' &&
|
||||||
|
msgs.last.isStreaming) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
)..start();
|
||||||
|
|
||||||
void chatHandler(Map<String, dynamic> ev) {
|
void chatHandler(Map<String, dynamic> ev) {
|
||||||
try {
|
try {
|
||||||
final data = ev['data'];
|
final data = ev['data'];
|
||||||
@@ -1475,6 +1498,9 @@ Future<void> _sendMessageInternal(
|
|||||||
final type = data['type'];
|
final type = data['type'];
|
||||||
final payload = data['data'];
|
final payload = data['data'];
|
||||||
DebugLogger.stream('Socket chat-events: type=$type');
|
DebugLogger.stream('Socket chat-events: type=$type');
|
||||||
|
// Any chat event indicates activity; reset inactivity watchdog
|
||||||
|
// (watchdog defined below, near handler registration)
|
||||||
|
_chatWatchdog.ping();
|
||||||
if (type == 'chat:completion' && payload != null) {
|
if (type == 'chat:completion' && payload != null) {
|
||||||
if (payload is Map<String, dynamic>) {
|
if (payload is Map<String, dynamic>) {
|
||||||
// Provider may emit tool_calls at the top level
|
// Provider may emit tool_calls at the top level
|
||||||
@@ -1591,6 +1617,10 @@ Future<void> _sendMessageInternal(
|
|||||||
try {
|
try {
|
||||||
socketService.offChatEvents();
|
socketService.offChatEvents();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
_chatWatchdog.ping(); // ensure timer exists
|
||||||
|
_chatWatchdog.stop();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Notify server that chat is completed (mirrors web client)
|
// Notify server that chat is completed (mirrors web client)
|
||||||
try {
|
try {
|
||||||
@@ -1703,6 +1733,9 @@ Future<void> _sendMessageInternal(
|
|||||||
}
|
}
|
||||||
// Normal path: finish now
|
// Normal path: finish now
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
try {
|
||||||
|
_chatWatchdog.stop();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type == 'request:chat:completion' && payload != null) {
|
} else if (type == 'request:chat:completion' && payload != null) {
|
||||||
@@ -1722,6 +1755,10 @@ Future<void> _sendMessageInternal(
|
|||||||
try {
|
try {
|
||||||
if (line is String) {
|
if (line is String) {
|
||||||
final s = line.trim();
|
final s = line.trim();
|
||||||
|
// Dynamic channel activity
|
||||||
|
try {
|
||||||
|
_chatWatchdog.ping();
|
||||||
|
} catch (_) {}
|
||||||
DebugLogger.stream(
|
DebugLogger.stream(
|
||||||
'Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}…' : s}',
|
'Socket [$channel] line=${s.length > 160 ? '${s.substring(0, 160)}…' : s}',
|
||||||
);
|
);
|
||||||
@@ -1988,27 +2025,15 @@ Future<void> _sendMessageInternal(
|
|||||||
.read(chatMessagesProvider.notifier)
|
.read(chatMessagesProvider.notifier)
|
||||||
.appendToLastMessage(content);
|
.appendToLastMessage(content);
|
||||||
_updateImagesFromCurrentContent(ref);
|
_updateImagesFromCurrentContent(ref);
|
||||||
|
_chatWatchdog.ping();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
socketService.onChannelEvents(channelEventsHandler);
|
socketService.onChannelEvents(channelEventsHandler);
|
||||||
Future.delayed(const Duration(seconds: 90), () {
|
// Start activity watchdog
|
||||||
try {
|
_chatWatchdog.ping();
|
||||||
socketService.offChatEvents();
|
|
||||||
socketService.offChannelEvents();
|
|
||||||
} catch (_) {}
|
|
||||||
// As a final safeguard, if we're still in streaming state, finish it to avoid stuck UI
|
|
||||||
try {
|
|
||||||
final msgs = ref.read(chatMessagesProvider);
|
|
||||||
if (msgs.isNotEmpty &&
|
|
||||||
msgs.last.role == 'assistant' &&
|
|
||||||
msgs.last.isStreaming) {
|
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare streaming and background handling
|
// Prepare streaming and background handling
|
||||||
@@ -2063,8 +2088,17 @@ Future<void> _sendMessageInternal(
|
|||||||
|
|
||||||
// Helpers were defined above
|
// Helpers were defined above
|
||||||
|
|
||||||
|
int _chunkSeq = 0;
|
||||||
final streamSubscription = persistentController.stream.listen(
|
final streamSubscription = persistentController.stream.listen(
|
||||||
(chunk) {
|
(chunk) {
|
||||||
|
_chunkSeq += 1;
|
||||||
|
try {
|
||||||
|
persistentService.updateStreamProgress(
|
||||||
|
streamId,
|
||||||
|
chunkSequence: _chunkSeq,
|
||||||
|
appendedContent: chunk,
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
var effectiveChunk = chunk;
|
var effectiveChunk = chunk;
|
||||||
// Check for web search indicators in the stream
|
// Check for web search indicators in the stream
|
||||||
if (webSearchEnabled && !isSearching) {
|
if (webSearchEnabled && !isSearching) {
|
||||||
@@ -2960,11 +2994,32 @@ void _attachSocketStreamingHandlers({
|
|||||||
|
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
|
|
||||||
|
// Activity-based watchdog for socket-driven streaming (resets on activity)
|
||||||
|
final _socketWatchdog = InactivityWatchdog(
|
||||||
|
window: const Duration(minutes: 5),
|
||||||
|
onTimeout: () {
|
||||||
|
try {
|
||||||
|
socketService.offChatEvents();
|
||||||
|
socketService.offChannelEvents();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
final msgs = ref.read(chatMessagesProvider);
|
||||||
|
if (msgs.isNotEmpty &&
|
||||||
|
msgs.last.role == 'assistant' &&
|
||||||
|
msgs.last.isStreaming) {
|
||||||
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
)..start();
|
||||||
|
|
||||||
void channelLineHandlerFactory(String channel) {
|
void channelLineHandlerFactory(String channel) {
|
||||||
void handler(dynamic line) {
|
void handler(dynamic line) {
|
||||||
try {
|
try {
|
||||||
if (line is String) {
|
if (line is String) {
|
||||||
final s = line.trim();
|
final s = line.trim();
|
||||||
|
// Any socket line is activity
|
||||||
|
_socketWatchdog.ping();
|
||||||
if (s == '[DONE]' || s == 'DONE') {
|
if (s == '[DONE]' || s == 'DONE') {
|
||||||
try {
|
try {
|
||||||
socketService.offEvent(channel);
|
socketService.offEvent(channel);
|
||||||
@@ -2982,6 +3037,7 @@ void _attachSocketStreamingHandlers({
|
|||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
_socketWatchdog.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (s.startsWith('data:')) {
|
if (s.startsWith('data:')) {
|
||||||
@@ -3003,6 +3059,7 @@ void _attachSocketStreamingHandlers({
|
|||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
_socketWatchdog.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -3065,11 +3122,13 @@ void _attachSocketStreamingHandlers({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (line is Map) {
|
} else if (line is Map) {
|
||||||
|
_socketWatchdog.ping();
|
||||||
if (line['done'] == true) {
|
if (line['done'] == true) {
|
||||||
try {
|
try {
|
||||||
socketService.offEvent(channel);
|
socketService.offEvent(channel);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||||
|
_socketWatchdog.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3077,11 +3136,8 @@ void _attachSocketStreamingHandlers({
|
|||||||
}
|
}
|
||||||
|
|
||||||
socketService.onEvent(channel, handler);
|
socketService.onEvent(channel, handler);
|
||||||
Future.delayed(const Duration(minutes: 3), () {
|
// Start activity watchdog now that handler is attached
|
||||||
try {
|
_socketWatchdog.ping();
|
||||||
socketService.offEvent(channel);
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void chatHandler(Map<String, dynamic> ev) {
|
void chatHandler(Map<String, dynamic> ev) {
|
||||||
@@ -3175,6 +3231,9 @@ void _attachSocketStreamingHandlers({
|
|||||||
try {
|
try {
|
||||||
socketService.offChatEvents();
|
socketService.offChatEvents();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
_socketWatchdog.stop();
|
||||||
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
unawaited(
|
unawaited(
|
||||||
api
|
api
|
||||||
@@ -3326,20 +3385,8 @@ void _attachSocketStreamingHandlers({
|
|||||||
|
|
||||||
socketService.onChatEvents(chatHandler);
|
socketService.onChatEvents(chatHandler);
|
||||||
socketService.onChannelEvents(channelEventsHandler);
|
socketService.onChannelEvents(channelEventsHandler);
|
||||||
Future.delayed(const Duration(seconds: 90), () {
|
// Start activity watchdog for chat/channel events
|
||||||
try {
|
_socketWatchdog.ping();
|
||||||
socketService.offChatEvents();
|
|
||||||
socketService.offChannelEvents();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
final msgs = ref.read(chatMessagesProvider);
|
|
||||||
if (msgs.isNotEmpty &&
|
|
||||||
msgs.last.role == 'assistant' &&
|
|
||||||
msgs.last.isStreaming) {
|
|
||||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Tool Servers (OpenAPI) Helpers ==========
|
// ========== Tool Servers (OpenAPI) Helpers ==========
|
||||||
|
|||||||
Reference in New Issue
Block a user