Add generation status and cancel control
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-10
@@ -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,
|
||||||
|
|||||||
+31
-8
@@ -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),
|
||||||
|
|||||||
+238
-157
@@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user