Add generation status and cancel control
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
- [x] اختيار نموذج Ollama مثبت من واجهة التطبيق، وتمريره صراحةً للطلب.
|
||||
- [x] وضع وكيل تجريبي للبحث وقراءة مقتطفات ملفات المشروع فقط، مع إرجاع مراجع الملفات.
|
||||
- [x] حفظ محاولات الإجابة كنسخ منفصلة بدل استبدالها، مع واجهة للتنقل بينها. (2026-10-01: اجتازت اختبارات Flutter التنقل والحفظ، واختبارات SQLite/API الترحيل والحفظ والاسترجاع.)
|
||||
- [ ] إظهار حالة النموذج والوقت وسبب الخطأ، وتوفير إيقاف التوليد.
|
||||
- [x] إظهار حالة النموذج والوقت وسبب الخطأ، وتوفير إيقاف التوليد. (2026-10-01: اختبار زر الإيقاف في الواجهة، حفظ النص الجزئي، وحماية الإجابة القديمة أثناء إلغاء إعادة التوليد؛ `flutter analyze` بلا ملاحظات، وWindows Debug أُعيد تشغيله.)
|
||||
- [ ] دعم إخراج Markdown كامل، وروابط قابلة للفتح، ونسخ الكتل البرمجية منفردة.
|
||||
|
||||
## المرحلة 2 — أساس موثوق للواجهة والـ API
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
## إضافات قبل توسيع الوكيل
|
||||
|
||||
- إضافة إلغاء التوليد وإظهار المدة وحالة الاتصال بكل وضوح.
|
||||
- [x] إلغاء التوليد وإظهار المدة وحالة الاتصال ورسالة الخطأ (2026-10-01: اختبارات Cubit وواجهة ناجحة).
|
||||
- [x] حفظ نسخ إعادة التوليد والتنقل بينها (2026-10-01: اختبار واجهة وAPI وقاعدة البيانات ناجح)؛ تجربة أفضل للأخطاء وإعادة المحاولة ما زالت لاحقة.
|
||||
- إكمال طبقة مزوّد النموذج وعقد API قبل ربط أدوات الوكيل.
|
||||
- حفظ إعدادات التنبيه والمظهر وعنوان API بطريقة محلية آمنة.
|
||||
@@ -100,12 +100,13 @@
|
||||
## ترتيب التنفيذ القادم
|
||||
|
||||
1. [x] التحقق من إجراءات المحادثة الجديدة على Windows Debug (2026-10-01): تشغيل API وGemma وGroq، اختبار صوت من الميكروفون حتى التفريغ والرد والحفظ في SQLite، نجاح اختبار الواجهة على نافذة 800px ونجاح `flutter analyze`.
|
||||
2. [x] حفظ نسخ الإجابات وترحيل SQLite (2026-10-01): ترحيل قاعدة قديمة مع الحفاظ على الرسائل، وحفظ النسخ واسترجاع النسخة المختارة عبر PUT/GET؛ اجتاز اختبارا Python واختبار API حي على قاعدة التطبيق ثم حذف سجل الاختبار. اختبارا Flutter نجحا، و`flutter analyze` بلا ملاحظات. شُغّلت نسخة Windows Debug باسم Mithqal AI واتصلت الخدمة بـGemma؛ اختبار API للمحادثة أعاد ردًا عربيًا. الإلغاء والمدة وحالة النموذج ما زالت مفتوحة ضمن الخطوة التالية.
|
||||
3. إضافة طبقة مزود النموذج واكتشاف النماذج.
|
||||
4. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم.
|
||||
5. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة.
|
||||
6. الهوية متعددة المستخدمين قبل أي نشر شبكي.
|
||||
7. البحث والوسائط وقياس النماذج، ثم ضبط دقيق عند توفر بيانات وعتاد مناسبين.
|
||||
2. [x] حفظ نسخ الإجابات وترحيل SQLite (2026-10-01): ترحيل قاعدة قديمة مع الحفاظ على الرسائل، وحفظ النسخ واسترجاع النسخة المختارة عبر PUT/GET؛ اجتاز اختبارا Python واختبار API حي على قاعدة التطبيق ثم حذف سجل الاختبار. اختبارا Flutter نجحا، و`flutter analyze` بلا ملاحظات. شُغّلت نسخة Windows Debug باسم Mithqal AI واتصلت الخدمة بـGemma؛ اختبار API للمحادثة أعاد ردًا عربيًا.
|
||||
3. [x] حالة النموذج والمدة والخطأ وإيقاف التوليد (2026-10-01): اختبار إلغاء الرد الجزئي وإلغاء إعادة التوليد قبل أول رمز مع الحفاظ على الإجابة السابقة، واختبار زر الإيقاف بالواجهة؛ `flutter analyze` و5 اختبارات Flutter ناجحة، وWindows Debug يعمل بعد hot restart.
|
||||
4. إضافة طبقة مزود النموذج واكتشاف النماذج.
|
||||
5. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم.
|
||||
6. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة.
|
||||
7. الهوية متعددة المستخدمين قبل أي نشر شبكي.
|
||||
8. البحث والوسائط وقياس النماذج، ثم ضبط دقيق عند توفر بيانات وعتاد مناسبين.
|
||||
|
||||
## شروط الانتقال بين المراحل
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+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),
|
||||
|
||||
+238
-157
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user