Add generation status and cancel control
This commit is contained in:
+139
-10
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../core/network/api_repository.dart';
|
||||
@@ -10,6 +12,43 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
}
|
||||
|
||||
final ApiRepository _api;
|
||||
Timer? _elapsedTimer;
|
||||
Stopwatch? _requestStopwatch;
|
||||
int _requestGeneration = 0;
|
||||
List<ChatMessage> _messagesBeforeRequest = const [];
|
||||
bool _requestAppendedUserMessage = true;
|
||||
bool _requestHasProducedText = false;
|
||||
|
||||
void _startElapsedTimer(int generation) {
|
||||
_elapsedTimer?.cancel();
|
||||
_requestStopwatch = Stopwatch()..start();
|
||||
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!isClosed && generation == _requestGeneration && state.isSending) {
|
||||
emit(state.copyWith(elapsed: _requestStopwatch!.elapsed));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Duration _stopElapsedTimer() {
|
||||
_elapsedTimer?.cancel();
|
||||
_elapsedTimer = null;
|
||||
final duration = _requestStopwatch?.elapsed ?? Duration.zero;
|
||||
_requestStopwatch?.stop();
|
||||
return duration;
|
||||
}
|
||||
|
||||
String _readableError(Object error) => error.toString().replaceFirst(
|
||||
RegExp(r'^(Exception|ClientException):\s*'),
|
||||
'',
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_requestGeneration++;
|
||||
_elapsedTimer?.cancel();
|
||||
_api.cancelActiveRequest();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _loadModelName() async {
|
||||
try {
|
||||
@@ -19,6 +58,7 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
modelName: modelName,
|
||||
modelStatus: ModelConnectionStatus.connected,
|
||||
availableModels: models,
|
||||
selectedModel: state.selectedModel ?? modelName,
|
||||
),
|
||||
@@ -29,6 +69,7 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
modelName: 'تعذر الاتصال بالخادم',
|
||||
modelStatus: ModelConnectionStatus.unavailable,
|
||||
availableModels: const [],
|
||||
),
|
||||
);
|
||||
@@ -143,7 +184,9 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
final messages = List<ChatMessage>.from(state.messages);
|
||||
final message = messages[messageIndex];
|
||||
final versions = message.answerVersions;
|
||||
if (message.fromUser || versionIndex < 0 || versionIndex >= versions.length) {
|
||||
if (message.fromUser ||
|
||||
versionIndex < 0 ||
|
||||
versionIndex >= versions.length) {
|
||||
return;
|
||||
}
|
||||
messages[messageIndex] = ChatMessage(
|
||||
@@ -187,22 +230,36 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
? [...history, ChatMessage(prompt, fromUser: true)]
|
||||
: List<ChatMessage>.from(history);
|
||||
final originalMessages = state.messages;
|
||||
_messagesBeforeRequest = originalMessages;
|
||||
_requestAppendedUserMessage = appendUserMessage;
|
||||
_requestHasProducedText = false;
|
||||
var answer = '';
|
||||
emit(state.copyWith(messages: messages, isSending: true, clearError: true));
|
||||
final generation = ++_requestGeneration;
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: messages,
|
||||
isSending: true,
|
||||
requestStatus: RequestStatus.generating,
|
||||
elapsed: Duration.zero,
|
||||
clearLastResponseDuration: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
try {
|
||||
if (appendUserMessage) await _saveCurrent(messages);
|
||||
if (isClosed) return;
|
||||
if (isClosed || generation != _requestGeneration) return;
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: [...messages, const ChatMessage('', fromUser: false)],
|
||||
),
|
||||
);
|
||||
_startElapsedTimer(generation);
|
||||
if (state.isWorkspaceMode) {
|
||||
answer = await _api.askWorkspaceAgent(
|
||||
prompt,
|
||||
model: state.selectedModel,
|
||||
);
|
||||
if (isClosed) return;
|
||||
if (isClosed || generation != _requestGeneration) return;
|
||||
if (answer.trim().isEmpty) {
|
||||
throw Exception('لم يصل نص في إجابة الوكيل.');
|
||||
}
|
||||
@@ -216,7 +273,16 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
selectedVersion: answerVersions.length - 1,
|
||||
),
|
||||
];
|
||||
emit(state.copyWith(messages: updated, isSending: false));
|
||||
final duration = _stopElapsedTimer();
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: updated,
|
||||
isSending: false,
|
||||
requestStatus: RequestStatus.completed,
|
||||
elapsed: duration,
|
||||
lastResponseDuration: duration,
|
||||
),
|
||||
);
|
||||
await _saveCurrent(updated);
|
||||
return;
|
||||
}
|
||||
@@ -224,8 +290,9 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
messages,
|
||||
model: state.selectedModel,
|
||||
)) {
|
||||
if (isClosed) return;
|
||||
if (isClosed || generation != _requestGeneration) return;
|
||||
answer += delta;
|
||||
if (delta.isNotEmpty) _requestHasProducedText = true;
|
||||
final answerVersions = [...previousAnswers, answer];
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -241,7 +308,7 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isClosed) return;
|
||||
if (isClosed || generation != _requestGeneration) return;
|
||||
if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.');
|
||||
final answerVersions = [...previousAnswers, answer];
|
||||
final updated = [
|
||||
@@ -253,10 +320,20 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
selectedVersion: answerVersions.length - 1,
|
||||
),
|
||||
];
|
||||
emit(state.copyWith(messages: updated, isSending: false));
|
||||
final duration = _stopElapsedTimer();
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: updated,
|
||||
isSending: false,
|
||||
requestStatus: RequestStatus.completed,
|
||||
elapsed: duration,
|
||||
lastResponseDuration: duration,
|
||||
),
|
||||
);
|
||||
await _saveCurrent(updated);
|
||||
} catch (error) {
|
||||
if (isClosed) return;
|
||||
if (isClosed || generation != _requestGeneration) return;
|
||||
final duration = _stopElapsedTimer();
|
||||
final partial =
|
||||
!appendUserMessage
|
||||
? originalMessages
|
||||
@@ -275,7 +352,10 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
state.copyWith(
|
||||
messages: partial,
|
||||
isSending: false,
|
||||
error: error.toString(),
|
||||
requestStatus: RequestStatus.failed,
|
||||
elapsed: duration,
|
||||
lastResponseDuration: duration,
|
||||
error: _readableError(error),
|
||||
),
|
||||
);
|
||||
if (appendUserMessage && answer.isNotEmpty) {
|
||||
@@ -288,6 +368,49 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelGeneration() async {
|
||||
if (!state.isSending) return;
|
||||
_requestGeneration++;
|
||||
_api.cancelActiveRequest();
|
||||
final duration = _stopElapsedTimer();
|
||||
final messages =
|
||||
!_requestAppendedUserMessage && !_requestHasProducedText
|
||||
? List<ChatMessage>.from(_messagesBeforeRequest)
|
||||
: List<ChatMessage>.from(state.messages);
|
||||
if (messages.isNotEmpty &&
|
||||
!messages.last.fromUser &&
|
||||
messages.last.text.isEmpty) {
|
||||
messages.removeLast();
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: messages,
|
||||
isSending: false,
|
||||
requestStatus: RequestStatus.cancelled,
|
||||
elapsed: duration,
|
||||
lastResponseDuration: duration,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
if (messages.isNotEmpty) {
|
||||
try {
|
||||
await _saveCurrent(messages);
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isSending: false,
|
||||
requestStatus: RequestStatus.cancelled,
|
||||
elapsed: duration,
|
||||
lastResponseDuration: duration,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// The visible partial reply stays available when local storage fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openConversation(SavedConversation conversation) async {
|
||||
if (state.isSending) return;
|
||||
try {
|
||||
@@ -296,6 +419,8 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
state.copyWith(
|
||||
messages: fullConversation.messages,
|
||||
activeConversationId: fullConversation.id,
|
||||
requestStatus: RequestStatus.idle,
|
||||
clearLastResponseDuration: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
@@ -310,6 +435,9 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
state.copyWith(
|
||||
messages: const [],
|
||||
clearActiveConversation: true,
|
||||
requestStatus: RequestStatus.idle,
|
||||
elapsed: Duration.zero,
|
||||
clearLastResponseDuration: true,
|
||||
clearError: true,
|
||||
),
|
||||
);
|
||||
@@ -346,6 +474,7 @@ class ChatCubit extends Cubit<ChatState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
apiBaseUrl: _api.baseUrl,
|
||||
modelStatus: ModelConnectionStatus.checking,
|
||||
availableModels: const [],
|
||||
clearSelectedModel: true,
|
||||
clearError: true,
|
||||
|
||||
+31
-8
@@ -1,3 +1,7 @@
|
||||
enum ModelConnectionStatus { checking, connected, unavailable }
|
||||
|
||||
enum RequestStatus { idle, generating, completed, cancelled, failed }
|
||||
|
||||
class ChatMessage {
|
||||
const ChatMessage(
|
||||
this.text, {
|
||||
@@ -18,14 +22,13 @@ class ChatMessage {
|
||||
'selectedVersion': selectedVersion,
|
||||
};
|
||||
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
|
||||
ChatMessage(
|
||||
json['text'] as String,
|
||||
fromUser: json['fromUser'] as bool,
|
||||
answerVersions:
|
||||
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
|
||||
selectedVersion: json['selectedVersion'] as int? ?? 0,
|
||||
);
|
||||
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
|
||||
json['text'] as String,
|
||||
fromUser: json['fromUser'] as bool,
|
||||
answerVersions:
|
||||
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
|
||||
selectedVersion: json['selectedVersion'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class SavedConversation {
|
||||
@@ -80,6 +83,10 @@ class ChatState {
|
||||
this.isWorkspaceMode = false,
|
||||
this.error,
|
||||
this.modelName = 'جارٍ اكتشاف النموذج…',
|
||||
this.modelStatus = ModelConnectionStatus.checking,
|
||||
this.requestStatus = RequestStatus.idle,
|
||||
this.elapsed = Duration.zero,
|
||||
this.lastResponseDuration,
|
||||
this.availableModels = const [],
|
||||
this.selectedModel,
|
||||
required this.apiBaseUrl,
|
||||
@@ -93,6 +100,10 @@ class ChatState {
|
||||
final bool isWorkspaceMode;
|
||||
final String? error;
|
||||
final String modelName;
|
||||
final ModelConnectionStatus modelStatus;
|
||||
final RequestStatus requestStatus;
|
||||
final Duration elapsed;
|
||||
final Duration? lastResponseDuration;
|
||||
final List<String> availableModels;
|
||||
final String? selectedModel;
|
||||
final String apiBaseUrl;
|
||||
@@ -108,6 +119,11 @@ class ChatState {
|
||||
String? error,
|
||||
bool clearError = false,
|
||||
String? modelName,
|
||||
ModelConnectionStatus? modelStatus,
|
||||
RequestStatus? requestStatus,
|
||||
Duration? elapsed,
|
||||
Duration? lastResponseDuration,
|
||||
bool clearLastResponseDuration = false,
|
||||
List<String>? availableModels,
|
||||
String? selectedModel,
|
||||
bool clearSelectedModel = false,
|
||||
@@ -124,6 +140,13 @@ class ChatState {
|
||||
isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
modelName: modelName ?? this.modelName,
|
||||
modelStatus: modelStatus ?? this.modelStatus,
|
||||
requestStatus: requestStatus ?? this.requestStatus,
|
||||
elapsed: elapsed ?? this.elapsed,
|
||||
lastResponseDuration:
|
||||
clearLastResponseDuration
|
||||
? null
|
||||
: (lastResponseDuration ?? this.lastResponseDuration),
|
||||
availableModels: availableModels ?? this.availableModels,
|
||||
selectedModel:
|
||||
clearSelectedModel ? null : (selectedModel ?? this.selectedModel),
|
||||
|
||||
Reference in New Issue
Block a user