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
@@ -15,8 +15,14 @@ class ApiRepository {
String _baseUrl;
String? _cachedUserId;
Future<String>? _loadingUserId;
http.Client? _activeRequestClient;
String get baseUrl => _baseUrl;
void cancelActiveRequest() {
_activeRequestClient?.close();
_activeRequestClient = null;
}
String newId() => _uuid.v4();
void setBaseUrl(String value) =>
@@ -172,19 +178,31 @@ class ApiRepository {
}
Future<String> askWorkspaceAgent(String task, {String? model}) async {
final response = await http
.post(
Uri.parse('$_baseUrl/v1/agent/workspace'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'task': task, if (model != null) 'model': model}),
)
.timeout(const Duration(minutes: 10));
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final result = data['result'] as String? ?? '';
final files = (data['files'] as List<dynamic>? ?? const []).cast<String>();
if (files.isEmpty) return result;
return '$result\n\nالمصادر المقروءة: ${files.join('، ')}';
final client = http.Client();
_activeRequestClient = client;
try {
final request =
http.Request('POST', Uri.parse('$_baseUrl/v1/agent/workspace'))
..headers['Content-Type'] = 'application/json'
..body = jsonEncode({
'task': task,
if (model != null) 'model': model,
});
final streamed = await client
.send(request)
.timeout(const Duration(minutes: 10));
final response = await http.Response.fromStream(streamed);
_checkStatus(response);
final data = jsonDecode(response.body) as Map<String, dynamic>;
final result = data['result'] as String? ?? '';
final files =
(data['files'] as List<dynamic>? ?? const []).cast<String>();
if (files.isEmpty) return result;
return '$result\n\nالمصادر المقروءة: ${files.join('، ')}';
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close();
}
}
Stream<String> completeStream(
@@ -192,6 +210,7 @@ class ApiRepository {
String? model,
}) async* {
final client = http.Client();
_activeRequestClient = client;
try {
final request =
http.Request('POST', Uri.parse('$_baseUrl/v1/chat/stream'))
@@ -237,6 +256,7 @@ class ApiRepository {
}
if (!completed) throw Exception('انقطع بث الإجابة قبل اكتماله.');
} finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close();
}
}
@@ -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),
@@ -30,7 +30,8 @@ class _ChatPageState extends State<ChatPage> {
@override
void initState() {
super.initState();
_notificationsEnabled = !kIsWeb &&
_notificationsEnabled =
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
@@ -134,6 +135,13 @@ class _ChatPageState extends State<ChatPage> {
await Share.share(text);
}
String _formatDuration(Duration duration) {
if (duration.inMinutes > 0) {
return '${duration.inMinutes} د ${duration.inSeconds.remainder(60)} ث';
}
return '${(duration.inMilliseconds / 1000).toStringAsFixed(1)} ث';
}
Future<void> _enableNotifications() async {
final allowed =
await CompletionNotificationService.instance.requestPermission();
@@ -145,8 +153,8 @@ class _ChatPageState extends State<ChatPage> {
kIsWeb
? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.'
: allowed
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
),
),
);
@@ -203,17 +211,22 @@ class _ChatPageState extends State<ChatPage> {
listener: (context, state) => _followStreamIfNearby(),
),
BlocListener<ChatCubit, ChatState>(
listenWhen: (previous, current) =>
previous.isSending && !current.isSending,
listenWhen:
(previous, current) => previous.isSending && !current.isSending,
listener: (context, state) {
final succeeded = state.error == null;
final succeeded = state.requestStatus == RequestStatus.completed;
final message = switch (state.requestStatus) {
RequestStatus.completed => 'اكتملت الإجابة.',
RequestStatus.cancelled => 'تم إيقاف التوليد.',
RequestStatus.failed =>
'فشل الطلب: ${state.error ?? 'سبب غير معروف'}',
_ => 'انتهى الطلب.',
};
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
succeeded ? 'اكتملت الإجابة.' : 'انتهى الطلب مع خطأ.',
),
content: Text(message),
duration: const Duration(seconds: 3),
),
);
@@ -385,15 +398,36 @@ class _ChatPageState extends State<ChatPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
Row(
children: [
Icon(Icons.circle, size: 9, color: _green),
SizedBox(width: 7),
Text(
'نموذج محلي',
style: TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
Icon(
Icons.circle,
size: 9,
color:
chat.modelStatus == ModelConnectionStatus.connected
? _green
: chat.modelStatus ==
ModelConnectionStatus.checking
? Colors.orange
: Colors.red,
),
const SizedBox(width: 7),
Flexible(
child: Text(
chat.isSending
? 'النموذج يولّد الآن'
: chat.modelStatus ==
ModelConnectionStatus.connected
? 'النموذج متصل'
: chat.modelStatus == ModelConnectionStatus.checking
? 'جارٍ الاتصال'
: 'النموذج غير متصل',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
],
@@ -453,143 +487,169 @@ class _ChatPageState extends State<ChatPage> {
Widget _topBar() => LayoutBuilder(
builder: (context, constraints) {
final chat = context.watch<ChatCubit>().state;
final compact = constraints.maxWidth < 640;
return Container(
height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
if (!compact) ...[
const SizedBox(height: 3),
Text(
chat.isSending
? 'جارٍ التوليد · ${_formatDuration(chat.elapsed)} · ${chat.modelName}'
: chat.modelStatus == ModelConnectionStatus.connected
? 'متصل · ${chat.modelName} · تفريغ الصوت عبر Groq'
: chat.modelStatus == ModelConnectionStatus.checking
? 'جارٍ الاتصال بالخادم…'
: 'غير متصل · ${chat.apiBaseUrl}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
chat.modelStatus == ModelConnectionStatus.connected
? Color(0xFF51806F)
: Color(0xFF9C3827),
fontSize: 11,
),
),
],
],
),
if (!compact) ...[
const SizedBox(height: 3),
const Text(
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11),
),
],
],
),
),
SizedBox(width: compact ? 4 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode ? _green : _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 112 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context.watch<ChatCubit>().state.availableModels.contains(
context.watch<ChatCubit>().state.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
),
SizedBox(width: compact ? 2 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode
? _green
: _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 100 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context
.watch<ChatCubit>()
.state
.availableModels
.contains(
context
.watch<ChatCubit>()
.state
.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: EdgeInsets.symmetric(horizontal: compact ? 7 : 11, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.circle, size: 7, color: _green),
SizedBox(width: 7),
Text(
'محلي',
style: TextStyle(
color: _green,
fontSize: 11,
fontWeight: FontWeight.w600,
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
),
],
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 11,
vertical: 7,
),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.circle, size: 7, color: _green),
SizedBox(width: 7),
Text(
'محلي',
style: TextStyle(
color: _green,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip:
_notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip: _notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
);
);
},
);
@@ -675,7 +735,7 @@ class _ChatPageState extends State<ChatPage> {
padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
itemCount: chat.messages.length + (showTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == chat.messages.length) return _typing();
if (index == chat.messages.length) return _typing(chat);
final message = chat.messages[index];
return Align(
alignment:
@@ -710,6 +770,21 @@ class _ChatPageState extends State<ChatPage> {
)
else
_AssistantMarkdown(text: message.text),
if (!message.fromUser &&
!chat.isSending &&
index == chat.messages.length - 1 &&
chat.lastResponseDuration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'${chat.modelName} · ${_formatDuration(chat.lastResponseDuration!)}',
textDirection: TextDirection.ltr,
style: const TextStyle(
color: Color(0xFF89948D),
fontSize: 10,
),
),
),
if (!message.fromUser && message.answerVersions.length > 1)
Align(
alignment: AlignmentDirectional.centerStart,
@@ -810,7 +885,7 @@ class _ChatPageState extends State<ChatPage> {
);
}
Widget _typing() => Align(
Widget _typing(ChatState chat) => Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 14),
@@ -820,18 +895,18 @@ class _ChatPageState extends State<ChatPage> {
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8EAE5)),
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
const SizedBox(width: 10),
Text(
'النموذج يفكر…',
style: TextStyle(color: Color(0xFF718078), fontSize: 12),
'النموذج يولّد · ${_formatDuration(chat.elapsed)}',
style: const TextStyle(color: Color(0xFF718078), fontSize: 12),
),
],
),
@@ -915,14 +990,20 @@ class _ChatPageState extends State<ChatPage> {
Padding(
padding: const EdgeInsets.all(7),
child: IconButton.filled(
onPressed: chat.isSending ? null : () => _send(),
onPressed:
chat.isSending
? () => context.read<ChatCubit>().cancelGeneration()
: () => _send(),
style: IconButton.styleFrom(
backgroundColor: _green,
backgroundColor: chat.isSending ? Colors.red : _green,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFAABDB5),
),
icon: const Icon(Icons.arrow_upward_rounded),
tooltip: 'إرسال',
icon: Icon(
chat.isSending
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
),
tooltip: chat.isSending ? 'إيقاف التوليد' : 'إرسال',
),
),
],
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
@@ -28,10 +29,40 @@ class _VersionTestApi extends ApiRepository {
}
@override
Stream<String> completeStream(
List<ChatMessage> messages, {
String? model,
}) => Stream<String>.fromIterable(responses.removeAt(0));
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
Stream<String>.fromIterable(responses.removeAt(0));
}
class _CancellableTestApi extends _VersionTestApi {
final controller = StreamController<String>();
var cancelled = false;
final storedConversation = SavedConversation(
id: 'conversation',
title: 'سؤال قديم',
updatedAt: DateTime.utc(2026),
messages: const [
ChatMessage('سؤال قديم', fromUser: true),
ChatMessage(
'إجابة محفوظة',
fromUser: false,
answerVersions: ['إجابة محفوظة'],
),
],
);
@override
Future<SavedConversation> getConversation(String id) async =>
storedConversation;
@override
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
controller.stream;
@override
void cancelActiveRequest() {
cancelled = true;
controller.close();
}
}
void main() {
@@ -42,18 +73,75 @@ void main() {
await cubit.send('ما معنى API؟');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول']);
expect(cubit.state.modelStatus, ModelConnectionStatus.connected);
expect(cubit.state.requestStatus, RequestStatus.completed);
expect(cubit.state.lastResponseDuration, isNotNull);
await cubit.regenerateLastAnswer();
expect(cubit.state.messages.last.text, 'الجواب الثاني');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']);
expect(cubit.state.messages.last.answerVersions, [
'الجواب الأول',
'الجواب الثاني',
]);
expect(cubit.state.messages.last.selectedVersion, 1);
await cubit.selectAnswerVersion(1, 0);
expect(cubit.state.messages.last.text, 'الجواب الأول');
expect(cubit.state.messages.last.selectedVersion, 0);
expect(api.lastSaved?.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']);
expect(api.lastSaved?.messages.last.answerVersions, [
'الجواب الأول',
'الجواب الثاني',
]);
expect(api.lastSaved?.messages.last.selectedVersion, 0);
await cubit.close();
});
test(
'cancelling generation preserves partial text and records duration',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
await Future<void>.delayed(Duration.zero);
final send = cubit.send('اكتب فقرة قصيرة');
await Future<void>.delayed(const Duration(milliseconds: 20));
api.controller.add('بداية الإجابة');
await Future<void>.delayed(Duration.zero);
await cubit.cancelGeneration();
await send;
expect(api.cancelled, isTrue);
expect(cubit.state.isSending, isFalse);
expect(cubit.state.requestStatus, RequestStatus.cancelled);
expect(cubit.state.messages.last.text, 'بداية الإجابة');
expect(cubit.state.lastResponseDuration, isNotNull);
expect(api.lastSaved?.messages.last.text, 'بداية الإجابة');
await cubit.close();
await api.controller.close();
},
);
test(
'cancelling regeneration before its first token keeps saved answer',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
await Future<void>.delayed(Duration.zero);
await cubit.openConversation(api.storedConversation);
final regeneration = cubit.regenerateLastAnswer();
await Future<void>.delayed(const Duration(milliseconds: 20));
await cubit.cancelGeneration();
await regeneration;
expect(cubit.state.messages.last.text, 'إجابة محفوظة');
expect(api.lastSaved?.messages.last.text, 'إجابة محفوظة');
expect(cubit.state.requestStatus, RequestStatus.cancelled);
await cubit.close();
await api.controller.close();
},
);
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:record/record.dart';
import 'dart:async';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
@@ -22,6 +23,22 @@ class _TestApiRepository extends ApiRepository {
Future<List<SavedConversation>> listConversations() async => [];
}
class _CancellableWidgetApi extends _TestApiRepository {
final controller = StreamController<String>();
@override
Future<void> saveConversation(SavedConversation conversation) async {}
@override
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
controller.stream;
@override
void cancelActiveRequest() {
controller.close();
}
}
void main() {
testWidgets('chat page fits a compact Windows window', (tester) async {
tester.view.physicalSize = const Size(800, 600);
@@ -47,4 +64,43 @@ void main() {
expect(find.text('مساعدك المحلي'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('stop button cancels an active answer', (tester) async {
tester.view.physicalSize = const Size(1000, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final api = _CancellableWidgetApi();
final cubit = ChatCubit(api);
await tester.pumpWidget(
MultiRepositoryProvider(
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
child: MultiBlocProvider(
providers: [
BlocProvider<ChatCubit>.value(value: cubit),
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
],
child: const SovereignApp(),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'أوقف هذا الرد');
await tester.tap(find.byTooltip('إرسال'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 30));
expect(find.byTooltip('إيقاف التوليد'), findsOneWidget);
await tester.tap(find.byTooltip('إيقاف التوليد'));
await tester.pump();
await tester.pumpAndSettle(const Duration(milliseconds: 50));
expect(cubit.state.requestStatus, RequestStatus.cancelled);
expect(find.byTooltip('إرسال'), findsOneWidget);
expect(tester.takeException(), isNull);
await cubit.close();
await api.controller.close();
});
}