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] وضع وكيل تجريبي للبحث وقراءة مقتطفات ملفات المشروع فقط، مع إرجاع مراجع الملفات.
- [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();
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/api_repository.dart';
@@ -10,6 +12,43 @@ class ChatCubit extends Cubit<ChatState> {
}
final ApiRepository _api;
Timer? _elapsedTimer;
Stopwatch? _requestStopwatch;
int _requestGeneration = 0;
List<ChatMessage> _messagesBeforeRequest = const [];
bool _requestAppendedUserMessage = true;
bool _requestHasProducedText = false;
void _startElapsedTimer(int generation) {
_elapsedTimer?.cancel();
_requestStopwatch = Stopwatch()..start();
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!isClosed && generation == _requestGeneration && state.isSending) {
emit(state.copyWith(elapsed: _requestStopwatch!.elapsed));
}
});
}
Duration _stopElapsedTimer() {
_elapsedTimer?.cancel();
_elapsedTimer = null;
final duration = _requestStopwatch?.elapsed ?? Duration.zero;
_requestStopwatch?.stop();
return duration;
}
String _readableError(Object error) => error.toString().replaceFirst(
RegExp(r'^(Exception|ClientException):\s*'),
'',
);
@override
Future<void> close() {
_requestGeneration++;
_elapsedTimer?.cancel();
_api.cancelActiveRequest();
return super.close();
}
Future<void> _loadModelName() async {
try {
@@ -19,6 +58,7 @@ class ChatCubit extends Cubit<ChatState> {
emit(
state.copyWith(
modelName: modelName,
modelStatus: ModelConnectionStatus.connected,
availableModels: models,
selectedModel: state.selectedModel ?? modelName,
),
@@ -29,6 +69,7 @@ class ChatCubit extends Cubit<ChatState> {
emit(
state.copyWith(
modelName: 'تعذر الاتصال بالخادم',
modelStatus: ModelConnectionStatus.unavailable,
availableModels: const [],
),
);
@@ -143,7 +184,9 @@ class ChatCubit extends Cubit<ChatState> {
final messages = List<ChatMessage>.from(state.messages);
final message = messages[messageIndex];
final versions = message.answerVersions;
if (message.fromUser || versionIndex < 0 || versionIndex >= versions.length) {
if (message.fromUser ||
versionIndex < 0 ||
versionIndex >= versions.length) {
return;
}
messages[messageIndex] = ChatMessage(
@@ -187,22 +230,36 @@ class ChatCubit extends Cubit<ChatState> {
? [...history, ChatMessage(prompt, fromUser: true)]
: List<ChatMessage>.from(history);
final originalMessages = state.messages;
_messagesBeforeRequest = originalMessages;
_requestAppendedUserMessage = appendUserMessage;
_requestHasProducedText = false;
var answer = '';
emit(state.copyWith(messages: messages, isSending: true, clearError: true));
final generation = ++_requestGeneration;
emit(
state.copyWith(
messages: messages,
isSending: true,
requestStatus: RequestStatus.generating,
elapsed: Duration.zero,
clearLastResponseDuration: true,
clearError: true,
),
);
try {
if (appendUserMessage) await _saveCurrent(messages);
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
emit(
state.copyWith(
messages: [...messages, const ChatMessage('', fromUser: false)],
),
);
_startElapsedTimer(generation);
if (state.isWorkspaceMode) {
answer = await _api.askWorkspaceAgent(
prompt,
model: state.selectedModel,
);
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
if (answer.trim().isEmpty) {
throw Exception('لم يصل نص في إجابة الوكيل.');
}
@@ -216,7 +273,16 @@ class ChatCubit extends Cubit<ChatState> {
selectedVersion: answerVersions.length - 1,
),
];
emit(state.copyWith(messages: updated, isSending: false));
final duration = _stopElapsedTimer();
emit(
state.copyWith(
messages: updated,
isSending: false,
requestStatus: RequestStatus.completed,
elapsed: duration,
lastResponseDuration: duration,
),
);
await _saveCurrent(updated);
return;
}
@@ -224,8 +290,9 @@ class ChatCubit extends Cubit<ChatState> {
messages,
model: state.selectedModel,
)) {
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
answer += delta;
if (delta.isNotEmpty) _requestHasProducedText = true;
final answerVersions = [...previousAnswers, answer];
emit(
state.copyWith(
@@ -241,7 +308,7 @@ class ChatCubit extends Cubit<ChatState> {
),
);
}
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
if (answer.trim().isEmpty) throw Exception('لم يصل نص في الإجابة.');
final answerVersions = [...previousAnswers, answer];
final updated = [
@@ -253,10 +320,20 @@ class ChatCubit extends Cubit<ChatState> {
selectedVersion: answerVersions.length - 1,
),
];
emit(state.copyWith(messages: updated, isSending: false));
final duration = _stopElapsedTimer();
emit(
state.copyWith(
messages: updated,
isSending: false,
requestStatus: RequestStatus.completed,
elapsed: duration,
lastResponseDuration: duration,
),
);
await _saveCurrent(updated);
} catch (error) {
if (isClosed) return;
if (isClosed || generation != _requestGeneration) return;
final duration = _stopElapsedTimer();
final partial =
!appendUserMessage
? originalMessages
@@ -275,7 +352,10 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith(
messages: partial,
isSending: false,
error: error.toString(),
requestStatus: RequestStatus.failed,
elapsed: duration,
lastResponseDuration: duration,
error: _readableError(error),
),
);
if (appendUserMessage && answer.isNotEmpty) {
@@ -288,6 +368,49 @@ class ChatCubit extends Cubit<ChatState> {
}
}
Future<void> cancelGeneration() async {
if (!state.isSending) return;
_requestGeneration++;
_api.cancelActiveRequest();
final duration = _stopElapsedTimer();
final messages =
!_requestAppendedUserMessage && !_requestHasProducedText
? List<ChatMessage>.from(_messagesBeforeRequest)
: List<ChatMessage>.from(state.messages);
if (messages.isNotEmpty &&
!messages.last.fromUser &&
messages.last.text.isEmpty) {
messages.removeLast();
}
emit(
state.copyWith(
messages: messages,
isSending: false,
requestStatus: RequestStatus.cancelled,
elapsed: duration,
lastResponseDuration: duration,
clearError: true,
),
);
if (messages.isNotEmpty) {
try {
await _saveCurrent(messages);
if (!isClosed) {
emit(
state.copyWith(
isSending: false,
requestStatus: RequestStatus.cancelled,
elapsed: duration,
lastResponseDuration: duration,
),
);
}
} catch (_) {
// The visible partial reply stays available when local storage fails.
}
}
}
Future<void> openConversation(SavedConversation conversation) async {
if (state.isSending) return;
try {
@@ -296,6 +419,8 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith(
messages: fullConversation.messages,
activeConversationId: fullConversation.id,
requestStatus: RequestStatus.idle,
clearLastResponseDuration: true,
clearError: true,
),
);
@@ -310,6 +435,9 @@ class ChatCubit extends Cubit<ChatState> {
state.copyWith(
messages: const [],
clearActiveConversation: true,
requestStatus: RequestStatus.idle,
elapsed: Duration.zero,
clearLastResponseDuration: true,
clearError: true,
),
);
@@ -346,6 +474,7 @@ class ChatCubit extends Cubit<ChatState> {
emit(
state.copyWith(
apiBaseUrl: _api.baseUrl,
modelStatus: ModelConnectionStatus.checking,
availableModels: const [],
clearSelectedModel: true,
clearError: true,
@@ -1,3 +1,7 @@
enum ModelConnectionStatus { checking, connected, unavailable }
enum RequestStatus { idle, generating, completed, cancelled, failed }
class ChatMessage {
const ChatMessage(
this.text, {
@@ -18,14 +22,13 @@ class ChatMessage {
'selectedVersion': selectedVersion,
};
factory ChatMessage.fromJson(Map<String, dynamic> json) =>
ChatMessage(
json['text'] as String,
fromUser: json['fromUser'] as bool,
answerVersions:
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
selectedVersion: json['selectedVersion'] as int? ?? 0,
);
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
json['text'] as String,
fromUser: json['fromUser'] as bool,
answerVersions:
(json['answerVersions'] as List<dynamic>? ?? const []).cast<String>(),
selectedVersion: json['selectedVersion'] as int? ?? 0,
);
}
class SavedConversation {
@@ -80,6 +83,10 @@ class ChatState {
this.isWorkspaceMode = false,
this.error,
this.modelName = 'جارٍ اكتشاف النموذج…',
this.modelStatus = ModelConnectionStatus.checking,
this.requestStatus = RequestStatus.idle,
this.elapsed = Duration.zero,
this.lastResponseDuration,
this.availableModels = const [],
this.selectedModel,
required this.apiBaseUrl,
@@ -93,6 +100,10 @@ class ChatState {
final bool isWorkspaceMode;
final String? error;
final String modelName;
final ModelConnectionStatus modelStatus;
final RequestStatus requestStatus;
final Duration elapsed;
final Duration? lastResponseDuration;
final List<String> availableModels;
final String? selectedModel;
final String apiBaseUrl;
@@ -108,6 +119,11 @@ class ChatState {
String? error,
bool clearError = false,
String? modelName,
ModelConnectionStatus? modelStatus,
RequestStatus? requestStatus,
Duration? elapsed,
Duration? lastResponseDuration,
bool clearLastResponseDuration = false,
List<String>? availableModels,
String? selectedModel,
bool clearSelectedModel = false,
@@ -124,6 +140,13 @@ class ChatState {
isWorkspaceMode: isWorkspaceMode ?? this.isWorkspaceMode,
error: clearError ? null : (error ?? this.error),
modelName: modelName ?? this.modelName,
modelStatus: modelStatus ?? this.modelStatus,
requestStatus: requestStatus ?? this.requestStatus,
elapsed: elapsed ?? this.elapsed,
lastResponseDuration:
clearLastResponseDuration
? null
: (lastResponseDuration ?? this.lastResponseDuration),
availableModels: availableModels ?? this.availableModels,
selectedModel:
clearSelectedModel ? null : (selectedModel ?? this.selectedModel),
@@ -30,7 +30,8 @@ class _ChatPageState extends State<ChatPage> {
@override
void initState() {
super.initState();
_notificationsEnabled = !kIsWeb &&
_notificationsEnabled =
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS);
@@ -134,6 +135,13 @@ class _ChatPageState extends State<ChatPage> {
await Share.share(text);
}
String _formatDuration(Duration duration) {
if (duration.inMinutes > 0) {
return '${duration.inMinutes} د ${duration.inSeconds.remainder(60)} ث';
}
return '${(duration.inMilliseconds / 1000).toStringAsFixed(1)} ث';
}
Future<void> _enableNotifications() async {
final allowed =
await CompletionNotificationService.instance.requestPermission();
@@ -145,8 +153,8 @@ class _ChatPageState extends State<ChatPage> {
kIsWeb
? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.'
: allowed
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
? 'تم تفعيل إشعارات اكتمال المهام.'
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
),
),
);
@@ -203,17 +211,22 @@ class _ChatPageState extends State<ChatPage> {
listener: (context, state) => _followStreamIfNearby(),
),
BlocListener<ChatCubit, ChatState>(
listenWhen: (previous, current) =>
previous.isSending && !current.isSending,
listenWhen:
(previous, current) => previous.isSending && !current.isSending,
listener: (context, state) {
final succeeded = state.error == null;
final succeeded = state.requestStatus == RequestStatus.completed;
final message = switch (state.requestStatus) {
RequestStatus.completed => 'اكتملت الإجابة.',
RequestStatus.cancelled => 'تم إيقاف التوليد.',
RequestStatus.failed =>
'فشل الطلب: ${state.error ?? 'سبب غير معروف'}',
_ => 'انتهى الطلب.',
};
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(
succeeded ? 'اكتملت الإجابة.' : 'انتهى الطلب مع خطأ.',
),
content: Text(message),
duration: const Duration(seconds: 3),
),
);
@@ -385,15 +398,36 @@ class _ChatPageState extends State<ChatPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
Row(
children: [
Icon(Icons.circle, size: 9, color: _green),
SizedBox(width: 7),
Text(
'نموذج محلي',
style: TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
Icon(
Icons.circle,
size: 9,
color:
chat.modelStatus == ModelConnectionStatus.connected
? _green
: chat.modelStatus ==
ModelConnectionStatus.checking
? Colors.orange
: Colors.red,
),
const SizedBox(width: 7),
Flexible(
child: Text(
chat.isSending
? 'النموذج يولّد الآن'
: chat.modelStatus ==
ModelConnectionStatus.connected
? 'النموذج متصل'
: chat.modelStatus == ModelConnectionStatus.checking
? 'جارٍ الاتصال'
: 'النموذج غير متصل',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
],
@@ -453,143 +487,169 @@ class _ChatPageState extends State<ChatPage> {
Widget _topBar() => LayoutBuilder(
builder: (context, constraints) {
final chat = context.watch<ChatCubit>().state;
final compact = constraints.maxWidth < 640;
return Container(
height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
height: 70,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 26),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
),
child: Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مساعدك المحلي',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _ink,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
if (!compact) ...[
const SizedBox(height: 3),
Text(
chat.isSending
? 'جارٍ التوليد · ${_formatDuration(chat.elapsed)} · ${chat.modelName}'
: chat.modelStatus == ModelConnectionStatus.connected
? 'متصل · ${chat.modelName} · تفريغ الصوت عبر Groq'
: chat.modelStatus == ModelConnectionStatus.checking
? 'جارٍ الاتصال بالخادم…'
: 'غير متصل · ${chat.apiBaseUrl}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
chat.modelStatus == ModelConnectionStatus.connected
? Color(0xFF51806F)
: Color(0xFF9C3827),
fontSize: 11,
),
),
],
],
),
if (!compact) ...[
const SizedBox(height: 3),
const Text(
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11),
),
],
],
),
),
SizedBox(width: compact ? 4 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode ? _green : _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 112 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context.watch<ChatCubit>().state.availableModels.contains(
context.watch<ChatCubit>().state.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
),
SizedBox(width: compact ? 2 : 12),
IconButton(
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
tooltip:
context.watch<ChatCubit>().state.isWorkspaceMode
? 'إيقاف وضع الوكيل (قراءة فقط)'
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
color:
context.watch<ChatCubit>().state.isWorkspaceMode
? _green
: _ink,
icon: Icon(
context.watch<ChatCubit>().state.isWorkspaceMode
? Icons.folder_open_rounded
: Icons.folder_outlined,
),
),
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 100 : 190),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value:
context
.watch<ChatCubit>()
.state
.availableModels
.contains(
context
.watch<ChatCubit>()
.state
.selectedModel,
)
? context.watch<ChatCubit>().state.selectedModel
: context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.first,
isExpanded: true,
isDense: true,
items:
context
.watch<ChatCubit>()
.state
.availableModels
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: EdgeInsets.symmetric(horizontal: compact ? 7 : 11, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.circle, size: 7, color: _green),
SizedBox(width: 7),
Text(
'محلي',
style: TextStyle(
color: _green,
fontSize: 11,
fontWeight: FontWeight.w600,
.map(
(model) => DropdownMenuItem(
value: model,
child: Text(
model,
overflow: TextOverflow.ellipsis,
textDirection: TextDirection.ltr,
style: const TextStyle(fontSize: 11),
),
),
)
.toList(),
onChanged: (model) {
if (model != null) {
context.read<ChatCubit>().selectModel(model);
}
},
),
),
),
],
),
SizedBox(width: compact ? 2 : 10),
Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 11,
vertical: 7,
),
decoration: BoxDecoration(
color: const Color(0xFFE6F2EC),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
children: [
Icon(Icons.circle, size: 7, color: _green),
SizedBox(width: 7),
Text(
'محلي',
style: TextStyle(
color: _green,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip:
_notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
SizedBox(width: compact ? 2 : 8),
IconButton(
onPressed: _settings,
tooltip: 'إعداد الاتصال',
icon: const Icon(Icons.tune_rounded, color: _ink),
),
IconButton(
onPressed: _enableNotifications,
tooltip: _notificationsEnabled
? 'إشعارات اكتمال المهام مفعلة'
: 'تفعيل إشعارات اكتمال المهام',
color: _notificationsEnabled ? _green : _ink,
icon: Icon(
_notificationsEnabled
? Icons.notifications_active_outlined
: Icons.notifications_none_outlined,
),
),
],
),
);
);
},
);
@@ -675,7 +735,7 @@ class _ChatPageState extends State<ChatPage> {
padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
itemCount: chat.messages.length + (showTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == chat.messages.length) return _typing();
if (index == chat.messages.length) return _typing(chat);
final message = chat.messages[index];
return Align(
alignment:
@@ -710,6 +770,21 @@ class _ChatPageState extends State<ChatPage> {
)
else
_AssistantMarkdown(text: message.text),
if (!message.fromUser &&
!chat.isSending &&
index == chat.messages.length - 1 &&
chat.lastResponseDuration != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'${chat.modelName} · ${_formatDuration(chat.lastResponseDuration!)}',
textDirection: TextDirection.ltr,
style: const TextStyle(
color: Color(0xFF89948D),
fontSize: 10,
),
),
),
if (!message.fromUser && message.answerVersions.length > 1)
Align(
alignment: AlignmentDirectional.centerStart,
@@ -810,7 +885,7 @@ class _ChatPageState extends State<ChatPage> {
);
}
Widget _typing() => Align(
Widget _typing(ChatState chat) => Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 14),
@@ -820,18 +895,18 @@ class _ChatPageState extends State<ChatPage> {
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8EAE5)),
),
child: const Row(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
const SizedBox(width: 10),
Text(
'النموذج يفكر…',
style: TextStyle(color: Color(0xFF718078), fontSize: 12),
'النموذج يولّد · ${_formatDuration(chat.elapsed)}',
style: const TextStyle(color: Color(0xFF718078), fontSize: 12),
),
],
),
@@ -915,14 +990,20 @@ class _ChatPageState extends State<ChatPage> {
Padding(
padding: const EdgeInsets.all(7),
child: IconButton.filled(
onPressed: chat.isSending ? null : () => _send(),
onPressed:
chat.isSending
? () => context.read<ChatCubit>().cancelGeneration()
: () => _send(),
style: IconButton.styleFrom(
backgroundColor: _green,
backgroundColor: chat.isSending ? Colors.red : _green,
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFFAABDB5),
),
icon: const Icon(Icons.arrow_upward_rounded),
tooltip: 'إرسال',
icon: Icon(
chat.isSending
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
),
tooltip: chat.isSending ? 'إيقاف التوليد' : 'إرسال',
),
),
],
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
@@ -28,10 +29,40 @@ class _VersionTestApi extends ApiRepository {
}
@override
Stream<String> completeStream(
List<ChatMessage> messages, {
String? model,
}) => Stream<String>.fromIterable(responses.removeAt(0));
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
Stream<String>.fromIterable(responses.removeAt(0));
}
class _CancellableTestApi extends _VersionTestApi {
final controller = StreamController<String>();
var cancelled = false;
final storedConversation = SavedConversation(
id: 'conversation',
title: 'سؤال قديم',
updatedAt: DateTime.utc(2026),
messages: const [
ChatMessage('سؤال قديم', fromUser: true),
ChatMessage(
'إجابة محفوظة',
fromUser: false,
answerVersions: ['إجابة محفوظة'],
),
],
);
@override
Future<SavedConversation> getConversation(String id) async =>
storedConversation;
@override
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
controller.stream;
@override
void cancelActiveRequest() {
cancelled = true;
controller.close();
}
}
void main() {
@@ -42,18 +73,75 @@ void main() {
await cubit.send('ما معنى API؟');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول']);
expect(cubit.state.modelStatus, ModelConnectionStatus.connected);
expect(cubit.state.requestStatus, RequestStatus.completed);
expect(cubit.state.lastResponseDuration, isNotNull);
await cubit.regenerateLastAnswer();
expect(cubit.state.messages.last.text, 'الجواب الثاني');
expect(cubit.state.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']);
expect(cubit.state.messages.last.answerVersions, [
'الجواب الأول',
'الجواب الثاني',
]);
expect(cubit.state.messages.last.selectedVersion, 1);
await cubit.selectAnswerVersion(1, 0);
expect(cubit.state.messages.last.text, 'الجواب الأول');
expect(cubit.state.messages.last.selectedVersion, 0);
expect(api.lastSaved?.messages.last.answerVersions, ['الجواب الأول', 'الجواب الثاني']);
expect(api.lastSaved?.messages.last.answerVersions, [
'الجواب الأول',
'الجواب الثاني',
]);
expect(api.lastSaved?.messages.last.selectedVersion, 0);
await cubit.close();
});
test(
'cancelling generation preserves partial text and records duration',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
await Future<void>.delayed(Duration.zero);
final send = cubit.send('اكتب فقرة قصيرة');
await Future<void>.delayed(const Duration(milliseconds: 20));
api.controller.add('بداية الإجابة');
await Future<void>.delayed(Duration.zero);
await cubit.cancelGeneration();
await send;
expect(api.cancelled, isTrue);
expect(cubit.state.isSending, isFalse);
expect(cubit.state.requestStatus, RequestStatus.cancelled);
expect(cubit.state.messages.last.text, 'بداية الإجابة');
expect(cubit.state.lastResponseDuration, isNotNull);
expect(api.lastSaved?.messages.last.text, 'بداية الإجابة');
await cubit.close();
await api.controller.close();
},
);
test(
'cancelling regeneration before its first token keeps saved answer',
() async {
final api = _CancellableTestApi();
final cubit = ChatCubit(api);
await Future<void>.delayed(Duration.zero);
await cubit.openConversation(api.storedConversation);
final regeneration = cubit.regenerateLastAnswer();
await Future<void>.delayed(const Duration(milliseconds: 20));
await cubit.cancelGeneration();
await regeneration;
expect(cubit.state.messages.last.text, 'إجابة محفوظة');
expect(api.lastSaved?.messages.last.text, 'إجابة محفوظة');
expect(cubit.state.requestStatus, RequestStatus.cancelled);
await cubit.close();
await api.controller.close();
},
);
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:record/record.dart';
import 'dart:async';
import 'package:flutter_app/core/network/api_repository.dart';
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
@@ -22,6 +23,22 @@ class _TestApiRepository extends ApiRepository {
Future<List<SavedConversation>> listConversations() async => [];
}
class _CancellableWidgetApi extends _TestApiRepository {
final controller = StreamController<String>();
@override
Future<void> saveConversation(SavedConversation conversation) async {}
@override
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
controller.stream;
@override
void cancelActiveRequest() {
controller.close();
}
}
void main() {
testWidgets('chat page fits a compact Windows window', (tester) async {
tester.view.physicalSize = const Size(800, 600);
@@ -47,4 +64,43 @@ void main() {
expect(find.text('مساعدك المحلي'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('stop button cancels an active answer', (tester) async {
tester.view.physicalSize = const Size(1000, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final api = _CancellableWidgetApi();
final cubit = ChatCubit(api);
await tester.pumpWidget(
MultiRepositoryProvider(
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
child: MultiBlocProvider(
providers: [
BlocProvider<ChatCubit>.value(value: cubit),
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
],
child: const SovereignApp(),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'أوقف هذا الرد');
await tester.tap(find.byTooltip('إرسال'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 30));
expect(find.byTooltip('إيقاف التوليد'), findsOneWidget);
await tester.tap(find.byTooltip('إيقاف التوليد'));
await tester.pump();
await tester.pumpAndSettle(const Duration(milliseconds: 50));
expect(cubit.state.requestStatus, RequestStatus.cancelled);
expect(find.byTooltip('إرسال'), findsOneWidget);
expect(tester.takeException(), isNull);
await cubit.close();
await api.controller.close();
});
}