Add generation status and cancel control

This commit is contained in:
Hamza Ayed
2026-10-01 13:18:49 +03:00
parent 7a042d036c
commit 6b271ad499
7 changed files with 601 additions and 203 deletions
@@ -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,
@@ -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),