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
+9 -8
View File
@@ -14,7 +14,7 @@
- [x] اختيار نموذج Ollama مثبت من واجهة التطبيق، وتمريره صراحةً للطلب. - [x] اختيار نموذج Ollama مثبت من واجهة التطبيق، وتمريره صراحةً للطلب.
- [x] وضع وكيل تجريبي للبحث وقراءة مقتطفات ملفات المشروع فقط، مع إرجاع مراجع الملفات. - [x] وضع وكيل تجريبي للبحث وقراءة مقتطفات ملفات المشروع فقط، مع إرجاع مراجع الملفات.
- [x] حفظ محاولات الإجابة كنسخ منفصلة بدل استبدالها، مع واجهة للتنقل بينها. (2026-10-01: اجتازت اختبارات Flutter التنقل والحفظ، واختبارات SQLite/API الترحيل والحفظ والاسترجاع.) - [x] حفظ محاولات الإجابة كنسخ منفصلة بدل استبدالها، مع واجهة للتنقل بينها. (2026-10-01: اجتازت اختبارات Flutter التنقل والحفظ، واختبارات SQLite/API الترحيل والحفظ والاسترجاع.)
- [ ] إظهار حالة النموذج والوقت وسبب الخطأ، وتوفير إيقاف التوليد. - [x] إظهار حالة النموذج والوقت وسبب الخطأ، وتوفير إيقاف التوليد. (2026-10-01: اختبار زر الإيقاف في الواجهة، حفظ النص الجزئي، وحماية الإجابة القديمة أثناء إلغاء إعادة التوليد؛ `flutter analyze` بلا ملاحظات، وWindows Debug أُعيد تشغيله.)
- [ ] دعم إخراج Markdown كامل، وروابط قابلة للفتح، ونسخ الكتل البرمجية منفردة. - [ ] دعم إخراج Markdown كامل، وروابط قابلة للفتح، ونسخ الكتل البرمجية منفردة.
## المرحلة 2 — أساس موثوق للواجهة والـ API ## المرحلة 2 — أساس موثوق للواجهة والـ API
@@ -89,7 +89,7 @@
## إضافات قبل توسيع الوكيل ## إضافات قبل توسيع الوكيل
- إضافة إلغاء التوليد وإظهار المدة وحالة الاتصال بكل وضوح. - [x] إلغاء التوليد وإظهار المدة وحالة الاتصال ورسالة الخطأ (2026-10-01: اختبارات Cubit وواجهة ناجحة).
- [x] حفظ نسخ إعادة التوليد والتنقل بينها (2026-10-01: اختبار واجهة وAPI وقاعدة البيانات ناجح)؛ تجربة أفضل للأخطاء وإعادة المحاولة ما زالت لاحقة. - [x] حفظ نسخ إعادة التوليد والتنقل بينها (2026-10-01: اختبار واجهة وAPI وقاعدة البيانات ناجح)؛ تجربة أفضل للأخطاء وإعادة المحاولة ما زالت لاحقة.
- إكمال طبقة مزوّد النموذج وعقد API قبل ربط أدوات الوكيل. - إكمال طبقة مزوّد النموذج وعقد API قبل ربط أدوات الوكيل.
- حفظ إعدادات التنبيه والمظهر وعنوان API بطريقة محلية آمنة. - حفظ إعدادات التنبيه والمظهر وعنوان API بطريقة محلية آمنة.
@@ -100,12 +100,13 @@
## ترتيب التنفيذ القادم ## ترتيب التنفيذ القادم
1. [x] التحقق من إجراءات المحادثة الجديدة على Windows Debug (2026-10-01): تشغيل API وGemma وGroq، اختبار صوت من الميكروفون حتى التفريغ والرد والحفظ في SQLite، نجاح اختبار الواجهة على نافذة 800px ونجاح `flutter analyze`. 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 للمحادثة أعاد ردًا عربيًا. الإلغاء والمدة وحالة النموذج ما زالت مفتوحة ضمن الخطوة التالية. 2. [x] حفظ نسخ الإجابات وترحيل SQLite (2026-10-01): ترحيل قاعدة قديمة مع الحفاظ على الرسائل، وحفظ النسخ واسترجاع النسخة المختارة عبر PUT/GET؛ اجتاز اختبارا Python واختبار API حي على قاعدة التطبيق ثم حذف سجل الاختبار. اختبارا Flutter نجحا، و`flutter analyze` بلا ملاحظات. شُغّلت نسخة Windows Debug باسم Mithqal AI واتصلت الخدمة بـGemma؛ اختبار API للمحادثة أعاد ردًا عربيًا.
3. إضافة طبقة مزود النموذج واكتشاف النماذج. 3. [x] حالة النموذج والمدة والخطأ وإيقاف التوليد (2026-10-01): اختبار إلغاء الرد الجزئي وإلغاء إعادة التوليد قبل أول رمز مع الحفاظ على الإجابة السابقة، واختبار زر الإيقاف بالواجهة؛ `flutter analyze` و5 اختبارات Flutter ناجحة، وWindows Debug يعمل بعد hot restart.
4. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم. 4. إضافة طبقة مزود النموذج واكتشاف النماذج.
5. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة. 5. تصميم عقد الأدوات وسجل التنفيذ، ثم أداة قراءة الملفات داخل مساحة يحددها المستخدم.
6. الهوية متعددة المستخدمين قبل أي نشر شبكي. 6. إضافة التعديل مع diff وموافقة، ثم الاختبارات/الأوامر المعزولة.
7. البحث والوسائط وقياس النماذج، ثم ضبط دقيق عند توفر بيانات وعتاد مناسبين. 7. الهوية متعددة المستخدمين قبل أي نشر شبكي.
8. البحث والوسائط وقياس النماذج، ثم ضبط دقيق عند توفر بيانات وعتاد مناسبين.
## شروط الانتقال بين المراحل ## شروط الانتقال بين المراحل
@@ -15,8 +15,14 @@ class ApiRepository {
String _baseUrl; String _baseUrl;
String? _cachedUserId; String? _cachedUserId;
Future<String>? _loadingUserId; Future<String>? _loadingUserId;
http.Client? _activeRequestClient;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
void cancelActiveRequest() {
_activeRequestClient?.close();
_activeRequestClient = null;
}
String newId() => _uuid.v4(); String newId() => _uuid.v4();
void setBaseUrl(String value) => void setBaseUrl(String value) =>
@@ -172,19 +178,31 @@ class ApiRepository {
} }
Future<String> askWorkspaceAgent(String task, {String? model}) async { Future<String> askWorkspaceAgent(String task, {String? model}) async {
final response = await http final client = http.Client();
.post( _activeRequestClient = client;
Uri.parse('$_baseUrl/v1/agent/workspace'), try {
headers: const {'Content-Type': 'application/json'}, final request =
body: jsonEncode({'task': task, if (model != null) 'model': model}), http.Request('POST', Uri.parse('$_baseUrl/v1/agent/workspace'))
) ..headers['Content-Type'] = 'application/json'
.timeout(const Duration(minutes: 10)); ..body = jsonEncode({
_checkStatus(response); 'task': task,
final data = jsonDecode(response.body) as Map<String, dynamic>; if (model != null) 'model': model,
final result = data['result'] as String? ?? ''; });
final files = (data['files'] as List<dynamic>? ?? const []).cast<String>(); final streamed = await client
if (files.isEmpty) return result; .send(request)
return '$result\n\nالمصادر المقروءة: ${files.join('، ')}'; .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( Stream<String> completeStream(
@@ -192,6 +210,7 @@ class ApiRepository {
String? model, String? model,
}) async* { }) async* {
final client = http.Client(); final client = http.Client();
_activeRequestClient = client;
try { try {
final request = final request =
http.Request('POST', Uri.parse('$_baseUrl/v1/chat/stream')) http.Request('POST', Uri.parse('$_baseUrl/v1/chat/stream'))
@@ -237,6 +256,7 @@ class ApiRepository {
} }
if (!completed) throw Exception('انقطع بث الإجابة قبل اكتماله.'); if (!completed) throw Exception('انقطع بث الإجابة قبل اكتماله.');
} finally { } finally {
if (identical(_activeRequestClient, client)) _activeRequestClient = null;
client.close(); client.close();
} }
} }
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_repository.dart'; import '../../../../core/network/api_repository.dart';
@@ -10,6 +12,43 @@ class ChatCubit extends Cubit<ChatState> {
} }
final ApiRepository _api; 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 { Future<void> _loadModelName() async {
try { try {
@@ -19,6 +58,7 @@ class ChatCubit extends Cubit<ChatState> {
emit( emit(
state.copyWith( state.copyWith(
modelName: modelName, modelName: modelName,
modelStatus: ModelConnectionStatus.connected,
availableModels: models, availableModels: models,
selectedModel: state.selectedModel ?? modelName, selectedModel: state.selectedModel ?? modelName,
), ),
@@ -29,6 +69,7 @@ class ChatCubit extends Cubit<ChatState> {
emit( emit(
state.copyWith( state.copyWith(
modelName: 'تعذر الاتصال بالخادم', modelName: 'تعذر الاتصال بالخادم',
modelStatus: ModelConnectionStatus.unavailable,
availableModels: const [], availableModels: const [],
), ),
); );
@@ -143,7 +184,9 @@ class ChatCubit extends Cubit<ChatState> {
final messages = List<ChatMessage>.from(state.messages); final messages = List<ChatMessage>.from(state.messages);
final message = messages[messageIndex]; final message = messages[messageIndex];
final versions = message.answerVersions; final versions = message.answerVersions;
if (message.fromUser || versionIndex < 0 || versionIndex >= versions.length) { if (message.fromUser ||
versionIndex < 0 ||
versionIndex >= versions.length) {
return; return;
} }
messages[messageIndex] = ChatMessage( messages[messageIndex] = ChatMessage(
@@ -187,22 +230,36 @@ class ChatCubit extends Cubit<ChatState> {
? [...history, ChatMessage(prompt, fromUser: true)] ? [...history, ChatMessage(prompt, fromUser: true)]
: List<ChatMessage>.from(history); : List<ChatMessage>.from(history);
final originalMessages = state.messages; final originalMessages = state.messages;
_messagesBeforeRequest = originalMessages;
_requestAppendedUserMessage = appendUserMessage;
_requestHasProducedText = false;
var answer = ''; 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 { try {
if (appendUserMessage) await _saveCurrent(messages); if (appendUserMessage) await _saveCurrent(messages);
if (isClosed) return; if (isClosed || generation != _requestGeneration) return;
emit( emit(
state.copyWith( state.copyWith(
messages: [...messages, const ChatMessage('', fromUser: false)], messages: [...messages, const ChatMessage('', fromUser: false)],
), ),
); );
_startElapsedTimer(generation);
if (state.isWorkspaceMode) { if (state.isWorkspaceMode) {
answer = await _api.askWorkspaceAgent( answer = await _api.askWorkspaceAgent(
prompt, prompt,
model: state.selectedModel, model: state.selectedModel,
); );
if (isClosed) return; if (isClosed || generation != _requestGeneration) return;
if (answer.trim().isEmpty) { if (answer.trim().isEmpty) {
throw Exception('لم يصل نص في إجابة الوكيل.'); throw Exception('لم يصل نص في إجابة الوكيل.');
} }
@@ -216,7 +273,16 @@ class ChatCubit extends Cubit<ChatState> {
selectedVersion: answerVersions.length - 1, 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); await _saveCurrent(updated);
return; return;
} }
@@ -224,8 +290,9 @@ class ChatCubit extends Cubit<ChatState> {
messages, messages,
model: state.selectedModel, model: state.selectedModel,
)) { )) {
if (isClosed) return; if (isClosed || generation != _requestGeneration) return;
answer += delta; answer += delta;
if (delta.isNotEmpty) _requestHasProducedText = true;
final answerVersions = [...previousAnswers, answer]; final answerVersions = [...previousAnswers, answer];
emit( emit(
state.copyWith( 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('لم يصل نص في الإجابة.'); if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.');
final answerVersions = [...previousAnswers, answer]; final answerVersions = [...previousAnswers, answer];
final updated = [ final updated = [
@@ -253,10 +320,20 @@ class ChatCubit extends Cubit<ChatState> {
selectedVersion: answerVersions.length - 1, 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); await _saveCurrent(updated);
} catch (error) { } catch (error) {
if (isClosed) return; if (isClosed || generation != _requestGeneration) return;
final duration = _stopElapsedTimer();
final partial = final partial =
!appendUserMessage !appendUserMessage
? originalMessages ? originalMessages
@@ -275,7 +352,10 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith( state.copyWith(
messages: partial, messages: partial,
isSending: false, isSending: false,
error: error.toString(), requestStatus: RequestStatus.failed,
elapsed: duration,
lastResponseDuration: duration,
error: _readableError(error),
), ),
); );
if (appendUserMessage && answer.isNotEmpty) { 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 { Future<void> openConversation(SavedConversation conversation) async {
if (state.isSending) return; if (state.isSending) return;
try { try {
@@ -296,6 +419,8 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith( state.copyWith(
messages: fullConversation.messages, messages: fullConversation.messages,
activeConversationId: fullConversation.id, activeConversationId: fullConversation.id,
requestStatus: RequestStatus.idle,
clearLastResponseDuration: true,
clearError: true, clearError: true,
), ),
); );
@@ -310,6 +435,9 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith( state.copyWith(
messages: const [], messages: const [],
clearActiveConversation: true, clearActiveConversation: true,
requestStatus: RequestStatus.idle,
elapsed: Duration.zero,
clearLastResponseDuration: true,
clearError: true, clearError: true,
), ),
); );
@@ -346,6 +474,7 @@ class ChatCubit extends Cubit<ChatState> {
emit( emit(
state.copyWith( state.copyWith(
apiBaseUrl: _api.baseUrl, apiBaseUrl: _api.baseUrl,
modelStatus: ModelConnectionStatus.checking,
availableModels: const [], availableModels: const [],
clearSelectedModel: true, clearSelectedModel: true,
clearError: true, clearError: true,
@@ -1,3 +1,7 @@
enum ModelConnectionStatus { checking, connected, unavailable }
enum RequestStatus { idle, generating, completed, cancelled, failed }
class ChatMessage { class ChatMessage {
const ChatMessage( const ChatMessage(
this.text, { this.text, {
@@ -18,14 +22,13 @@ class ChatMessage {
'selectedVersion': selectedVersion, 'selectedVersion': selectedVersion,
}; };
factory ChatMessage.fromJson(Map<String, dynamic> json) => factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
ChatMessage( json['text'] as String,
json['text'] as String, fromUser: json['fromUser'] as bool,
fromUser: json['fromUser'] as bool, answerVersions:
answerVersions: (json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(), selectedVersion: json['selectedVersion'] as int? ?? 0,
selectedVersion: json['selectedVersion'] as int? ?? 0, );
);
} }
class SavedConversation { class SavedConversation {
@@ -80,6 +83,10 @@ class ChatState {
this.isWorkspaceMode = false, this.isWorkspaceMode = false,
this.error, this.error,
this.modelName = 'جارٍ اكتشاف النموذج…', this.modelName = 'جارٍ اكتشاف النموذج…',
this.modelStatus = ModelConnectionStatus.checking,
this.requestStatus = RequestStatus.idle,
this.elapsed = Duration.zero,
this.lastResponseDuration,
this.availableModels = const [], this.availableModels = const [],
this.selectedModel, this.selectedModel,
required this.apiBaseUrl, required this.apiBaseUrl,
@@ -93,6 +100,10 @@ class ChatState {
final bool isWorkspaceMode; final bool isWorkspaceMode;
final String? error; final String? error;
final String modelName; final String modelName;
final ModelConnectionStatus modelStatus;
final RequestStatus requestStatus;
final Duration elapsed;
final Duration? lastResponseDuration;
final List<String> availableModels; final List<String> availableModels;
final String? selectedModel; final String? selectedModel;
final String apiBaseUrl; final String apiBaseUrl;
@@ -108,6 +119,11 @@ class ChatState {
String? error, String? error,
bool clearError = false, bool clearError = false,
String? modelName, String? modelName,
ModelConnectionStatus? modelStatus,
RequestStatus? requestStatus,
Duration? elapsed,
Duration? lastResponseDuration,
bool clearLastResponseDuration = false,
List<String>? availableModels, List<String>? availableModels,
String? selectedModel, String? selectedModel,
bool clearSelectedModel = false, bool clearSelectedModel = false,
@@ -124,6 +140,13 @@ class ChatState {
isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode, isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode,
error: clearError ? null : (error ?? this.error), error: clearError ? null : (error ?? this.error),
modelName: modelName ?? this.modelName, 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, availableModels: availableModels ?? this.availableModels,
selectedModel: selectedModel:
clearSelectedModel ? null : (selectedModel ?? this.selectedModel), clearSelectedModel ? null : (selectedModel ?? this.selectedModel),
@@ -30,7 +30,8 @@ class _ChatPageState extends State<ChatPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_notificationsEnabled = !kIsWeb && _notificationsEnabled =
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows || (defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux || defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS); defaultTargetPlatform == TargetPlatform.macOS);
@@ -134,6 +135,13 @@ class _ChatPageState extends State<ChatPage> {
await Share.share(text); 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 { Future<void> _enableNotifications() async {
final allowed = final allowed =
await CompletionNotificationService.instance.requestPermission(); await CompletionNotificationService.instance.requestPermission();
@@ -145,8 +153,8 @@ class _ChatPageState extends State<ChatPage> {
kIsWeb kIsWeb
? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.' ? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.'
: allowed : allowed
? 'تم تفعيل إشعارات اكتمال المهام.' ? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.', : 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
), ),
), ),
); );
@@ -203,17 +211,22 @@ class _ChatPageState extends State<ChatPage> {
listener: (context, state) => _followStreamIfNearby(), listener: (context, state) => _followStreamIfNearby(),
), ),
BlocListener<ChatCubit, ChatState>( BlocListener<ChatCubit, ChatState>(
listenWhen: (previous, current) => listenWhen:
previous.isSending && !current.isSending, (previous, current) => previous.isSending && !current.isSending,
listener: (context, state) { 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) ScaffoldMessenger.of(context)
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar( ..showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(message),
succeeded ? 'اكتملت الإجابة.' : 'انتهى الطلب مع خطأ.',
),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
@@ -385,15 +398,36 @@ class _ChatPageState extends State<ChatPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Row( Row(
children: [ children: [
Icon(Icons.circle, size: 9, color: _green), Icon(
SizedBox(width: 7), Icons.circle,
Text( size: 9,
'نموذج محلي', color:
style: TextStyle( chat.modelStatus == ModelConnectionStatus.connected
fontWeight: FontWeight.w600, ? _green
color: _ink, : 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( Widget _topBar() => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final chat = context.watch<ChatCubit>().state;
final compact = constraints.maxWidth < 640; final compact = constraints.maxWidth < 640;
return Container( return Container(
height: 70, height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 26), padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 26),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))), border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
), ),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
'مساعدك المحلي', 'مساعدك المحلي',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: _ink, color: _ink,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 16, 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), SizedBox(width: compact ? 2 : 12),
const Text( IconButton(
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq', onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
maxLines: 1, tooltip:
overflow: TextOverflow.ellipsis, context.watch<ChatCubit>().state.isWorkspaceMode
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11), ? 'إيقاف وضع الوكيل (قراءة فقط)'
), : 'وضع الوكيل: قراءة ملفات المشروع فقط',
], color:
], context.watch<ChatCubit>().state.isWorkspaceMode
), ? _green
), : _ink,
SizedBox(width: compact ? 4 : 12), icon: Icon(
IconButton( context.watch<ChatCubit>().state.isWorkspaceMode
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(), ? Icons.folder_open_rounded
tooltip: : Icons.folder_outlined,
context.watch<ChatCubit>().state.isWorkspaceMode ),
? 'إيقاف وضع الوكيل (قراءة فقط)' ),
: 'وضع الوكيل: قراءة ملفات المشروع فقط', if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
color: ConstrainedBox(
context.watch<ChatCubit>().state.isWorkspaceMode ? _green : _ink, constraints: BoxConstraints(maxWidth: compact ? 100 : 190),
icon: Icon( child: DropdownButtonHideUnderline(
context.watch<ChatCubit>().state.isWorkspaceMode child: DropdownButton<String>(
? Icons.folder_open_rounded value:
: Icons.folder_outlined, context
), .watch<ChatCubit>()
), .state
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty) .availableModels
ConstrainedBox( .contains(
constraints: BoxConstraints(maxWidth: compact ? 112 : 190), context
child: DropdownButtonHideUnderline( .watch<ChatCubit>()
child: DropdownButton<String>( .state
value: .selectedModel,
context.watch<ChatCubit>().state.availableModels.contains( )
context.watch<ChatCubit>().state.selectedModel, ? context.watch<ChatCubit>().state.selectedModel
) : context
? context.watch<ChatCubit>().state.selectedModel .watch<ChatCubit>()
: context .state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>() .watch<ChatCubit>()
.state .state
.availableModels .availableModels
.first, .map(
isExpanded: true, (model) => DropdownMenuItem(
isDense: true, value: model,
items: child: Text(
context model,
.watch<ChatCubit>() overflow: TextOverflow.ellipsis,
.state textDirection: TextDirection.ltr,
.availableModels style: const TextStyle(fontSize: 11),
.map( ),
(model) => DropdownMenuItem( ),
value: model, )
child: Text( .toList(),
model, onChanged: (model) {
overflow: TextOverflow.ellipsis, if (model != null) {
textDirection: TextDirection.ltr, context.read<ChatCubit>().selectModel(model);
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 : 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), padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
itemCount: chat.messages.length + (showTyping ? 1 : 0), itemCount: chat.messages.length + (showTyping ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == chat.messages.length) return _typing(); if (index == chat.messages.length) return _typing(chat);
final message = chat.messages[index]; final message = chat.messages[index];
return Align( return Align(
alignment: alignment:
@@ -710,6 +770,21 @@ class _ChatPageState extends State<ChatPage> {
) )
else else
_AssistantMarkdown(text: message.text), _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) if (!message.fromUser && message.answerVersions.length > 1)
Align( Align(
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
@@ -810,7 +885,7 @@ class _ChatPageState extends State<ChatPage> {
); );
} }
Widget _typing() => Align( Widget _typing(ChatState chat) => Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 14), margin: const EdgeInsets.only(bottom: 14),
@@ -820,18 +895,18 @@ class _ChatPageState extends State<ChatPage> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8EAE5)), border: Border.all(color: const Color(0xFFE8EAE5)),
), ),
child: const Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox( const SizedBox(
width: 15, width: 15,
height: 15, height: 15,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
'النموذج يفكر…', 'النموذج يولّد · ${_formatDuration(chat.elapsed)}',
style: TextStyle(color: Color(0xFF718078), fontSize: 12), style: const TextStyle(color: Color(0xFF718078), fontSize: 12),
), ),
], ],
), ),
@@ -915,14 +990,20 @@ class _ChatPageState extends State<ChatPage> {
Padding( Padding(
padding: const EdgeInsets.all(7), padding: const EdgeInsets.all(7),
child: IconButton.filled( child: IconButton.filled(
onPressed: chat.isSending ? null : () => _send(), onPressed:
chat.isSending
? () => context.read<ChatCubit>().cancelGeneration()
: () => _send(),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: _green, backgroundColor: chat.isSending ? Colors.red : _green,
foregroundColor: Colors.white, foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFAABDB5),
), ),
icon: const Icon(Icons.arrow_upward_rounded), icon: Icon(
tooltip: 'إرسال', 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/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_cubit.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart'; import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
@@ -28,10 +29,40 @@ class _VersionTestApi extends ApiRepository {
} }
@override @override
Stream<String> completeStream( Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
List<ChatMessage> messages, { Stream<String>.fromIterable(responses.removeAt(0));
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() { void main() {
@@ -42,18 +73,75 @@ void main() {
await cubit.send('ما معنى API؟'); await cubit.send('ما معنى API؟');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول']); 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(); await cubit.regenerateLastAnswer();
expect(cubit.state.messages.last.text, 'الجواب الثاني'); 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); expect(cubit.state.messages.last.selectedVersion, 1);
await cubit.selectAnswerVersion(1, 0); await cubit.selectAnswerVersion(1, 0);
expect(cubit.state.messages.last.text, 'الجواب الأول'); expect(cubit.state.messages.last.text, 'الجواب الأول');
expect(cubit.state.messages.last.selectedVersion, 0); 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); expect(api.lastSaved?.messages.last.selectedVersion, 0);
await cubit.close(); 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_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:record/record.dart'; import 'package:record/record.dart';
import 'dart:async';
import 'package:flutter_app/core/network/api_repository.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_cubit.dart';
@@ -22,6 +23,22 @@ class _TestApiRepository extends ApiRepository {
Future<List<SavedConversation>> listConversations() async => []; 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() { void main() {
testWidgets('chat page fits a compact Windows window', (tester) async { testWidgets('chat page fits a compact Windows window', (tester) async {
tester.view.physicalSize = const Size(800, 600); tester.view.physicalSize = const Size(800, 600);
@@ -47,4 +64,43 @@ void main() {
expect(find.text('مساعدك المحلي'), findsOneWidget); expect(find.text('مساعدك المحلي'), findsOneWidget);
expect(tester.takeException(), isNull); 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();
});
} }