diff --git a/SovereignAI-Starter/ROADMAP.md b/SovereignAI-Starter/ROADMAP.md index b90e2eb..ed23ea0 100644 --- a/SovereignAI-Starter/ROADMAP.md +++ b/SovereignAI-Starter/ROADMAP.md @@ -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. البحث والوسائط وقياس النماذج، ثم ضبط دقيق عند توفر بيانات وعتاد مناسبين. ## شروط الانتقال بين المراحل diff --git a/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart b/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart index 42fcea4..39521f2 100644 --- a/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart +++ b/SovereignAI-Starter/flutter_app/lib/core/network/api_repository.dart @@ -15,8 +15,14 @@ class ApiRepository { String _baseUrl; String? _cachedUserId; Future? _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 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; - final result = data['result'] as String? ?? ''; - final files = (data['files'] as List? ?? const []).cast(); - 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; + final result = data['result'] as String? ?? ''; + final files = + (data['files'] as List? ?? const []).cast(); + if (files.isEmpty) return result; + return '$result\n\nالمصادر المقروءة: ${files.join('، ')}'; + } finally { + if (identical(_activeRequestClient, client)) _activeRequestClient = null; + client.close(); + } } Stream 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(); } } diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart index 76d4c09..9ddc220 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_cubit.dart @@ -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 { } final ApiRepository _api; + Timer? _elapsedTimer; + Stopwatch? _requestStopwatch; + int _requestGeneration = 0; + List _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 close() { + _requestGeneration++; + _elapsedTimer?.cancel(); + _api.cancelActiveRequest(); + return super.close(); + } Future _loadModelName() async { try { @@ -19,6 +58,7 @@ class ChatCubit extends Cubit { emit( state.copyWith( modelName: modelName, + modelStatus: ModelConnectionStatus.connected, availableModels: models, selectedModel: state.selectedModel ?? modelName, ), @@ -29,6 +69,7 @@ class ChatCubit extends Cubit { emit( state.copyWith( modelName: 'تعذر الاتصال بالخادم', + modelStatus: ModelConnectionStatus.unavailable, availableModels: const [], ), ); @@ -143,7 +184,9 @@ class ChatCubit extends Cubit { final messages = List.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 { ? [...history, ChatMessage(prompt, fromUser: true)] : List.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 { 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 { 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 { ), ); } - 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 { 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 { 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 { } } + Future cancelGeneration() async { + if (!state.isSending) return; + _requestGeneration++; + _api.cancelActiveRequest(); + final duration = _stopElapsedTimer(); + final messages = + !_requestAppendedUserMessage && !_requestHasProducedText + ? List.from(_messagesBeforeRequest) + : List.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 openConversation(SavedConversation conversation) async { if (state.isSending) return; try { @@ -296,6 +419,8 @@ class ChatCubit extends Cubit { state.copyWith( messages: fullConversation.messages, activeConversationId: fullConversation.id, + requestStatus: RequestStatus.idle, + clearLastResponseDuration: true, clearError: true, ), ); @@ -310,6 +435,9 @@ class ChatCubit extends Cubit { state.copyWith( messages: const [], clearActiveConversation: true, + requestStatus: RequestStatus.idle, + elapsed: Duration.zero, + clearLastResponseDuration: true, clearError: true, ), ); @@ -346,6 +474,7 @@ class ChatCubit extends Cubit { emit( state.copyWith( apiBaseUrl: _api.baseUrl, + modelStatus: ModelConnectionStatus.checking, availableModels: const [], clearSelectedModel: true, clearError: true, diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart index 6531e81..16c8d36 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/cubit/chat_state.dart @@ -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 json) => - ChatMessage( - json['text'] as String, - fromUser: json['fromUser'] as bool, - answerVersions: - (json['answerVersions'] as List? ?? const []).cast(), - selectedVersion: json['selectedVersion'] as int? ?? 0, - ); + factory ChatMessage.fromJson(Map json) => ChatMessage( + json['text'] as String, + fromUser: json['fromUser'] as bool, + answerVersions: + (json['answerVersions'] as List? ?? const []).cast(), + 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 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? 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), diff --git a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart index 8dbbf4f..4e8c751 100644 --- a/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart +++ b/SovereignAI-Starter/flutter_app/lib/features/chat/presentation/pages/chat_page.dart @@ -30,7 +30,8 @@ class _ChatPageState extends State { @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 { 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 _enableNotifications() async { final allowed = await CompletionNotificationService.instance.requestPermission(); @@ -145,8 +153,8 @@ class _ChatPageState extends State { kIsWeb ? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.' : allowed - ? 'تم تفعيل إشعارات اكتمال المهام.' - : 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.', + ? 'تم تفعيل إشعارات اكتمال المهام.' + : 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.', ), ), ); @@ -203,17 +211,22 @@ class _ChatPageState extends State { listener: (context, state) => _followStreamIfNearby(), ), BlocListener( - 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 { 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 { Widget _topBar() => LayoutBuilder( builder: (context, constraints) { + final chat = context.watch().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().toggleWorkspaceMode(), - tooltip: - context.watch().state.isWorkspaceMode - ? 'إيقاف وضع الوكيل (قراءة فقط)' - : 'وضع الوكيل: قراءة ملفات المشروع فقط', - color: - context.watch().state.isWorkspaceMode ? _green : _ink, - icon: Icon( - context.watch().state.isWorkspaceMode - ? Icons.folder_open_rounded - : Icons.folder_outlined, - ), - ), - if (context.watch().state.availableModels.isNotEmpty) - ConstrainedBox( - constraints: BoxConstraints(maxWidth: compact ? 112 : 190), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: - context.watch().state.availableModels.contains( - context.watch().state.selectedModel, - ) - ? context.watch().state.selectedModel - : context + ), + SizedBox(width: compact ? 2 : 12), + IconButton( + onPressed: () => context.read().toggleWorkspaceMode(), + tooltip: + context.watch().state.isWorkspaceMode + ? 'إيقاف وضع الوكيل (قراءة فقط)' + : 'وضع الوكيل: قراءة ملفات المشروع فقط', + color: + context.watch().state.isWorkspaceMode + ? _green + : _ink, + icon: Icon( + context.watch().state.isWorkspaceMode + ? Icons.folder_open_rounded + : Icons.folder_outlined, + ), + ), + if (context.watch().state.availableModels.isNotEmpty) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 100 : 190), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: + context + .watch() + .state + .availableModels + .contains( + context + .watch() + .state + .selectedModel, + ) + ? context.watch().state.selectedModel + : context + .watch() + .state + .availableModels + .first, + isExpanded: true, + isDense: true, + items: + context .watch() .state .availableModels - .first, - isExpanded: true, - isDense: true, - items: - context - .watch() - .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().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().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 { 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 { ) 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 { ); } - 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 { 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 { Padding( padding: const EdgeInsets.all(7), child: IconButton.filled( - onPressed: chat.isSending ? null : () => _send(), + onPressed: + chat.isSending + ? () => context.read().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 ? 'إيقاف التوليد' : 'إرسال', ), ), ], diff --git a/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart b/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart index fe6e49a..bdeb40b 100644 --- a/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart +++ b/SovereignAI-Starter/flutter_app/test/chat_cubit_test.dart @@ -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 completeStream( - List messages, { - String? model, - }) => Stream.fromIterable(responses.removeAt(0)); + Stream completeStream(List messages, {String? model}) => + Stream.fromIterable(responses.removeAt(0)); +} + +class _CancellableTestApi extends _VersionTestApi { + final controller = StreamController(); + var cancelled = false; + final storedConversation = SavedConversation( + id: 'conversation', + title: 'سؤال قديم', + updatedAt: DateTime.utc(2026), + messages: const [ + ChatMessage('سؤال قديم', fromUser: true), + ChatMessage( + 'إجابة محفوظة', + fromUser: false, + answerVersions: ['إجابة محفوظة'], + ), + ], + ); + + @override + Future getConversation(String id) async => + storedConversation; + + @override + Stream completeStream(List 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.delayed(Duration.zero); + + final send = cubit.send('اكتب فقرة قصيرة'); + await Future.delayed(const Duration(milliseconds: 20)); + api.controller.add('بداية الإجابة'); + await Future.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.delayed(Duration.zero); + await cubit.openConversation(api.storedConversation); + + final regeneration = cubit.regenerateLastAnswer(); + await Future.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(); + }, + ); } diff --git a/SovereignAI-Starter/flutter_app/test/widget_test.dart b/SovereignAI-Starter/flutter_app/test/widget_test.dart index 85317d1..29a0bcc 100644 --- a/SovereignAI-Starter/flutter_app/test/widget_test.dart +++ b/SovereignAI-Starter/flutter_app/test/widget_test.dart @@ -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> listConversations() async => []; } +class _CancellableWidgetApi extends _TestApiRepository { + final controller = StreamController(); + + @override + Future saveConversation(SavedConversation conversation) async {} + + @override + Stream completeStream(List 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.value(value: api)], + child: MultiBlocProvider( + providers: [ + BlocProvider.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(); + }); }