Add generation status and cancel control
This commit is contained in:
+238
-157
@@ -30,7 +30,8 @@ class _ChatPageState extends State<ChatPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationsEnabled = !kIsWeb &&
|
||||
_notificationsEnabled =
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS);
|
||||
@@ -134,6 +135,13 @@ class _ChatPageState extends State<ChatPage> {
|
||||
await Share.share(text);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
if (duration.inMinutes > 0) {
|
||||
return '${duration.inMinutes} د ${duration.inSeconds.remainder(60)} ث';
|
||||
}
|
||||
return '${(duration.inMilliseconds / 1000).toStringAsFixed(1)} ث';
|
||||
}
|
||||
|
||||
Future<void> _enableNotifications() async {
|
||||
final allowed =
|
||||
await CompletionNotificationService.instance.requestPermission();
|
||||
@@ -145,8 +153,8 @@ class _ChatPageState extends State<ChatPage> {
|
||||
kIsWeb
|
||||
? 'إشعارات النظام غير مفعلة في نسخة الويب؛ سيظهر إشعار داخل الصفحة.'
|
||||
: allowed
|
||||
? 'تم تفعيل إشعارات اكتمال المهام.'
|
||||
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
|
||||
? 'تم تفعيل إشعارات اكتمال المهام.'
|
||||
: 'لم يُمنح إذن الإشعارات؛ ستبقى رسائل الاكتمال داخل التطبيق.',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -203,17 +211,22 @@ class _ChatPageState extends State<ChatPage> {
|
||||
listener: (context, state) => _followStreamIfNearby(),
|
||||
),
|
||||
BlocListener<ChatCubit, ChatState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.isSending && !current.isSending,
|
||||
listenWhen:
|
||||
(previous, current) => previous.isSending && !current.isSending,
|
||||
listener: (context, state) {
|
||||
final succeeded = state.error == null;
|
||||
final succeeded = state.requestStatus == RequestStatus.completed;
|
||||
final message = switch (state.requestStatus) {
|
||||
RequestStatus.completed => 'اكتملت الإجابة.',
|
||||
RequestStatus.cancelled => 'تم إيقاف التوليد.',
|
||||
RequestStatus.failed =>
|
||||
'فشل الطلب: ${state.error ?? 'سبب غير معروف'}',
|
||||
_ => 'انتهى الطلب.',
|
||||
};
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
succeeded ? 'اكتملت الإجابة.' : 'انتهى الطلب مع خطأ.',
|
||||
),
|
||||
content: Text(message),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -385,15 +398,36 @@ class _ChatPageState extends State<ChatPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 9, color: _green),
|
||||
SizedBox(width: 7),
|
||||
Text(
|
||||
'نموذج محلي',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _ink,
|
||||
Icon(
|
||||
Icons.circle,
|
||||
size: 9,
|
||||
color:
|
||||
chat.modelStatus == ModelConnectionStatus.connected
|
||||
? _green
|
||||
: chat.modelStatus ==
|
||||
ModelConnectionStatus.checking
|
||||
? Colors.orange
|
||||
: Colors.red,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
chat.isSending
|
||||
? 'النموذج يولّد الآن'
|
||||
: chat.modelStatus ==
|
||||
ModelConnectionStatus.connected
|
||||
? 'النموذج متصل'
|
||||
: chat.modelStatus == ModelConnectionStatus.checking
|
||||
? 'جارٍ الاتصال'
|
||||
: 'النموذج غير متصل',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -453,143 +487,169 @@ class _ChatPageState extends State<ChatPage> {
|
||||
|
||||
Widget _topBar() => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final chat = context.watch<ChatCubit>().state;
|
||||
final compact = constraints.maxWidth < 640;
|
||||
return Container(
|
||||
height: 70,
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 12 : 26),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'مساعدك المحلي',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: _ink,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
height: 70,
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 26),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(bottom: BorderSide(color: Color(0xFFE7E9E3))),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'مساعدك المحلي',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: _ink,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
chat.isSending
|
||||
? 'جارٍ التوليد · ${_formatDuration(chat.elapsed)} · ${chat.modelName}'
|
||||
: chat.modelStatus == ModelConnectionStatus.connected
|
||||
? 'متصل · ${chat.modelName} · تفريغ الصوت عبر Groq'
|
||||
: chat.modelStatus == ModelConnectionStatus.checking
|
||||
? 'جارٍ الاتصال بالخادم…'
|
||||
: 'غير متصل · ${chat.apiBaseUrl}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color:
|
||||
chat.modelStatus == ModelConnectionStatus.connected
|
||||
? Color(0xFF51806F)
|
||||
: Color(0xFF9C3827),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (!compact) ...[
|
||||
const SizedBox(height: 3),
|
||||
const Text(
|
||||
'المحادثة بالنموذج المحلي · تفريغ الصوت عبر Groq',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Color(0xFF7A867F), fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: compact ? 4 : 12),
|
||||
IconButton(
|
||||
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
|
||||
tooltip:
|
||||
context.watch<ChatCubit>().state.isWorkspaceMode
|
||||
? 'إيقاف وضع الوكيل (قراءة فقط)'
|
||||
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
|
||||
color:
|
||||
context.watch<ChatCubit>().state.isWorkspaceMode ? _green : _ink,
|
||||
icon: Icon(
|
||||
context.watch<ChatCubit>().state.isWorkspaceMode
|
||||
? Icons.folder_open_rounded
|
||||
: Icons.folder_outlined,
|
||||
),
|
||||
),
|
||||
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: compact ? 112 : 190),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value:
|
||||
context.watch<ChatCubit>().state.availableModels.contains(
|
||||
context.watch<ChatCubit>().state.selectedModel,
|
||||
)
|
||||
? context.watch<ChatCubit>().state.selectedModel
|
||||
: context
|
||||
),
|
||||
SizedBox(width: compact ? 2 : 12),
|
||||
IconButton(
|
||||
onPressed: () => context.read<ChatCubit>().toggleWorkspaceMode(),
|
||||
tooltip:
|
||||
context.watch<ChatCubit>().state.isWorkspaceMode
|
||||
? 'إيقاف وضع الوكيل (قراءة فقط)'
|
||||
: 'وضع الوكيل: قراءة ملفات المشروع فقط',
|
||||
color:
|
||||
context.watch<ChatCubit>().state.isWorkspaceMode
|
||||
? _green
|
||||
: _ink,
|
||||
icon: Icon(
|
||||
context.watch<ChatCubit>().state.isWorkspaceMode
|
||||
? Icons.folder_open_rounded
|
||||
: Icons.folder_outlined,
|
||||
),
|
||||
),
|
||||
if (context.watch<ChatCubit>().state.availableModels.isNotEmpty)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: compact ? 100 : 190),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value:
|
||||
context
|
||||
.watch<ChatCubit>()
|
||||
.state
|
||||
.availableModels
|
||||
.contains(
|
||||
context
|
||||
.watch<ChatCubit>()
|
||||
.state
|
||||
.selectedModel,
|
||||
)
|
||||
? context.watch<ChatCubit>().state.selectedModel
|
||||
: context
|
||||
.watch<ChatCubit>()
|
||||
.state
|
||||
.availableModels
|
||||
.first,
|
||||
isExpanded: true,
|
||||
isDense: true,
|
||||
items:
|
||||
context
|
||||
.watch<ChatCubit>()
|
||||
.state
|
||||
.availableModels
|
||||
.first,
|
||||
isExpanded: true,
|
||||
isDense: true,
|
||||
items:
|
||||
context
|
||||
.watch<ChatCubit>()
|
||||
.state
|
||||
.availableModels
|
||||
.map(
|
||||
(model) => DropdownMenuItem(
|
||||
value: model,
|
||||
child: Text(
|
||||
model,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textDirection: TextDirection.ltr,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (model) {
|
||||
if (model != null) {
|
||||
context.read<ChatCubit>().selectModel(model);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: compact ? 2 : 10),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 7 : 11, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE6F2EC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 7, color: _green),
|
||||
SizedBox(width: 7),
|
||||
Text(
|
||||
'محلي',
|
||||
style: TextStyle(
|
||||
color: _green,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
.map(
|
||||
(model) => DropdownMenuItem(
|
||||
value: model,
|
||||
child: Text(
|
||||
model,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textDirection: TextDirection.ltr,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (model) {
|
||||
if (model != null) {
|
||||
context.read<ChatCubit>().selectModel(model);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: compact ? 2 : 10),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 7 : 11,
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE6F2EC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.circle, size: 7, color: _green),
|
||||
SizedBox(width: 7),
|
||||
Text(
|
||||
'محلي',
|
||||
style: TextStyle(
|
||||
color: _green,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: compact ? 2 : 8),
|
||||
IconButton(
|
||||
onPressed: _settings,
|
||||
tooltip: 'إعداد الاتصال',
|
||||
icon: const Icon(Icons.tune_rounded, color: _ink),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _enableNotifications,
|
||||
tooltip:
|
||||
_notificationsEnabled
|
||||
? 'إشعارات اكتمال المهام مفعلة'
|
||||
: 'تفعيل إشعارات اكتمال المهام',
|
||||
color: _notificationsEnabled ? _green : _ink,
|
||||
icon: Icon(
|
||||
_notificationsEnabled
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_none_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: compact ? 2 : 8),
|
||||
IconButton(
|
||||
onPressed: _settings,
|
||||
tooltip: 'إعداد الاتصال',
|
||||
icon: const Icon(Icons.tune_rounded, color: _ink),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _enableNotifications,
|
||||
tooltip: _notificationsEnabled
|
||||
? 'إشعارات اكتمال المهام مفعلة'
|
||||
: 'تفعيل إشعارات اكتمال المهام',
|
||||
color: _notificationsEnabled ? _green : _ink,
|
||||
icon: Icon(
|
||||
_notificationsEnabled
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_none_outlined,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -675,7 +735,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
|
||||
itemCount: chat.messages.length + (showTyping ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == chat.messages.length) return _typing();
|
||||
if (index == chat.messages.length) return _typing(chat);
|
||||
final message = chat.messages[index];
|
||||
return Align(
|
||||
alignment:
|
||||
@@ -710,6 +770,21 @@ class _ChatPageState extends State<ChatPage> {
|
||||
)
|
||||
else
|
||||
_AssistantMarkdown(text: message.text),
|
||||
if (!message.fromUser &&
|
||||
!chat.isSending &&
|
||||
index == chat.messages.length - 1 &&
|
||||
chat.lastResponseDuration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'${chat.modelName} · ${_formatDuration(chat.lastResponseDuration!)}',
|
||||
textDirection: TextDirection.ltr,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF89948D),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!message.fromUser && message.answerVersions.length > 1)
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
@@ -810,7 +885,7 @@ class _ChatPageState extends State<ChatPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _typing() => Align(
|
||||
Widget _typing(ChatState chat) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
@@ -820,18 +895,18 @@ class _ChatPageState extends State<ChatPage> {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE8EAE5)),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
const SizedBox(
|
||||
width: 15,
|
||||
height: 15,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'النموذج يفكر…',
|
||||
style: TextStyle(color: Color(0xFF718078), fontSize: 12),
|
||||
'النموذج يولّد · ${_formatDuration(chat.elapsed)}',
|
||||
style: const TextStyle(color: Color(0xFF718078), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -915,14 +990,20 @@ class _ChatPageState extends State<ChatPage> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(7),
|
||||
child: IconButton.filled(
|
||||
onPressed: chat.isSending ? null : () => _send(),
|
||||
onPressed:
|
||||
chat.isSending
|
||||
? () => context.read<ChatCubit>().cancelGeneration()
|
||||
: () => _send(),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: _green,
|
||||
backgroundColor: chat.isSending ? Colors.red : _green,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: const Color(0xFFAABDB5),
|
||||
),
|
||||
icon: const Icon(Icons.arrow_upward_rounded),
|
||||
tooltip: 'إرسال',
|
||||
icon: Icon(
|
||||
chat.isSending
|
||||
? Icons.stop_rounded
|
||||
: Icons.arrow_upward_rounded,
|
||||
),
|
||||
tooltip: chat.isSending ? 'إيقاف التوليد' : 'إرسال',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user